@pithy-sh/email 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/package.json +55 -0
  4. package/pithy.manifest.json +73 -0
  5. package/src/analytics.ts +39 -0
  6. package/src/audit/actions.ts +48 -0
  7. package/src/bounce/classify.ts +103 -0
  8. package/src/bounce/handler.ts +136 -0
  9. package/src/capability.ts +385 -0
  10. package/src/cloudflare-test.d.ts +19 -0
  11. package/src/crypto/signingKey.ts +44 -0
  12. package/src/crypto/token.ts +148 -0
  13. package/src/data/emailEvent.ts +42 -0
  14. package/src/data/emailJob.ts +138 -0
  15. package/src/data/emailSuppression.ts +40 -0
  16. package/src/data/enums.ts +75 -0
  17. package/src/data/tables.ts +47 -0
  18. package/src/error/errors.ts +129 -0
  19. package/src/http/callbacks.ts +200 -0
  20. package/src/http/guards.ts +154 -0
  21. package/src/http/responses.ts +192 -0
  22. package/src/http/routes.ts +467 -0
  23. package/src/http/schemas.ts +203 -0
  24. package/src/http/view.ts +139 -0
  25. package/src/index.ts +73 -0
  26. package/src/jobs/read.ts +273 -0
  27. package/src/jobs/retry.ts +214 -0
  28. package/src/migrations/0001_init.ts +174 -0
  29. package/src/migrations/0001_suppressions.ts +40 -0
  30. package/src/provision/devDelivery.ts +47 -0
  31. package/src/provision/hostCatalogs.ts +107 -0
  32. package/src/provision/provisionEmail.ts +179 -0
  33. package/src/provision/resolveEmailConfig.ts +225 -0
  34. package/src/provision/settingsCheck.ts +212 -0
  35. package/src/send/batchIdentity.ts +47 -0
  36. package/src/send/enqueue.ts +391 -0
  37. package/src/send/errorMapping.ts +73 -0
  38. package/src/send/events.ts +34 -0
  39. package/src/send/fromComposition.ts +57 -0
  40. package/src/send/retryPolicy.ts +42 -0
  41. package/src/send/runSend.ts +320 -0
  42. package/src/send/sendAt.ts +77 -0
  43. package/src/send/sender.ts +44 -0
  44. package/src/send/senderBinding.ts +56 -0
  45. package/src/send/suppression.ts +194 -0
  46. package/src/templates/engine.ts +392 -0
  47. package/src/templates/messages.es.ts +109 -0
  48. package/src/templates/messages.ts +315 -0
  49. package/src/templates/partials.ts +88 -0
  50. package/src/templates/precompiled.generated.ts +1342 -0
  51. package/src/templates/registry.ts +550 -0
  52. package/src/templates/samples.ts +75 -0
  53. package/src/templates/severity.ts +102 -0
  54. package/src/templates/theme.ts +212 -0
  55. package/src/version.generated.ts +16 -0
  56. package/src/workflows/hostApp.ts +54 -0
  57. package/src/workflows/hostEnv.ts +219 -0
  58. package/src/workflows/instanceLiveness.ts +39 -0
  59. package/src/workflows/instances.ts +16 -0
  60. package/src/workflows/params.ts +35 -0
  61. package/src/workflows/scheduler.ts +220 -0
  62. package/src/workflows/sendBatch.ts +154 -0
  63. package/src/workflows/worker.ts +203 -0
  64. package/src/workflows/wrangler.jsonc +75 -0
@@ -0,0 +1,194 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { normalizeAddress } from "@pithy-sh/core/src/address/address";
5
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
6
+ import { decodeCursor, type PageCursor, pageLimit, toPage } from "@pithy-sh/core/src/data/cursor";
7
+ import { EmailSuppression } from "../data/emailSuppression";
8
+ import { type EmailKind, SuppressionReason } from "../data/enums";
9
+ import type { EmailSuppressionDatabase } from "../data/tables";
10
+
11
+ /**
12
+ * The suppression list: addresses that must not be emailed, fed by hard bounces, complaints, and
13
+ * unsubscribes. The send path checks it before every send and skips a match, naming the reason it
14
+ * skipped for.
15
+ *
16
+ * Every address here goes through `normalizeAddress` from core, so a check and a write agree on the
17
+ * key — and so does `auth` matching a sign-in, `support` linking a sender, and `testers` reading this
18
+ * very table. A suppression written under one rule and read under another is a suppression that does
19
+ * not suppress, and it reports itself as "the list did not work" rather than as anything about case.
20
+ *
21
+ * The list is also the one thing in this capability a management client both reads and writes, and it
22
+ * is **global** — one database shared by every environment, so a row here stops mail from staging and
23
+ * production alike. That is why reading it, adding to it, and removing from it are three separate
24
+ * control-plane scopes rather than one.
25
+ *
26
+ * **The reason is part of the answer, not just a label on the row.** The list is keyed by address and
27
+ * holds no memory of which message a person was refusing, so treating every reason alike meant one
28
+ * unsubscribe from a weekly digest also withheld that person's sign-in link — and passwordless has no
29
+ * password to fall back on. `suppressionBlocks` is where the four reasons stop being interchangeable.
30
+ */
31
+
32
+ /**
33
+ * Whether a live suppression for this reason blocks a message of this kind.
34
+ *
35
+ * | reason | elective | transactional |
36
+ * |---|---|---|
37
+ * | `hard_bounce` | block | **block** — the mailbox does not exist. Sending is futile, and hammering dead addresses damages the sending domain for every other adopter on it. |
38
+ * | `complaint` | block | **block** — they reported us as spam. Continuing after a complaint is how a domain gets blocked outright. |
39
+ * | `unsubscribe` | block | **send** — an opt-out is a statement about mail somebody chose to receive. A sign-in link is not that; withholding it respects no preference, it locks the account. |
40
+ * | `manual` | block | block — an operator's deliberate act, and the one reason a human can point at. Narrowing it would silently overrule the person who set it. |
41
+ *
42
+ * Bounce and complaint are facts about the mailbox; unsubscribe and manual are decisions about mail.
43
+ * Only the unsubscribe decision is about a *category* of mail, which is why it is the only one the kind
44
+ * can narrow.
45
+ */
46
+ export function suppressionBlocks(reason: SuppressionReason, kind: EmailKind): boolean {
47
+ if (reason === "unsubscribe") return kind === "elective";
48
+ return true;
49
+ }
50
+
51
+ /**
52
+ * The live suppression reason blocking a message of this kind, or `null` if nothing does.
53
+ *
54
+ * The kind is required rather than defaulted. A default would have to be one of the two, and the one
55
+ * that reads as safe — block everything — is the one that locks people out of their own accounts.
56
+ */
57
+ export async function blockingSuppression(
58
+ db: EmailSuppressionDatabase,
59
+ email: string,
60
+ now: Date,
61
+ kind: EmailKind,
62
+ ): Promise<SuppressionReason | null> {
63
+ const row = await db
64
+ .selectFrom("pithyEmailSuppressions")
65
+ .select(["reason", "expiresAt"])
66
+ // The same key `suppress` writes under. A read normalized differently from the write is a
67
+ // suppression that silently stops suppressing.
68
+ .where("email", "=", normalizeAddress(email))
69
+ .executeTakeFirst();
70
+ if (!row) return null;
71
+ const live =
72
+ row.expiresAt === null || row.expiresAt === undefined || SQLiteDate.parse(row.expiresAt).getTime() > now.getTime();
73
+ if (!live) return null;
74
+ // Parsed, not cast: this crosses the D1 boundary, and an unrecognized reason must not fall through the
75
+ // `=== "unsubscribe"` test into "send it anyway". A row nobody can read is reported as `manual`, the
76
+ // one reason that claims no observed fact — it blocks, and it does not pretend to know why.
77
+ const parsed = SuppressionReason.safeParse(row.reason);
78
+ if (!parsed.success) return "manual";
79
+ return suppressionBlocks(parsed.data, kind) ? parsed.data : null;
80
+ }
81
+
82
+ /** Add (or refresh) a suppression for an address. Idempotent on the unique `email` column. */
83
+ export async function suppress(
84
+ db: EmailSuppressionDatabase,
85
+ input: {
86
+ email: string;
87
+ reason: SuppressionReason;
88
+ jobId?: string | null;
89
+ environment?: string | null;
90
+ detail?: string | null;
91
+ expiresAt?: Date | null;
92
+ },
93
+ now: Date,
94
+ ): Promise<void> {
95
+ const email = normalizeAddress(input.email);
96
+ const jobId = input.jobId ?? null;
97
+ const environment = input.environment ?? null;
98
+ const detail = input.detail ?? null;
99
+ const expiresAt = input.expiresAt ? SQLiteDate.encode(input.expiresAt) : null;
100
+ await db
101
+ .insertInto("pithyEmailSuppressions")
102
+ .values({ email, reason: input.reason, jobId, environment, detail, expiresAt, createdAt: SQLiteDate.encode(now) })
103
+ .onConflict((oc) => oc.column("email").doUpdateSet({ reason: input.reason, jobId, environment, detail, expiresAt }))
104
+ .execute();
105
+ }
106
+
107
+ /**
108
+ * Remove a suppression. Returns whether a row was actually removed.
109
+ *
110
+ * The boolean matters: an operator unblocking an address that was never blocked, and one unblocking an
111
+ * address that was, are two different events and the audit trail should not record them as the same
112
+ * one. It also keeps the route idempotent — asking twice is not an error, it is simply the second one
113
+ * finding nothing to do.
114
+ *
115
+ * There is no soft delete. A suppression is a "do not send" flag, and a lifted flag that stays in the
116
+ * table is one bad query away from still being enforced.
117
+ */
118
+ export async function unsuppress(db: EmailSuppressionDatabase, email: string): Promise<boolean> {
119
+ const result = await db
120
+ .deleteFrom("pithyEmailSuppressions")
121
+ .where("email", "=", normalizeAddress(email))
122
+ .executeTakeFirst();
123
+ return (result.numDeletedRows ?? 0n) > 0n;
124
+ }
125
+
126
+ /** What the suppression list filters and pages by. */
127
+ export interface SuppressionListFilter {
128
+ /** One reason, or every reason when absent. */
129
+ reason?: SuppressionReason;
130
+ /** Look one address up exactly, rather than paging the list. */
131
+ email?: string;
132
+ /** The previous page's `nextCursor`. A malformed one is a first page. */
133
+ cursor?: string;
134
+ /** How many rows to return, clamped into range. */
135
+ limit?: number;
136
+ }
137
+
138
+ /** One page of the suppression list. */
139
+ export interface SuppressionPage {
140
+ /** The suppressed addresses, most recently blocked first. */
141
+ items: EmailSuppression[];
142
+ /** Where the next page starts, or null at the end of the list. */
143
+ nextCursor: string | null;
144
+ }
145
+
146
+ /**
147
+ * A cursor this table can resume from.
148
+ *
149
+ * Both halves must be numbers here — `createdAt` is a ms-epoch and `id` is an autoincrement integer —
150
+ * and anything else is treated exactly as a malformed cursor is. A string compared against an integer
151
+ * column in SQLite does not fail; it orders by something nobody meant, which is worse.
152
+ */
153
+ function suppressionCursor(raw: string | undefined): { sort: number; id: number } | undefined {
154
+ const cursor: PageCursor | undefined = decodeCursor(raw);
155
+ if (!cursor || typeof cursor.sort !== "number" || typeof cursor.id !== "number") return undefined;
156
+ return { sort: cursor.sort, id: cursor.id };
157
+ }
158
+
159
+ /**
160
+ * One page of the suppression list, most recently blocked first.
161
+ *
162
+ * Keyset, never offset — the same rule every list in this repo follows, and it applies here even though
163
+ * the table is quieter than the job log: a hard bounce arriving mid-scroll would otherwise skip a row
164
+ * for the person reading it.
165
+ */
166
+ export async function listSuppressions(
167
+ db: EmailSuppressionDatabase,
168
+ filter: SuppressionListFilter,
169
+ ): Promise<SuppressionPage> {
170
+ const limit = pageLimit(filter.limit);
171
+ const after = suppressionCursor(filter.cursor);
172
+
173
+ let query = db
174
+ .selectFrom("pithyEmailSuppressions")
175
+ .selectAll()
176
+ .orderBy("createdAt", "desc")
177
+ .orderBy("id", "desc")
178
+ .limit(limit + 1);
179
+
180
+ if (filter.reason) query = query.where("reason", "=", filter.reason);
181
+ // Exact equality on the normalized key, never a prefix or a LIKE: a lookup that also matched
182
+ // neighbors would be a way to enumerate the list one query at a time while looking like a question
183
+ // about a single address.
184
+ if (filter.email) query = query.where("email", "=", normalizeAddress(filter.email));
185
+ if (after) {
186
+ query = query.where((eb) =>
187
+ eb.or([eb("createdAt", "<", after.sort), eb.and([eb("createdAt", "=", after.sort), eb("id", "<", after.id)])]),
188
+ );
189
+ }
190
+
191
+ const rows = await query.execute();
192
+ const items = rows.map((row) => EmailSuppression.parse(row));
193
+ return toPage(items, limit, (row) => ({ sort: row.createdAt.getTime(), id: row.id }));
194
+ }
@@ -0,0 +1,392 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
5
+ import type { Translator } from "@pithy-sh/core/src/i18n/translator";
6
+ import Handlebars from "handlebars";
7
+ import { mintToken } from "../crypto/token";
8
+ import type { EmailKind } from "../data/enums";
9
+ import { EmailInvalidPayloadError, EmailTemplateNotFoundError } from "../error/errors";
10
+ import { emailTranslator, kitEmailLayers } from "./messages";
11
+ import { precompiledPartials, precompiledTemplates } from "./precompiled.generated";
12
+ import { type EmailTemplate, templates } from "./registry";
13
+ import { severityColor, severityLabelKey } from "./severity";
14
+ import { type EmailTheme, widthPx } from "./theme";
15
+
16
+ /**
17
+ * The render engine. Templates are compiled **once at module load** — which in a Worker happens during
18
+ * isolate startup, where dynamic compilation is permitted — and cached, so a send never compiles or
19
+ * evals at request time (CLAUDE.md §Email: "precompiled, no runtime compile/eval"). Rendering is then
20
+ * pure string substitution.
21
+ *
22
+ * Tracking is applied here, not in the template: callback tokens are minted ahead of render (signing
23
+ * is async; Handlebars helpers are not) and the resulting URLs are placed into the render context.
24
+ * Click tracking rewrites the declared link locations; open tracking injects a pixel; and an **elective**
25
+ * template gets an unsubscribe link, which a marketing template additionally cannot render without.
26
+ *
27
+ * **A transactional template can never be given one.** The affordance is gated on the kind the template
28
+ * itself declares, so there is no argument a call site could pass to put an opt-out on a sign-in link —
29
+ * and `renderEmail` reports the URL it minted, so the send path sets `List-Unsubscribe` from what was
30
+ * actually rendered rather than from a second, drift-prone judgment of its own.
31
+ */
32
+
33
+ /** The mount prefix of the callback routes. Tracking URLs are built against `${baseUrl}${CALLBACK_BASE}`. */
34
+ export const CALLBACK_BASE = "/_pithy/email";
35
+
36
+ // Build the engine from the **precompiled** specs (generated by `scripts/precompile.ts`).
37
+ // `Handlebars.template(spec)` wraps spec functions that were defined at module-parse time — it never
38
+ // calls `eval`/`new Function`, so this works in the Workers runtime, which forbids runtime code
39
+ // generation entirely (CLAUDE.md §Email: "precompiled, no runtime compile/eval").
40
+ const hbs = Handlebars.create();
41
+ for (const [name, spec] of Object.entries(precompiledPartials)) {
42
+ hbs.registerPartial(name, hbs.template(spec));
43
+ }
44
+
45
+ // The only two helpers this engine has, and both exist for one reason: a severity has to be said in
46
+ // three places — the subject line, the body, and the plain-text part — and a mapping repeated three
47
+ // times is a mapping that will disagree with itself. Handlebars has no equality test, so without them a
48
+ // template would carry nine `{{#if}}` blocks to render one word. Registered on this instance at module
49
+ // load; precompiled specs resolve a helper by name at render time, so `scripts/precompile.ts` needs to
50
+ // know nothing about them.
51
+ hbs.registerHelper("severityColor", severityColor);
52
+
53
+ /**
54
+ * The English every render falls back to — and the whole render when nothing composed an i18n
55
+ * capability, which is the seam's zero-config behavior everywhere else in the kit.
56
+ *
57
+ * Module-level and shared, because it holds no request state: a translator over one fixed catalog is a
58
+ * pure lookup table. The per-job translators the send path builds are the ones that carry a locale, and
59
+ * they live and die with one render.
60
+ */
61
+ const ENGLISH: Translator = emailTranslator("en", kitEmailLayers);
62
+
63
+ /** The key the render context carries its translator under. Read by the helpers, never by a template. */
64
+ const TRANSLATOR_KEY = "i18n";
65
+
66
+ /** The slice of Handlebars' helper options these helpers read. Declared rather than imported, to stay off `any`. */
67
+ interface HelperContext {
68
+ /** The `key=value` arguments written on the helper call — the message's interpolation parameters. */
69
+ hash?: Record<string, unknown>;
70
+ /** Handlebars' data frame. `root` is the render context, which is where the translator rides. */
71
+ data?: { root?: unknown };
72
+ }
73
+
74
+ /** Whether a value is a translator, structurally — the seam is an interface, so there is no class to test. */
75
+ function isTranslator(value: unknown): value is Translator {
76
+ return typeof (value as { t?: unknown } | null | undefined)?.t === "function";
77
+ }
78
+
79
+ /**
80
+ * The translator this render is going through, or the kit's English.
81
+ *
82
+ * Read off `@root` rather than closed over, because the engine is built once per isolate and a
83
+ * translator belongs to one message. A module-level "current locale" would be the same hazard
84
+ * `z.config()` is banned repo-wide for: an isolate serves many recipients, and the one holding the
85
+ * locale would apply the last render's language to the next.
86
+ */
87
+ function translatorOf(options: HelperContext): Translator {
88
+ const held = (options.data?.root as Record<string, unknown> | undefined)?.[TRANSLATOR_KEY];
89
+ return isTranslator(held) ? held : ENGLISH;
90
+ }
91
+
92
+ /** The helper's `key=value` arguments as message parameters — scalars only, like every catalog value. */
93
+ function messageParams(hash: Record<string, unknown> | undefined): MessageParams {
94
+ const params: MessageParams = {};
95
+ for (const [name, value] of Object.entries(hash ?? {})) {
96
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") params[name] = value;
97
+ }
98
+ return params;
99
+ }
100
+
101
+ /**
102
+ * `{{t "email/magic_link.heading"}}` — one catalog message, with `key=value` interpolation.
103
+ *
104
+ * It returns a plain string and **never** a `SafeString`. That is the security constraint, not a style
105
+ * choice: the HTML body escapes what a mustache produces, so a catalog value — the kit's, or the one
106
+ * sentence an adopter overrode it with — is escaped on exactly the path a payload value is. `subject`
107
+ * and `text` are precompiled with `noEscape`, where nothing is escaped and nothing needs to be, which
108
+ * is why no catalog value in this kit carries markup and why `docs/I18N.md` says so to adopters.
109
+ */
110
+ hbs.registerHelper("t", (key: unknown, options: HelperContext): string =>
111
+ translatorOf(options).t(String(key), messageParams(options.hash)),
112
+ );
113
+
114
+ /**
115
+ * `{{tn "email/otp.expiry" count=expiresMinutes}}` — the plural form the count calls for.
116
+ *
117
+ * Separate from `t` because plural selection is the thing a second locale exposes: "It expires in 1
118
+ * minutes" was already wrong in English and unfixable without it, and Russian needs three forms where
119
+ * English needs two. The count is a hash argument rather than a positional one so that a payload field
120
+ * reaching a template through this helper is still visible to `engine.test.ts`'s check that every
121
+ * variable a template renders is declared on its payload schema.
122
+ */
123
+ hbs.registerHelper("tn", (key: unknown, options: HelperContext): string => {
124
+ const params = messageParams(options.hash);
125
+ return translatorOf(options).plural(String(key), Number(params.count ?? 0), params);
126
+ });
127
+
128
+ /** `{{severityLabel severity}}` — the level's word, in the reader's language. */
129
+ hbs.registerHelper("severityLabel", (severity: unknown, options: HelperContext): string =>
130
+ translatorOf(options).t(severityLabelKey(severity)),
131
+ );
132
+
133
+ interface Compiled {
134
+ def: EmailTemplate;
135
+ subject: HandlebarsTemplateDelegate;
136
+ html: HandlebarsTemplateDelegate;
137
+ text: HandlebarsTemplateDelegate;
138
+ }
139
+
140
+ function delegate(key: string): HandlebarsTemplateDelegate {
141
+ const spec = (precompiledTemplates as Record<string, unknown>)[key];
142
+ if (!spec) throw new Error(`missing precompiled template spec '${key}' — run \`bun run precompile\``);
143
+ return hbs.template(spec);
144
+ }
145
+
146
+ const compiled = new Map<string, Compiled>(
147
+ Object.values(templates).map((def) => [
148
+ def.id,
149
+ { def, subject: delegate(`${def.id}:subject`), html: delegate(`${def.id}:html`), text: delegate(`${def.id}:text`) },
150
+ ]),
151
+ );
152
+
153
+ /** The signing + addressing context a tracked render needs. Shaped to plug straight into the send path. */
154
+ export interface RenderTracking {
155
+ /** The public base URL of the app worker; callback links are built against it. */
156
+ baseUrl: string;
157
+ /** The job this render belongs to — embedded in every token. */
158
+ jobId: string;
159
+ /** The recipient address — embedded in every token. */
160
+ recipient: string;
161
+ /** The marketing campaign id, for click/open attribution. */
162
+ campaignId?: string;
163
+ /** The current signing key value (from `@pithy-sh/secrets`). */
164
+ key: string;
165
+ /** The current signing key version, recorded as the token `kid`. */
166
+ kid: string;
167
+ /** When the tokens (and so the links) expire. */
168
+ expiresAt: Date;
169
+ /** Whether to inject the open-tracking pixel. */
170
+ openTracking: boolean;
171
+ /** Whether to rewrite links to tracked click callbacks. */
172
+ clickTracking: boolean;
173
+ }
174
+
175
+ /** A rendered email: the three parts a send needs, plus the opt-out URL it actually carries. */
176
+ export interface RenderResult {
177
+ subject: string;
178
+ html: string;
179
+ text: string;
180
+ /**
181
+ * The unsubscribe URL rendered into the body, when one was. Present only for an elective template with
182
+ * a signing context; the send path turns it into the `List-Unsubscribe` header, so the header and the
183
+ * link in the body can never disagree about whether this message can be opted out of.
184
+ */
185
+ unsubscribeUrl?: string;
186
+ }
187
+
188
+ /** Look up a template by id, or throw `email/template_not_found`. */
189
+ export function getTemplate(id: string): EmailTemplate {
190
+ const found = compiled.get(id);
191
+ if (!found) throw new EmailTemplateNotFoundError({ detail: `no template registered with id '${id}'` });
192
+ return found.def;
193
+ }
194
+
195
+ /**
196
+ * The kind a template declares — the one answer to "may this message be refused".
197
+ *
198
+ * An id nobody registered is reported as `elective`, deliberately. It cannot be *proved* transactional,
199
+ * and the alternative reading would let a template deleted in a later release quietly reopen sending to
200
+ * addresses that opted out. Nothing is locked out by the choice: a job naming an unknown template fails
201
+ * at render moments later either way, so this only decides which of two failures gets reported.
202
+ */
203
+ export function templateKind(templateId: string): EmailKind {
204
+ return compiled.get(templateId)?.def.kind ?? "elective";
205
+ }
206
+
207
+ /**
208
+ * Whether this template's payload is dropped once the message is delivered.
209
+ *
210
+ * **Keyed on the category, not the kind.** The kind answers "may this be refused"; this question is
211
+ * "are these inputs a one-time credential". `testerNudge` is the template that proves they are
212
+ * different axes — it is *elective*, because somebody may say stop chasing me, and its payload carries
213
+ * an opt-in URL that authenticates a tester. Keying on the kind would have left exactly that one live.
214
+ *
215
+ * A transactional message is a reply to something one person just did, and its payload is that
216
+ * person's one-time input: a sign-in URL, a code, an invitation token. A marketing message's payload is
217
+ * copy authored for a batch, carrying no per-recipient credential and answering the real question
218
+ * "what did the forty thousand of them actually receive". So the category is the line, and it falls in
219
+ * the right place on every template in the registry.
220
+ *
221
+ * There is no per-template override, because no template needs one today and an escape hatch nobody
222
+ * uses is where the bug comes back. The day a marketing template carries a per-recipient credential,
223
+ * the override is the change to make — with a reason written beside it.
224
+ *
225
+ * An id nobody registered redacts, which is the safe direction and, like `templateKind`, decides
226
+ * nothing real: a job naming an unknown template fails at render and never reaches a delivery.
227
+ */
228
+ export function redactsPayloadOnDelivery(templateId: string): boolean {
229
+ return compiled.get(templateId)?.def.category !== "marketing";
230
+ }
231
+
232
+ /** The registered template ids, for introspection and the CLI. */
233
+ export function listTemplates(): EmailTemplate[] {
234
+ return Object.values(templates);
235
+ }
236
+
237
+ /**
238
+ * Validate a payload against its template and render just the subject line. Used at enqueue to fail
239
+ * fast on a bad payload and to store the job's subject without rendering the whole body. Throws
240
+ * `email/template_not_found` or `email/invalid_payload`.
241
+ */
242
+ export function renderSubject(
243
+ templateId: string,
244
+ payload: unknown,
245
+ theme: EmailTheme,
246
+ translator: Translator = ENGLISH,
247
+ ): string {
248
+ const entry = compiled.get(templateId);
249
+ if (!entry) throw new EmailTemplateNotFoundError({ detail: `no template registered with id '${templateId}'` });
250
+ const parsed = entry.def.payload.safeParse(payload);
251
+ if (!parsed.success) {
252
+ const summary = parsed.error.issues.map((i) => `${i.path.join(".") || "<root>"}:${i.code}`).join(", ");
253
+ throw new EmailInvalidPayloadError({ detail: `template '${templateId}' payload invalid: ${summary}` });
254
+ }
255
+ return entry.subject(renderContext(parsed.data as Record<string, unknown>, entry, theme, translator)).trim();
256
+ }
257
+
258
+ /**
259
+ * The render context: the parsed payload, the theme, and what the shell needs to be written in a
260
+ * language.
261
+ *
262
+ * One builder for both render sites, because the two used to assemble it separately and the *subject*
263
+ * is the field they were most able to disagree about — `renderSubject` runs at enqueue and `renderEmail`
264
+ * runs at send, and until the locale landed on the row there was nothing tying them to one language.
265
+ */
266
+ function renderContext(
267
+ payload: Record<string, unknown>,
268
+ entry: Compiled,
269
+ theme: EmailTheme,
270
+ translator: Translator,
271
+ ): Record<string, unknown> {
272
+ return {
273
+ ...payload,
274
+ theme,
275
+ layoutWidth: widthPx(entry.def.width),
276
+ // What the document declares itself as. `lang` is the catalog locale — the language the words are
277
+ // actually in — and `dir` is what an RTL reader's client needs before it lays anything out.
278
+ lang: translator.catalogLocale,
279
+ dir: translator.direction,
280
+ [TRANSLATOR_KEY]: translator,
281
+ };
282
+ }
283
+
284
+ function trimTrailingSlash(value: string): string {
285
+ return value.endsWith("/") ? value.slice(0, -1) : value;
286
+ }
287
+
288
+ /** Rewrite one declared link path (`"url"` or `"articles[].link"`) in the context through `makeUrl`. */
289
+ async function rewriteLink(
290
+ context: Record<string, unknown>,
291
+ path: string,
292
+ label: string,
293
+ makeUrl: (destination: string, label: string) => Promise<string>,
294
+ ): Promise<void> {
295
+ const arrayMatch = path.split("[].");
296
+ if (arrayMatch.length === 2) {
297
+ const arrayKey = arrayMatch[0];
298
+ const field = arrayMatch[1];
299
+ if (!arrayKey || !field) return;
300
+ const items = context[arrayKey];
301
+ if (Array.isArray(items)) {
302
+ for (let i = 0; i < items.length; i += 1) {
303
+ const item = items[i] as Record<string, unknown>;
304
+ if (item && typeof item[field] === "string")
305
+ item[field] = await makeUrl(item[field] as string, `${label}-${i}`);
306
+ }
307
+ }
308
+ return;
309
+ }
310
+ if (typeof context[path] === "string") context[path] = await makeUrl(context[path] as string, label);
311
+ }
312
+
313
+ /**
314
+ * Render a template to `{ subject, html, text }`. Validates the payload against the template's schema
315
+ * (throws `email/invalid_payload`), applies click/open tracking and — for an elective template — the
316
+ * unsubscribe link, then substitutes. A marketing template with no tracking context throws: it cannot
317
+ * render without an unsubscribe link.
318
+ *
319
+ * The opt-out rule is two-tier on purpose. Every elective template carries the link *when the engine can
320
+ * mint one*; a marketing template additionally *refuses to render* without it. Tying the hard refusal to
321
+ * the kind instead would mean a project that has not configured a link-signing key suddenly cannot send
322
+ * its testing-cohort nudges — a new outage, in a change whose whole subject is mail that fails silently.
323
+ */
324
+ export async function renderEmail(
325
+ templateId: string,
326
+ payload: unknown,
327
+ theme: EmailTheme,
328
+ tracking?: RenderTracking,
329
+ translator: Translator = ENGLISH,
330
+ ): Promise<RenderResult> {
331
+ const entry = compiled.get(templateId);
332
+ if (!entry) throw new EmailTemplateNotFoundError({ detail: `no template registered with id '${templateId}'` });
333
+
334
+ const parsed = entry.def.payload.safeParse(payload);
335
+ if (!parsed.success) {
336
+ const summary = parsed.error.issues.map((i) => `${i.path.join(".") || "<root>"}:${i.code}`).join(", ");
337
+ throw new EmailInvalidPayloadError({ detail: `template '${templateId}' payload invalid: ${summary}` });
338
+ }
339
+
340
+ const isMarketing = entry.def.category === "marketing";
341
+ const isElective = entry.def.kind === "elective";
342
+ if (isMarketing && !tracking) {
343
+ throw new EmailInvalidPayloadError({
344
+ message: "Marketing emails require an unsubscribe link.",
345
+ detail: `template '${templateId}' is marketing but no signing/tracking context was provided`,
346
+ });
347
+ }
348
+
349
+ const context: Record<string, unknown> = renderContext(
350
+ parsed.data as Record<string, unknown>,
351
+ entry,
352
+ theme,
353
+ translator,
354
+ );
355
+ let unsubscribeUrl: string | undefined;
356
+
357
+ if (tracking) {
358
+ const base = trimTrailingSlash(tracking.baseUrl) + CALLBACK_BASE;
359
+ const mint = (kind: "click" | "open" | "unsubscribe", extra: { destination?: string; linkLabel?: string }) =>
360
+ mintToken(
361
+ { kind, jobId: tracking.jobId, recipient: tracking.recipient, campaignId: tracking.campaignId, ...extra },
362
+ { key: tracking.key, kid: tracking.kid, expiresAt: tracking.expiresAt },
363
+ );
364
+
365
+ if (tracking.clickTracking) {
366
+ for (const link of entry.def.links) {
367
+ await rewriteLink(context, link.path, link.label, async (destination, label) => {
368
+ const token = await mint("click", { destination, linkLabel: label });
369
+ return `${base}/c/${token}`;
370
+ });
371
+ }
372
+ }
373
+
374
+ if (tracking.openTracking) {
375
+ context.openPixelUrl = `${base}/o/${await mint("open", {})}.png`;
376
+ }
377
+
378
+ // Elective mail carries an unsubscribe link; transactional mail never does. Keyed on the kind the
379
+ // template declared, so this cannot be reached for a sign-in link by any argument a caller passes.
380
+ if (isElective) {
381
+ unsubscribeUrl = `${base}/u/${await mint("unsubscribe", {})}`;
382
+ context.unsubscribeUrl = unsubscribeUrl;
383
+ }
384
+ }
385
+
386
+ return {
387
+ subject: entry.subject(context).trim(),
388
+ html: entry.html(context),
389
+ text: entry.text(context),
390
+ ...(unsubscribeUrl ? { unsubscribeUrl } : {}),
391
+ };
392
+ }