@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,391 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { normalizeAddress } from "@pithy-sh/core/src/address/address";
5
+ import { isLocale } from "@pithy-sh/core/src/i18n/locale";
6
+ import type { EmailJob } from "../data/emailJob";
7
+ import { EmailJob as EmailJobSchema } from "../data/emailJob";
8
+ import type { EmailJobStatus, SendMode, SuppressionReason } from "../data/enums";
9
+ import type { EmailDatabase, EmailSuppressionDatabase } from "../data/tables";
10
+ import { EmailInvalidPayloadError } from "../error/errors";
11
+ import { getTemplate, renderSubject, templateKind } from "../templates/engine";
12
+ import { type EmailMessageLayers, emailTranslator, kitEmailLayers } from "../templates/messages";
13
+ import type { EmailTheme } from "../templates/theme";
14
+ import { mintBatchId } from "./batchIdentity";
15
+ import { recordEvent } from "./events";
16
+ import { resolveTimezoneSendAt } from "./sendAt";
17
+ import { blockingSuppression } from "./suppression";
18
+
19
+ /**
20
+ * Enqueue an email. A request handler only ever does this — it never sends inline. Every email becomes
21
+ * a `pithy_email_jobs` row; an `immediate` job also kicks the send Workflow now (lowest latency), while
22
+ * `scheduled` and `timezone` jobs are left for the every-minute scheduler to pick up. The payload is
23
+ * validated against the template schema here, so a bad call fails at enqueue, not mid-send.
24
+ *
25
+ * **Suppression is consulted here too, and no caller asks for it** (pithy-sh/pithy#355). A blocked
26
+ * recipient never becomes a queued send: the row is born `suppressed`, no Workflow is started, and the
27
+ * reason comes back on the result. The point is not a second gate — `runSend` is and stays the
28
+ * authority, because whether an address is blocked is a question about the instant of sending and a
29
+ * scheduled job is enqueued days before that. The point is that the caller **learns**, at the moment it
30
+ * asked, without holding the suppression database itself. A three-person account whose addresses have
31
+ * all hard-bounced is otherwise three ordinary skips in a send log nobody reads, rather than one notice
32
+ * that reached nobody, said at the moment it went out.
33
+ *
34
+ * **The kind comes from the template, never from a caller** — {@link templateKind}, the same accessor
35
+ * `runSend` uses. That is what keeps an unsubscribe from a newsletter from withholding an invitation:
36
+ * the four suppression reasons stopped being interchangeable, and a check that restated
37
+ * `"transactional"` at the call site would be making a claim about somebody else's template.
38
+ */
39
+
40
+ /**
41
+ * The Workflow binding used to start a send (Cloudflare `Workflow.create`). At enqueue it sends a batch
42
+ * of one; the scheduler's fan-out starts one instance per batch through the same binding.
43
+ *
44
+ * `id` names the instance being created, and **every dispatcher passes one** (pithy-sh/pithy#342): the
45
+ * scheduler passes its batch's, an enqueue passes the one it just stamped on the row, and a retry passes
46
+ * the one it minted to replace the failed batch's. It is optional here only because the platform allows
47
+ * omitting it, and omitting it is what made an immediate send unattributable to any instance — so the
48
+ * row could not say a Workflow was coming and the safety net sent a second one. See
49
+ * `send/batchIdentity.ts`.
50
+ */
51
+ export interface SendWorkflowBinding {
52
+ create(options: { id?: string; params: { jobIds: string[] } }): Promise<unknown>;
53
+ }
54
+
55
+ /** What the caller provides to enqueue an email. */
56
+ export interface EnqueueInput {
57
+ /** The recipient address. */
58
+ to: string;
59
+ /** The template id (e.g. `magicLink`). */
60
+ template: string;
61
+ /** The template input variables; validated against the template's payload schema. */
62
+ payload: unknown;
63
+ /** Send mode; defaults to `immediate`. */
64
+ mode?: SendMode;
65
+ /** For `scheduled` mode: the absolute time to send. */
66
+ sendAt?: Date;
67
+ /** For `timezone` mode: the recipient-local time-of-day, `HH:MM`. */
68
+ localTime?: string;
69
+ /** For `timezone` mode: the recipient's IANA timezone. */
70
+ timezone?: string;
71
+ /** The marketing campaign id, for attribution. */
72
+ campaignId?: string;
73
+ /**
74
+ * The language to write this message in, as a BCP-47 tag. Omit where the recipient has not chosen one.
75
+ *
76
+ * **The recipient's, never the caller's.** A magic link is answered by the person who asked for it,
77
+ * and the request that triggers it is theirs — but a nudge, an invitation and an operational notice
78
+ * are all enqueued by somebody else's request, or by a cron with no request at all. So the tag comes
79
+ * from what is known about the *recipient* (`pithy_auth_users.locale` for a signed-up reader, the
80
+ * negotiated request locale for somebody signing in for the first time), and it is stored on the row
81
+ * so the send Workflow renders the body in the language the subject was already written in.
82
+ *
83
+ * Omitted renders the kit's English, which is the seam's behavior everywhere: a project that never
84
+ * composed an i18n capability sends exactly what it sent before any of this landed.
85
+ */
86
+ locale?: string;
87
+ /**
88
+ * What this message is *about*, when the template id does not say it on its own.
89
+ *
90
+ * The discriminator for a template that carries more than one kind of message (pithy-sh/pithy#382).
91
+ * `sentSince` matches on it, so a caller deciding whether to send — or whether to send a *correction* —
92
+ * can tell two messages apart that share a template and a recipient. Opaque: nothing here parses it,
93
+ * renders it, or puts it on a header or a link.
94
+ *
95
+ * **Not `campaignId`.** That one is marketing attribution and it leaves this row — onto every event,
96
+ * into `campaignStats`, and signed into the tracking token that travels in a delivered email's URLs.
97
+ * See the column's own note on `EmailJob`.
98
+ *
99
+ * Omit it where the template id already answers "what is this", which is most templates.
100
+ */
101
+ correlation?: string;
102
+ /** Override click-link rewriting (defaults on for marketing, off for transactional). */
103
+ clickTracking?: boolean;
104
+ /** Override open-pixel injection (defaults on for marketing, off for transactional). */
105
+ openTracking?: boolean;
106
+ /**
107
+ * The address a recipient's answer should go to, when it is not `fromAddress`.
108
+ *
109
+ * `@pithy-sh/support` is what this is for: a reply is sent as the adopter's onboarded sending
110
+ * identity (Cloudflare validates the `From` domain against exactly what was onboarded) while the
111
+ * conversation has to come back to the support inbox, which is usually a different subdomain.
112
+ */
113
+ replyTo?: string;
114
+ /**
115
+ * The `Message-ID` this job answers, angle brackets included — the `In-Reply-To` header.
116
+ *
117
+ * Set together with {@link references}. Threading on ids is what keeps a customer's client showing
118
+ * one conversation instead of one message per answer, and only the Worker holds the chain, which
119
+ * is why a reply is enqueued here rather than composed by whatever surface a human typed it into.
120
+ */
121
+ inReplyTo?: string;
122
+ /** The `References` chain, angle-bracketed and space-separated. Built by the caller from the parent. */
123
+ references?: string;
124
+ }
125
+
126
+ /** Dependencies enqueue needs: the database, the from identity, optional send-Workflow binding, time, ids. */
127
+ export interface EnqueueDeps {
128
+ db: EmailDatabase;
129
+ fromAddress: string;
130
+ fromName: string;
131
+ theme: EmailTheme;
132
+ /**
133
+ * Where the words for a locale come from — the composed project's catalogs.
134
+ *
135
+ * Absent walks this package's own English, which is what keeps the i18n capability optional. The
136
+ * email capability fills it from a composed `i18n` at assembly; nothing here imports that package.
137
+ */
138
+ layersFor?: EmailMessageLayers;
139
+ /** The send Workflow binding. When present, an immediate job is dispatched now; absent, the scheduler takes it. */
140
+ sender?: SendWorkflowBinding;
141
+ /**
142
+ * The global suppression list. When present, a blocked recipient is recorded here and never queued.
143
+ *
144
+ * Optional for the same reason {@link EnqueueDeps.sender} is, and it is worth being exact about which
145
+ * reason: absence does not mean "send to blocked addresses". The capability declares
146
+ * `EMAIL_SUPPRESSIONS` a required binding, so a composed app worker always has one; and where a caller
147
+ * genuinely has none, `runSend` still refuses the recipient before anything leaves. Making it fatal
148
+ * here would mean refusing to *queue* a message because a list that will be consulted again before it
149
+ * goes could not be consulted yet, which is strictly worse than queueing it.
150
+ */
151
+ suppressionDb?: EmailSuppressionDatabase;
152
+ now: Date;
153
+ /** Generate a job id (a UUID in production). */
154
+ newId: () => string;
155
+ /**
156
+ * Mint the id of the batch this enqueue dispatches — **the send Workflow instance's id**
157
+ * (pithy-sh/pithy#342). Defaults to {@link mintBatchId}; injected only so a test can name it.
158
+ */
159
+ newBatchId?: () => string;
160
+ }
161
+
162
+ /** The result of enqueuing: the new job id and its initial status. */
163
+ export interface EnqueueResult {
164
+ jobId: string;
165
+ /**
166
+ * What the row was born as — and, for the caller, whether anything is coming for it.
167
+ *
168
+ * `pending` and `scheduled` both mean a send is on its way. `suppressed` means the address is
169
+ * blocked. **`undispatched` means this composition binds no send Workflow**, so nothing was started
170
+ * and nothing is coming while it stays that way (pithy-sh/pithy#410): a caller that renders "check
171
+ * your inbox" off it is reporting a delivery that cannot happen. It is the one status here that
172
+ * describes the *deployment* rather than the message — and the scheduler drains those rows once a
173
+ * host exists, so it is not the end of the job.
174
+ */
175
+ status: EmailJobStatus;
176
+ /**
177
+ * Why nothing was queued, when the recipient is on the suppression list.
178
+ *
179
+ * The same field {@link import("./runSend").SendOutcome} carries and for the same reason: a caller
180
+ * that only saw a status other than `failed` would report a delivery that never happened. "Suppressed"
181
+ * alone is not enough either — whether the mailbox bounced, complained, or opted out is what an
182
+ * operator's next move depends on.
183
+ */
184
+ suppressionReason?: SuppressionReason;
185
+ /**
186
+ * The **enqueue-time** render — the sentence this call wrote to `pithy_email_jobs.subject`, in the
187
+ * recipient's language, and the value the row is born with (pithy-sh/pithy#443).
188
+ *
189
+ * **For the caller that has to say what it queued.** Enqueuing is what a surface does; auditing it is
190
+ * what a surface with an administrative trail also does. Without this, that caller had to render the
191
+ * same key a second time — restating the theme it configured and the layer stack it composed, then
192
+ * holding a test pinning its own copy against the kit's catalog to notice when the kit's wording
193
+ * moved. An audit row written at enqueue now reads the same string the job row was written with,
194
+ * rather than a second rendering free to disagree with it.
195
+ *
196
+ * **Locale is what makes it more than a convenience.** A caller that mirrored the English sentence by
197
+ * hand agreed with the row by coincidence, for as long as there was one language. Pass a `locale` and
198
+ * the template renders the recipient's catalog while the mirror keeps restating English, and the trail
199
+ * then claims a subject nobody was ever sent.
200
+ *
201
+ * **{@link import("./runSend").runSend} remains the authority on what was *delivered***, because it
202
+ * renders again — in the send Worker, at the moment the message leaves — and rewrites this column
203
+ * from that render. Three things part the two renders — the three `runSend` names at the line where
204
+ * it rewrites: a template corrected, a theme renamed, or a catalog sentence retranslated. A subject
205
+ * can interpolate the theme (`welcome`'s takes `theme.appName`), so a rename parts them with no
206
+ * catalog movement at all.
207
+ *
208
+ * **And the gap between the two is not only the wait in the queue.** Waiting sets one bound on it,
209
+ * seconds for an immediate send and days for a `scheduled` one. The other is that the send Workflow
210
+ * is its own deploy: it carries the kit's email copy in its own bundle, and the theme and any
211
+ * override sentences in vars stamped at provision (`docs/I18N.md`). So a send Worker whose copy has
212
+ * drifted from the Worker that enqueued is not a window a fast send outruns — it stands until the two
213
+ * are back in step, and an immediate send lands inside it like any other. This is a record of what
214
+ * was queued, never a promise about the delivered sentence — a trail that must reflect delivery reads
215
+ * the row back after the send.
216
+ *
217
+ * Present on every result, including a `suppressed` one: the withheld row carries a rendered subject
218
+ * like any other, no send will ever rewrite it, and a message that reached nobody is the one an
219
+ * operator most needs on the record.
220
+ *
221
+ * **Deliberately not the body.** A body is large, it is the thing this capability is careful never to
222
+ * log, and no caller has a reason to hold one — `runSend` renders it inside the Workflow, at the
223
+ * moment it leaves, and that is the only place it exists.
224
+ */
225
+ subject: string;
226
+ }
227
+
228
+ /** Resolve the absolute send time and initial status from the requested mode. */
229
+ function resolveSchedule(input: EnqueueInput, now: Date): { mode: SendMode; sendAt: Date; status: EmailJobStatus } {
230
+ const mode = input.mode ?? "immediate";
231
+ if (mode === "immediate") return { mode, sendAt: now, status: "pending" };
232
+ if (mode === "scheduled") {
233
+ if (!input.sendAt) throw new EmailInvalidPayloadError({ detail: "scheduled mode requires an absolute sendAt" });
234
+ return { mode, sendAt: input.sendAt, status: "scheduled" };
235
+ }
236
+ if (!input.localTime || !input.timezone) {
237
+ throw new EmailInvalidPayloadError({ detail: "timezone mode requires localTime and timezone" });
238
+ }
239
+ return { mode, sendAt: resolveTimezoneSendAt(input.localTime, input.timezone, now), status: "scheduled" };
240
+ }
241
+
242
+ /** Enqueue an email job, dispatching the send Workflow immediately when the mode is `immediate`. */
243
+ export async function enqueueEmail(deps: EnqueueDeps, input: EnqueueInput): Promise<EnqueueResult> {
244
+ const template = getTemplate(input.template);
245
+ // Validates the payload against the template schema and computes the stored subject — in the
246
+ // recipient's language, which is also what the row records so the body can be rendered in it later.
247
+ const translator = emailTranslator(input.locale, deps.layersFor ?? kitEmailLayers);
248
+ const subject = renderSubject(input.template, input.payload, deps.theme, translator);
249
+ const { mode, sendAt, status: scheduled } = resolveSchedule(input, deps.now);
250
+
251
+ /**
252
+ * Whether the suppression list withholds *this* message from *this* address.
253
+ *
254
+ * `templateKind(input.template)` and never a literal — `runSend` reads the same accessor, and the two
255
+ * agreeing is what makes a hard bounce withhold an invitation while an unsubscribe does not. A caller
256
+ * cannot influence this and is not asked to: it is the template's own declaration.
257
+ */
258
+ const blocked: SuppressionReason | null = deps.suppressionDb
259
+ ? await blockingSuppression(deps.suppressionDb, input.to, deps.now, templateKind(input.template))
260
+ : null;
261
+ // The match key, on the same normalization the suppression list is written and read under. The row
262
+ // keeps the address as the caller typed it in `toAddress` — an operator diagnosing a send needs the
263
+ // string that was actually addressed — and carries this beside it as `recipientKey`, which is what
264
+ // the events table is keyed on and what `sentSince` matches. One value, computed once, so a job and
265
+ // its events cannot disagree about who the person is.
266
+ const recipient = normalizeAddress(input.to);
267
+
268
+ // Whether this call starts a send Workflow at all: only an immediate job does, only where a binding
269
+ // exists to start one on, and never for a recipient the list withholds this message from. Everything
270
+ // else is left for the scheduler to claim.
271
+ const sender = !blocked && mode === "immediate" ? deps.sender : undefined;
272
+ /**
273
+ * An immediate job with nothing to dispatch it on (pithy-sh/pithy#410).
274
+ *
275
+ * A missing binding is a **configuration fact known at compose time**, not a transient failure, and
276
+ * the two used to be recorded identically: both left the row `pending` and told the caller "on its
277
+ * way". That reads as deferral because of the scheduler's safety net — but the net is the
278
+ * every-minute cron on the host worker, and a composition with no send Workflow binding has no host
279
+ * worker either. So there is nothing to defer to *yet*, and `pending` was a promise the deployment
280
+ * could not keep. A magic link enqueued under `pithy dev` sat in that state forever while the sign-in
281
+ * screen said "check your inbox".
282
+ *
283
+ * It is a truthful status, never a grave. The day a host is deployed, its first tick claims these
284
+ * rows exactly as it claims a stranded `pending` one — a tick running at all is the host existing —
285
+ * so mail enqueued before `pithy email provision` is delayed and not lost.
286
+ *
287
+ * A `scheduled` or `timezone` job is deliberately not this: the scheduler claims it by `sendAt` and
288
+ * never needed a binding at enqueue. Nor is a suppressed recipient, which has its own status and its
289
+ * own event. This is only the case where the caller asked for a send now and nothing exists to make
290
+ * one.
291
+ */
292
+ const undispatchable = !blocked && mode === "immediate" && !deps.sender;
293
+ const status: EmailJobStatus = blocked ? "suppressed" : undispatchable ? "undispatched" : scheduled;
294
+ /**
295
+ * The batch this enqueue is about to start — named here, before the row exists, because the row has to
296
+ * carry it (pithy-sh/pithy#342).
297
+ *
298
+ * Without it the row is born naming nobody, and a row naming nobody is stranded by definition: the
299
+ * scheduler's safety net claims any `pending` job older than `graceMs` and starts a *second* send
300
+ * Workflow for it. That is not a hypothetical race with a slow enqueue — it is the ordinary shape of a
301
+ * transient send failure. `runSend` throws on a retryable error so the Workflow step backs off, and a
302
+ * backoff writes nothing at all, so the very first retry leaves the row looking exactly like a dispatch
303
+ * that died. `runSend` short-circuits only a job already `sent`, so both instances would render and
304
+ * both would call the Email Service. One person, two emails.
305
+ *
306
+ * Null for a `scheduled` or `timezone` job, and for an immediate one with no binding, because in those
307
+ * cases nothing is coming for the row and saying otherwise would hold it against a batch that does not
308
+ * exist.
309
+ */
310
+ const batchId = sender ? (deps.newBatchId ?? mintBatchId)() : null;
311
+
312
+ const marketing = template.category === "marketing";
313
+ const job: EmailJob = {
314
+ id: deps.newId(),
315
+ toAddress: input.to,
316
+ recipientKey: recipient,
317
+ fromAddress: deps.fromAddress,
318
+ fromName: deps.fromName,
319
+ subject,
320
+ template: input.template,
321
+ category: template.category,
322
+ payload: input.payload as Record<string, unknown>,
323
+ payloadRedactedAt: null,
324
+ status,
325
+ mode,
326
+ attempts: 0,
327
+ batchId,
328
+ sendAt,
329
+ timezone: input.timezone ?? null,
330
+ localTime: input.localTime ?? null,
331
+ campaignId: input.campaignId ?? null,
332
+ // Normalized, never taken on trust. `EmailJob.encode` below enforces `Locale` — the BCP-47 shape
333
+ // and an `Intl` refinement — so a tag that fails it throws a raw `ZodError` at the insert, and the
334
+ // caller sees `core/internal` with nothing an operator can act on. `en_US` is not a hypothetical
335
+ // shape either: it is exactly what Android, iOS and Java `Locale.toString()` produce, so a mobile
336
+ // client reporting its own locale sends one.
337
+ //
338
+ // Callers that already validate lose nothing (every kit feeder is a `Locale` column or the
339
+ // negotiated `LocaleContext`), and a caller that does not gets the kit's English rather than a 500.
340
+ // The same shape as `renderLocale` on the send side, and for the same reason.
341
+ locale: isLocale(input.locale ?? "") ? (input.locale ?? null) : null,
342
+ correlation: input.correlation ?? null,
343
+ openTracking: input.openTracking ?? marketing,
344
+ clickTracking: input.clickTracking ?? marketing,
345
+ messageId: null,
346
+ // The same sentence `runSend` writes when it skips one, so the send log reads the same whichever
347
+ // pass caught it — and, for the undispatchable row, the one sentence that says why it stopped
348
+ // here. The status is the state; this column is what an operator reads next to it.
349
+ error: blocked
350
+ ? `recipient suppressed: ${blocked}`
351
+ : undispatchable
352
+ ? "no EMAIL_SENDER binding: this composition can start no send Workflow"
353
+ : null,
354
+ bounceCode: null,
355
+ bounceType: null,
356
+ replyTo: input.replyTo ?? null,
357
+ inReplyTo: input.inReplyTo ?? null,
358
+ references: input.references ?? null,
359
+ createdAt: deps.now,
360
+ updatedAt: deps.now,
361
+ sentAt: null,
362
+ };
363
+
364
+ await deps.db.insertInto("pithyEmailJobs").values(EmailJobSchema.encode(job)).execute();
365
+
366
+ // A withheld message is on the record as an event, not merely as a status — the send log's history is
367
+ // what an operator reads to find out that an advisory reached nobody, and a row with no event in it
368
+ // looks exactly like a job still waiting its turn.
369
+ if (blocked) {
370
+ await recordEvent(deps.db, { jobId: job.id, recipient, type: "suppressed", detail: blocked }, deps.now);
371
+ return { jobId: job.id, status, subject, suppressionReason: blocked };
372
+ }
373
+
374
+ // Immediate sends start the Workflow now for lowest latency, under the id the row already carries —
375
+ // the row is written first so the instance can never be alive before the row can name it.
376
+ //
377
+ // If the dispatch fails, the row stays `pending` naming an instance the runtime has never heard of.
378
+ // `batchIsAlive` turns that into "not alive", so the scheduler re-drives it on the next tick exactly
379
+ // as it did before batch ids existed — a failed dispatch still never loses an email. And if the
380
+ // failure was only the *answer* going missing, the instance is there and alive, the row names it, and
381
+ // the tick holds off: the case that used to be a double-send is now the case the id was minted for.
382
+ if (sender && batchId) {
383
+ try {
384
+ await sender.create({ id: batchId, params: { jobIds: [job.id] } });
385
+ } catch {
386
+ // Swallowed deliberately: the scheduler's safety net owns recovery.
387
+ }
388
+ }
389
+
390
+ return { jobId: job.id, status, subject };
391
+ }
@@ -0,0 +1,73 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PithyError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { EmailRateLimitedError, EmailSendFailedError, EmailSuppressedError } from "../error/errors";
6
+
7
+ /**
8
+ * Map a Cloudflare Email Service binding error (thrown with an `E_*` `.code`) to a Pithy error and a
9
+ * retry decision. The codes are documented at
10
+ * https://developers.cloudflare.com/email-service/api/send-emails/rest-api/ and in the binding's error
11
+ * table. Retryable codes (rate/quota/transient delivery) are surfaced so the send Workflow re-drives
12
+ * them with backoff; validation/sender/content codes are terminal — retrying cannot help.
13
+ */
14
+
15
+ /** How a given send error is handled: which Pithy error to raise, and whether the send Workflow retries. */
16
+ export interface ClassifiedSendError {
17
+ /** The `E_*` code reported by the binding, or `E_UNKNOWN` when none was present. */
18
+ code: string;
19
+ /** Whether the send should be retried with backoff. */
20
+ retryable: boolean;
21
+ /** Whether the failure means the recipient is suppressed (so the address should be locally suppressed). */
22
+ suppressed: boolean;
23
+ /** The Pithy error carrying the public-safe message and the code/detail for logs. */
24
+ error: PithyError;
25
+ }
26
+
27
+ /** Codes the Email Service retries past — rate, daily quota, and transient delivery/server faults. */
28
+ const RETRYABLE = new Set([
29
+ "E_RATE_LIMIT_EXCEEDED",
30
+ "E_DAILY_LIMIT_EXCEEDED",
31
+ "E_DELIVERY_FAILED",
32
+ "E_INTERNAL_SERVER_ERROR",
33
+ ]);
34
+
35
+ /** Rate/quota codes map to the dedicated 429 error so the cause reads clearly in logs. */
36
+ const RATE_LIMITED = new Set(["E_RATE_LIMIT_EXCEEDED", "E_DAILY_LIMIT_EXCEEDED"]);
37
+
38
+ /** Read a thrown value's `.code`/`.message` without assuming its shape. */
39
+ function readError(err: unknown): { code: string; message: string } {
40
+ if (err && typeof err === "object") {
41
+ const code = "code" in err && typeof err.code === "string" ? err.code : "E_UNKNOWN";
42
+ const message = "message" in err && typeof err.message === "string" ? err.message : "";
43
+ return { code, message };
44
+ }
45
+ return { code: "E_UNKNOWN", message: typeof err === "string" ? err : "" };
46
+ }
47
+
48
+ /**
49
+ * Classify a send failure. `E_RECIPIENT_SUPPRESSED` is terminal and means the address must be
50
+ * suppressed locally too; rate/quota and transient delivery codes are retryable; everything else
51
+ * (validation, sender, content, headers) is terminal. An unknown code is treated as a transient
52
+ * failure so a momentary fault gets one bounded retry rather than failing the job outright.
53
+ */
54
+ export function classifySendError(err: unknown): ClassifiedSendError {
55
+ const { code, message } = readError(err);
56
+ const detail = `email send failed: ${code}${message ? ` — ${message}` : ""}`;
57
+
58
+ if (code === "E_RECIPIENT_SUPPRESSED") {
59
+ return {
60
+ code,
61
+ retryable: false,
62
+ suppressed: true,
63
+ error: new EmailSuppressedError({ detail }, { cause: err }),
64
+ };
65
+ }
66
+
67
+ if (RATE_LIMITED.has(code)) {
68
+ return { code, retryable: true, suppressed: false, error: new EmailRateLimitedError({ detail }, { cause: err }) };
69
+ }
70
+
71
+ const retryable = RETRYABLE.has(code) || code === "E_UNKNOWN";
72
+ return { code, retryable, suppressed: false, error: new EmailSendFailedError({ detail }, { cause: err }) };
73
+ }
@@ -0,0 +1,34 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import type { EmailEventType } from "../data/enums";
6
+ import type { EmailDatabase } from "../data/tables";
7
+
8
+ /** A per-recipient event to append to `pithy_email_events`. */
9
+ export interface EventInput {
10
+ jobId: string;
11
+ recipient: string;
12
+ type: EmailEventType;
13
+ linkLabel?: string | null;
14
+ linkUrl?: string | null;
15
+ campaignId?: string | null;
16
+ detail?: string | null;
17
+ }
18
+
19
+ /** Append one event row. The history/attribution log written by the send path, callbacks, and bounce handler. */
20
+ export async function recordEvent(db: EmailDatabase, event: EventInput, now: Date): Promise<void> {
21
+ await db
22
+ .insertInto("pithyEmailEvents")
23
+ .values({
24
+ jobId: event.jobId,
25
+ recipient: event.recipient,
26
+ type: event.type,
27
+ linkLabel: event.linkLabel ?? null,
28
+ linkUrl: event.linkUrl ?? null,
29
+ campaignId: event.campaignId ?? null,
30
+ detail: event.detail ?? null,
31
+ createdAt: SQLiteDate.encode(now),
32
+ })
33
+ .execute();
34
+ }
@@ -0,0 +1,57 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { composedCapability } from "@pithy-sh/core/src/capability/composition";
5
+ import { type EmailCapability, type EmailEnqueueEnv, isEmailCapability } from "../capability";
6
+ import type { EnqueueInput, EnqueueResult } from "./enqueue";
7
+
8
+ /**
9
+ * Sending mail from a Workflow (pithy-sh/pithy#356).
10
+ *
11
+ * A route reaches `enqueue` through the `compose` hook, and should keep doing that — it is typed, it is
12
+ * explicit, and it does not depend on module load order. **A Workflow class has no such route.** The
13
+ * runtime constructs it with the worker `env` and nothing else, `enqueue` is a closure rather than a
14
+ * binding, and Workflow params are serialized so a closure cannot travel in one either. Until this
15
+ * existed, a durable job could not send mail without rebuilding the sending identity from `env` — the
16
+ * same from-address in a second place, free to drift from `pithy.config.ts` — which is exactly what this
17
+ * capability's own doc asks consumers not to do.
18
+ *
19
+ * So: one function, taking the env a Workflow already has, restating nothing.
20
+ *
21
+ * ```ts
22
+ * export class RotationWorkflow extends WorkflowEntrypoint<Env, RotationParams> {
23
+ * override async run(event: WorkflowEvent<RotationParams>, step: WorkflowStep) {
24
+ * await step.do("notify", async () => {
25
+ * await enqueueFromEnv(this.env, { to, template: "operationalNotice", payload });
26
+ * });
27
+ * }
28
+ * }
29
+ * ```
30
+ *
31
+ * The Workflow class must be exported from the same worker entrypoint that calls `createBackend` —
32
+ * which Cloudflare requires anyway — so the composition has already happened in this isolate by the time
33
+ * a step body runs. Where it has not, this raises a wiring fault naming what to compose rather than
34
+ * sending mail as some invented identity.
35
+ */
36
+
37
+ /**
38
+ * The composed email capability, or a raised wiring fault.
39
+ *
40
+ * Narrowed by `isEmailCapability`, the capability's own guard, so a capability composed under the name
41
+ * `email` but carrying no seams is caught here rather than at a call site whose `enqueue` is undefined.
42
+ */
43
+ export function composedEmail(): EmailCapability {
44
+ return composedCapability<EmailCapability>("email", isEmailCapability);
45
+ }
46
+
47
+ /**
48
+ * Enqueue an email from a worker env alone — the seam a Workflow step uses.
49
+ *
50
+ * Identical in every respect to `EmailCapability.enqueue`, because it *is* that function: the
51
+ * from-identity, the theme, the bindings and the automatic suppression check (pithy-sh/pithy#355) all
52
+ * come from the one composed capability. Nothing about a durable send differs from a request-time one
53
+ * except how the caller got here.
54
+ */
55
+ export function enqueueFromEnv(env: EmailEnqueueEnv, input: EnqueueInput): Promise<EnqueueResult> {
56
+ return composedEmail().enqueue(env, input);
57
+ }
@@ -0,0 +1,42 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { WorkflowRetryPolicy } from "@pithy-sh/core/src/workflow/faults";
5
+
6
+ /**
7
+ * **What a send retries, and what it refuses to.**
8
+ *
9
+ * Email already classified the *provider's* vocabulary — `errorMapping.ts` maps every `E_*` code the
10
+ * Email Service returns to retryable or terminal, and says why. This is the other half: the same
11
+ * decision in the form the durable step reads, covering the faults that never came from the binding at
12
+ * all (pithy-sh/pithy#338).
13
+ *
14
+ * ## Retryable
15
+ *
16
+ * - **`email/rate_limited`** — the per-second or daily quota. The next window is a different answer.
17
+ * - **`email/send_failed`** — raised by `runSend` **only** when `classifySendError` judged the code
18
+ * transient (`E_DELIVERY_FAILED`, `E_INTERNAL_SERVER_ERROR`, or an unknown code getting its one
19
+ * bounded retry). A validation, sender, or content code takes the terminal branch there and never
20
+ * reaches a throw, which is why this entry is not the hole it looks like — and `retryPolicy.test.ts`
21
+ * holds the two classifications to each other rather than trusting this sentence.
22
+ * - **A transient D1 fault** — the jobs, events and suppression tables. Classified in core.
23
+ *
24
+ * ## Terminal
25
+ *
26
+ * - **`core/not_found`** — the job row is gone. A send cannot invent the message it was asked to send,
27
+ * and a deleted row does not come back over a backoff.
28
+ * - **`email/template_not_found`, `email/invalid_payload`** — a render that cannot be attempted twice
29
+ * with a different result.
30
+ * - **Anything unclassified**, including a payload that will not parse.
31
+ *
32
+ * A suppressed recipient is neither: `runSend` records it and returns, because a person who asked not to
33
+ * be mailed is an outcome rather than a failure.
34
+ */
35
+ export const emailWorkflowRetry: WorkflowRetryPolicy = {
36
+ capability: "email",
37
+ retryable: {
38
+ "email/rate_limited": "The send was over a rate or daily quota; the next window admits it.",
39
+ "email/send_failed":
40
+ "Only thrown for a code classifySendError judged transient — delivery, an upstream 5xx, or one bounded retry of an unknown code.",
41
+ },
42
+ };