535 lines
16 KiB
TypeScript
535 lines
16 KiB
TypeScript
/**
|
|
* 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<keyof EmailPalette, "spice">;
|
|
|
|
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 `<a href="${esc(href)}"${
|
|
this.paint(
|
|
{ fg: "link" },
|
|
`text-decoration:${s.linkUnderline ? "underline" : "none"};${
|
|
strong ? `font-weight:${s.heavy};` : ""
|
|
}`,
|
|
)
|
|
}>${html}</a>`;
|
|
}
|
|
|
|
/** Returns `@username`, set off as a name. */
|
|
name(username: string): string {
|
|
return `<b${
|
|
this.paint(
|
|
{ fg: "text" },
|
|
`font-weight:${Math.max(600, this.style.heavy)};`,
|
|
)
|
|
}>@${esc(username)}</b>`;
|
|
}
|
|
|
|
/** Returns `html` in the muted color, inline. */
|
|
muted(html: string): string {
|
|
return `<span${this.paint({ fg: "muted" })}>${html}</span>`;
|
|
}
|
|
|
|
/** Returns the email's heading, in the style's title type. */
|
|
heading(html: string, marginBottom = 24): string {
|
|
const t = this.style.title;
|
|
return `<h1${
|
|
this.paint(
|
|
{ fg: t.accent ? "accent" : "text" },
|
|
`font-family:${this.font};font-size:${
|
|
this.fs(t.size)
|
|
};line-height:1.15;font-weight:${t.weight};${
|
|
t.upper ? "text-transform:uppercase;" : ""
|
|
}${
|
|
t.spacing ? `letter-spacing:${t.spacing};` : ""
|
|
}margin:0 0 ${marginBottom}px;`,
|
|
)
|
|
}>${html}</h1>`;
|
|
}
|
|
|
|
/** Returns a paragraph. `css` adds to or overrides its style. */
|
|
para(html: string, css = ""): string {
|
|
return `<p${
|
|
this.paint(
|
|
{ fg: "text" },
|
|
`font-family:${this.font};font-size:${
|
|
this.fs(15)
|
|
};line-height:1.6;margin:0 0 16px;${css}`,
|
|
)
|
|
}>${html}</p>`;
|
|
}
|
|
|
|
/** Returns a smaller paragraph in the muted color. */
|
|
note(html: string, css = ""): string {
|
|
return `<p${
|
|
this.paint(
|
|
{ fg: "muted" },
|
|
`font-family:${this.font};font-size:${
|
|
this.fs(13.5)
|
|
};line-height:1.6;margin:0 0 16px;${css}`,
|
|
)
|
|
}>${html}</p>`;
|
|
}
|
|
|
|
/** 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 `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:6px 0 22px;"><tr><td${
|
|
this.paint(
|
|
{
|
|
bg: "accent",
|
|
bd: s.frame === "hard" ? "text" : undefined,
|
|
extra: s.frame === "hard" ? ["sh-btn"] : [],
|
|
},
|
|
`border-radius:${s.radius.btn}px;${frame}`,
|
|
)
|
|
}><a href="${esc(href)}"${
|
|
this.paint(
|
|
{ fg: "onAccent" },
|
|
`display:inline-block;padding:13px 22px;font-family:${this.font};font-size:${
|
|
this.fs(13.5)
|
|
};font-weight:${
|
|
Math.max(600, s.heavy)
|
|
};letter-spacing:0.08em;text-transform:uppercase;line-height:1;text-decoration:none;`,
|
|
)
|
|
}>${label}</a></td></tr></table>`;
|
|
}
|
|
|
|
/** Returns a small uppercase pill, as on one's own dump in a list. */
|
|
pill(label: string): string {
|
|
return `<span${
|
|
this.paint(
|
|
{ fg: "sec", bg: "soft" },
|
|
`display:inline-block;margin-left:6px;padding:1px 7px;border-radius:${this.style.radius.pill}px;font-size:${
|
|
this.fs(11)
|
|
};font-weight:600;letter-spacing:0.08em;text-transform:uppercase;vertical-align:2px;`,
|
|
)
|
|
}>${label}</span>`;
|
|
}
|
|
|
|
/** Returns an image block, rounded as the style rounds images. */
|
|
image(
|
|
src: string,
|
|
alt: string,
|
|
width: number,
|
|
height: number,
|
|
href?: string,
|
|
): string {
|
|
const img = `<img src="${
|
|
esc(src)
|
|
}" width="${width}" height="${height}" alt="${
|
|
esc(alt)
|
|
}" style="display:block;max-width:100%;height:auto;border:0;border-radius:${
|
|
Math.min(this.style.radius.img, width / 8)
|
|
}px;">`;
|
|
return href ? `<a href="${esc(href)}">${img}</a>` : img;
|
|
}
|
|
|
|
/** Returns a small bold heading inside a section. */
|
|
subhead(html: string): string {
|
|
return `<div${
|
|
this.paint(
|
|
{ fg: "text" },
|
|
`font-family:${this.font};font-size:${this.fs(14)};font-weight:${
|
|
Math.max(600, this.style.heavy)
|
|
};margin:0 0 8px;`,
|
|
)
|
|
}>${html}</div>`;
|
|
}
|
|
|
|
/** Returns `lines`, one per line, in the secondary color. */
|
|
lines(lines: string[]): string {
|
|
return `<div${
|
|
this.paint(
|
|
{ fg: "sec" },
|
|
`font-family:${this.font};font-size:${
|
|
this.fs(14)
|
|
};line-height:1.7;margin:0 0 18px;`,
|
|
)
|
|
}>${lines.join("<br>")}</div>`;
|
|
}
|
|
|
|
/** Returns a block set off from what precedes it by a hairline. */
|
|
aside(html: string): string {
|
|
return `<div${
|
|
this.paint(
|
|
{ fg: "muted", bd: "rule" },
|
|
`border-top:1px solid;font-family:${this.font};font-size:${
|
|
this.fs(13.5)
|
|
};line-height:1.6;padding:16px 0 14px;`,
|
|
)
|
|
}>${html}</div>`;
|
|
}
|
|
|
|
/**
|
|
* 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 =
|
|
`<div style="height:3px;line-height:3px;font-size:0;background-color:${
|
|
RAINBOW[0]
|
|
};background-image:${
|
|
gradient(RAINBOW)
|
|
};margin:0 0 26px;"> </div>`;
|
|
} else {
|
|
border = `border-top:${
|
|
s.rule === "thick" ? 2 : 1
|
|
}px solid;padding-top:26px;`;
|
|
}
|
|
}
|
|
return `<div${
|
|
this.paint(
|
|
{ bd: border ? (s.rule === "thick" ? "text" : "rule") : undefined },
|
|
`${border}padding-bottom:26px;`,
|
|
)
|
|
}>${divider}<div${
|
|
this.paint(
|
|
{ fg: "accent" },
|
|
`font-family:${this.font};font-size:${this.fs(12)};font-weight:${
|
|
Math.max(600, s.heavy)
|
|
};letter-spacing:0.12em;text-transform:uppercase;margin:0 0 16px;`,
|
|
)
|
|
}>${title}</div>${html}</div>`;
|
|
}
|
|
|
|
/**
|
|
* 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 `<!doctype html>
|
|
<html lang="${this.locale}">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<meta name="color-scheme" content="${scheme}">
|
|
<meta name="supported-color-schemes" content="${scheme}">
|
|
<title>${esc(subject)}</title>
|
|
${
|
|
s.webFont
|
|
? `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${s.webFont}&display=swap">`
|
|
: ""
|
|
}
|
|
<style>
|
|
body{margin:0;padding:0;}
|
|
@media (max-width:620px){.wrap{padding:20px 10px 28px!important}.card{padding:24px 20px 8px!important}}
|
|
${this.darkCss()}
|
|
</style>
|
|
</head>
|
|
<body${this.paint({ bg: "outer" }, "margin:0;padding:0;")}>
|
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">${
|
|
esc(preheader)
|
|
}</div>
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"${
|
|
this.paint({ bg: "outer" })
|
|
}>
|
|
<tr><td align="center" class="wrap" style="padding:32px 16px 40px;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;">
|
|
<tr><td style="padding:0 6px 16px;">
|
|
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
|
|
<td style="padding-right:10px;vertical-align:middle;font-size:24px;line-height:1;">${
|
|
esc(SITE_EMOJI)
|
|
}</td>
|
|
<td${
|
|
this.paint(
|
|
{ fg: p.brand ? "brand" : "text" },
|
|
`vertical-align:middle;font-family:${this.font};font-weight:700;font-size:${
|
|
this.fs(21)
|
|
};line-height:1;letter-spacing:-0.01em;`,
|
|
)
|
|
}>${esc(OG_SITE_NAME)}</td>
|
|
</tr></table>
|
|
</td></tr>
|
|
${
|
|
bar
|
|
? `<tr><td class="bar" style="height:5px;line-height:5px;font-size:0;background-color:${
|
|
bar[0]
|
|
};background-image:${
|
|
gradient(bar)
|
|
};border-radius:${R}px ${R}px 0 0;"> </td></tr>`
|
|
: ""
|
|
}
|
|
<tr><td${
|
|
this.paint(
|
|
{
|
|
bg: "card",
|
|
...frameRoles,
|
|
extra: ["card", ...(frameRoles.extra ?? [])],
|
|
},
|
|
`border-radius:${
|
|
bar ? `0 0 ${R}px ${R}px` : `${R}px`
|
|
};${frame}padding:34px 36px 12px;`,
|
|
)
|
|
}>
|
|
${
|
|
eyebrow
|
|
? `<div${
|
|
this.paint(
|
|
{ fg: "muted" },
|
|
`font-family:${this.font};font-size:${
|
|
this.fs(12)
|
|
};font-weight:600;letter-spacing:0.12em;text-transform:uppercase;margin:0 0 8px;`,
|
|
)
|
|
}>${eyebrow}</div>`
|
|
: ""
|
|
}
|
|
${title ? this.heading(title) : ""}
|
|
${body}
|
|
</td></tr>
|
|
<tr><td${
|
|
this.paint(
|
|
{ fg: "muted" },
|
|
`padding:22px 6px 0;font-family:${this.font};font-size:${
|
|
this.fs(12.5)
|
|
};line-height:1.6;`,
|
|
)
|
|
}>${footer}</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
/**
|
|
* 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]) =>
|
|
`<a href="${esc(href)}"${
|
|
this.paint({ fg: "muted" }, "text-decoration:underline;")
|
|
}>${label}</a>`
|
|
).join(" · ");
|
|
return `${reason}${linkHtml ? `<br>${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("")}}`;
|
|
}
|
|
}
|