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 `
`;
+ }
+
+ /** 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 = ``;
+ 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 =
+ `