@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,385 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
6
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
7
+ import type { LocaleCatalogs } from "@pithy-sh/core/src/i18n/catalog";
8
+ import { z } from "zod";
9
+ import { createBounceHandler } from "./bounce/handler";
10
+ import { emailSigningRegistry } from "./crypto/signingKey";
11
+ import {
12
+ type EmailSuppressionDatabase,
13
+ emailDatabase,
14
+ emailSuppressionDatabase,
15
+ emailSuppressionTables,
16
+ emailTables,
17
+ } from "./data/tables";
18
+ import { registerCallbacks } from "./http/callbacks";
19
+ import { emailAdminRoutes } from "./http/guards";
20
+ import { registerEmailAdminRoutes } from "./http/routes";
21
+ import { email_0001_init } from "./migrations/0001_init";
22
+ import { email_0001_suppressions } from "./migrations/0001_suppressions";
23
+ import { DevMailDelivery } from "./provision/devDelivery";
24
+ import { emailHostCatalogs } from "./provision/hostCatalogs";
25
+ import { emailSettings } from "./provision/settingsCheck";
26
+ import { type EnqueueInput, type EnqueueResult, enqueueEmail } from "./send/enqueue";
27
+ import { type EmailSenderEnv, emailSenderBinding } from "./send/senderBinding";
28
+ import { EMAIL_MESSAGES, type EmailMessageLayers, kitEmailLayers } from "./templates/messages";
29
+ import { CustomTheme, type EmailTheme, resolveTheme } from "./templates/theme";
30
+ import { PACKAGE_VERSION } from "./version.generated";
31
+ import { EmailScheduleParams, EmailSendParams } from "./workflows/params";
32
+
33
+ /**
34
+ * The bindings the enqueue seam reads from the request env: the shared `DB`, the send Workflow, and the
35
+ * global suppression database. A consumer (e.g. `@pithy-sh/auth`) forwards its worker env; it never
36
+ * names these bindings itself.
37
+ *
38
+ * **`EMAIL_SUPPRESSIONS` is on this list, and that is the whole of pithy-sh/pithy#355.** The wiring is
39
+ * not the adopter's problem: the capability declares the binding, `pithy add email` provisions it,
40
+ * `enqueue` reads it off the env, and no consumer config, route option, or constant names it. What an
41
+ * adopter *may* do — because it is their database — is ask for it back through
42
+ * {@link EmailCapability.suppressions} and read or write it like any other table.
43
+ */
44
+ export interface EmailEnqueueEnv extends EmailSenderEnv {
45
+ DB: D1Database;
46
+ /**
47
+ * The global, durable suppression list.
48
+ *
49
+ * Optional in the type for the same reason `EMAIL_SENDER` is — a hand-built env in a test harness has
50
+ * neither — and required in practice: it is in `requiredBindings` below, so `validateBindings` refuses
51
+ * the first request of a composed worker that lacks it. Absent, `enqueue` queues the job and
52
+ * `runSend` still refuses a blocked recipient before anything leaves.
53
+ */
54
+ EMAIL_SUPPRESSIONS?: D1Database;
55
+ }
56
+
57
+ /**
58
+ * Sort order of the email migrations within the app database, relative to other capabilities
59
+ * (core low, app high). Unique per database; the migration registry composes the key
60
+ * `0200_email_0001_init`.
61
+ */
62
+ export const EMAIL_MIGRATION_ORDER = 200;
63
+
64
+ /** Sort order of the suppression migration within the dedicated `EMAIL_SUPPRESSIONS` database. */
65
+ export const EMAIL_SUPPRESSIONS_MIGRATION_ORDER = 100;
66
+
67
+ /**
68
+ * Configuration for the email capability, passed in `pithy.config.ts` — the thin user-owned surface.
69
+ * `fromAddress` and `baseUrl` are required; branding is one `theme` preset plus an optional
70
+ * `customTheme` that deep-merges over it (so a project tweaks just an accent or a palette color, not a
71
+ * dozen fields). Body width is intentionally *not* here — it is a property of each template (newsletters
72
+ * render wide, transactional narrow). The resolved theme is attached to the capability and serialized
73
+ * into the email worker's single `EMAIL_THEME` var at provision.
74
+ */
75
+ export const EmailConfig = z
76
+ .object({
77
+ fromAddress: z
78
+ .string()
79
+ .describe("The address every email is sent from. Must use a domain onboarded onto Cloudflare Email Service."),
80
+ fromName: z.string().default("Pithy").describe("The sender display name recipients see."),
81
+ baseUrl: z
82
+ .string()
83
+ .describe("The public base URL of the app worker; tracking and unsubscribe links are built against it."),
84
+ basePath: z
85
+ .string()
86
+ .startsWith("/")
87
+ .default("/email")
88
+ .describe(
89
+ "Where the management routes mount (the send log and the suppression list). Not the callback links: those keep their fixed `/_pithy/email` prefix, because a tracking URL is already minted into mail sitting in somebody's inbox and moving it would break every link ever sent.",
90
+ ),
91
+ theme: z
92
+ .enum(["saffron", "midnight", "forest", "rose"])
93
+ .default("saffron")
94
+ .describe("The off-the-shelf theme preset to start from: `saffron` (default), `midnight`, `forest`, or `rose`."),
95
+ customTheme: CustomTheme.optional().describe(
96
+ "A partial override deep-merged over the preset — change any theme field, inherit the rest.",
97
+ ),
98
+ // KNOWN DEFECT — this value never reaches the worker. `EmailConfigParams` carries no
99
+ // `schedulerEnabled`, so `resolveEmailConfig` never writes `SCHEDULER_ENABLED` and the template's
100
+ // hardcoded "true" always wins. Setting it false is silently ignored. Wiring it through means an
101
+ // eighth `resolveEmailConfig` param and a new `CloudflareEmailProvisioner` option, both of which
102
+ // are pinned by tests this change may not edit — so it is filed, not fixed here.
103
+ schedulerEnabled: z.boolean().default(true).describe("Whether the every-minute scheduler Workflow runs."),
104
+ devDelivery: DevMailDelivery.default("remote").describe(
105
+ "What the prebuilt email host does with a message when it is running on your machine under `pithy dev`. **The default, `remote`, sends real mail:** the Worker runs locally and delivers through Cloudflare Email Service, so a magic link you trigger from localhost arrives in the real inbox, with the same DKIM and the same delivery logs as production. That needs a Cloudflare login `wrangler dev` can use and a sending domain already onboarded onto Email Service. Set it to `simulator` to send nothing — `wrangler dev` logs the sender, recipient and subject and writes the rendered HTML and text bodies to disk, which is what an offline machine and CI want. It changes local development only; every deployed environment always sends for real.",
106
+ ),
107
+ })
108
+ .describe("Configuration for the email capability.");
109
+ export type EmailConfig = z.output<typeof EmailConfig>;
110
+ export type EmailConfigInput = z.input<typeof EmailConfig>;
111
+
112
+ /** The resolved from identity, base URL, and theme — what the app's enqueue calls need. */
113
+ export interface ResolvedEmailConfig {
114
+ fromAddress: string;
115
+ fromName: string;
116
+ baseUrl: string;
117
+ schedulerEnabled: boolean;
118
+ /**
119
+ * What the host's `send_email` binding does under `pithy dev` — `remote` (real mail, the default)
120
+ * or `simulator`. Attached here because `pithy dev` reads it off the composed capability when it
121
+ * resolves the host's local config; nothing inside the app worker consults it.
122
+ */
123
+ devDelivery: DevMailDelivery;
124
+ theme: EmailTheme;
125
+ }
126
+
127
+ /** The email capability, with its resolved config and a bound enqueue seam attached. */
128
+ export interface EmailCapability extends Capability {
129
+ emailConfig: ResolvedEmailConfig;
130
+ /**
131
+ * Enqueue an email job from a request env. The capability owns the `DB`/`EMAIL_SENDER` bindings, the
132
+ * from-identity, and the theme — a consumer passes only its worker env and the high-level input
133
+ * (`to`, `template`, `payload`). This is the seam other capabilities depend on; they never assemble
134
+ * `EnqueueDeps` or name the email bindings themselves.
135
+ */
136
+ enqueue: (env: EmailEnqueueEnv, input: EnqueueInput) => Promise<EnqueueResult>;
137
+ /**
138
+ * The suppression list, from a request env — **it is the adopter's database, and this is how they open
139
+ * it** (pithy-sh/pithy#355).
140
+ *
141
+ * Nobody has to call this to be protected: `enqueue` consults the list on its own and `runSend`
142
+ * refuses a blocked recipient regardless. This exists for the adopter who genuinely wants to look —
143
+ * an operator un-suppressing an address a customer has fixed, a support screen explaining why a letter
144
+ * did not go, a report of who a notice could not reach. Reading and writing it is ordinary Kysely over
145
+ * `pithyEmailSuppressions`; `send/suppression.ts` publishes `blockingSuppression`, `suppress`,
146
+ * `unsuppress` and `listSuppressions` for the four things anybody actually does with it.
147
+ *
148
+ * It takes the env and no binding name, which is the point: the capability owns which binding the list
149
+ * lives behind, exactly as it owns `DB` and `EMAIL_SENDER`.
150
+ */
151
+ suppressions: (env: EmailEnqueueEnv) => EmailSuppressionDatabase;
152
+ /**
153
+ * The catalogs `pithy email provision` stamps into the host worker's `EMAIL_MESSAGES` var.
154
+ *
155
+ * A function rather than a field, and read after composition rather than at construction, for the
156
+ * reason the `compose` hook below exists at all: a capability sees only itself when it is built, and
157
+ * the translations belong to a capability that may be composed after this one. A snapshot taken in
158
+ * the returned object literal would be the pre-compose English, permanently — the same trap
159
+ * `@pithy-sh/i18n` names on its own `composedMessages` accessor.
160
+ *
161
+ * Empty when nothing composed an i18n capability, which is what a project serving one language wants:
162
+ * no var is written and the host renders the English it bundles.
163
+ */
164
+ hostCatalogs: () => LocaleCatalogs;
165
+ }
166
+
167
+ /**
168
+ * The email capability. It contributes the three email tables to the app `DB`, mounts the
169
+ * click/open/unsubscribe callback routes, and registers the inbound bounce/complaint `email()` handler.
170
+ * It requires the `DB` binding and the `EMAIL_SENDER` Workflow binding (to start an immediate send at
171
+ * enqueue). The send and scheduler Workflows and the every-minute cron live in the prebuilt email
172
+ * worker (`workflows/worker.ts`), deployed per environment by `pithy add email`.
173
+ */
174
+ export function email(config: EmailConfigInput): EmailCapability {
175
+ const resolved = EmailConfig.parse(config);
176
+ // Build the full theme from the preset, deep-merging any customTheme override.
177
+ const theme = resolveTheme(resolved.theme, resolved.customTheme);
178
+ /**
179
+ * Where an enqueue finds words for a locale — this package's own English until `compose` says
180
+ * otherwise.
181
+ *
182
+ * Reassigned rather than resolved once at construction, because a capability sees only itself when it
183
+ * is built and the translations belong to a capability that may be composed after this one.
184
+ */
185
+ let layersFor: EmailMessageLayers = kitEmailLayers;
186
+ /**
187
+ * The locales the project told the i18n capability it serves — the keys `pithy email provision`
188
+ * flattens a catalog for.
189
+ *
190
+ * `layersFor` alone cannot answer this. It is a function of a locale, so it can say what `es` reads
191
+ * and never that `es` is one of the languages this project speaks; the set lives on the i18n
192
+ * capability's own config, and this is the only thing that knows it.
193
+ */
194
+ let supportedLocales: readonly string[] = [];
195
+ const capability = defineCapability({
196
+ name: "email",
197
+ // The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
198
+ // cannot read its own package.json. Reported per capability by the control-plane manifest.
199
+ version: PACKAGE_VERSION,
200
+ // The link-signing key is read through @pithy-sh/secrets, so the secrets capability must be
201
+ // composed; createBackend fails fast if it isn't (rather than 500-ing each link-signing request).
202
+ dependsOn: ["secrets"],
203
+ // The slice of secrets email reads — aggregated into the shared per-invocation accessor at startup.
204
+ secretRegistry: emailSigningRegistry,
205
+ /**
206
+ * This capability's own copy — the seven templates whose words the kit owns, plus the shell's
207
+ * severity vocabulary and its opt-out link. All under `email/`, which `composeMessages` enforces.
208
+ *
209
+ * **In every language it is written in, not English alone (#442).** The send Worker is a separate
210
+ * deploy with no request and no config, so anything it does not bundle is stamped into it as a
211
+ * variable — and the kit's own translations were doing that on every provision run. Held here,
212
+ * they are built into the host and only an adopter's diff travels.
213
+ *
214
+ * A kit sentence still lives in exactly one place; which package that is follows how it reaches a
215
+ * reader. `@pithy-sh/i18n` keeps what no capability can — the error taxonomy, whose domains are
216
+ * not capability names, and the screens, which are copied rather than imported.
217
+ */
218
+ messages: EMAIL_MESSAGES,
219
+ /**
220
+ * Take the composed project's catalog layers, when it has any.
221
+ *
222
+ * Duck-typed, and deliberately not an import. `@pithy-sh/i18n` is optional — a project composing
223
+ * only `email` must not acquire it as a dependency — and CLAUDE.md's rule is that capabilities
224
+ * depend on core seams rather than on each other. `layersFor` is that seam's shape
225
+ * (`(locale) => catalogs`), declared in `@pithy-sh/core`'s i18n module and published by the i18n
226
+ * capability, so recognizing it structurally is the whole of the coupling.
227
+ *
228
+ * What arrives is the full stack in the right order: the adopter's overrides, the kit's
229
+ * translation, every composed capability's English. Absent, {@link kitEmailLayers} answers and a
230
+ * project sends what it always sent.
231
+ */
232
+ compose: ({ capabilities }) => {
233
+ for (const composed of capabilities) {
234
+ if (composed.name !== "i18n") continue;
235
+ const candidate = (composed as { layersFor?: unknown }).layersFor;
236
+ if (typeof candidate === "function") layersFor = candidate as EmailMessageLayers;
237
+ // Duck-typed on the same terms as `layersFor`: `i18nConfig.supportedLocales` is the shape the
238
+ // i18n capability publishes, and recognizing it structurally is the whole of the coupling.
239
+ const locales = (composed as { i18nConfig?: { supportedLocales?: unknown } }).i18nConfig?.supportedLocales;
240
+ if (Array.isArray(locales)) supportedLocales = locales.filter((tag) => typeof tag === "string");
241
+ }
242
+ },
243
+ // Email provisioning wires inbound bounce routing (Email Routing rules), so the CI credential that
244
+ // provisions email needs that scope — contributed into the one `ci-system` token.
245
+ ciPermissions: ["email:routing"],
246
+ requiredBindings: [
247
+ { type: "d1", name: "DB" },
248
+ { type: "d1", name: "EMAIL_SUPPRESSIONS" },
249
+ { type: "workflow", name: "EMAIL_SENDER" },
250
+ ],
251
+ databases: {
252
+ app: {
253
+ binding: "DB",
254
+ tables: emailTables,
255
+ migrationOrder: EMAIL_MIGRATION_ORDER,
256
+ migrations: { "0001_init": email_0001_init },
257
+ },
258
+ emailSuppressions: {
259
+ binding: "EMAIL_SUPPRESSIONS",
260
+ tables: emailSuppressionTables,
261
+ migrationOrder: EMAIL_SUPPRESSIONS_MIGRATION_ORDER,
262
+ migrations: {
263
+ "0001_suppressions": email_0001_suppressions,
264
+ },
265
+ },
266
+ },
267
+ /**
268
+ * The two durable jobs email owns. Both classes live in the prebuilt email worker
269
+ * (`workflows/worker.ts`), never in the app worker — the app only ever dispatches into `send`.
270
+ *
271
+ * The map keys are the wire names: `workflowScriptName("email", "send", "staging")` resolves to
272
+ * `pithy-email-send-staging`, which is byte-for-byte what the committed template already deploys.
273
+ * Declaring them here is what lets the CLI generate the host's `workflows` array and its cron from
274
+ * the specs instead of a hand-maintained block that can drift from this file.
275
+ */
276
+ workflows: {
277
+ send: {
278
+ binding: "EMAIL_SENDER",
279
+ className: "EmailSendWorkflow",
280
+ // The same object the host's own registry and its dispatch route validate against
281
+ // (`workflows/params.ts`). One schema, three readers: what the app dispatches, what the host
282
+ // deploys, and what a loopback dispatch is checked against at the door (#410).
283
+ params: EmailSendParams,
284
+ },
285
+ schedule: {
286
+ binding: "EMAIL_SCHEDULER",
287
+ className: "EmailSchedulerWorkflow",
288
+ params: EmailScheduleParams,
289
+ schedule: "* * * * *",
290
+ // Optional because the binding exists only on the prebuilt email worker, which self-fires it
291
+ // from its cron. An app worker binds EMAIL_SENDER and nothing else, so deriving a required
292
+ // EMAIL_SCHEDULER binding would fail every app's first request.
293
+ optional: true,
294
+ },
295
+ },
296
+ /**
297
+ * Two route trees, one hook.
298
+ *
299
+ * The public callbacks keep their fixed `/_pithy/email` prefix — those URLs are already minted into
300
+ * mail nobody can recall — and the management routes mount under the configured `basePath`. They
301
+ * are registered together here rather than being two capabilities, because they are one
302
+ * capability's surface and `adminRoutes` below has to describe what this exact composition mounted.
303
+ */
304
+ routes: (app) => {
305
+ registerCallbacks(app);
306
+ registerEmailAdminRoutes({ basePath: resolved.basePath })(app);
307
+ },
308
+ /**
309
+ * The management surface, advertised on `GET /control-plane/manifest`.
310
+ *
311
+ * Built from the **resolved** `basePath` — the same value `registerEmailAdminRoutes` mounts
312
+ * against, read once. A default here and a default there is how a manifest comes to describe a
313
+ * route tree nobody serves.
314
+ */
315
+ adminRoutes: emailAdminRoutes(resolved.basePath),
316
+ /**
317
+ * How `pithy doctor` checks that these settings **work**, not merely that they are written (#411).
318
+ *
319
+ * Built from the resolved config, on the instance, so discovery needs no `pithy.manifest.json` and no
320
+ * import of this package by the CLI. The local half runs `workflows/hostEnv.ts` — the very declaration
321
+ * the prebuilt host refuses to start without — so doctor and the host cannot come to two answers.
322
+ */
323
+ settings: emailSettings({ fromAddress: resolved.fromAddress, baseUrl: resolved.baseUrl, theme }),
324
+ email: createBounceHandler(),
325
+ });
326
+ /**
327
+ * The suppression list off an env, or a wiring fault naming the binding.
328
+ *
329
+ * **Fails closed, unlike the enqueue-time check.** The two are asked different questions. `enqueue`
330
+ * asks "is this address blocked", and where it cannot ask, the send path asks again before anything
331
+ * leaves — so queueing the job is right. A caller here has asked for the list itself, and the only
332
+ * alternatives to raising are handing back a fabricated database or an empty answer that reads as
333
+ * "nobody is suppressed". Both are worse than a message naming what to bind.
334
+ */
335
+ const suppressions = (env: EmailEnqueueEnv): EmailSuppressionDatabase => {
336
+ const binding = env.EMAIL_SUPPRESSIONS;
337
+ if (!binding) {
338
+ throw new InternalError({
339
+ message: "The suppression list is not available.",
340
+ action: "Run `pithy add email` to provision it, or bind the EMAIL_SUPPRESSIONS D1 database.",
341
+ detail: "email.suppressions() was called with an env carrying no `EMAIL_SUPPRESSIONS` D1 binding.",
342
+ });
343
+ }
344
+ return emailSuppressionDatabase(binding);
345
+ };
346
+ const enqueue = (env: EmailEnqueueEnv, input: EnqueueInput): Promise<EnqueueResult> =>
347
+ enqueueEmail(
348
+ {
349
+ db: emailDatabase(env.DB),
350
+ fromAddress: resolved.fromAddress,
351
+ fromName: resolved.fromName,
352
+ theme,
353
+ // Read through the closure, not captured: `compose` runs after this function is defined and
354
+ // before any request reaches it, so a snapshot taken here would be the pre-compose English.
355
+ layersFor: (locale) => layersFor(locale),
356
+ sender: emailSenderBinding(env),
357
+ // Read straight off the env the consumer forwarded. This one line is what makes suppression
358
+ // automatic: a consumer names nothing, and a hard-bounced address is never queued for a send.
359
+ suppressionDb: env.EMAIL_SUPPRESSIONS ? emailSuppressionDatabase(env.EMAIL_SUPPRESSIONS) : undefined,
360
+ now: new Date(),
361
+ newId: () => crypto.randomUUID(),
362
+ },
363
+ input,
364
+ );
365
+ return Object.assign(capability, {
366
+ emailConfig: {
367
+ fromAddress: resolved.fromAddress,
368
+ fromName: resolved.fromName,
369
+ baseUrl: resolved.baseUrl,
370
+ schedulerEnabled: resolved.schedulerEnabled,
371
+ devDelivery: resolved.devDelivery,
372
+ theme,
373
+ },
374
+ enqueue,
375
+ suppressions,
376
+ // Read through the closures, never captured — `compose` runs after this object is built, and both
377
+ // values it fills are the whole point of the call.
378
+ hostCatalogs: () => emailHostCatalogs(supportedLocales, (locale) => layersFor(locale)),
379
+ });
380
+ }
381
+
382
+ /** Whether a capability is the email capability — carries its resolved config. */
383
+ export function isEmailCapability(capability: Capability): capability is EmailCapability {
384
+ return capability.name === "email" && "emailConfig" in capability;
385
+ }
@@ -0,0 +1,19 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /// <reference types="@cloudflare/vitest-plugin/types" />
5
+
6
+ // Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare
7
+ // config in `vitest.workers.config.ts`: the app `DB` database the email tables live in, and the
8
+ // dedicated `SECRETS` database plus master key the link-signing key's row is read through.
9
+ // `cloudflare:test` types its `env` as `Cloudflare.Env`, so test bindings are declared by augmenting
10
+ // that interface.
11
+ declare namespace Cloudflare {
12
+ interface Env {
13
+ DB: D1Database;
14
+ EMAIL_SUPPRESSIONS: D1Database;
15
+ SECRETS: D1Database;
16
+ /** The master-key config as a string (the `.dev.vars` shape), set in `vitest.workers.config.ts`. */
17
+ SECRETS_ENCRYPTION_KEYS: string;
18
+ }
19
+ }
@@ -0,0 +1,44 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
5
+ import { defineSecretRegistry } from "@pithy-sh/secrets/src/registry";
6
+ import type { VersionedSecret } from "@pithy-sh/secrets/src/secretsStore";
7
+ import { sharedSecretsStore } from "@pithy-sh/secrets/src/sharedSecretsStore";
8
+
9
+ /**
10
+ * The link-signing key lives in `@pithy-sh/secrets` as a rotatable, global secret. Email resolves it
11
+ * by name through its own minimal registry — `secretsStore` reads the same encrypted D1 row regardless
12
+ * of which registry names it, so email never needs the project-wide registry to sign or verify a link.
13
+ */
14
+ export const EMAIL_LINK_SIGNING_KEY = "email-link-signing-key";
15
+
16
+ /** The minimal registry email uses to resolve its signing key. Rotatable so old links verify after rotation. */
17
+ export const emailSigningRegistry = defineSecretRegistry({
18
+ // Mintable for dev: the key signs links this app both mints and verifies, so any random string
19
+ // serves. Nothing else names it, so without `devValue` the first tracked link is the first anyone
20
+ // hears of it — and by then the mail is in an inbox.
21
+ [EMAIL_LINK_SIGNING_KEY]: {
22
+ backend: "d1",
23
+ scope: "global",
24
+ rotatable: true,
25
+ valueType: "text",
26
+ devValue: "random",
27
+ // Arbitrary in production for the same reason it is arbitrary in dev: this app mints the links and
28
+ // this app verifies them. So it is `minted` in every environment, and `local` to replace.
29
+ origin: { kind: "minted", recipe: { kind: "random", bytes: 32, encoding: "base64url" } },
30
+ rotation: { kind: "local" },
31
+ },
32
+ });
33
+
34
+ /**
35
+ * Resolve the current signing key plus every still-valid prior version. The current version is the
36
+ * `kid` new tokens are signed with; the full version set is what `verifyToken` checks a token's `kid`
37
+ * against, so a link minted before a rotation still verifies until its version is pruned.
38
+ */
39
+ export async function resolveSigningKeys(
40
+ env: SecretsStoreEnv,
41
+ ): Promise<VersionedSecret<(typeof emailSigningRegistry)[typeof EMAIL_LINK_SIGNING_KEY]>> {
42
+ const secrets = await sharedSecretsStore(env, emailSigningRegistry);
43
+ return secrets.getVersions(EMAIL_LINK_SIGNING_KEY);
44
+ }
@@ -0,0 +1,148 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { EmailInvalidTokenError } from "../error/errors";
6
+
7
+ /**
8
+ * HMAC-signed callback tokens. Click, open, and unsubscribe links carry one of these so a callback
9
+ * is tamper-proof: the claims (which job, which recipient, where a click goes, which campaign) are
10
+ * signed, and the signature is verified constant-time via `crypto.subtle.verify` before the callback
11
+ * acts. The signing secret is a **rotatable** key from `@pithy-sh/secrets`; every token records the
12
+ * key version (`kid`) it was signed with, so a link in a months-old email still verifies against the
13
+ * retained version set after a rotation — and is rejected once that version is pruned.
14
+ */
15
+
16
+ const encoder = new TextEncoder();
17
+ const decoder = new TextDecoder();
18
+
19
+ export const TokenKind = z
20
+ .enum(["click", "open", "unsubscribe"])
21
+ .describe("Which callback a token authorizes: a tracked link `click`, an open-pixel `open`, or an `unsubscribe`.");
22
+ export type TokenKind = z.output<typeof TokenKind>;
23
+
24
+ /** The signed claims a callback token carries. `kid`/`exp`/`v` are set by `mintToken`; the rest are caller claims. */
25
+ export const CallbackToken = z
26
+ .object({
27
+ v: z.literal(1).describe("Token format version, so the scheme can evolve without ambiguity."),
28
+ kid: z.string().describe("The signing-key version this token was signed with; selects the key to verify against."),
29
+ kind: TokenKind.describe("Which callback this token authorizes."),
30
+ jobId: z.string().describe("The `pithy_email_jobs.id` this token is bound to."),
31
+ recipient: z.string().describe("The recipient address the callback is recorded against."),
32
+ exp: z.number().int().describe("Expiry as a Unix timestamp in seconds; the callback rejects a token past it."),
33
+ destination: z
34
+ .string()
35
+ .optional()
36
+ .describe("For a `click` token, the absolute URL to 302-redirect to after recording."),
37
+ linkLabel: z
38
+ .string()
39
+ .optional()
40
+ .describe("For a `click` token, the link's identity/label, recorded for attribution."),
41
+ campaignId: z.string().optional().describe("The marketing campaign this token is attributed to, when applicable."),
42
+ })
43
+ .describe("The signed claims carried by an email callback token (click/open/unsubscribe).");
44
+ export type CallbackToken = z.output<typeof CallbackToken>;
45
+
46
+ /** The caller-supplied claims for a token — everything except the fields `mintToken` fills in. */
47
+ export type TokenClaims = Omit<CallbackToken, "v" | "kid" | "exp">;
48
+
49
+ function base64UrlEncode(bytes: Uint8Array): string {
50
+ let binary = "";
51
+ for (const byte of bytes) binary += String.fromCharCode(byte);
52
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
53
+ }
54
+
55
+ function base64UrlDecode(value: string): Uint8Array {
56
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (value.length % 4)) % 4);
57
+ const binary = atob(padded);
58
+ const bytes = new Uint8Array(binary.length);
59
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
60
+ return bytes;
61
+ }
62
+
63
+ async function importKey(secret: string): Promise<CryptoKey> {
64
+ return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
65
+ "sign",
66
+ "verify",
67
+ ]);
68
+ }
69
+
70
+ /**
71
+ * Mint a signed token. The claims plus `kid`/`exp`/`v` are JSON-encoded and base64url-packed, then
72
+ * HMAC-SHA-256 signed with `key`. The token is `<payload>.<signature>`; `kid` rides inside the signed
73
+ * payload so the verifier knows which key to check without trusting an unsigned header.
74
+ */
75
+ export async function mintToken(
76
+ claims: TokenClaims,
77
+ options: { key: string; kid: string; expiresAt: Date },
78
+ ): Promise<string> {
79
+ const payload: CallbackToken = {
80
+ v: 1,
81
+ kid: options.kid,
82
+ exp: Math.floor(options.expiresAt.getTime() / 1000),
83
+ ...claims,
84
+ };
85
+ const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
86
+ const key = await importKey(options.key);
87
+ const signature = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payloadB64)));
88
+ return `${payloadB64}.${base64UrlEncode(signature)}`;
89
+ }
90
+
91
+ /**
92
+ * Verify a token against the valid signing-key version set (`@pithy-sh/secrets`' `getVersions` shape)
93
+ * and return its claims. Rejects — as `email/invalid_token` — a malformed token, an unknown/pruned
94
+ * `kid`, a bad signature (constant-time via `crypto.subtle.verify`), or an expired token. The order
95
+ * is deliberate: structure, then signature, then expiry, so a forged token never reaches the expiry
96
+ * check.
97
+ */
98
+ export async function verifyToken(
99
+ token: string,
100
+ keys: { versions: Record<string, string> },
101
+ now: Date,
102
+ ): Promise<CallbackToken> {
103
+ const parts = token.split(".");
104
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
105
+ throw new EmailInvalidTokenError({ detail: "token is not in <payload>.<signature> form" });
106
+ }
107
+ const [payloadB64, signatureB64] = parts;
108
+
109
+ let raw: unknown;
110
+ try {
111
+ raw = JSON.parse(decoder.decode(base64UrlDecode(payloadB64)));
112
+ } catch (cause) {
113
+ throw new EmailInvalidTokenError({ detail: "token payload is not valid base64url JSON" }, { cause });
114
+ }
115
+
116
+ const parsed = CallbackToken.safeParse(raw);
117
+ if (!parsed.success) {
118
+ throw new EmailInvalidTokenError({ detail: "token payload does not match the claim schema" });
119
+ }
120
+
121
+ // Look the key up as an OWN property only — a bracket lookup with an attacker-controlled `kid`
122
+ // like `__proto__`/`constructor` would otherwise resolve up the prototype chain to a truthy
123
+ // non-string (Object.prototype / the Object function), pass an `if (!secret)` check, and let
124
+ // `importKey` coerce it to a fixed, attacker-known key string — forging the signature. The
125
+ // own-property + string-type checks close that off.
126
+ const versions = keys.versions;
127
+ const secret = Object.hasOwn(versions, parsed.data.kid) ? versions[parsed.data.kid] : undefined;
128
+ if (typeof secret !== "string") {
129
+ throw new EmailInvalidTokenError({ detail: `token kid '${parsed.data.kid}' is not in the valid key set` });
130
+ }
131
+
132
+ const key = await importKey(secret);
133
+ let valid: boolean;
134
+ try {
135
+ valid = await crypto.subtle.verify("HMAC", key, base64UrlDecode(signatureB64), encoder.encode(payloadB64));
136
+ } catch (cause) {
137
+ throw new EmailInvalidTokenError({ detail: "token signature could not be decoded" }, { cause });
138
+ }
139
+ if (!valid) {
140
+ throw new EmailInvalidTokenError({ detail: "token signature did not verify" });
141
+ }
142
+
143
+ if (parsed.data.exp * 1000 <= now.getTime()) {
144
+ throw new EmailInvalidTokenError({ detail: "token has expired" });
145
+ }
146
+
147
+ return parsed.data;
148
+ }
@@ -0,0 +1,42 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+ import { EmailEventType } from "./enums";
7
+
8
+ /**
9
+ * One row in `pithy_email_events` — a per-recipient event for history and campaign attribution. The
10
+ * send path, the click/open/unsubscribe callbacks, and the inbound bounce handler all append here.
11
+ * `z.output` is the app shape; `z.input` is the SQLite row.
12
+ */
13
+ export const EmailEvent = z
14
+ .object({
15
+ id: z.number().int().describe("Surrogate primary key, autoincremented by SQLite."),
16
+ jobId: z.string().describe("The `pithy_email_jobs.id` this event belongs to."),
17
+ recipient: z.string().describe("The recipient address the event is about."),
18
+ type: EmailEventType.describe(
19
+ "What happened: sent, open, click, bounce, complaint, unsubscribe, suppressed, or failed.",
20
+ ),
21
+ linkLabel: z
22
+ .string()
23
+ .nullish()
24
+ .describe("For a `click` event, the link's identity/label as declared at render; null otherwise."),
25
+ linkUrl: z
26
+ .string()
27
+ .nullish()
28
+ .describe("For a `click` event, the destination URL that was followed; null otherwise."),
29
+ campaignId: z
30
+ .string()
31
+ .nullish()
32
+ .describe("The marketing campaign this event is attributed to; null for transactional."),
33
+ detail: z
34
+ .string()
35
+ .nullish()
36
+ .describe(
37
+ "Free-form context — a bounce code, the user agent on an open, the suppression reason; null when not applicable.",
38
+ ),
39
+ createdAt: SQLiteDate.describe("When the event was recorded."),
40
+ })
41
+ .describe("One per-recipient email event in `pithy_email_events`.");
42
+ export type EmailEvent = z.output<typeof EmailEvent>;