SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
10.1 KB · 130 lines typescript
Raw Blame History
1/**2 * Branded transactional templates: text + HTML for every message. Neutral palette, one accent,3 * no images (deliverability), numbers always with context. Keep copy short and factual.4 */56const SITE = () => (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io').replace(/\/$/, '');78function esc(s: string): string {9  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');10}1112export interface Rendered {13  subject: string;14  html: string;15  text: string;16}1718function layout(opts: { title: string; preheader?: string; blocks: string[]; footer?: string }): string {19  const pre = opts.preheader ? `<span style="display:none!important;visibility:hidden;opacity:0;color:transparent;height:0;width:0;overflow:hidden">${esc(opts.preheader)}</span>` : '';20  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${esc(opts.title)}</title></head>21<body style="margin:0;padding:0;background:#f4f4f3;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Helvetica,Arial,sans-serif;color:#0b0b0c">22${pre}23<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f4f3;padding:32px 12px"><tr><td align="center">24<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border:1px solid #e4e4e2;border-radius:8px">25<tr><td style="padding:22px 28px 0 28px">26  <table role="presentation" cellspacing="0" cellpadding="0"><tr>27    <td style="width:24px;height:24px;background:#0b0b0c;color:#fff;border-radius:4px;text-align:center;font-weight:700;font-size:12px;line-height:24px">R</td>28    <td style="padding-left:8px;font-size:15px;font-weight:600;letter-spacing:-0.01em">Rare<span style="color:#55555b">Index</span></td>29  </tr></table>30</td></tr>31<tr><td style="padding:20px 28px 8px 28px;font-size:20px;font-weight:600;letter-spacing:-0.015em">${esc(opts.title)}</td></tr>32${opts.blocks.map((b) => `<tr><td style="padding:6px 28px;font-size:14px;line-height:22px;color:#2a2a2e">${b}</td></tr>`).join('')}33<tr><td style="padding:22px 28px 26px 28px;font-size:12px;line-height:18px;color:#8a8a91;border-top:1px solid #e4e4e2">${opts.footer ?? `Sent by RareIndex · <a href="${SITE()}" style="color:#8a8a91">rareindex.io</a>. Valuations are estimates derived from observed public data; listing prices are not confirmed transactions.`}</td></tr>34</table>35<p style="font-size:11px;color:#8a8a91;margin:14px 0 0 0">RareIndex does not authenticate items. Manage e-mail preferences in <a href="${SITE()}/account/notifications" style="color:#8a8a91">account settings</a>.</p>36</td></tr></table></body></html>`;37}3839function codeBlock(code: string): string {40  return `<div style="margin:10px 0 4px 0;padding:14px 18px;background:#fafaf9;border:1px solid #e4e4e2;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:28px;letter-spacing:0.28em;font-weight:600;text-align:center">${esc(code)}</div>`;41}4243function button(href: string, label: string): string {44  return `<a href="${esc(href)}" style="display:inline-block;margin:8px 0;padding:10px 16px;background:#0b0b0c;color:#ffffff;text-decoration:none;border-radius:6px;font-size:13px;font-weight:600">${esc(label)}</a>`;45}4647export function verificationEmail(p: { code: string; minutes: number }): Rendered {48  const subject = `${p.code} is your RareIndex verification code`;49  return {50    subject,51    text: `Your RareIndex verification code is ${p.code}. It expires in ${p.minutes} minutes.\n\nIf you did not create an account, ignore this message.`,52    html: layout({ title: 'Confirm your e-mail', preheader: `Code ${p.code} · expires in ${p.minutes} min`, blocks: [`Enter this code to activate your RareIndex account. It expires in <strong>${p.minutes} minutes</strong>.`, codeBlock(p.code), `If you did not create an account, you can ignore this message.`] }),53  };54}5556export function mfaCodeEmail(p: { code: string; minutes: number; ip?: string | null; userAgent?: string | null }): Rendered {57  const ctx = [p.ip ? `IP ${esc(p.ip)}` : null, p.userAgent ? esc(p.userAgent.slice(0, 80)) : null].filter(Boolean).join(' · ');58  return {59    subject: `${p.code} is your RareIndex sign-in code`,60    text: `Your sign-in code is ${p.code} (valid ${p.minutes} minutes).${ctx ? `\nSign-in attempt: ${ctx}` : ''}\n\nIf this was not you, change your password immediately.`,61    html: layout({ title: 'Your sign-in code', preheader: `Code ${p.code}`, blocks: [`Use this code to finish signing in. Valid for <strong>${p.minutes} minutes</strong>.`, codeBlock(p.code), ctx ? `<span style="color:#55555b;font-size:12px">Attempt: ${ctx}</span>` : '', `If this was not you, <a href="${SITE()}/account/security">secure your account</a> now.`] }),62  };63}6465export function newLoginEmail(p: { when: Date; ip?: string | null; userAgent?: string | null; method: string }): Rendered {66  const line = `${p.when.toUTCString()}${p.ip ? ` · IP ${p.ip}` : ''}${p.userAgent ? ` · ${p.userAgent.slice(0, 90)}` : ''} · ${p.method}`;67  return {68    subject: 'New sign-in to your RareIndex account',69    text: `A new sign-in to your RareIndex account was completed.\n${line}\n\nIf this was not you, reset your password and review trusted devices: ${SITE()}/account/security`,70    html: layout({ title: 'New sign-in', blocks: [`A new sign-in to your account was completed from a device we had not seen before.`, `<span style="color:#55555b;font-size:12px">${esc(line)}</span>`, `If this was not you, reset your password and revoke devices.`, button(`${SITE()}/account/security`, 'Review security')] }),71  };72}7374export function passwordResetEmail(p: { code: string; link: string; minutes: number }): Rendered {75  return {76    subject: 'Reset your RareIndex password',77    text: `Reset your password: ${p.link}\n\nOr enter code ${p.code}. The link and code expire in ${p.minutes} minutes. If you did not request this, ignore this message.`,78    html: layout({ title: 'Reset your password', preheader: `Code ${p.code}`, blocks: [`Click the button or enter the code below. Expires in <strong>${p.minutes} minutes</strong>.`, button(p.link, 'Choose a new password'), codeBlock(p.code), `If you did not request a reset, nothing changes.`] }),79  };80}8182export function changeEmailEmail(p: { code: string; minutes: number; newEmail: string }): Rendered {83  return {84    subject: `${p.code} — confirm your new RareIndex e-mail`,85    text: `Confirm ${p.newEmail} as your new e-mail with code ${p.code} (valid ${p.minutes} minutes).`,86    html: layout({ title: 'Confirm your new e-mail', blocks: [`Enter this code to switch your account to <strong>${esc(p.newEmail)}</strong>.`, codeBlock(p.code)] }),87  };88}8990export interface AlertMail {91  title: string;92  body: string;93  href: string;94  facts?: Array<[string, string]>;95}9697export function alertEmail(p: AlertMail): Rendered {98  const facts = p.facts?.length ? `<table role="presentation" cellspacing="0" cellpadding="0" style="margin:8px 0;font-size:13px">${p.facts.map(([k, v]) => `<tr><td style="padding:3px 14px 3px 0;color:#55555b">${esc(k)}</td><td style="padding:3px 0;font-variant-numeric:tabular-nums;font-weight:600">${esc(v)}</td></tr>`).join('')}</table>` : '';99  return {100    subject: `Alert · ${p.title}`,101    text: `${p.title}\n${p.body}\n${(p.facts ?? []).map(([k, v]) => `${k}: ${v}`).join('\n')}\n\n${p.href}`,102    html: layout({ title: p.title, preheader: p.body, blocks: [esc(p.body), facts, button(p.href, 'Open in RareIndex')] }),103  };104}105106export interface DigestSection {107  heading: string;108  rows: Array<{ label: string; value: string; delta?: string; href?: string }>;109}110111export function digestEmail(p: { name: string | null; period: string; sections: DigestSection[]; portfolio?: { valueUsd: string; change: string } | null }): Rendered {112  const blocks: string[] = [`${p.name ? `Hi ${esc(p.name)}, here` : 'Here'} is your ${esc(p.period)} RareIndex digest.`];113  if (p.portfolio) blocks.push(`<div style="padding:12px 16px;background:#fafaf9;border:1px solid #e4e4e2;border-radius:6px"><div style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:#8a8a91">Collection value</div><div style="font-size:22px;font-weight:600">${esc(p.portfolio.valueUsd)} <span style="font-size:13px;font-weight:500;color:#55555b">${esc(p.portfolio.change)} this period</span></div></div>`);114  for (const s of p.sections) {115    if (!s.rows.length) continue;116    blocks.push(`<div style="margin-top:10px;font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:#8a8a91">${esc(s.heading)}</div><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="font-size:13px">${s.rows.map((r) => `<tr><td style="padding:5px 0;border-bottom:1px solid #eeeeec">${r.href ? `<a href="${esc(r.href)}" style="color:#0b0b0c;text-decoration:none">${esc(r.label)}</a>` : esc(r.label)}</td><td align="right" style="padding:5px 0;border-bottom:1px solid #eeeeec;font-variant-numeric:tabular-nums;font-weight:600">${esc(r.value)}</td><td align="right" style="padding:5px 0 5px 10px;border-bottom:1px solid #eeeeec;font-variant-numeric:tabular-nums;color:${r.delta?.startsWith('-') ? '#c8262c' : '#0f8a4a'}">${esc(r.delta ?? '')}</td></tr>`).join('')}</table>`);117  }118  blocks.push(button(`${SITE()}/collections`, 'Open my RareIndex'));119  const text = [`Your ${p.period} RareIndex digest`, p.portfolio ? `Collection value ${p.portfolio.valueUsd} (${p.portfolio.change})` : '', ...p.sections.flatMap((s) => [``, s.heading.toUpperCase(), ...s.rows.map((r) => `- ${r.label}: ${r.value} ${r.delta ?? ''}`)])].join('\n');120  return { subject: `Your ${p.period} RareIndex digest`, text, html: layout({ title: `${p.period[0]?.toUpperCase()}${p.period.slice(1)} digest`, blocks }) };121}122123export function accountDeletionEmail(p: { purgeAt: Date }): Rendered {124  return {125    subject: 'Your RareIndex account is scheduled for deletion',126    text: `Your account will be permanently deleted on ${p.purgeAt.toUTCString()}. Sign in before then to cancel.`,127    html: layout({ title: 'Account deletion scheduled', blocks: [`Your account and all private data will be permanently deleted on <strong>${esc(p.purgeAt.toUTCString())}</strong>.`, `Signing in before that date cancels the deletion.`, button(`${SITE()}/login`, 'Sign in to keep my account')] }),128  };129}130