import { emailIsConfigured, getSettings, type InstanceSettings } from "@/lib/settings"; import { recordSystemEvent } from "@/lib/data/system"; /** * Resend over its REST API. No SDK: it is one POST, and skipping the package * keeps the deploy smaller and the dependency surface smaller. */ const RESEND_ENDPOINT = "https://api.resend.com/emails"; export type SendResult = { ok: true; id: string } | { ok: false; error: string }; export async function sendEmail(input: { to: string; subject: string; html: string; text: string; settings?: InstanceSettings; }): Promise { const settings = input.settings ?? (await getSettings()); if (!emailIsConfigured(settings)) { return { ok: false, error: "Email is not configured. Add a Resend API key and a from address in Admin → Email.", }; } const from = settings.resendFromName ? `${settings.resendFromName} <${settings.resendFromEmail}>` : settings.resendFromEmail; try { const response = await fetch(RESEND_ENDPOINT, { method: "POST", headers: { Authorization: `Bearer ${settings.resendApiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ from, to: [input.to], subject: input.subject, html: input.html, text: input.text, }), signal: AbortSignal.timeout(15000), }); const body = (await response.json().catch(() => ({}))) as { id?: string; message?: string; name?: string; }; if (!response.ok) { // Resend puts the useful part in `message`; fall back to the status. const error = body.message || `Resend returned ${response.status}`; await recordSystemEvent({ source: "email.send", message: error, userEmail: input.to }); return { ok: false, error }; } await recordSystemEvent({ level: "INFO", source: "email.send", message: input.subject, userEmail: input.to, }); return { ok: true, id: body.id ?? "" }; } catch (error) { const message = error instanceof Error ? error.message : String(error); await recordSystemEvent({ source: "email.send", message, userEmail: input.to }); return { ok: false, error: message }; } } // --------------------------------------------------------------------------- // The look // // An email is the only surface of this product that cannot read globals.css. // No custom properties, no stylesheet, no cascade worth trusting — so the // palette is repeated here as hex, converted once from the oklch tokens, and // the layout is built the way mail is built rather than the way the app is. // // Three rules hold the design together: // // 1. TABLES CARRY THE LAYOUT. Outlook on Windows renders through Word, which // has no `max-width`, so a centred div becomes a full-bleed one and the // whole thing falls apart on the one client a hiring manager is most likely // to be reading it in. This supersedes the older note here that said the // templates were table-free: table-free was the thing that broke the client // it was meant to protect. // 2. COLOUR STILL MEANS SOMETHING. Same rule as the interface: near-monochrome // surface, type does the hierarchy, and the one blue is the primary action // and links. There is no email-only accent. // 3. NOTHING IS FETCHED. The mark is drawn out of a table cell and three // coloured strips rather than loaded as an image, so it survives blocked // images, and no font is pulled from Google — the same reason the company // logo switch exists, applied to mail. // --------------------------------------------------------------------------- /** * The tokens from globals.css, converted to hex because mail clients want hex. * If a value here drifts from its counterpart in `:root` or `.dark`, the emails * stop looking like the app — which is the only reason to touch this block. */ const C = { page: "#f2f2f4", // --canvas card: "#ffffff", // --card inset: "#f6f6f8", // --inset, a shade lighter so it reads on white ink: "#101214", // --foreground muted: "#626569", // --muted-foreground faint: "#919499", // --faint border: "#e4e5e8", // --border primary: "#0c71d1", // --primary onPrimary: "#ffffff", // --primary-foreground } as const; const D = { page: "#0d0e10", card: "#18191b", inset: "#1f2023", ink: "#f4f4f6", muted: "#96989d", faint: "#75787d", border: "#2d2e30", primary: "#4ea1f5", // lifted: the light-theme blue disappears on a dark card onPrimary: "#0d0e10", } as const; /** * Inter first for the handful of people who have it installed, then the system * stack everyone else lands on. Deliberately not loaded from Google: an email * that fetches a font tells a third party the moment it was opened, and this * product asks before doing that anywhere else. */ const FONT = "Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"; const WIDTH = 552; /** * The mark, in HTML. Same 64-unit grid as `hired-mark.tsx` — bar height 7, * gap 4.5, x 16, widths 13/22.5/32, radius 15 — scaled to a 40px tile and * rounded to whole pixels, because half a pixel is a blurry bar in mail. * * Outlook drops the corner radius and gets a square tile with square bars. * That is still the mark; it is not still the mark if it is a broken image * icon, which is what an would be for everyone with images off. */ function mark() { const bar = (w: number, first = false) => `
 
`; return ``; } /** The hidden line the inbox shows next to the subject. Worth writing by hand. */ function preheader(text: string) { return `
${escapeHtml(text)}${"͏‌ ".repeat(60)}
`; } /** * The frame every email shares: masthead, one card, footer. * * `preview` is the inbox line. `footer` is the small print under the card — * the sentence that says what happens next, or what to do if this wasn't for * you. Both are required because an email without either is the kind of email * that gets reported as spam. */ function shell(input: { instanceName: string; title: string; preview: string; body: string; footer: string; }) { return ` ${escapeHtml(input.title)} ${preheader(input.preview)} `; } // --- Pieces the templates are built from ---------------------------------- // Small enough to read at a glance, which is the point: a template below // should be its words, not its markup. /** A paragraph. `last` drops the bottom margin where something follows it. */ function p(html: string, opts?: { muted?: boolean; last?: boolean }) { const color = opts?.muted ? C.muted : C.ink; const cls = opts?.muted ? "muted" : "ink"; return `

${html}

`; } /** The one primary action. A cell carries the fill so Outlook keeps the padding. */ function button(href: string, label: string) { return `
${escapeHtml(label)}
`; } /** Every button needs this under it: half of mail clients eat the button. */ function fallbackLink(href: string) { return `

Or paste this into your browser:
${escapeHtml(href)}

`; } /** A recessed block of label/value pairs — the app's inset surface, in mail. */ function facts(rows: [string, string][]) { const cells = rows .map( ([label, value], i) => `
${escapeHtml(label)}
${escapeHtml(value)}
`, ) .join(""); return ``; } /** A small heading inside the card. Never an

: the h1 is the only title. */ function eyebrow(text: string) { return `
${escapeHtml(text)}
`; } /** A hairline. Border on a cell, because a 1px div collapses in Outlook. */ function rule() { return `
 
`; } /** What the product is, in three lines. The three areas, in their own words. */ function areas() { const items: [string, string][] = [ ["Me", "Everything you've done, kept in one place instead of six resumes."], ["Resumes", "Documents built out of that material, one per job you go for."], // "Pipeline" was the word the app itself stopped saying: the rail reads // Board, and this email is the very first thing a new person reads. ["Board", "Every job you are going for, as a card you drag along as things happen."], ]; return items .map( ([name, line], i) => `
 
${name} — ${line}
`, ) .join(""); } // --------------------------------------------------------------------------- // The templates // --------------------------------------------------------------------------- export function inviteEmail(input: { instanceName: string; inviterName: string; acceptUrl: string; expiresInDays: number; /** * Whether the inviter set the password. The password itself is deliberately * not a parameter: sending a link and the credential it opens in one message * makes the message the whole account, and this one is going to an address * nobody has proven yet. All the email does is stop the invitee expecting * the accept page to ask them for a password when it will not. */ passwordSet?: boolean; }) { const subject = `${input.inviterName} invited you to ${input.instanceName}`; const html = shell({ instanceName: input.instanceName, title: "You've been invited", preview: input.passwordSet ? `${input.instanceName} is yours — you'll need the password ${input.inviterName} gave you. The link is good for ${input.expiresInDays} days.` : `Pick a password and ${input.instanceName} is yours. The link is good for ${input.expiresInDays} days.`, body: `${p( `${escapeHtml(input.inviterName)} has invited you to join ${escapeHtml(input.instanceName)} — a place to keep everything about your career in one spot, build resumes out of it, and track where you've applied.`, )} ${p( input.passwordSet ? `${escapeHtml(input.inviterName)} has already set your password — they'll have sent it to you separately, not in this email. Follow the link, put your name in, and sign in with it.` : "Pick a password and you're in. It takes about a minute.", { muted: true, last: true }, )} ${button(input.acceptUrl, "Accept invitation")} ${fallbackLink(input.acceptUrl)} ${rule()} ${eyebrow("What's inside")} ${areas()}`, footer: `This invitation expires in ${input.expiresInDays} days and nothing happens until you accept it. If you weren't expecting it, ignore this email.`, }); const text = `${input.inviterName} invited you to join ${input.instanceName} — a place to keep everything about your career in one spot, build resumes out of it, and track where you've applied. Accept your invitation: ${input.acceptUrl} ${ input.passwordSet ? ` ${input.inviterName} has already set your password. It is not in this email — they will have sent it to you separately. ` : "" } The link expires in ${input.expiresInDays} days. If you weren't expecting it, ignore this email.`; return { subject, html, text }; } /** * The one email that goes to the instance owner rather than to a user: someone * asked for access from the marketing site. It exists so the owner doesn't have * to poll an admin screen to find out that a stranger is waiting. */ export function waitlistNoticeEmail(input: { instanceName: string; email: string; name: string; context: string; source: string; total: number; adminUrl: string; }) { const who = input.name ? `${input.name} (${input.email})` : input.email; const subject = `${input.instanceName}: ${who} wants access`; const rows = [ ["Email", input.email], input.name ? ["Name", input.name] : null, input.context ? ["Looking for", input.context] : null, input.source ? ["From", input.source] : null, ].filter(Boolean) as [string, string][]; const waiting = `${input.total} ${input.total === 1 ? "person is" : "people are"} on the list.`; const html = shell({ instanceName: input.instanceName, title: "Someone asked for access", preview: `${who} — ${waiting}`, body: `${p("They filled in the form on your site. Nothing has been given to them yet.", { muted: true })} ${facts(rows)} ${p(waiting, { muted: true, last: true })} ${input.adminUrl ? button(input.adminUrl, "Open the waitlist") : ""}`, footer: "Invite them from Admin → People → Waiting for access when you're ready.", }); const text = `${who} asked for access to ${input.instanceName}. ${rows.map(([label, value]) => `${label}: ${value}`).join("\n")} ${waiting}${input.adminUrl ? `\n\nInvite them: ${input.adminUrl}` : ""} Nobody has been given access yet.`; return { subject, html, text }; } /** * The two messages somebody asked for: a weekly summary and a due-today nudge. * * One function, two subjects, because the difference between them is what is in * the sections rather than how it is laid out — and two near-identical * templates is two places to fix a rendering bug. * * The footer says where the switch is. Every message this app sends to a person * who asked for it has to say how to stop, and "Settings → Account" is one * click from wherever they are reading it. */ export function digestEmail(input: { instanceName: string; name: string; content: { subject: string; intro: string; sections: { heading: string; lines: string[] }[] }; appUrl: string; }) { return digestShell({ ...input, cta: "Open the board", path: "/applications" }); } export function nudgeEmail(input: { instanceName: string; name: string; content: { subject: string; intro: string; sections: { heading: string; lines: string[] }[] }; appUrl: string; }) { return digestShell({ ...input, cta: "Open today", path: "/" }); } /** * The monthly one, and the only mail here addressed to somebody who is NOT * searching. It points at Me rather than at the board, because the ask is to * write one sentence down rather than to go and look at anything. */ export function winsEmail(input: { instanceName: string; name: string; content: { subject: string; intro: string; sections: { heading: string; lines: string[] }[] }; appUrl: string; }) { return digestShell({ ...input, cta: "Open Me", path: "/me" }); } function digestShell(input: { instanceName: string; name: string; content: { subject: string; intro: string; sections: { heading: string; lines: string[] }[] }; appUrl: string; cta: string; path: string; }) { const first = input.name.trim().split(/\s+/)[0] ?? ""; const href = input.appUrl ? `${input.appUrl}${input.path}` : ""; const sections = input.content.sections .filter((section) => section.lines.length > 0) .map((section) => `${eyebrow(section.heading)}${bullets(section.lines)}`) .join(rule()); return { subject: input.content.subject, html: shell({ instanceName: input.instanceName, title: input.content.subject, preview: input.content.intro, body: `${p(first ? `${escapeHtml(first)} — ${escapeHtml(input.content.intro)}` : escapeHtml(input.content.intro))} ${sections} ${href ? button(href, input.cta) : ""}`, footer: "You asked for this one. Settings → Account turns it off.", }), text: [ input.content.intro, "", ...input.content.sections.flatMap((section) => section.lines.length === 0 ? [] : [section.heading.toUpperCase(), ...section.lines.map((line) => ` - ${line}`), ""], ), href ? `${input.cta}: ${href}` : "", "", "You asked for this one. Settings → Account turns it off.", ].join("\n"), }; } /** A plain list. Not a