v4: every email is rendered by its template in api/mail/ on one layout that takes the instance's style, and sent one way — the welcome email was built and sent inside the register route and the reset link in its service, each writing Markdown, running marked, and calling a nodemailer wrapper whose cc, bcc, replyTo and send result nothing used. Now api/mail/ holds one template per email (welcome.ts, password-reset.ts) on layout.ts's EmailKit, which draws each block with inline styles in a style from styles.ts: the six site styles' palettes, copied from src/themes/, and what makes each recognisable in a mail client (Brutalist's hard border and shadow, GeoCities' ridge and rainbow rules, Times' underlined ink links, Kanagawa's colored quotes, dates and wordmark, Melange's spice bar). On the "auto" scheme the light palette is inlined and the dark one swapped in by a prefers-color-scheme block; a chosen scheme sends only its own. User-written text is escaped where it lands. strings.ts holds every email's words in English and French. GERBEUR_EMAIL_LOCALE picks the language and GERBEUR_DEFAULT_STYLE the instance's style, which the welcome and reset emails are drawn in; GERBEUR_WELCOME_EMAIL_BODY still replaces the welcome's Markdown, whose blocks now take the style too. email-service sends a rendered email with its recipient and headers, refuses to load when GERBEUR_SMTPS_URL is set without GERBEUR_FROM_EMAIL (sends used to fail one by one), and takes a test transport, through which the tests now read what was sent. The welcome send moves to user-service's sendWelcomeEmail. The style and scheme names move to shared/theme.ts, which the server now needs. README, mod.ts and .env.example describe mail/ and the new variables, and drop the "notifications, invites" email that never existed. 221 backend tests pass (new: the layout's schemes, styles and escaping, both templates, the welcome on sign-up, a reset link from the email that works), and deno check, deno lint, doc:lint, deno fmt, tsc and eslint are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-04 21:11:54 +00:00
parent 5f0cf86fe6
commit f20ca83f0c
21 changed files with 1459 additions and 179 deletions

108
api/mail/strings.ts Normal file
View File

@@ -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<EmailLocale, MailStrings> = {
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.",
},
},
};