@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.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +55 -0
- package/pithy.manifest.json +73 -0
- package/src/analytics.ts +39 -0
- package/src/audit/actions.ts +48 -0
- package/src/bounce/classify.ts +103 -0
- package/src/bounce/handler.ts +136 -0
- package/src/capability.ts +385 -0
- package/src/cloudflare-test.d.ts +19 -0
- package/src/crypto/signingKey.ts +44 -0
- package/src/crypto/token.ts +148 -0
- package/src/data/emailEvent.ts +42 -0
- package/src/data/emailJob.ts +138 -0
- package/src/data/emailSuppression.ts +40 -0
- package/src/data/enums.ts +75 -0
- package/src/data/tables.ts +47 -0
- package/src/error/errors.ts +129 -0
- package/src/http/callbacks.ts +200 -0
- package/src/http/guards.ts +154 -0
- package/src/http/responses.ts +192 -0
- package/src/http/routes.ts +467 -0
- package/src/http/schemas.ts +203 -0
- package/src/http/view.ts +139 -0
- package/src/index.ts +73 -0
- package/src/jobs/read.ts +273 -0
- package/src/jobs/retry.ts +214 -0
- package/src/migrations/0001_init.ts +174 -0
- package/src/migrations/0001_suppressions.ts +40 -0
- package/src/provision/devDelivery.ts +47 -0
- package/src/provision/hostCatalogs.ts +107 -0
- package/src/provision/provisionEmail.ts +179 -0
- package/src/provision/resolveEmailConfig.ts +225 -0
- package/src/provision/settingsCheck.ts +212 -0
- package/src/send/batchIdentity.ts +47 -0
- package/src/send/enqueue.ts +391 -0
- package/src/send/errorMapping.ts +73 -0
- package/src/send/events.ts +34 -0
- package/src/send/fromComposition.ts +57 -0
- package/src/send/retryPolicy.ts +42 -0
- package/src/send/runSend.ts +320 -0
- package/src/send/sendAt.ts +77 -0
- package/src/send/sender.ts +44 -0
- package/src/send/senderBinding.ts +56 -0
- package/src/send/suppression.ts +194 -0
- package/src/templates/engine.ts +392 -0
- package/src/templates/messages.es.ts +109 -0
- package/src/templates/messages.ts +315 -0
- package/src/templates/partials.ts +88 -0
- package/src/templates/precompiled.generated.ts +1342 -0
- package/src/templates/registry.ts +550 -0
- package/src/templates/samples.ts +75 -0
- package/src/templates/severity.ts +102 -0
- package/src/templates/theme.ts +212 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/hostApp.ts +54 -0
- package/src/workflows/hostEnv.ts +219 -0
- package/src/workflows/instanceLiveness.ts +39 -0
- package/src/workflows/instances.ts +16 -0
- package/src/workflows/params.ts +35 -0
- package/src/workflows/scheduler.ts +220 -0
- package/src/workflows/sendBatch.ts +154 -0
- package/src/workflows/worker.ts +203 -0
- 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">‍</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}} · {{/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">‍</td></tr>
|
|
82
|
+
</table>
|
|
83
|
+
</td></tr>
|
|
84
|
+
</table>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</body>
|
|
88
|
+
</html>`;
|