@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,109 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // LOCALE es — an unreviewed first pass. Not American English by design.
5
+
6
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
7
+
8
+ /**
9
+ * The kit-authored email copy, in Spanish.
10
+ *
11
+ * **Beside the English rather than in `@pithy-sh/i18n`, and that is the point of #442.** The send
12
+ * Workflow runs in its own Worker, deployed by `pithy email provision` with no request and no access
13
+ * to the adopter's config — so anything it does not *bundle* has to be stamped into it as a variable.
14
+ * Held in the translations package, the kit's own Spanish was static data traveling through a
15
+ * configuration channel every provision run, against a 5 KB per-variable ceiling. Held here, the host
16
+ * is built with it and the variable carries only what an adopter changed, which is usually nothing.
17
+ *
18
+ * It also puts this file where the domain rule already says it belongs: a capability may only declare
19
+ * keys under its own name, and `@pithy-sh/email` contributes `email/` in every language it has rather
20
+ * than English alone. `@pithy-sh/i18n` never imports a capability and no capability imports it —
21
+ * principle 4 holds in both directions, which a dependency edge for this data would have broken.
22
+ *
23
+ * Only the seven templates whose words the kit writes — `magicLink`, `otp`, `welcome`,
24
+ * `securityAlert`, `invite`, `passwordChanged`, `leadCapture` — plus the shell: the severity labels
25
+ * and the footer's unsubscribe link.
26
+ *
27
+ * The five templates whose words arrive as payload (`testerNudge`, `supportReply`,
28
+ * `operationalNotice`, `newsletter`, `marketingCampaign`) are the adopter's copy and are not here.
29
+ * **The shell follows the job's locale; a payload-carrying template is only as localized as the
30
+ * adopter's own copy** — a job at locale `es` renders the severity word from this catalog and the
31
+ * summary from the adopter's payload. That is the right behavior and it is a surprise unless stated,
32
+ * so `docs/I18N.md` states both halves.
33
+ */
34
+ export const EMAIL_ES: MessageCatalog = {
35
+ // --- The shared shell ---
36
+ // Two greetings, because Spanish addresses an unnamed reader differently rather than with the name
37
+ // removed — the case that made a single string with an optional placeholder the wrong shape.
38
+ "email/shell.greeting": "Hola:",
39
+ "email/shell.greeting_named": "Hola {name}:",
40
+ "email/shell.unsubscribe": "Cancelar la suscripción",
41
+
42
+ // --- Severity: the word first, the color second ---
43
+ // "Requiere acción" rather than "Advertencia" for the same reason the English says "Action needed"
44
+ // and not "Warning": the label names what the message wants from the reader, not how loudly it is
45
+ // being said.
46
+ "email/severity.info": "Aviso",
47
+ "email/severity.warning": "Requiere acción",
48
+ "email/severity.critical": "Crítico",
49
+
50
+ // --- magicLink ---
51
+ "email/magic_link.subject": "Tu enlace de acceso",
52
+ "email/magic_link.heading": "Iniciar sesión",
53
+ "email/magic_link.instruction": "usa el botón de abajo para iniciar sesión.",
54
+ "email/magic_link.expiry.one": "Caduca en {count} minuto.",
55
+ "email/magic_link.expiry.other": "Caduca en {count} minutos.",
56
+ "email/magic_link.cta": "Iniciar sesión",
57
+ "email/magic_link.ignore": "Si no lo solicitaste, puedes ignorar este correo.",
58
+ "email/magic_link.text_instruction.one": "Usa este enlace para iniciar sesión (caduca en {count} minuto):",
59
+ "email/magic_link.text_instruction.other": "Usa este enlace para iniciar sesión (caduca en {count} minutos):",
60
+ "email/magic_link.text_ignore": "Si no lo solicitaste, ignora este correo.",
61
+
62
+ // --- otp ---
63
+ "email/otp.subject": "Tu código de verificación",
64
+ "email/otp.heading": "Tu código",
65
+ "email/otp.lead": "tu código de verificación es:",
66
+ "email/otp.expiry.one": "Caduca en {count} minuto.",
67
+ "email/otp.expiry.other": "Caduca en {count} minutos.",
68
+ "email/otp.text_body.one": "Tu código de verificación es {code}. Caduca en {count} minuto.",
69
+ "email/otp.text_body.other": "Tu código de verificación es {code}. Caduca en {count} minutos.",
70
+
71
+ // --- welcome ---
72
+ "email/welcome.subject": "Te damos la bienvenida a {app}",
73
+ "email/welcome.heading": "Te damos la bienvenida a {app}",
74
+ "email/welcome.body": "Hola {name}: te damos la bienvenida a {app}. Nos alegra tenerte aquí.",
75
+ "email/welcome.text_body": "Te damos la bienvenida a {app}. Nos alegra tenerte aquí.",
76
+
77
+ // --- securityAlert ---
78
+ "email/security_alert.subject": "Alerta de seguridad: {event}",
79
+ "email/security_alert.heading": "Alerta de seguridad",
80
+ "email/security_alert.body": "{event} el {when}.",
81
+ "email/security_alert.ip": "Dirección IP: {ip}.",
82
+ "email/security_alert.text_ip": "IP: {ip}.",
83
+ "email/security_alert.reassure": "Si fuiste tú, no hace falta hacer nada.",
84
+ "email/security_alert.cta": "Revisar la actividad",
85
+ "email/security_alert.text_action": "Si no fuiste tú, protege tu cuenta:",
86
+
87
+ // --- invite ---
88
+ "email/invite.subject": "{inviter} te ha invitado a {organization}",
89
+ "email/invite.heading": "Tienes una invitación",
90
+ "email/invite.body": "{inviter} te ha invitado a unirte a {organization} en {app}.",
91
+ "email/invite.cta": "Aceptar la invitación",
92
+ "email/invite.text_accept": "Aceptar:",
93
+
94
+ // --- passwordChanged ---
95
+ "email/password_changed.subject": "Tu contraseña ha cambiado",
96
+ "email/password_changed.heading": "Tu contraseña ha cambiado",
97
+ "email/password_changed.body": "las credenciales de tu cuenta cambiaron el {when}.",
98
+ "email/password_changed.warn": "Si no fuiste tú, contacta con soporte de inmediato.",
99
+ "email/password_changed.cta": "Contactar con soporte",
100
+ "email/password_changed.text_body":
101
+ "Las credenciales de tu cuenta cambiaron el {when}. Si no fuiste tú, contacta con soporte:",
102
+
103
+ // --- leadCapture ---
104
+ "email/lead_capture.subject": "Tu descarga: {asset}",
105
+ "email/lead_capture.heading": "Tu descarga está lista",
106
+ "email/lead_capture.ready": "Tu copia de {asset} ya está lista.",
107
+ "email/lead_capture.cta": "Descargar ahora",
108
+ "email/lead_capture.text_ready": "Tu copia de {asset} ya está lista:",
109
+ };
@@ -0,0 +1,315 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { type LocaleCatalogs, MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
5
+ import { parseLocale } from "@pithy-sh/core/src/i18n/locale";
6
+ import { createTranslator, DEFAULT_LOCALE, type Translator } from "@pithy-sh/core/src/i18n/translator";
7
+ import { EMAIL_ES } from "./messages.es";
8
+
9
+ /**
10
+ * The words this capability writes, in the language it writes them in.
11
+ *
12
+ * ## Why the words live here and not in the template source
13
+ *
14
+ * The obvious way to make an email speak a second language is to precompile the template twice. It is
15
+ * also the expensive way, and the expense is not the part that varies. `precompiled.generated.ts` is
16
+ * 132KB for 38 specs, and almost none of that is prose: it is a Gmail-safe table shell, a VML button
17
+ * fallback, an inline color on every cell, and a `prefers-color-scheme` block — markup that is
18
+ * byte-identical in every language and would be duplicated wholesale per locale, into a Worker bundle
19
+ * that has no lazy loading to hide it behind.
20
+ *
21
+ * It also cannot be done without breaking something structural. The engine registers `emailHead` and
22
+ * `emailFoot` **once, by bare name, on one `Handlebars.create()`**, and caches a compiled entry per
23
+ * template **id**. Per-locale sources force one of two changes: locale-qualified partial names
24
+ * (`emailHead:es`), which means every template body has to know its own locale before it can name its
25
+ * own shell, or one engine instance per locale, which multiplies the partial registry and the compiled
26
+ * cache by the locale count for a document shell that never changes. Neither buys anything, because
27
+ * `engine.test.ts` pins the sorted template id list — so a locale variant could never be an id anyway.
28
+ *
29
+ * So the split is: **the layout is the template's, the words are the catalog's.** One precompiled
30
+ * template per id, one partial registry, one engine, and `{{t}}` / `{{tn}}` join them at render. Adding
31
+ * a locale then costs a catalog and nothing else — no rebuild of the specs, no growth in the shipped
32
+ * artifact beyond the sentences themselves.
33
+ *
34
+ * ## Escaping
35
+ *
36
+ * A catalog value reaching the HTML body goes through an ordinary `{{t …}}` mustache, so Handlebars
37
+ * escapes it exactly as it escapes a payload value — the helpers return plain strings and **never** a
38
+ * `SafeString`, which is what keeps that true. `subject` and `text` are precompiled with
39
+ * `noEscape: true`, so a value substituted there is unescaped; that is correct for those parts (a
40
+ * subject line and a plain-text body are not HTML contexts) and it is why nothing in this file, and
41
+ * nothing an adopter overrides it with, may carry markup. Interpolated parameters are escaped with the
42
+ * sentence that carries them, so `{{t "email/welcome.body" name=name}}` escapes `name` exactly as
43
+ * `{{name}}` did.
44
+ *
45
+ * ## Only the kit's own copy
46
+ *
47
+ * Seven templates are here. The five whose words arrive as payload — `testerNudge`, `supportReply`,
48
+ * `operationalNotice`, `newsletter`, `marketingCampaign` — are the adopter's copy, chosen by a human
49
+ * for one message, and a catalog cannot translate a sentence it has never seen. Their **shell** still
50
+ * follows the job's locale (the severity word, the footer, the document's `lang` and `dir`), so a
51
+ * notice at locale `es` reads its severity in Spanish and its summary in whatever the caller wrote.
52
+ */
53
+
54
+ /** The kit's English for every string the seven kit-authored templates and the shared shell render. */
55
+ const EMAIL_EN: MessageCatalog = {
56
+ // --- The shared shell ---
57
+ // The greeting is two keys rather than one with an optional placeholder, because a language that
58
+ // greets an unnamed reader differently — not merely with the name removed — has no way to say so in
59
+ // a single string. English happens to differ only by the name; Spanish already differs by more.
60
+ "email/shell.greeting": "Hi,",
61
+ "email/shell.greeting_named": "Hi {name},",
62
+ "email/shell.unsubscribe": "Unsubscribe",
63
+
64
+ // --- Severity, which is words first and color second (see `severity.ts`) ---
65
+ "email/severity.info": "Notice",
66
+ "email/severity.warning": "Action needed",
67
+ "email/severity.critical": "Critical",
68
+
69
+ // --- magicLink ---
70
+ "email/magic_link.subject": "Your sign-in link",
71
+ "email/magic_link.heading": "Sign in",
72
+ "email/magic_link.instruction": "use the button below to sign in.",
73
+ "email/magic_link.expiry.one": "It expires in {count} minute.",
74
+ "email/magic_link.expiry.other": "It expires in {count} minutes.",
75
+ "email/magic_link.cta": "Sign in",
76
+ "email/magic_link.ignore": "If you didn't request this, you can ignore this email.",
77
+ "email/magic_link.text_instruction.one": "Use this link to sign in (expires in {count} minute):",
78
+ "email/magic_link.text_instruction.other": "Use this link to sign in (expires in {count} minutes):",
79
+ "email/magic_link.text_ignore": "If you didn't request this, ignore this email.",
80
+
81
+ // --- otp ---
82
+ "email/otp.subject": "Your verification code",
83
+ "email/otp.heading": "Your code",
84
+ "email/otp.lead": "your verification code is:",
85
+ "email/otp.expiry.one": "It expires in {count} minute.",
86
+ "email/otp.expiry.other": "It expires in {count} minutes.",
87
+ "email/otp.text_body.one": "Your verification code is {code}. It expires in {count} minute.",
88
+ "email/otp.text_body.other": "Your verification code is {code}. It expires in {count} minutes.",
89
+
90
+ // --- welcome ---
91
+ "email/welcome.subject": "Welcome to {app}",
92
+ "email/welcome.heading": "Welcome to {app}",
93
+ "email/welcome.body": "Hi {name}, welcome to {app}. We're glad you're here.",
94
+ "email/welcome.text_body": "Welcome to {app}. We're glad you're here.",
95
+
96
+ // --- securityAlert ---
97
+ "email/security_alert.subject": "Security alert: {event}",
98
+ "email/security_alert.heading": "Security alert",
99
+ "email/security_alert.body": "{event} on {when}.",
100
+ "email/security_alert.ip": "IP address: {ip}.",
101
+ "email/security_alert.text_ip": "IP: {ip}.",
102
+ "email/security_alert.reassure": "If this was you, no action is needed.",
103
+ "email/security_alert.cta": "Review activity",
104
+ "email/security_alert.text_action": "If this wasn't you, secure your account:",
105
+
106
+ // --- invite ---
107
+ "email/invite.subject": "{inviter} invited you to {organization}",
108
+ "email/invite.heading": "You're invited",
109
+ // The organization name lost its `<strong>` when this sentence moved into the catalog, deliberately.
110
+ // Markup in a catalog value is the one thing `interpolate` refuses to reason about, and splitting the
111
+ // sentence into three fragments so the middle one could be bold would pin English word order into
112
+ // every translation of it. A bold noun is not worth a sentence that cannot be reordered.
113
+ "email/invite.body": "{inviter} invited you to join {organization} on {app}.",
114
+ "email/invite.cta": "Accept invitation",
115
+ "email/invite.text_accept": "Accept:",
116
+
117
+ // --- passwordChanged ---
118
+ "email/password_changed.subject": "Your password was changed",
119
+ "email/password_changed.heading": "Your password was changed",
120
+ "email/password_changed.body": "your account credentials were changed on {when}.",
121
+ "email/password_changed.warn": "If this wasn't you, contact support immediately.",
122
+ "email/password_changed.cta": "Contact support",
123
+ "email/password_changed.text_body":
124
+ "Your account credentials were changed on {when}. If this wasn't you, contact support:",
125
+
126
+ // --- leadCapture ---
127
+ "email/lead_capture.subject": "Your download: {asset}",
128
+ "email/lead_capture.heading": "Your download is ready",
129
+ // Same trade as `invite.body`: the asset name was `<strong>` and is now plain, because the
130
+ // alternative is a sentence assembled from fragments in English order.
131
+ "email/lead_capture.ready": "Your copy of {asset} is ready.",
132
+ "email/lead_capture.cta": "Download now",
133
+ "email/lead_capture.text_ready": "Your copy of {asset} is ready:",
134
+ };
135
+
136
+ /**
137
+ * Every language this capability's own copy is written in.
138
+ *
139
+ * **Bundled, not stamped.** The send Worker is deployed with this map inside it, so adding a language
140
+ * to the kit costs an adopter a package upgrade and no configuration at all — which is the whole of
141
+ * #442. What still travels as a variable is the adopter's diff against this: usually nothing, and at
142
+ * most the sentences they changed.
143
+ */
144
+ export const EMAIL_MESSAGES: LocaleCatalogs = { en: EMAIL_EN, es: EMAIL_ES };
145
+
146
+ /**
147
+ * How the render engine finds words for a locale: the catalogs to walk, most-specific first.
148
+ *
149
+ * The same shape `@pithy-sh/i18n` already exposes as `layersFor`, so a composed project hands its own
150
+ * — adopter overrides, the kit's translation, every capability's English — straight through, and this
151
+ * package never imports that one. Absent, {@link kitEmailLayers} answers, which is what makes the i18n
152
+ * capability optional here exactly as it is everywhere else.
153
+ */
154
+ export type EmailMessageLayers = (locale: string) => readonly (MessageCatalog | undefined)[];
155
+
156
+ /** The layers a project that composed no i18n capability walks: this package's own English, and nothing else. */
157
+ export const kitEmailLayers: EmailMessageLayers = (locale) => [bundledFor(locale), EMAIL_EN];
158
+
159
+ /** The primary language subtag of a tag — `es` of `es-AR`. Null when it is not a tag at all. */
160
+ function languageOf(tag: string): string | null {
161
+ return parseLocale(tag)?.language ?? null;
162
+ }
163
+
164
+ /**
165
+ * This package's own copy for `locale`, reduced to the language when the region was not written for.
166
+ *
167
+ * **The reduction has to happen here too, and not only over the stamped catalogs.** It did not, and
168
+ * that was a regression the moment #442 bundled the kit's translations: the stamped catalog used to
169
+ * *be* the kit's Spanish, so reducing `es-AR` to `es` over it found the words. With the kit's copy
170
+ * bundled here instead, the stamped catalog is empty for a project that overrode nothing, and an
171
+ * exact-tag lookup on `es-AR` answered `undefined` — so a reader whose `pithy_auth_users.locale` is
172
+ * `es-AR` or `es-MX` got an English letter from a project that ships Spanish.
173
+ */
174
+ function bundledFor(locale: string): MessageCatalog | undefined {
175
+ const language = languageOf(locale);
176
+ return EMAIL_MESSAGES[locale] ?? (language ? EMAIL_MESSAGES[language] : undefined);
177
+ }
178
+
179
+ /**
180
+ * The layers behind a serialized catalog set — what the prebuilt email host worker uses.
181
+ *
182
+ * That worker is standalone: nothing composes capabilities inside it, so it cannot be handed a
183
+ * composed project's `layersFor`. It reads the catalogs as one JSON var instead, and this turns them
184
+ * into the same seam. English stays last, so a locale that translated nine sentences out of ten still
185
+ * renders the tenth rather than its key.
186
+ */
187
+ /**
188
+ * The prefix every per-locale catalog variable carries on the email and testers hosts.
189
+ *
190
+ * **One variable per locale, not one variable holding every locale.** Cloudflare's 5 KB ceiling is per
191
+ * variable, and a Worker gets 64 of them on the free plan and 128 on paid — so a project shipping
192
+ * twenty languages has twenty 3 KB values, each comfortably inside the limit, rather than one 62 KB
193
+ * value that is refused outright.
194
+ *
195
+ * It was one variable at first, and the ceiling that produced was absurd on inspection: every language
196
+ * pack fits with 2 KB to spare, and the project still could not deploy a second one. Nothing about the
197
+ * data was near a limit — the limit was manufactured by concatenating packs that are read one at a
198
+ * time. The host renders one email, in one locale, from the job's row; it never needs the other
199
+ * nineteen. Splitting the transport is the whole fix, and the in-memory shape is unchanged.
200
+ */
201
+ export const EMAIL_CATALOG_VAR_PREFIX = "EMAIL_MESSAGES_";
202
+
203
+ /**
204
+ * The variable name a locale's catalog travels under — `es-AR` becomes `EMAIL_MESSAGES_ES_AR`.
205
+ *
206
+ * Upper-cased with dashes as underscores, which is the shape a Worker variable name takes. The mapping
207
+ * is injective because `Locale` forbids `_` in a tag, so no two tags can collide on one name.
208
+ */
209
+ export function emailCatalogVarName(locale: string): string {
210
+ return `${EMAIL_CATALOG_VAR_PREFIX}${locale.replaceAll("-", "_").toUpperCase()}`;
211
+ }
212
+
213
+ /**
214
+ * The locale a variable name carries, or `null` when the name is not one of ours.
215
+ *
216
+ * **Canonicalized through `Intl`, not merely lower-cased.** A variable name has one case and a tag has
217
+ * three — `pt-BR`, `zh-Hant-TW`, `es-419` — so the name alone cannot say which. `Intl.Locale` restores
218
+ * it: `pt_br` reads back as `pt-BR`, script subtags title-cased and regions upper-cased, which is the
219
+ * spelling `catalogLayers` looks the catalog up under. Lower-cased instead, a `pt-BR` job would miss a
220
+ * `pt-br` catalog and silently render the kit's English.
221
+ */
222
+ export function localeFromCatalogVar(name: string): string | null {
223
+ if (!name.startsWith(EMAIL_CATALOG_VAR_PREFIX)) return null;
224
+ const tag = name.slice(EMAIL_CATALOG_VAR_PREFIX.length).replaceAll("_", "-").toLowerCase();
225
+ if (tag.length === 0) return null;
226
+ return parseLocale(tag)?.baseName ?? null;
227
+ }
228
+
229
+ /**
230
+ * Every catalog this host was deployed with, collected off the raw env.
231
+ *
232
+ * **Read from the raw env rather than declared on `EmailHostEnv`**, because the variable *names* are
233
+ * the project's locales and a Zod object can only declare names known when it was written. The values
234
+ * are still validated — each one is parsed through `LocaleCatalogs`' own `MessageCatalog`, so a
235
+ * variable holding something that is not a catalog is a boot failure rather than a render failure.
236
+ *
237
+ * A locale whose value will not parse is skipped rather than fatal: the render falls through to the
238
+ * kit's English for that language and every other language still works. One unreadable variable
239
+ * should not stop a host from sending mail.
240
+ */
241
+ export function catalogsFromEnv(env: Record<string, unknown>): LocaleCatalogs {
242
+ const catalogs: LocaleCatalogs = {};
243
+ for (const [name, raw] of Object.entries(env)) {
244
+ const locale = localeFromCatalogVar(name);
245
+ if (locale === null || typeof raw !== "string" || raw.trim().length === 0) continue;
246
+ try {
247
+ const parsed = MessageCatalog.safeParse(JSON.parse(raw));
248
+ if (parsed.success) catalogs[locale] = parsed.data;
249
+ } catch {
250
+ // Not JSON. Skipped for the same reason an unparseable one is: the other locales still send.
251
+ }
252
+ }
253
+ return catalogs;
254
+ }
255
+
256
+ export function catalogLayers(catalogs: LocaleCatalogs): EmailMessageLayers {
257
+ return (locale) => {
258
+ const language = languageOf(locale);
259
+ // `es-AR` reads the `es` catalog. The reduction happens here rather than in
260
+ // {@link emailTranslator} because this is the only seam that can *see* the whole catalog map and
261
+ // therefore answer whether the region was written for; a translator that appended a second layer
262
+ // set blindly would put the first set's English fallback ahead of the second set's Spanish, which
263
+ // is a regional reader silently getting the source language. A composed project does not reach
264
+ // this path: `@pithy-sh/i18n` negotiates `es-AR` down to `es` at the request, before a locale is
265
+ // ever stored or enqueued.
266
+ const exact = catalogs[locale] ?? (language ? catalogs[language] : undefined);
267
+ return [exact, bundledFor(locale), EMAIL_EN];
268
+ };
269
+ }
270
+
271
+ /**
272
+ * The tag this render will actually be written in — the one it was asked for, or the kit's default.
273
+ *
274
+ * **The guard is here because this is the last place before `Intl`.** `Translator.plural` builds an
275
+ * `Intl.PluralRules` on the catalog locale, and `Intl` refuses tags that look perfectly well formed:
276
+ * `en-x`, `en-t`, `en-u`, `en-1`, `en-US-x` and `en-a-bbb-a-ccc` are a singleton subtag with nothing
277
+ * after it, or a repeated extension, and every one of them raises `RangeError`. `magicLink` and `otp`
278
+ * — the two sends the whole of authentication rests on — render `{{tn}}`, so that throw lands inside
279
+ * `renderEmail`: at enqueue it is a raw `RangeError` out of a request handler rather than a
280
+ * `PithyError`, and at send it is one inside the send Workflow, where `classifySendError` sees no code
281
+ * it knows and the job burns its retries and wedges.
282
+ *
283
+ * `@pithy-sh/core`'s `Locale` refuses the same six on the way into D1, and that is the repair that
284
+ * matters — a tag `Intl` will not take should never reach `pithy_email_jobs.locale`. This is the
285
+ * belt: the two callers here (`enqueueEmail`, `runSend`) take a bare `string`, one of them *before*
286
+ * anything has been validated, and a row written by an adopter's own SQL or by a build that predates
287
+ * that refinement is still a row this Worker has to render. Falling back costs a message in the wrong
288
+ * language; not falling back costs the message.
289
+ */
290
+ function renderLocale(locale: string | null | undefined): string {
291
+ const tag = locale?.trim();
292
+ if (!tag) return DEFAULT_LOCALE;
293
+ return parseLocale(tag) === null ? DEFAULT_LOCALE : tag;
294
+ }
295
+
296
+ /**
297
+ * The translator one email renders through.
298
+ *
299
+ * **Built per render from the job's own locale, never from the request's.** A send happens inside a
300
+ * Workflow hours or days after the enqueue, in a Worker with no request on it at all, so `c.var.t` —
301
+ * the seam every HTTP surface uses — does not exist at the moment that matters. The locale is on the
302
+ * row for exactly that reason, and this is what turns it back into words.
303
+ *
304
+ * **The two locales stay apart.** `catalogLocale` is the tag as stored — it is what the document
305
+ * declares itself as and what decides text direction — and `formattingLocale` is the same tag handed to
306
+ * `Intl`, so an Argentine reading Spanish still gets Argentine numbers and dates. Collapsing them is
307
+ * the bug `Translator` carries two fields to prevent. Which *catalog* answers a regional tag is
308
+ * {@link catalogLayers}'s question, not this one's.
309
+ *
310
+ * Both of them come from {@link renderLocale}, so a tag `Intl` refuses can reach neither.
311
+ */
312
+ export function emailTranslator(locale: string | null | undefined, layersFor: EmailMessageLayers): Translator {
313
+ const tag = renderLocale(locale);
314
+ return createTranslator({ catalogLocale: tag, formattingLocale: tag, layers: layersFor(tag) });
315
+ }
@@ -0,0 +1,88 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The shared head + footer partials every template includes via `{{> emailHead}}` ... `{{> emailFoot}}`.
6
+ * They carry the full document shell: a Gmail-safe table layout, the theme (logo light/dark swap, accent,
7
+ * width), the light-mode colors applied inline, and a `prefers-color-scheme: dark` style block that swaps
8
+ * in the dark palette (honored by Apple Mail, iOS Mail, and the Outlook app; Gmail self-inverts and ignores
9
+ * the query — a known limit). The open-tracking pixel and the unsubscribe link render only when the render
10
+ * context supplies them; the engine guarantees a marketing send always supplies the unsubscribe.
11
+ *
12
+ * The dark block also carries the `.sev-*` severity colors, which are the one thing in this shell that
13
+ * is not the brand's to set — see `severity.ts`. They live here because this is where every other
14
+ * dark-mode swap lives, and they are generated from the same table the inline light colors come from.
15
+ *
16
+ * **The shell speaks the job's language.** `lang` and `dir` are injected by the engine from the
17
+ * render's translator, so a document is declared in the language it is actually written in and an RTL
18
+ * locale lays out right-to-left rather than being mirrored by hand — there was no `dir` here at all
19
+ * before pithy-sh/pithy#441, which meant an Arabic body inherited a left-to-right shell. The footer's
20
+ * opt-out word comes from the catalog for the same reason: it is the one sentence in this file, and a
21
+ * Spanish reader offered `Unsubscribe` has been told the rest of the letter was a translation of
22
+ * something.
23
+ *
24
+ * Restyled from the Leed/Pithy email shell. Variables are Handlebars: `{{theme.*}}`, `{{layoutWidth}}`,
25
+ * `{{lang}}`, `{{dir}}`, `{{openPixelUrl}}`, `{{unsubscribeUrl}}`.
26
+ */
27
+
28
+ import { severityDarkModeCss } from "./severity";
29
+
30
+ const FONT = "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
31
+
32
+ /** Doctype, head (with the dark-mode style block), body open, the branded masthead, and the content cell. */
33
+ export const emailHead = `<!DOCTYPE html>
34
+ <html lang="{{lang}}" dir="{{dir}}" xmlns:v="urn:schemas-microsoft-com:vml">
35
+ <head>
36
+ <meta charset="utf-8">
37
+ <meta name="x-apple-disable-message-reformatting">
38
+ <meta name="viewport" content="width=device-width, initial-scale=1">
39
+ <meta name="color-scheme" content="light dark">
40
+ <meta name="supported-color-schemes" content="light dark">
41
+ <style>
42
+ .hover-underline:hover { text-decoration: underline !important }
43
+ @media (prefers-color-scheme: dark) {
44
+ .email-bg { background-color: {{theme.dark.background}} !important; }
45
+ .email-card { background-color: {{theme.dark.cardBackground}} !important; box-shadow: none !important; }
46
+ .t-ink { color: {{theme.dark.text}} !important; }
47
+ .t-muted { color: {{theme.dark.textMuted}} !important; }
48
+ .t-subtle { color: {{theme.dark.textSubtle}} !important; }
49
+ .sep { background-color: {{theme.dark.separator}} !important; }
50
+ .logo-light { display: none !important; }
51
+ .logo-dark { display: inline-block !important; max-height: none !important; overflow: visible !important; }
52
+ ${severityDarkModeCss}
53
+ }
54
+ </style>
55
+ </head>
56
+ <body class="email-bg" style="margin: 0; width: 100%; background-color: {{theme.light.background}}; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
57
+ <div role="article" aria-roledescription="email" lang="{{lang}}" dir="{{dir}}">
58
+ {{#if openPixelUrl}}<img src="{{openPixelUrl}}" width="1" height="1" alt="" style="position: absolute; left: 0; top: 0; max-width: 1px; max-height: 1px; opacity: 0; border: 0">{{/if}}
59
+ <div class="email-bg" style="background-color: {{theme.light.background}}; font-family: ${FONT}; padding: 0 16px">
60
+ <table align="center" cellpadding="0" cellspacing="0" role="none" style="width: {{layoutWidth}}px; max-width: {{layoutWidth}}px">
61
+ <tr><td>
62
+ <div style="margin-top: 40px; margin-bottom: 32px; text-align: center">
63
+ {{#if theme.logoUrl}}<img src="{{theme.logoUrl}}" width="150" alt="{{theme.appName}}" class="logo-light" style="max-width: 100%; vertical-align: middle; border: 0">{{#if theme.logoDarkUrl}}<img src="{{theme.logoDarkUrl}}" width="150" alt="{{theme.appName}}" class="logo-dark" style="display: none; max-height: 0; overflow: hidden; max-width: 100%; vertical-align: middle; border: 0">{{/if}}{{else}}<span class="t-ink" style="font-size: 22px; font-weight: 700; color: {{theme.accent}}">{{theme.appName}}</span>{{/if}}
64
+ </div>
65
+ <table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
66
+ <tr>
67
+ <td class="email-card t-muted" style="border-radius: 8px; background-color: {{theme.light.cardBackground}}; padding: 44px; font-size: 16px; line-height: 26px; color: {{theme.light.textMuted}}; box-shadow: 0 1px 2px 0 rgba(17,17,17,0.06); border-top: 3px solid {{theme.accent}}">`;
68
+
69
+ /** Closes the content cell, renders the footer (logo, links, address, optional unsubscribe), and the document. */
70
+ export const emailFoot = `</td>
71
+ </tr>
72
+ <tr role="separator"><td style="line-height: 40px">&zwj;</td></tr>
73
+ <tr>
74
+ <td class="t-subtle" style="padding: 0 24px; text-align: center; font-size: 12px; line-height: 18px; color: {{theme.light.textSubtle}}">
75
+ <p class="t-subtle" style="margin: 0; color: {{theme.light.textSubtle}}">{{theme.appName}}</p>
76
+ {{#if theme.links.length}}<p style="margin: 12px 0 0">{{#each theme.links}}{{#unless @first}} &middot; {{/unless}}<a href="{{href}}" class="hover-underline t-muted" style="color: {{../theme.light.textMuted}}; text-decoration: none">{{label}}</a>{{/each}}</p>{{/if}}
77
+ {{#if theme.footerAddress}}<p class="t-subtle" style="margin: 14px 0 0; color: {{theme.light.textSubtle}}">{{theme.footerAddress}}</p>{{/if}}
78
+ {{#if unsubscribeUrl}}<p style="margin: 12px 0 0"><a href="{{unsubscribeUrl}}" class="hover-underline t-subtle" style="color: {{theme.light.textSubtle}}; text-decoration: underline">{{t "email/shell.unsubscribe"}}</a></p>{{/if}}
79
+ </td>
80
+ </tr>
81
+ <tr role="separator"><td style="line-height: 40px">&zwj;</td></tr>
82
+ </table>
83
+ </td></tr>
84
+ </table>
85
+ </div>
86
+ </div>
87
+ </body>
88
+ </html>`;