/** * Every email the site sends shares one layout: the site's emoji and name, * then a card holding the content, then a footer. The templates beside this * module (welcome, password reset, digest) build their content with an * {@link EmailKit}, which draws each block in a style from `styles.ts`, and * `email-service.ts` sends what they return. * * Mail clients drop stylesheets, so every block carries inline styles. A * reader on the "auto" scheme gets the light palette inline and the dark one * in a `prefers-color-scheme` block that Apple's mail apps honour; elsewhere * the light one shows. * * @module */ import { type EmailLocale, OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; import type { ColorScheme, StyleName } from "../../shared/theme.ts"; import { EMAIL_STYLES, type EmailPalette, type EmailStyle, RAINBOW, } from "./styles.ts"; /** * Returned by each template to its caller, which hands it to `sendEmail` * with a recipient. */ export interface RenderedEmail { subject: string; html: string; /** The plain-text part, for clients that show no HTML. */ text: string; } /** * Passed to {@link EmailKit}. The instance's style draws the emails that * aren't a user's own; a digest takes its reader's settings. */ export interface EmailLook { style: StyleName; scheme: ColorScheme; } /** * Names a color of an {@link EmailPalette}, as {@link EmailKit.paint} takes * it, so that the dark block can recolor the element by the same name. */ export type PaletteRole = Exclude; const ROLES: PaletteRole[] = [ "outer", "card", "text", "sec", "muted", "accent", "link", "onAccent", "rule", "soft", "quote", "date", "brand", "border", ]; /** * Escapes `value` for HTML text and attributes. Everything users wrote (names, * titles, quotes) goes through it, since none of it may become markup. */ export function esc(value: string): string { return value.replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ c ]!, ); } const gradient = (colors: string[]) => `linear-gradient(90deg,${colors.join(",")})`; /** * Draws the blocks of an email in one {@link EmailLook}. A template creates * one, builds its body with the block methods, and wraps it with * {@link EmailKit.document}. The blocks return HTML: text passed to them must * already be escaped with {@link esc}. */ export class EmailKit { /** The style being drawn. */ readonly style: EmailStyle; /** The palette inlined into the HTML. */ readonly palette: EmailPalette; /** The palette that replaces it in dark mode, on the "auto" scheme only. */ readonly darkPalette: EmailPalette | null; /** The font stack, for templates that set their own blocks. */ readonly font: string; constructor(look: EmailLook, readonly locale: EmailLocale) { this.style = EMAIL_STYLES[look.style]; this.palette = look.scheme === "dark" ? this.style.dark : this.style.light; this.darkPalette = look.scheme === "auto" ? this.style.dark : null; this.font = this.style.font; } /** Returns `px`, scaled for the style, as a CSS length. */ fs(px: number): string { return `${Math.round(px * this.style.scale * 2) / 2}px`; } /** * Returns the `class` and `style` attributes of an element colored by * roles: `fg` for its text, `bg` for its fill, `bd` for its border. The * classes let the dark block recolor it; `css` is the rest of its style. */ paint( roles: { fg?: PaletteRole; bg?: PaletteRole; bd?: PaletteRole; extra?: string[]; }, css = "", ): string { const p = this.palette; const classes: string[] = [...(roles.extra ?? [])]; let style = ""; if (roles.fg) { classes.push(`f-${roles.fg}`); style += `color:${p[roles.fg] ?? p.text};`; } if (roles.bg) { classes.push(`b-${roles.bg}`); style += `background-color:${p[roles.bg] ?? p.card};`; } if (roles.bd) { classes.push(`d-${roles.bd}`); style += `border-color:${p[roles.bd] ?? p.rule};`; } const cls = classes.length ? ` class="${classes.join(" ")}"` : ""; return `${cls} style="${style}${css}"`; } /** Returns a link to `href` around `html`, in the link color. */ link(html: string, href: string, strong = false): string { const s = this.style; return `${html}`; } /** Returns `@username`, set off as a name. */ name(username: string): string { return `@${esc(username)}`; } /** Returns `html` in the muted color, inline. */ muted(html: string): string { return `${html}`; } /** Returns the email's heading, in the style's title type. */ heading(html: string, marginBottom = 24): string { const t = this.style.title; return `${html}`; } /** Returns a paragraph. `css` adds to or overrides its style. */ para(html: string, css = ""): string { return `${html}

`; } /** Returns a smaller paragraph in the muted color. */ note(html: string, css = ""): string { return `${html}

`; } /** Returns a button linking to `href`. */ button(label: string, href: string): string { const s = this.style; const frame = s.frame === "hard" ? `border:2px solid ${this.palette.text};box-shadow:2px 2px 0 ${this.palette.text};` : ""; return `${label}
`; } /** Returns a small uppercase pill, as on one's own dump in a list. */ pill(label: string): string { return `${label}`; } /** Returns an image block, rounded as the style rounds images. */ image( src: string, alt: string, width: number, height: number, href?: string, ): string { const img = `${
      esc(alt)
    }`; return href ? `${img}` : img; } /** Returns a small bold heading inside a section. */ subhead(html: string): string { return `${html}`; } /** Returns `lines`, one per line, in the secondary color. */ lines(lines: string[]): string { return `${lines.join("
")}`; } /** Returns a block set off from what precedes it by a hairline. */ aside(html: string): string { return `${html}`; } /** * Returns a section of the card: a divider in the style's rule (none before * the first), an uppercase title in the accent color, and `html`. */ section(title: string, html: string, first = false): string { const s = this.style; let divider = ""; let border = ""; if (!first) { if (s.rule === "rainbow") { divider = `
 
`; } else { border = `border-top:${ s.rule === "thick" ? 2 : 1 }px solid;padding-top:26px;`; } } return `${divider}${title}${html}`; } /** * Returns the whole HTML document: the brand, the card with the optional * `eyebrow` and `title` over `body`, and the `footer`. `preheader` is the * hidden first line inboxes show beside the subject. */ document( { subject, preheader, eyebrow, title, body, footer }: { subject: string; preheader: string; eyebrow?: string; title?: string; body: string; footer: string; }, ): string { const s = this.style; const p = this.palette; const R = s.radius.card; const bar = s.topBar === "rainbow" ? RAINBOW : s.topBar === "spice" ? p.spice ?? null : null; const frame = s.frame === "hard" ? `border:2px solid ${p.text};box-shadow:5px 5px 0 ${p.text};` : s.frame === "ridge" ? `border:3px ridge ${p.border};` : s.frame === "hairline" ? `border:1px solid ${p.rule};` : s.frame === "soft" ? `box-shadow:0 14px 34px -10px ${p.shadow};` : ""; const frameRoles = s.frame === "hard" ? { bd: "text" as const, extra: ["sh-card"] } : s.frame === "ridge" ? { bd: "border" as const } : s.frame === "hairline" ? { bd: "rule" as const } : s.frame === "soft" ? { extra: ["sh-soft"] } : {}; const scheme = this.darkPalette ? "light dark" : p === s.dark ? "dark" : "light"; return ` ${esc(subject)} ${ s.webFont ? `` : "" }
${ esc(preheader) }
${ bar ? `` : "" } ${ eyebrow ? `${eyebrow}` : "" } ${title ? this.heading(title) : ""} ${body} ${footer}
${esc(OG_SITE_NAME)}
${ esc(SITE_EMOJI) }
 
`; } /** * Returns a footer: `reason` (why the reader gets this email), then * `links`, each `[label, href]`, joined on one line. */ footer(reason: string, links: [string, string][] = []): string { const linkHtml = links.map(([label, href]) => `${label}` ).join(" ยท "); return `${reason}${linkHtml ? `
${linkHtml}` : ""}`; } /** * Returns the CSS that recolors every painted element in dark mode, or * nothing off the "auto" scheme. */ private darkCss(): string { const d = this.darkPalette; if (!d) return ""; const rules: string[] = []; for (const role of ROLES) { const color = d[role]; if (!color) continue; rules.push( `.f-${role}{color:${color}!important}`, `.b-${role}{background-color:${color}!important}`, `.d-${role}{border-color:${color}!important}`, ); } rules.push( `.sh-card{box-shadow:5px 5px 0 ${d.text}!important}`, `.sh-btn{box-shadow:2px 2px 0 ${d.text}!important}`, ); if (d.shadow) { rules.push( `.sh-soft{box-shadow:0 14px 34px -10px ${d.shadow}!important}`, ); } if (this.style.topBar === "spice" && d.spice) { rules.push( `.bar{background-color:${d.spice[0]}!important;background-image:${ gradient(d.spice) }!important}`, ); } return `@media (prefers-color-scheme:dark){${rules.join("")}}`; } }