From f20ca83f0c1630050072654b44a841be947bb6e0 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 4 Oct 2026 21:11:54 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20every=20email=20is=20rendered=20by=20its?= =?UTF-8?q?=20template=20in=20api/mail/=20on=20one=20layout=20that=20takes?= =?UTF-8?q?=20the=20instance's=20style,=20and=20sent=20one=20way=20?= =?UTF-8?q?=E2=80=94=20the=20welcome=20email=20was=20built=20and=20sent=20?= =?UTF-8?q?inside=20the=20register=20route=20and=20the=20reset=20link=20in?= =?UTF-8?q?=20its=20service,=20each=20writing=20Markdown,=20running=20mark?= =?UTF-8?q?ed,=20and=20calling=20a=20nodemailer=20wrapper=20whose=20cc,=20?= =?UTF-8?q?bcc,=20replyTo=20and=20send=20result=20nothing=20used.=20Now=20?= =?UTF-8?q?api/mail/=20holds=20one=20template=20per=20email=20(welcome.ts,?= =?UTF-8?q?=20password-reset.ts)=20on=20layout.ts's=20EmailKit,=20which=20?= =?UTF-8?q?draws=20each=20block=20with=20inline=20styles=20in=20a=20style?= =?UTF-8?q?=20from=20styles.ts:=20the=20six=20site=20styles'=20palettes,?= =?UTF-8?q?=20copied=20from=20src/themes/,=20and=20what=20makes=20each=20r?= =?UTF-8?q?ecognisable=20in=20a=20mail=20client=20(Brutalist's=20hard=20bo?= =?UTF-8?q?rder=20and=20shadow,=20GeoCities'=20ridge=20and=20rainbow=20rul?= =?UTF-8?q?es,=20Times'=20underlined=20ink=20links,=20Kanagawa's=20colored?= =?UTF-8?q?=20quotes,=20dates=20and=20wordmark,=20Melange's=20spice=20bar)?= =?UTF-8?q?.=20On=20the=20"auto"=20scheme=20the=20light=20palette=20is=20i?= =?UTF-8?q?nlined=20and=20the=20dark=20one=20swapped=20in=20by=20a=20prefe?= =?UTF-8?q?rs-color-scheme=20block;=20a=20chosen=20scheme=20sends=20only?= =?UTF-8?q?=20its=20own.=20User-written=20text=20is=20escaped=20where=20it?= =?UTF-8?q?=20lands.=20strings.ts=20holds=20every=20email's=20words=20in?= =?UTF-8?q?=20English=20and=20French.=20GERBEUR=5FEMAIL=5FLOCALE=20picks?= =?UTF-8?q?=20the=20language=20and=20GERBEUR=5FDEFAULT=5FSTYLE=20the=20ins?= =?UTF-8?q?tance's=20style,=20which=20the=20welcome=20and=20reset=20emails?= =?UTF-8?q?=20are=20drawn=20in;=20GERBEUR=5FWELCOME=5FEMAIL=5FBODY=20still?= =?UTF-8?q?=20replaces=20the=20welcome's=20Markdown,=20whose=20blocks=20no?= =?UTF-8?q?w=20take=20the=20style=20too.=20email-service=20sends=20a=20ren?= =?UTF-8?q?dered=20email=20with=20its=20recipient=20and=20headers,=20refus?= =?UTF-8?q?es=20to=20load=20when=20GERBEUR=5FSMTPS=5FURL=20is=20set=20with?= =?UTF-8?q?out=20GERBEUR=5FFROM=5FEMAIL=20(sends=20used=20to=20fail=20one?= =?UTF-8?q?=20by=20one),=20and=20takes=20a=20test=20transport,=20through?= =?UTF-8?q?=20which=20the=20tests=20now=20read=20what=20was=20sent.=20The?= =?UTF-8?q?=20welcome=20send=20moves=20to=20user-service's=20sendWelcomeEm?= =?UTF-8?q?ail.=20The=20style=20and=20scheme=20names=20move=20to=20shared/?= =?UTF-8?q?theme.ts,=20which=20the=20server=20now=20needs.=20README,=20mod?= =?UTF-8?q?.ts=20and=20.env.example=20describe=20mail/=20and=20the=20new?= =?UTF-8?q?=20variables,=20and=20drop=20the=20"notifications,=20invites"?= =?UTF-8?q?=20email=20that=20never=20existed.=20221=20backend=20tests=20pa?= =?UTF-8?q?ss=20(new:=20the=20layout's=20schemes,=20styles=20and=20escapin?= =?UTF-8?q?g,=20both=20templates,=20the=20welcome=20on=20sign-up,=20a=20re?= =?UTF-8?q?set=20link=20from=20the=20email=20that=20works),=20and=20deno?= =?UTF-8?q?=20check,=20deno=20lint,=20doc:lint,=20deno=20fmt,=20tsc=20and?= =?UTF-8?q?=20eslint=20are=20clean.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .env.example | 27 +- README.md | 12 +- api/config.ts | 49 +- api/mail/layout.ts | 534 ++++++++++++++++++ api/mail/password-reset.ts | 67 +++ api/mail/strings.ts | 108 ++++ api/mail/styles.ts | 334 +++++++++++ api/mail/welcome.ts | 78 +++ api/routes/users.ts | 18 +- api/services/email-service.ts | 153 ++--- api/services/password-reset-service.ts | 12 +- api/services/user-service.ts | 13 + api/test/helpers.ts | 27 + api/test/mail/layout.test.ts | 79 +++ api/test/mail/templates.test.ts | 25 + api/test/routes/users.test.ts | 26 + mod.ts | 2 + shared/theme.ts | 43 ++ src/components/profile/ProfileSettingsTab.tsx | 5 +- src/contexts/theme/ThemeContext.ts | 22 +- src/contexts/theme/ThemeProvider.tsx | 4 +- 21 files changed, 1459 insertions(+), 179 deletions(-) create mode 100644 api/mail/layout.ts create mode 100644 api/mail/password-reset.ts create mode 100644 api/mail/strings.ts create mode 100644 api/mail/styles.ts create mode 100644 api/mail/welcome.ts create mode 100644 api/test/mail/layout.test.ts create mode 100644 api/test/mail/templates.test.ts create mode 100644 shared/theme.ts diff --git a/.env.example b/.env.example index dc482967..2bf403ce 100644 --- a/.env.example +++ b/.env.example @@ -48,19 +48,30 @@ GERBEUR_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # Secret key used to sign JWTs. Generate with: openssl rand -hex 32 GERBEUR_JWT_SECRET= -# SMTP connection URL used by the email service (notifications, invites, etc.) +# SMTP connection URL for outgoing email: the welcome email, password reset +# links and the weekly/monthly digests. Leave empty to send no email at all. # Example: smtps://username:password@smtp.example.com:465 GERBEUR_SMTPS_URL= -# Sender address used in outgoing emails (e.g. no-reply@example.com) -# Required when GERBEUR_SMTPS_URL is set. +# Sender of every email, as an address or "Name
" +# (e.g. gerbeur ). +# Required when GERBEUR_SMTPS_URL is set: the server refuses to start without it. GERBEUR_FROM_EMAIL= -# Markdown body for the account creation welcome email. -# Supports {{username}} and {{site_name}} placeholders. -# Defaults to a built-in template when unset. -# Use \n for line breaks in single-line .env values, or use a quoted multiline block. -GERBEUR_WELCOME_EMAIL_BODY="# Welcome to {{site_name}}!\n\nHi **{{username}}**,\n\nYour account has been created successfully. Welcome aboard!" +# Language of every email: en (default) or fr. The interface follows each +# visitor's browser, which the server never sees, so emails use the instance's. +GERBEUR_EMAIL_LOCALE=en + +# The instance's style: smooth (default), brutalist, geocities, nyt, kanagawa +# or melange. The welcome and password reset emails are drawn in it; a digest +# uses its reader's own style. +GERBEUR_DEFAULT_STYLE=smooth + +# Markdown body of the welcome email sent on account creation, replacing the +# built-in one (written in GERBEUR_EMAIL_LOCALE). Supports {{username}} and +# {{site_name}}. Use \n for line breaks in single-line .env values, or a +# quoted multiline block. +# GERBEUR_WELCOME_EMAIL_BODY="# Welcome to {{site_name}}!\n\nHi **{{username}}**,\n\nYour account has been created successfully. Welcome aboard!" # ── Frontend (Vite) ─────────────────────────────────────────────────────────── # These must be prefixed with VITE_ to be exposed to the client bundle. diff --git a/README.md b/README.md index ea656956..ad66db39 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,10 @@ variables: | `GERBEUR_FRONTEND_URL` | Frontend base URL for email links and CORS; auto-added to allowed origins — the only variable needed when the frontend runs on a separate host | `GERBEUR_PUBLIC_URL` | | `GERBEUR_ALLOWED_ORIGINS` | Comma-separated extra origins for CORS/WebSocket; `PUBLIC_URL` and `FRONTEND_URL` are always included — typically only needed in dev for the Vite server | `""` (empty) | | `GERBEUR_SITE_NAME` | Site name used in the browser tab, app header, OG meta tags, and emails | `gerbeur` | -| `GERBEUR_SMTPS_URL` | SMTPS connection URL for outgoing email (`smtps://user:pass@host:465`) | unset | -| `GERBEUR_FROM_EMAIL` | Sender address for outgoing emails — required when `GERBEUR_SMTPS_URL` is set | unset | +| `GERBEUR_SMTPS_URL` | SMTPS connection URL for outgoing email (`smtps://user:pass@host:465`); unset sends no email | unset | +| `GERBEUR_FROM_EMAIL` | Sender of every email — required when `GERBEUR_SMTPS_URL` is set, or the server won't start | unset | +| `GERBEUR_EMAIL_LOCALE` | Language of every email: `en` or `fr` | `en` | +| `GERBEUR_DEFAULT_STYLE` | The instance's style (`smooth`, `brutalist`, `geocities`, `nyt`, `kanagawa`, `melange`), used by the welcome and reset emails | `smooth` | | `GERBEUR_WELCOME_EMAIL_BODY` | Markdown body for the account-creation welcome email; supports `{{username}}` and `{{site_name}}` | built-in template | | `VITE_API_PROTOCOL` | API protocol baked into the frontend bundle (see [Production](#production)) | `http` | | `VITE_API_HOSTNAME` | API hostname baked into the frontend bundle | `localhost` | @@ -130,6 +132,7 @@ api/ routes/ # One router per URL prefix: HTTP + WebSocket, no SQL services/ # Business logic and every SQL query remote/ # Everything that fetches from other sites + mail/ # One template per email, and the layout they share providers/ # Link metadata providers (YouTube, SoundCloud, Bandcamp, …) model/ # Per table: row type, row guard, row ↔ API mappers validators/ # Per domain: request body guards @@ -169,8 +172,9 @@ The backend is split by layer, one kind of module per folder, and each file is named after its domain: the dumps are `routes/dumps.ts`, `services/dump-service.ts`, `model/dump.ts` and `validators/dump.ts`. Routes parse and answer, services hold the logic and the SQL, `remote/` is the only -place that fetches from other sites. Tests mirror the source one file per -module, so the tests of `services/chat-service.ts` are in +place that fetches from other sites, and `mail/` renders each email that +`services/email-service.ts` sends. Tests mirror the source one file per module, +so the tests of `services/chat-service.ts` are in `test/services/chat-service.test.ts`. ### Comments diff --git a/api/config.ts b/api/config.ts index f3464a6f..8fa87cd7 100644 --- a/api/config.ts +++ b/api/config.ts @@ -7,33 +7,50 @@ * @module */ +import { isStyleName, type StyleName } from "../shared/theme.ts"; + /** The port the server listens on (`GERBEUR_PORT`, default 8000). */ export const PORT: number = Number(Deno.env.get("GERBEUR_PORT")) || 8000; /** - * The `smtps://` URL of the server email-service sends mail through - * (`GERBEUR_SMTPS_URL`). When it is empty, the welcome and password reset - * emails are silently skipped. + * The `smtps://` URL of the server `email-service.ts` sends mail through + * (`GERBEUR_SMTPS_URL`). When it is empty, no email is sent: sign-up sends no + * welcome, a password reset request does nothing, and there are no digests. */ export const SMTPS_URL: string = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || ""; -/** The `From` address of the welcome and password reset emails. */ +/** + * The `From` address of every email (`GERBEUR_FROM_EMAIL`), as an address or + * as `Name
`. `email-service.ts` refuses to load without it when + * {@link SMTPS_URL} is set. + */ export const FROM_EMAIL: string = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() || ""; - -const DEFAULT_WELCOME_EMAIL_BODY = `# Welcome to {{site_name}}! - -Hi **{{username}}**, - -Your account has been created successfully. Welcome aboard!`; - /** * The Markdown of the welcome email sent on registration - * (`GERBEUR_WELCOME_EMAIL_BODY`). The users route replaces `{{site_name}}` and - * `{{username}}` in it and sends it as both text and HTML. + * (`GERBEUR_WELCOME_EMAIL_BODY`), or null for the built-in one in the + * {@link EMAIL_LOCALE}. `api/mail/welcome.ts` replaces `{{site_name}}` and + * `{{username}}` in it. */ -export const WELCOME_EMAIL_BODY: string = - Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() || - DEFAULT_WELCOME_EMAIL_BODY; +export const WELCOME_EMAIL_BODY: string | null = + Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() || null; +/** A language the emails are written in. */ +export type EmailLocale = "en" | "fr"; +/** + * The language of every email (`GERBEUR_EMAIL_LOCALE`). The interface picks + * each visitor's language in their browser, which the server never learns, so + * the emails take the instance's. Any value but `"fr"` means `"en"`. + */ +export const EMAIL_LOCALE: EmailLocale = + Deno.env.get("GERBEUR_EMAIL_LOCALE")?.trim() === "fr" ? "fr" : "en"; +/** + * The instance's style (`GERBEUR_DEFAULT_STYLE`). The emails that aren't a + * user's own (the welcome and the password reset) are drawn in it. A value + * that isn't a style means "smooth". + */ +export const DEFAULT_STYLE: StyleName = (() => { + const style = Deno.env.get("GERBEUR_DEFAULT_STYLE")?.trim(); + return isStyleName(style) ? style : "smooth"; +})(); /** * The key lib/jwt.ts signs and verifies tokens with (`GERBEUR_JWT_SECRET`). * The server refuses to start without it. diff --git a/api/mail/layout.ts b/api/mail/layout.ts new file mode 100644 index 00000000..c7806383 --- /dev/null +++ b/api/mail/layout.ts @@ -0,0 +1,534 @@ +/** + * 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("")}}`; + } +} diff --git a/api/mail/password-reset.ts b/api/mail/password-reset.ts new file mode 100644 index 00000000..e9278a5a --- /dev/null +++ b/api/mail/password-reset.ts @@ -0,0 +1,67 @@ +/** + * Renders the email carrying a password reset link. `requestPasswordReset` + * (`password-reset-service.ts`) sends it, in the instance's style and + * language: whoever asks isn't signed in, so their own settings don't apply. + * + * @module + */ + +import { DEFAULT_STYLE, EMAIL_LOCALE, OG_SITE_NAME } from "../config.ts"; +import { EmailKit, esc, type RenderedEmail } from "./layout.ts"; +import { STRINGS } from "./strings.ts"; + +/** + * Returns the reset email of `username`, whose button opens `resetUrl`. The + * link stays usable for `hours`, which the email states. + */ +export function renderPasswordResetEmail( + username: string, + resetUrl: string, + hours: number, +): RenderedEmail { + const t = STRINGS[EMAIL_LOCALE].reset; + const kit = new EmailKit( + { style: DEFAULT_STYLE, scheme: "auto" }, + EMAIL_LOCALE, + ); + const subject = t.subject(OG_SITE_NAME); + const urlBox = `${esc(resetUrl)}`; + const body = kit.para(esc(t.hi(username))) + + kit.para(esc(t.body(OG_SITE_NAME, hours))) + + kit.button(esc(t.button), resetUrl) + + kit.note(esc(t.fallback), "margin-bottom:8px;") + + urlBox + + kit.note(esc(t.ignore)); + + return { + subject, + html: kit.document({ + subject, + preheader: t.preheader(hours), + title: esc(t.title), + body, + footer: kit.footer(esc(t.reason)), + }), + text: [ + t.title, + "", + t.hi(username), + "", + t.body(OG_SITE_NAME, hours), + "", + resetUrl, + "", + t.ignore, + "", + "--", + t.reason, + ].join("\n"), + }; +} diff --git a/api/mail/strings.ts b/api/mail/strings.ts new file mode 100644 index 00000000..263479d9 --- /dev/null +++ b/api/mail/strings.ts @@ -0,0 +1,108 @@ +/** + * The words of every email, in each language an instance can pick with + * `GERBEUR_EMAIL_LOCALE`. The templates beside this module read + * `STRINGS[EMAIL_LOCALE]`. French follows the interface's own words (a dump is + * a "reco", a playlist a "collection") and puts a no-break space before + * `: ! ?`. + * + * @module + */ + +import type { EmailLocale } from "../config.ts"; + +/** A no-break space, which French typography puts before `: ! ?`. */ +const NB = " "; + +/** + * Read by the templates for one language. A function takes what varies, the + * site's name first; none escapes what it is given. + */ +export interface MailStrings { + /** The footer's link to the settings. */ + settings: string; + welcome: { + subject: (site: string) => string; + preheader: string; + /** The Markdown body, unless `GERBEUR_WELCOME_EMAIL_BODY` replaces it. */ + body: string; + button: (site: string) => string; + reason: (site: string) => string; + }; + reset: { + subject: (site: string) => string; + preheader: (hours: number) => string; + title: string; + hi: (username: string) => string; + body: (site: string, hours: number) => string; + button: string; + fallback: string; + ignore: string; + reason: string; + }; +} + +/** Every email's words, by language. */ +export const STRINGS: Record = { + en: { + settings: "Settings", + welcome: { + subject: (site) => `Welcome to ${site}`, + preheader: "Your account is ready.", + body: + "# Welcome to {{site_name}}!\n\nHi **{{username}}**,\n\nYour account has been created successfully. Welcome aboard!", + button: (site) => `Open ${site}`, + reason: (site) => + `You're getting this email because an account was created on ${site} with this address.`, + }, + reset: { + subject: (site) => `Reset your ${site} password`, + preheader: (hours) => + `The link works once, for ${hours} ${hours === 1 ? "hour" : "hours"}.`, + title: "Reset your password", + hi: (username) => `Hi ${username},`, + body: (site, hours) => + `Someone asked to reset the password of your ${site} account. The button below lets you choose a new one. It works once, for ${hours} ${ + hours === 1 ? "hour" : "hours" + }.`, + button: "Reset password", + fallback: + "If the button doesn't work, paste this link into your browser:", + ignore: + "If you didn't ask for this, ignore this email. Your password stays the same.", + reason: + "You're getting this email because a password reset was requested for this address.", + }, + }, + fr: { + settings: "Paramètres", + welcome: { + subject: (site) => `Bienvenue sur ${site}`, + preheader: "Votre compte est prêt.", + body: + `# Bienvenue sur {{site_name}}${NB}!\n\nBonjour **{{username}}**,\n\nVotre compte a bien été créé. Bienvenue à bord${NB}!`, + button: (site) => `Ouvrir ${site}`, + reason: (site) => + `Vous recevez cet e-mail car un compte ${site} a été créé avec cette adresse.`, + }, + reset: { + subject: (site) => `Réinitialisez votre mot de passe ${site}`, + preheader: (hours) => + `Le lien ne fonctionne qu'une fois, pendant ${hours}${NB}${ + hours < 2 ? "heure" : "heures" + }.`, + title: "Réinitialiser votre mot de passe", + hi: (username) => `Bonjour ${username},`, + body: (site, hours) => + `Quelqu'un a demandé à réinitialiser le mot de passe de votre compte ${site}. Le bouton ci-dessous vous permet d'en choisir un nouveau. Il ne fonctionne qu'une fois, pendant ${hours}${NB}${ + hours < 2 ? "heure" : "heures" + }.`, + button: "Choisir un nouveau mot de passe", + fallback: + `Si le bouton ne fonctionne pas, collez ce lien dans votre navigateur${NB}:`, + ignore: + "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. Votre mot de passe ne change pas.", + reason: + "Vous recevez cet e-mail car une réinitialisation de mot de passe a été demandée pour cette adresse.", + }, + }, +}; diff --git a/api/mail/styles.ts b/api/mail/styles.ts new file mode 100644 index 00000000..f5770f80 --- /dev/null +++ b/api/mail/styles.ts @@ -0,0 +1,334 @@ +/** + * Carries each site style into email, so a digest looks like the site its + * reader chose and the other emails like the instance's style. A mail client + * takes no stylesheet of ours, so each style is spelled out here: its palettes, + * copied from its theme under `src/themes/`, and the few touches that make it + * recognisable once flattened into inline styles. `layout.ts` reads them. + * + * @module + */ + +import type { StyleName } from "../../shared/theme.ts"; + +/** + * The colors of one style in one scheme, by role. Most are the theme's own + * tokens; the optional ones carry a style's particular touches. + */ +export interface EmailPalette { + /** The page around the card (`--color-bg`). */ + outer: string; + /** The card holding the content (`--color-surface`). */ + card: string; + text: string; + sec: string; + muted: string; + /** Section titles, ranks and button fills (`--color-accent`). */ + accent: string; + link: string; + /** Text on an accent fill. */ + onAccent: string; + /** Dividers and hairlines. */ + rule: string; + /** Pills and the reset link's box. */ + soft: string; + /** Quoted comments and messages. */ + quote: string; + /** Kanagawa's fujiGray dates; others use `muted`. */ + date?: string; + /** Kanagawa's oniViolet wordmark; others use `text`. */ + brand?: string; + /** GeoCities' ridged border. */ + border?: string; + /** Melange's warm card shadow. */ + shadow?: string; + /** Melange's spice gradient, left to right. */ + spice?: string[]; +} + +/** + * How a style draws an email. `layout.ts` builds every block from it, so + * adding a style to `shared/theme.ts` means adding its entry here. + */ +export interface EmailStyle { + /** + * The font stack. Its first family is a web font only Apple's mail apps + * load, through {@link webFont}; other clients fall back along the stack. + */ + font: string; + /** The Google Fonts `family` parameter of the web font, if it is one. */ + webFont?: string; + /** Multiplies every font size: Times sets its serif larger, Kanagawa its monospace smaller. */ + scale: number; + /** Corner radii, in pixels. */ + radius: { + card: number; + img: number; + btn: number; + pill: number; + avatar: number; + }; + /** The heading: its size in pixels, weight, and Brutalist's uppercase and spacing. */ + title: { + size: number; + weight: number; + upper?: boolean; + spacing?: string; + accent?: boolean; + }; + /** The weight of titles of items, figures and names. */ + heavy: number; + /** How the card stands out from the page. */ + frame: "none" | "hard" | "ridge" | "hairline" | "soft"; + /** The divider between sections. */ + rule: "hairline" | "thick" | "rainbow"; + /** A colored bar along the card's top. */ + topBar?: "rainbow" | "spice"; + /** Underlines links, for styles whose links are the color of the text. */ + linkUnderline?: boolean; + light: EmailPalette; + dark: EmailPalette; +} + +/** GeoCities' rainbow, as its theme draws its dividers. */ +export const RAINBOW = [ + "#ff004d", + "#ff8a00", + "#ffee00", + "#00d11a", + "#00b3ff", + "#b300ff", +]; + +/** Every style's email look, keyed by its name. */ +export const EMAIL_STYLES: Record = { + smooth: { + font: "Saira, 'Helvetica Neue', Helvetica, Arial, sans-serif", + webFont: "Saira:wght@400;600;700", + scale: 1, + radius: { card: 14, img: 6, btn: 8, pill: 999, avatar: 16 }, + title: { size: 30, weight: 600 }, + heavy: 600, + frame: "none", + rule: "hairline", + light: { + outer: "#faf5e9", + card: "#fffdf8", + text: "#0e3a42", + sec: "#35626b", + muted: "#4d7178", + accent: "#b54a41", + link: "#b54a41", + onAccent: "#fffaf0", + rule: "#ebe4d4", + soft: "#f3ecdd", + quote: "#5c7d83", + }, + dark: { + outer: "#01152a", + card: "#0a2139", + text: "#e9eef2", + sec: "#a3b9c7", + muted: "#7b93a6", + accent: "#f4d58d", + link: "#f4d58d", + onAccent: "#001427", + rule: "#1b3350", + soft: "#112a46", + quote: "#a3b9c7", + }, + }, + brutalist: { + font: "'Space Grotesk', Arial, Helvetica, sans-serif", + webFont: "Space+Grotesk:wght@400;700", + scale: 1, + radius: { card: 0, img: 0, btn: 0, pill: 0, avatar: 0 }, + title: { size: 30, weight: 900, upper: true, spacing: "0.03em" }, + heavy: 700, + frame: "hard", + rule: "thick", + light: { + outer: "#f4f1de", + card: "#f8f5e8", + text: "#3d405b", + sec: "#5a5e80", + muted: "#7a7e9a", + accent: "#e07a5f", + link: "#b85a3a", + onAccent: "#f4f1de", + rule: "#8a8da8", + soft: "#ede9d4", + quote: "#5a5e80", + }, + dark: { + outer: "#252838", + card: "#2e3145", + text: "#f4f1de", + sec: "#c8c5b2", + muted: "#8e8c80", + accent: "#f2cc8f", + link: "#f2cc8f", + onAccent: "#3d405b", + rule: "#4a4e6a", + soft: "#222436", + quote: "#c8c5b2", + }, + }, + geocities: { + font: + "'Comic Sans MS', 'Comic Sans', 'Chalkboard SE', 'Comic Neue', cursive", + scale: 1, + radius: { card: 0, img: 0, btn: 0, pill: 0, avatar: 0 }, + title: { size: 30, weight: 700, accent: true }, + heavy: 700, + frame: "ridge", + rule: "rainbow", + topBar: "rainbow", + linkUnderline: true, + light: { + outer: "#fff0f8", + card: "#ffffff", + text: "#2a1a4e", + sec: "#aa00aa", + muted: "#7a6a9a", + accent: "#d000d0", + link: "#0000ee", + onAccent: "#ffffff", + rule: "#f0c0f0", + soft: "#fff0fb", + quote: "#aa00aa", + border: "#d000d0", + }, + dark: { + outer: "#0a0a2e", + card: "#1b1b46", + text: "#f2f0ff", + sec: "#7fdbff", + muted: "#a99ad6", + accent: "#ff00ff", + link: "#00ffff", + onAccent: "#000000", + rule: "#34346e", + soft: "#14143a", + quote: "#7fdbff", + border: "#00ffff", + }, + }, + nyt: { + font: "'Instrument Serif', Georgia, 'Times New Roman', serif", + webFont: "Instrument+Serif:ital@0;1", + scale: 1.12, + radius: { card: 0, img: 0, btn: 0, pill: 0, avatar: 16 }, + title: { size: 38, weight: 400 }, + heavy: 400, + frame: "hairline", + rule: "hairline", + linkUnderline: true, + light: { + outer: "#ffffff", + card: "#ffffff", + text: "#121212", + sec: "#363636", + muted: "#767676", + accent: "#121212", + link: "#121212", + onAccent: "#ffffff", + rule: "#e2e2e2", + soft: "#f3f3f3", + quote: "#5a5a5a", + }, + dark: { + outer: "#121212", + card: "#1e1e1e", + text: "#e6e6e6", + sec: "#c4c4c4", + muted: "#8a8a8a", + accent: "#ececec", + link: "#ececec", + onAccent: "#121212", + rule: "#3a3a3a", + soft: "#2a2a2a", + quote: "#ababab", + }, + }, + kanagawa: { + font: "'JetBrains Mono', Menlo, Consolas, 'Courier New', monospace", + webFont: "JetBrains+Mono:ital,wght@0,400;0,600;1,400", + scale: 0.9, + radius: { card: 6, img: 4, btn: 4, pill: 3, avatar: 4 }, + title: { size: 26, weight: 600 }, + heavy: 600, + frame: "hairline", + rule: "hairline", + light: { + outer: "#f2ecbc", + card: "#e7dfb2", + text: "#545464", + sec: "#716e61", + muted: "#766b90", + accent: "#4d699b", + link: "#4d699b", + onAccent: "#f2ecbc", + rule: "#d0c79d", + soft: "#dcd5ac", + quote: "#6f894e", + date: "#8a8980", + brand: "#624c83", + }, + dark: { + outer: "#1f1f28", + card: "#2a2a37", + text: "#dcd7ba", + sec: "#c8c093", + muted: "#938aa9", + accent: "#7e9cd8", + link: "#7e9cd8", + onAccent: "#16161d", + rule: "#43435a", + soft: "#16161d", + quote: "#98bb6c", + date: "#727169", + brand: "#957fb8", + }, + }, + melange: { + font: "Fraunces, Georgia, serif", + webFont: "Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700", + scale: 1, + radius: { card: 18, img: 10, btn: 12, pill: 999, avatar: 16 }, + title: { size: 32, weight: 600 }, + heavy: 600, + frame: "soft", + rule: "hairline", + topBar: "spice", + light: { + outer: "#e9e1db", + card: "#f6f2ef", + text: "#54433a", + sec: "#7d6658", + muted: "#a98a78", + accent: "#a06d00", + link: "#a06d00", + onAccent: "#f7f4f1", + rule: "#e0d3ca", + soft: "#ece4dd", + quote: "#7d6658", + shadow: "rgba(84,67,58,0.28)", + spice: ["#a06d00", "#bc5c00", "#c77b8b", "#be79bb", "#6e9b72"], + }, + dark: { + outer: "#292522", + card: "#34302c", + text: "#ece1d7", + sec: "#c1a78e", + muted: "#867462", + accent: "#ebc06d", + link: "#ebc06d", + onAccent: "#292522", + rule: "#4a423b", + soft: "#403a36", + quote: "#c1a78e", + shadow: "rgba(0,0,0,0.5)", + spice: ["#ebc06d", "#e49b5d", "#bd8183", "#b380b0", "#78997a"], + }, + }, +}; diff --git a/api/mail/welcome.ts b/api/mail/welcome.ts new file mode 100644 index 00000000..210067ba --- /dev/null +++ b/api/mail/welcome.ts @@ -0,0 +1,78 @@ +/** + * Renders the email that greets a new account. `sendWelcomeEmail` + * (`user-service.ts`) sends it after sign-up, in the instance's style and + * language. Its body is Markdown: the instance's `GERBEUR_WELCOME_EMAIL_BODY`, + * or the built-in one. + * + * @module + */ + +import { Marked } from "marked"; + +import { + DEFAULT_STYLE, + EMAIL_LOCALE, + FRONTEND_URL, + OG_SITE_NAME, + WELCOME_EMAIL_BODY, +} from "../config.ts"; +import { EmailKit, esc, type RenderedEmail } from "./layout.ts"; +import { STRINGS } from "./strings.ts"; + +/** + * Returns a Markdown renderer whose blocks are `kit`'s, so the body takes the + * email's style. Raw HTML in the Markdown passes through: the body comes from + * whoever runs the instance, never from a user. + */ +function markdownIn(kit: EmailKit): Marked { + return new Marked({ + renderer: { + heading({ tokens, depth }) { + const html = this.parser.parseInline(tokens); + return depth === 1 ? kit.heading(html, 20) : kit.subhead(html); + }, + paragraph({ tokens }) { + return kit.para(this.parser.parseInline(tokens)); + }, + link({ href, tokens }) { + return kit.link(this.parser.parseInline(tokens), href); + }, + strong({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + }, + }, + }); +} + +/** + * Returns the welcome email of `username`, with `{{username}}` and + * `{{site_name}}` filled into the body. + */ +export function renderWelcomeEmail(username: string): RenderedEmail { + const t = STRINGS[EMAIL_LOCALE].welcome; + const kit = new EmailKit( + { style: DEFAULT_STYLE, scheme: "auto" }, + EMAIL_LOCALE, + ); + const markdown = (WELCOME_EMAIL_BODY ?? t.body) + .replaceAll("{{username}}", username) + .replaceAll("{{site_name}}", OG_SITE_NAME); + const subject = t.subject(OG_SITE_NAME); + const body = markdownIn(kit).parse(markdown, { async: false }) + + kit.button(esc(t.button(OG_SITE_NAME)), FRONTEND_URL); + + return { + subject, + html: kit.document({ + subject, + preheader: t.preheader, + body, + footer: kit.footer(esc(t.reason(OG_SITE_NAME))), + }), + text: `${markdown}\n\n${t.button(OG_SITE_NAME)}: ${FRONTEND_URL}\n\n--\n${ + t.reason(OG_SITE_NAME) + }`, + }; +} diff --git a/api/routes/users.ts b/api/routes/users.ts index 971b84ba..3cfd72e0 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -82,6 +82,7 @@ import { getUserById, getUserByUsername, searchUsers, + sendWelcomeEmail, updateUser, } from "../services/user-service.ts"; import { claimInvite, releaseInvite } from "../services/invite-service.ts"; @@ -89,10 +90,7 @@ import { requestPasswordReset, resetPassword, } from "../services/password-reset-service.ts"; -import { isEmailEnabled, sendEmail } from "../services/email-service.ts"; -import { FROM_EMAIL, OG_SITE_NAME, WELCOME_EMAIL_BODY } from "../config.ts"; import { VALIDATION } from "../../shared/validation.ts"; -import { marked } from "marked"; import { broadcastUserUpdated } from "../services/ws-service.ts"; import { getDumpsByUser, @@ -153,19 +151,7 @@ router.post("/register", async (ctx) => { throw err; } - // Not awaited: a slow or failing SMTP server mustn't hold up the sign-up. - if (isEmailEnabled()) { - const emailMarkdown = WELCOME_EMAIL_BODY - .replaceAll("{{username}}", user.username) - .replaceAll("{{site_name}}", OG_SITE_NAME); - sendEmail({ - from: FROM_EMAIL, - to: user.email, - subject: `Welcome to ${OG_SITE_NAME}`, - text: emailMarkdown, - html: await marked(emailMarkdown), - }).catch((err) => console.error("[register] welcome email failed:", err)); - } + sendWelcomeEmail(user); const responseBody: APIResponse = { success: true, diff --git a/api/services/email-service.ts b/api/services/email-service.ts index 1da82761..4c4e6150 100644 --- a/api/services/email-service.ts +++ b/api/services/email-service.ts @@ -1,121 +1,76 @@ /** - * Sends mail through the SMTP server of {@link SMTPS_URL}, which may be unset. - * Its two callers, the welcome email at sign-up and the password reset link, - * check {@link isEmailEnabled} first and don't wait for delivery. + * Sends the site's email through the SMTP server of {@link SMTPS_URL}, which + * may be unset. The welcome email, the password reset link and the digests go + * through it, each rendered first by its module in `api/mail/`. They check + * {@link isEmailEnabled} before rendering. + * + * Importing this module throws when {@link SMTPS_URL} is set without + * {@link FROM_EMAIL}, so a misconfigured server doesn't start rather than fail + * at its first send. * * @module */ -import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer"; +import nodemailer from "nodemailer"; -import { SMTPS_URL } from "../config.ts"; +import { FROM_EMAIL, SMTPS_URL } from "../config.ts"; +import type { RenderedEmail } from "../mail/layout.ts"; -/** - * One address, a comma-separated list of them, or an array, as nodemailer - * takes them. - */ -export type EmailRecipient = string | string[]; - -/** - * Passed to {@link sendEmail}. It needs a recipient, a subject, and a text or - * HTML body; the callers send the same Markdown as both, rendered for `html`. - */ -export interface EmailMessage { - from: string; - to: EmailRecipient; - subject: string; - text?: string; - html?: string; - cc?: EmailRecipient; - bcc?: EmailRecipient; - replyTo?: string; +if (SMTPS_URL && !FROM_EMAIL) { + throw new Error( + "GERBEUR_FROM_EMAIL environment variable is required when GERBEUR_SMTPS_URL is set.", + ); } /** - * Returned by {@link sendEmail} once the SMTP server has taken the message. - * No caller reads it yet. + * Passed to {@link sendEmail}: a rendered email, its recipient, and the + * headers some emails add (the digests' `List-Unsubscribe`). */ -export interface EmailSendResult { - messageId: string; - accepted: string[]; - rejected: string[]; +export interface OutgoingEmail extends RenderedEmail { + to: string; + headers?: Record; } -let transporter: Transporter | null = null; +/** + * Hands a message to a mail server. nodemailer's SMTP transport is one, and + * the tests put a recording one in its place with {@link setEmailTransport}. + */ +export interface EmailTransport { + sendMail(message: OutgoingEmail & { from: string }): Promise; +} + +let transport: EmailTransport | null = SMTPS_URL + ? nodemailer.createTransport(SMTPS_URL) + : null; /** - * Checks whether an SMTP server is configured. Without one, sign-up sends no - * welcome email and a password reset request does nothing. + * Checks whether email can be sent. Without an SMTP server, sign-up sends no + * welcome email, a password reset request does nothing, and no digest goes + * out. */ export function isEmailEnabled(): boolean { - return SMTPS_URL.length > 0; -} - -/** Flattens the recipient forms nodemailer takes and returns into addresses. */ -function normalizeRecipients( - recipient: string | readonly (string | { address?: string })[] | undefined, -): string[] { - if (!recipient) return []; - if (typeof recipient === "string") { - return recipient.split(",").map((s) => s.trim()).filter(Boolean); - } - return recipient.flatMap((entry) => - typeof entry === "string" ? entry.trim() : (entry.address?.trim() ?? "") - ).filter(Boolean); -} - -function getTransporter(): Transporter { - if (!isEmailEnabled()) { - throw new Error( - "GERBEUR_SMTPS_URL environment variable is required to send emails.", - ); - } - - if (!transporter) { - transporter = nodemailer.createTransport(SMTPS_URL); - } - - return transporter; + return transport !== null; } /** - * Sends `message` and resolves once the SMTP server has accepted it. The - * transport is created on the first send. - * - * @throws {Error} When email is disabled, the message lacks a recipient, - * subject or body, or the server refuses it. + * Replaces the transport, so that email is enabled and every message goes to + * `next`; null disables email again. The tests use it to read what was sent. */ -export async function sendEmail( - message: EmailMessage, -): Promise { - if (normalizeRecipients(message.to).length === 0) { - throw new Error("Email recipient is required."); - } - - if (!message.subject.trim()) { - throw new Error("Email subject is required."); - } - - if (!message.text?.trim() && !message.html?.trim()) { - throw new Error("Email body is required (text and/or html)."); - } - - const options: SendMailOptions = { - from: message.from, - to: message.to, - cc: message.cc, - bcc: message.bcc, - subject: message.subject, - text: message.text, - html: message.html, - replyTo: message.replyTo, - }; - - const info = await getTransporter().sendMail(options); - - return { - messageId: info.messageId, - accepted: normalizeRecipients(info.accepted), - rejected: normalizeRecipients(info.rejected), - }; +export function setEmailTransport(next: EmailTransport | null): void { + transport = next; +} + +/** + * Sends `email` from {@link FROM_EMAIL} and resolves once the SMTP server has + * accepted it. The welcome and reset emails don't wait for it: a slow server + * mustn't hold up the request. + * + * @throws {Error} When email is disabled, or when the server refuses the + * message or can't be reached. + */ +export async function sendEmail(email: OutgoingEmail): Promise { + if (!transport) { + throw new Error("Email is disabled: GERBEUR_SMTPS_URL is unset."); + } + await transport.sendMail({ ...email, from: FROM_EMAIL }); } diff --git a/api/services/password-reset-service.ts b/api/services/password-reset-service.ts index 79476f06..63a99be0 100644 --- a/api/services/password-reset-service.ts +++ b/api/services/password-reset-service.ts @@ -16,9 +16,9 @@ import { } from "../lib/jwt.ts"; import { getUserByEmail, updateUser } from "./user-service.ts"; import { isEmailEnabled, sendEmail } from "./email-service.ts"; -import { FROM_EMAIL, FRONTEND_URL, OG_SITE_NAME } from "../config.ts"; +import { FRONTEND_URL } from "../config.ts"; import { VALIDATION } from "../../shared/validation.ts"; -import { marked } from "marked"; +import { renderPasswordResetEmail } from "../mail/password-reset.ts"; import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; /** Must match the `1h` that `createPasswordResetToken` signs into the token. */ @@ -56,15 +56,9 @@ export async function requestPasswordReset(email: string): Promise { const resetUrl = `${FRONTEND_URL}/reset-password?token=${ encodeURIComponent(token) }`; - const body = - `# Reset your ${OG_SITE_NAME} password\n\nHi **${user.username}**,\n\nClick the link below to set a new password. It expires in ${RESET_TOKEN_TTL_HOURS} hour.\n\n[Reset password](${resetUrl})\n\nIf you did not request this, ignore this email.`; - sendEmail({ - from: FROM_EMAIL, to: user.email, - subject: `Reset your ${OG_SITE_NAME} password`, - text: body, - html: await marked(body), + ...renderPasswordResetEmail(user.username, resetUrl, RESET_TOKEN_TTL_HOURS), }).catch((err) => console.error("[password-reset] failed to send email:", err) ); diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 519cf7ac..712deaf1 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -30,6 +30,8 @@ import { hashPassword } from "../lib/password.ts"; import { linkAttachments } from "./attachment-service.ts"; import { LIKE, likePattern } from "../lib/like.ts"; import { parseRows } from "../model/rows.ts"; +import { isEmailEnabled, sendEmail } from "./email-service.ts"; +import { renderWelcomeEmail } from "../mail/welcome.ts"; /** Selects full `UserRow`s from `users u`, with the inviter's name joined. */ const USER_SELECT = @@ -94,6 +96,17 @@ export async function createUser( }; } +/** + * Emails `user` the welcome message, if email is configured. The register + * route calls it once the account exists, and doesn't wait: a slow or failing + * SMTP server mustn't hold up the sign-up, so a failure is only logged. + */ +export function sendWelcomeEmail(user: User): void { + if (!isEmailEnabled()) return; + sendEmail({ to: user.email, ...renderWelcomeEmail(user.username) }) + .catch((err) => console.error("[register] welcome email failed:", err)); +} + /** * Returns user `userId`, with their hash and email. * diff --git a/api/test/helpers.ts b/api/test/helpers.ts index 1ee759a2..729fdae2 100644 --- a/api/test/helpers.ts +++ b/api/test/helpers.ts @@ -10,6 +10,10 @@ import { makeSlug } from "../lib/slugify.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { clearRichContentCache } from "../services/rich-content-service.ts"; import { clearImageCache } from "../remote/image-proxy.ts"; +import { + type OutgoingEmail, + setEmailTransport, +} from "../services/email-service.ts"; import type { APIError, APIResponse } from "../../shared/api.ts"; import type { Role } from "../../shared/user.ts"; @@ -391,6 +395,29 @@ export function stubNetwork( }; } +// ── Email ──────────────────────────────────────────────────────────────────── + +/** + * Turns email on for the life of the returned handle + * (`using mail = captureEmails()`), recording each message instead of sending + * it. Sends that aren't awaited by their route land on the next tick. + */ +export function captureEmails(): { sent: OutgoingEmail[] } & Disposable { + const sent: OutgoingEmail[] = []; + setEmailTransport({ + sendMail(message) { + sent.push(message); + return Promise.resolve(); + }, + }); + return { + sent, + [Symbol.dispose]() { + setEmailTransport(null); + }, + }; +} + export function html(body: string, status = 200): Response { return new Response(body, { status, diff --git a/api/test/mail/layout.test.ts b/api/test/mail/layout.test.ts new file mode 100644 index 00000000..4452544f --- /dev/null +++ b/api/test/mail/layout.test.ts @@ -0,0 +1,79 @@ +import "../setup.ts"; +import { + assert, + assertEquals, + assertFalse, + assertStringIncludes, +} from "@std/assert"; +import { EmailKit, esc } from "../../mail/layout.ts"; +import { EMAIL_STYLES } from "../../mail/styles.ts"; +import { STYLE_NAMES } from "../../../shared/theme.ts"; + +const doc = (kit: EmailKit) => + kit.document({ + subject: "S <1>", + preheader: "P & Q", + title: "T", + body: kit.para("body"), + footer: kit.footer("why", [["Settings", "https://x.test/?a=1&b=2"]]), + }); + +Deno.test("esc: user text never becomes markup", () => { + assertEquals( + esc(`'&'`), + "<b a="1">'&'</b>", + ); +}); + +Deno.test("EmailKit: auto inlines light and recolors in a dark media block", () => { + const html = doc(new EmailKit({ style: "smooth", scheme: "auto" }, "en")); + const { light, dark } = EMAIL_STYLES.smooth; + assertStringIncludes(html, `background-color:${light.card}`); + assertStringIncludes(html, "@media (prefers-color-scheme:dark)"); + assertStringIncludes( + html, + `.b-card{background-color:${dark.card}!important}`, + ); + assertStringIncludes(html, `content="light dark"`); + // The subject, preheader and link are escaped where they land. + assertStringIncludes(html, "S <1>"); + assertStringIncludes(html, "P & Q"); + assertStringIncludes(html, `href="https://x.test/?a=1&b=2"`); +}); + +Deno.test("EmailKit: a chosen scheme is the only one sent", () => { + const { light, dark } = EMAIL_STYLES.kanagawa; + const darkHtml = doc( + new EmailKit({ style: "kanagawa", scheme: "dark" }, "en"), + ); + assertStringIncludes(darkHtml, `background-color:${dark.card}`); + assertFalse(darkHtml.includes(light.card)); + assertFalse(darkHtml.includes("prefers-color-scheme")); + assertStringIncludes(darkHtml, `content="dark"`); + const lightHtml = doc( + new EmailKit({ style: "kanagawa", scheme: "light" }, "fr"), + ); + assertStringIncludes(lightHtml, `background-color:${light.card}`); + assertStringIncludes(lightHtml, ``); + assertFalse(lightHtml.includes("prefers-color-scheme")); +}); + +Deno.test("EmailKit: every style draws its own card and font", () => { + for (const name of STYLE_NAMES) { + const style = EMAIL_STYLES[name]; + const html = doc(new EmailKit({ style: name, scheme: "light" }, "en")); + assertStringIncludes(html, `background-color:${style.light.card}`); + assertStringIncludes(html, `font-family:${style.font}`); + assertEquals(html.includes("fonts.googleapis.com"), !!style.webFont, name); + } + // The rainbow and the hard shadow are what GeoCities and Brutalist look like. + const geo = new EmailKit({ style: "geocities", scheme: "light" }, "en"); + assertStringIncludes( + geo.section("A", "", false), + "linear-gradient(90deg,#ff004d", + ); + const brutal = doc( + new EmailKit({ style: "brutalist", scheme: "light" }, "en"), + ); + assert(brutal.includes("box-shadow:5px 5px 0")); +}); diff --git a/api/test/mail/templates.test.ts b/api/test/mail/templates.test.ts new file mode 100644 index 00000000..26e8bf98 --- /dev/null +++ b/api/test/mail/templates.test.ts @@ -0,0 +1,25 @@ +import "../setup.ts"; +import { assertFalse, assertStringIncludes } from "@std/assert"; +import { renderWelcomeEmail } from "../../mail/welcome.ts"; +import { renderPasswordResetEmail } from "../../mail/password-reset.ts"; + +Deno.test("renderWelcomeEmail: fills the built-in body, in both parts", () => { + const email = renderWelcomeEmail("newbie"); + assertStringIncludes(email.subject, "Welcome to gerbeur"); + assertStringIncludes(email.text, "Hi **newbie**"); + assertFalse(email.text.includes("{{")); + assertStringIncludes(email.html, "newbie"); + assertStringIncludes(email.html, "Welcome to gerbeur!"); +}); + +Deno.test("renderPasswordResetEmail: the button and the raw link carry the URL", () => { + const url = "http://gerbeur.test/reset-password?token=a.b&x="; + const email = renderPasswordResetEmail("bob", url, 1); + assertStringIncludes(email.text, url); + assertStringIncludes(email.text, "for 1 hour"); + assertStringIncludes( + email.html, + `href="http://gerbeur.test/reset-password?token=a.b&x=<y>"`, + ); + assertFalse(email.html.includes("")); +}); diff --git a/api/test/routes/users.test.ts b/api/test/routes/users.test.ts index 4381bfd1..b096a358 100644 --- a/api/test/routes/users.test.ts +++ b/api/test/routes/users.test.ts @@ -1,4 +1,5 @@ import { + captureEmails, count, createUser, db, @@ -277,6 +278,7 @@ test("POST /api/users/login: rate limited per account, case-insensitively", asyn }); test("POST /api/users/register: an invite opens one account", async () => { + using mail = captureEmails(); const inviter = await createUser(); const inviteToken = invite(inviter.id); const res = await post("/api/users/register", { @@ -296,6 +298,9 @@ test("POST /api/users/register: an invite opens one account", async () => { `SELECT invited_by FROM users WHERE username = 'newbie_reg';`, ).get() as { invited_by: string }; assertEquals(row.invited_by, inviter.id); + await new Promise((r) => setTimeout(r, 0)); + assertEquals(mail.sent.map((m) => m.to), ["newbie@example.test"]); + assert(mail.sent[0].html.includes("newbie_reg")); const again = await post("/api/users/register", { json: { @@ -457,6 +462,27 @@ test("request-password-reset: always 200, stores nothing without SMTP", async () assertEquals(count(`SELECT COUNT(*) as n FROM password_reset_tokens;`), 0); }); +test("request-password-reset: emails a link that resets the password", async () => { + using mail = captureEmails(); + const user = await createUser(); + await post("/api/users/request-password-reset", { + json: { email: user.email }, + }); + await post("/api/users/request-password-reset", { + json: { email: "nobody@example.test" }, + }); + await new Promise((r) => setTimeout(r, 0)); + assertEquals(mail.sent.length, 1); + assertEquals(mail.sent[0].to, user.email); + const token = decodeURIComponent( + mail.sent[0].text.match(/token=([^\s]+)/)![1], + ); + const res = await post("/api/users/reset-password", { + json: { token, newPassword: "from-the-email" }, + }); + assertEquals(res.status, 200); +}); + test("reset-password: a stored token works once", async () => { const user = await createUser(); const token = await createPasswordResetToken(user.id); diff --git a/mod.ts b/mod.ts index 60ab4ffd..8edd45c0 100644 --- a/mod.ts +++ b/mod.ts @@ -23,6 +23,8 @@ * - `validators/` holds the guards of the request bodies. * - `remote/` is the only place that fetches from other sites, and its * `providers/` build the previews of each kind of site. + * - `mail/` renders each email, one template per email on a shared layout + * that takes every site style, and `services/email-service.ts` sends them. * - `lib/` holds the helpers that belong to no layer. * - `db/` opens, creates and migrates the database, and cleans it up at * startup. `db/migrations/` are applied in order, each once. diff --git a/shared/theme.ts b/shared/theme.ts new file mode 100644 index 00000000..2442cb92 --- /dev/null +++ b/shared/theme.ts @@ -0,0 +1,43 @@ +/** + * The site's look is a style and a color scheme, which the client applies and + * the emails render in. This module names them for both halves: the client's + * `ThemeProvider` checks a stored choice against them, and the server checks + * `GERBEUR_DEFAULT_STYLE` and picks an email's palette by them. + * + * @module + */ + +/** + * Lists every site style. Each has a stylesheet under `src/themes/`, keyed on + * the root element's `data-style`, and an email style in `api/mail/styles.ts`. + */ +export const STYLE_NAMES = [ + "smooth", + "brutalist", + "geocities", + "nyt", + "kanagawa", + "melange", +] as const; +/** One of the {@link STYLE_NAMES}. */ +export type StyleName = typeof STYLE_NAMES[number]; + +/** Lists every color scheme. "auto" follows the system's. */ +export const COLOR_SCHEMES = ["auto", "light", "dark"] as const; +/** One of the {@link COLOR_SCHEMES}. */ +export type ColorScheme = typeof COLOR_SCHEMES[number]; + +/** + * Checks whether `value` is a {@link StyleName}, as a stored choice or an + * environment variable must be before it is applied. + */ +export function isStyleName(value: unknown): value is StyleName { + return typeof value === "string" && + (STYLE_NAMES as readonly string[]).includes(value); +} + +/** Checks whether `value` is a {@link ColorScheme}. */ +export function isColorScheme(value: unknown): value is ColorScheme { + return typeof value === "string" && + (COLOR_SCHEMES as readonly string[]).includes(value); +} diff --git a/src/components/profile/ProfileSettingsTab.tsx b/src/components/profile/ProfileSettingsTab.tsx index 3fbfa262..b22b93d1 100644 --- a/src/components/profile/ProfileSettingsTab.tsx +++ b/src/components/profile/ProfileSettingsTab.tsx @@ -7,10 +7,7 @@ import { useState } from "react"; import { Trans } from "@lingui/react/macro"; -import type { - ColorScheme, - StyleName, -} from "../../contexts/theme/ThemeContext.ts"; +import type { ColorScheme, StyleName } from "../../../shared/theme.ts"; import type { FeedTab } from "../../config/feedTabs.ts"; import { useTheme } from "../../hooks/theme/useTheme.ts"; import { useDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts"; diff --git a/src/contexts/theme/ThemeContext.ts b/src/contexts/theme/ThemeContext.ts index 7e75f8de..97b45388 100644 --- a/src/contexts/theme/ThemeContext.ts +++ b/src/contexts/theme/ThemeContext.ts @@ -6,27 +6,7 @@ */ import { createContext } from "react"; - -/** - * Lists every site style, which `ThemeProvider` checks a stored choice - * against. Each has a stylesheet under `src/themes/`, keyed on the root - * element's `data-style`, which is absent for "smooth". - */ -export const STYLE_NAMES = [ - "smooth", - "brutalist", - "geocities", - "nyt", - "kanagawa", - "melange", -] as const; -/** Held by `ThemeProvider` for the style chosen in this browser. */ -export type StyleName = typeof STYLE_NAMES[number]; - -/** Lists every color scheme. "auto" follows the system's. */ -export const COLOR_SCHEMES = ["auto", "light", "dark"] as const; -/** Held by `ThemeProvider` for the color scheme chosen in this browser. */ -export type ColorScheme = typeof COLOR_SCHEMES[number]; +import type { ColorScheme, StyleName } from "../../../shared/theme.ts"; /** * Provided by `ThemeProvider` to `useTheme`. A setter applies and stores the diff --git a/src/contexts/theme/ThemeProvider.tsx b/src/contexts/theme/ThemeProvider.tsx index b14cd56f..86c796af 100644 --- a/src/contexts/theme/ThemeProvider.tsx +++ b/src/contexts/theme/ThemeProvider.tsx @@ -7,13 +7,13 @@ */ import { useEffect, useMemo, useState } from "react"; +import { ThemeContext } from "./ThemeContext.ts"; import { COLOR_SCHEMES, type ColorScheme, STYLE_NAMES, type StyleName, - ThemeContext, -} from "./ThemeContext.ts"; +} from "../../../shared/theme.ts"; /** Passed to {@link ThemeProvider} by `App`. */ export interface ThemeProviderProps {