@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,35 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * What each of email's two durable jobs is started with.
8
+ *
9
+ * Declared here rather than inline on the capability because **three places now need the same
10
+ * object**: the capability's `workflows` map (which is what the app worker dispatches through), the
11
+ * host's mirrored registry in `provision/resolveEmailConfig.ts` (which derives the deployed Workflow
12
+ * names), and the host's dispatch route, which validates an incoming loopback payload against the
13
+ * declaring spec's own schema before it starts anything (pithy-sh/pithy#410).
14
+ *
15
+ * That last one is why the mirror could no longer carry `z.unknown()`. A params schema that accepts
16
+ * everything turns a malformed dispatch into a durable instance that fails somewhere inside its first
17
+ * step, which is exactly the class of failure the request contract exists to name at the door.
18
+ */
19
+
20
+ /** The batch of queued rows one send Workflow instance is responsible for. */
21
+ export const EmailSendParams = z
22
+ .object({
23
+ jobIds: z
24
+ .array(z.string().min(1).describe("A queued `pithy_email_jobs` row id."))
25
+ .min(1)
26
+ .describe("The batch of queued job ids this instance sends — one durable step each."),
27
+ })
28
+ .describe("Parameters for one durable send batch.");
29
+ export type EmailSendParams = z.infer<typeof EmailSendParams>;
30
+
31
+ /** The scheduler's parameters: none. It finds its own work. */
32
+ export const EmailScheduleParams = z
33
+ .object({})
34
+ .describe("The scheduler takes no parameters — it finds its own due jobs.");
35
+ export type EmailScheduleParams = z.infer<typeof EmailScheduleParams>;
@@ -0,0 +1,220 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
5
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { EmailDatabase } from "../data/tables";
7
+
8
+ /**
9
+ * The every-minute scheduler. It finds due rows — `scheduled`/`timezone` jobs whose `sendAt` has
10
+ * arrived, plus the safety net of `pending` and `undispatched` immediate jobs that never dispatched and
11
+ * `sending` jobs left stale by a dead dispatch — claims them (status → `sending`, so the next tick won't
12
+ * repick them), and fans them out into sender batches. **The fan-out scales with volume:** the more rows are due, the
13
+ * more batches are dispatched, each its own durable send Workflow. Cron is cheap; this runs every
14
+ * minute and does nothing when nothing is due.
15
+ *
16
+ * Jobs are claimed before dispatch, so a dispatch failure strands a row in `sending` rather than
17
+ * double-sending; the `sending` re-drive recovers it on a later tick, and `runSend`'s idempotency makes
18
+ * any recovery a no-op for a job that did go out.
19
+ *
20
+ * **The claim is the batch.** Every job claimed in one dispatch carries that batch's id, which is the id
21
+ * of the send Workflow instance started for it, so the tick that finds those rows stale can ask the
22
+ * runtime whether the batch is still alive instead of guessing from a timestamp. That is the whole of
23
+ * the re-drive decision (pithy-sh/pithy#342), and it costs the claim one column and the tick one question
24
+ * per batch.
25
+ */
26
+
27
+ /** Inputs the scheduler needs. `dispatch` creates one send Workflow per batch. */
28
+ export interface SchedulerDeps {
29
+ db: EmailDatabase;
30
+ now: Date;
31
+ /** How stale (ms) a `pending`/`undispatched` immediate job must be before the safety net re-drives it. */
32
+ graceMs: number;
33
+ /**
34
+ * How stale (ms) a job must be before this tick will *consider* re-driving it.
35
+ *
36
+ * **It is a filter, not the verdict.** A row's timestamp cannot say whether the batch holding it is
37
+ * alive: a batch is claimed whole and walked one job at a time, so a job it has not reached carries the
38
+ * claim instant however busy the batch is, and a step waiting out its retry backoff writes nothing at
39
+ * all while being entirely alive. {@link SchedulerDeps.batchIsAlive} settles both; this only decides
40
+ * what is old enough to ask about (pithy-sh/pithy#342).
41
+ *
42
+ * So it is not a knob for outrunning a long queue. Widening it to cover one would be a race with a
43
+ * slower horse, and the queue gets longer.
44
+ */
45
+ stuckMs: number;
46
+ /**
47
+ * Jobs per dispatched batch — one send Workflow each. From `SCHEDULER_BATCH_SIZE`.
48
+ *
49
+ * **A fan-out knob, and nothing else.** It has no ceiling: the claim statement sizes itself against
50
+ * D1's bound-parameter cap independently, so raising this changes how many Workflows a tick starts and
51
+ * cannot make a statement too wide. It used to be both, and 100 was enough to fail every tick (#250).
52
+ */
53
+ batchSize: number;
54
+ /** The most jobs to claim in one tick. */
55
+ maxJobs: number;
56
+ /**
57
+ * Mint the id of a batch about to be dispatched. It **is** the send Workflow's instance id, which is
58
+ * what lets {@link SchedulerDeps.batchIsAlive} ask the runtime about it later.
59
+ */
60
+ newBatchId: () => string;
61
+ /**
62
+ * Is the send Workflow instance holding this batch still alive? (pithy-sh/pithy#342)
63
+ *
64
+ * The question a row cannot answer. A Workflow that exists and is scheduled to retry is alive, and its
65
+ * rows are as untouched as a dead dispatch's; a batch three quarters of the way down a long queue is
66
+ * alive, and the quarter it has not reached is as untouched again. Both used to read as stranded, and
67
+ * a re-drive of either is a second send Workflow over a job the first will reach — a double-send.
68
+ *
69
+ * **It may only ever veto a re-drive, never cause one.** A stale row with no batch, or one whose batch
70
+ * this cannot vouch for, is re-driven exactly as it was before this existed. That is what keeps the
71
+ * safety net intact and makes the new question incapable of sending an extra email: the worst an
72
+ * unavailable answer can do is decline to save one.
73
+ */
74
+ batchIsAlive: (batchId: string) => Promise<boolean>;
75
+ /** Create a send Workflow for a batch of job ids, under the batch's id as the instance id. */
76
+ dispatch: (batchId: string, jobIds: string[]) => Promise<void>;
77
+ }
78
+
79
+ /** What one scheduler tick did. */
80
+ export interface SchedulerResult {
81
+ /** How many due jobs were claimed. */
82
+ due: number;
83
+ /** How many batches were dispatched (scales with volume). */
84
+ batches: number;
85
+ /** How many stale-looking jobs were left alone because the batch holding them is still alive. */
86
+ held: number;
87
+ }
88
+
89
+ /**
90
+ * How many parameters the claim statement binds besides the job ids: `status`, `updatedAt`, `batchId`.
91
+ *
92
+ * Named here so that adding a column to the `set` is a one-number edit beside it, rather than a silent
93
+ * re-break of a limit nobody re-derived.
94
+ */
95
+ const CLAIM_FIXED_PARAMETERS = 3;
96
+
97
+ /**
98
+ * Refuse a batch size that is not a count at all — checked before the tick does any work.
99
+ *
100
+ * `Number(env.SCHEDULER_BATCH_SIZE)` yields `NaN` for a typo, and `NaN` used to produce exactly one
101
+ * empty batch: no job claimed, no job sent, no error, every minute. There is no safe number to clamp a
102
+ * typo to, so it is named and refused.
103
+ *
104
+ * What is *not* refused is a large one. It carries no platform limit any more — see
105
+ * {@link dispatchBatches}.
106
+ */
107
+ function assertBatchSize(batchSize: number): void {
108
+ if (!Number.isInteger(batchSize) || batchSize < 1) {
109
+ throw new ValidationError({
110
+ message: "The email scheduler is misconfigured.",
111
+ action: "Set SCHEDULER_BATCH_SIZE to a whole number of one or more, or unset it for the default of 50.",
112
+ detail: `SCHEDULER_BATCH_SIZE resolved to ${batchSize}; it must be a positive integer.`,
113
+ });
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Split the due jobs into the batches this tick will dispatch — the **fan-out** split, not the
119
+ * parameter one.
120
+ *
121
+ * The two used to be the same list, which is how `SCHEDULER_BATCH_SIZE` came to decide the width of a
122
+ * D1 statement. They are separated now: this decides how many send Workflows a tick starts, and
123
+ * {@link chunkByBoundParameters} decides how wide the claim that precedes each one may be. So the
124
+ * operator's number carries no platform limit, and the platform limit needs no operator.
125
+ */
126
+ function dispatchBatches(ids: string[], batchSize: number): string[][] {
127
+ const out: string[][] = [];
128
+ for (let start = 0; start < ids.length; start += batchSize) out.push(ids.slice(start, start + batchSize));
129
+ return out;
130
+ }
131
+
132
+ /**
133
+ * Drop the candidates a live batch still holds, and return the rest (pithy-sh/pithy#342).
134
+ *
135
+ * A candidate is a row the timestamps say *might* be stranded. Deciding it actually is takes the batch,
136
+ * so every row naming one is settled by {@link SchedulerDeps.batchIsAlive} — asked **once per batch**,
137
+ * not once per row, because fifty rows of a stalled batch are one question about one Workflow.
138
+ *
139
+ * A row naming no batch is stranded by definition: nothing claimed it, or whatever did died before it
140
+ * could say so. That is the safety net, and it is deliberately untouched here.
141
+ */
142
+ async function strandedIds(
143
+ deps: SchedulerDeps,
144
+ rows: readonly { id: string; batchId?: string | null }[],
145
+ ): Promise<string[]> {
146
+ const answered = new Map<string, boolean>();
147
+ const stranded: string[] = [];
148
+ for (const row of rows) {
149
+ const batchId = row.batchId;
150
+ if (batchId) {
151
+ let alive = answered.get(batchId);
152
+ if (alive === undefined) {
153
+ alive = await deps.batchIsAlive(batchId);
154
+ answered.set(batchId, alive);
155
+ }
156
+ if (alive) continue;
157
+ }
158
+ stranded.push(row.id);
159
+ }
160
+ return stranded;
161
+ }
162
+
163
+ /** Run one scheduler tick: find due rows, claim them, fan out batches. */
164
+ export async function runScheduler(deps: SchedulerDeps): Promise<SchedulerResult> {
165
+ // Before the query, so a misconfigured worker says so on its first tick rather than on its first
166
+ // busy one. An idle cron that quietly accepts a broken batch size is the shape of the bug, not a
167
+ // reason to postpone the complaint.
168
+ assertBatchSize(deps.batchSize);
169
+ const nowMs = deps.now.getTime();
170
+ const graceCutoff = nowMs - deps.graceMs;
171
+ const stuckCutoff = nowMs - deps.stuckMs;
172
+
173
+ const rows = await deps.db
174
+ .selectFrom("pithyEmailJobs")
175
+ .select(["id", "batchId"])
176
+ .where((eb) =>
177
+ eb.or([
178
+ eb.and([eb("status", "=", "scheduled"), eb("sendAt", "<=", nowMs)]),
179
+ // `undispatched` beside `pending`, and it is the whole recovery path for one
180
+ // (pithy-sh/pithy#410). A row born `undispatched` was enqueued by a composition that binds no
181
+ // send Workflow — deployed before `pithy <capability> provision`, or a plain `wrangler dev` —
182
+ // and the tick reading this query is running on the host that composition was missing. So the
183
+ // first tick after the host exists is what drains that backlog; without this line the row is a
184
+ // dead end, because `retryJob` takes only `failed` and no command moves it.
185
+ eb.and([eb("status", "in", ["pending", "undispatched"]), eb("createdAt", "<=", graceCutoff)]),
186
+ eb.and([eb("status", "=", "sending"), eb("updatedAt", "<=", stuckCutoff)]),
187
+ ]),
188
+ )
189
+ .orderBy("sendAt", "asc")
190
+ .limit(deps.maxJobs)
191
+ .execute();
192
+
193
+ if (rows.length === 0) return { due: 0, batches: 0, held: 0 };
194
+
195
+ const ids = await strandedIds(deps, rows);
196
+ const held = rows.length - ids.length;
197
+ if (ids.length === 0) return { due: 0, batches: 0, held };
198
+
199
+ const batches = dispatchBatches(ids, deps.batchSize);
200
+ let dispatched = 0;
201
+ for (const batch of batches) {
202
+ // The batch's id, minted before the claim so the rows can carry it: it is the send Workflow's
203
+ // instance id, and a row that names it is a row the next tick can ask the runtime about.
204
+ const batchId = deps.newBatchId();
205
+ // Claim this batch first so a re-run never double-dispatches it, then start its send Workflow. The
206
+ // claim is chunked against D1's cap, so a batch of any size is claimed in full before it dispatches
207
+ // — a batch wider than one statement takes several, and the batch is still one unit of work.
208
+ for (const claim of chunkByBoundParameters(batch, CLAIM_FIXED_PARAMETERS)) {
209
+ await deps.db
210
+ .updateTable("pithyEmailJobs")
211
+ .set({ status: "sending", updatedAt: nowMs, batchId })
212
+ .where("id", "in", claim)
213
+ .execute();
214
+ }
215
+ await deps.dispatch(batchId, batch);
216
+ dispatched += 1;
217
+ }
218
+
219
+ return { due: ids.length, batches: dispatched, held };
220
+ }
@@ -0,0 +1,154 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { runSend, type SendDeps, type SendOutcome } from "../send/runSend";
5
+
6
+ /**
7
+ * The send batch — the body `EmailSendWorkflow.run` hands its step runner to.
8
+ *
9
+ * **Why it is here and not in the Workflow class.** `worker.ts` imports `cloudflare:workers`, which
10
+ * resolves in workerd and nowhere else, so anything inside it can only be exercised by deploying it.
11
+ * Every property worth proving about this body is a property of a *resume* — a Workflow does not resume
12
+ * inside the step it died in, it re-executes this function from the top and serves every completed step
13
+ * from the journal — and the only way to know a resume behaves is to drive one.
14
+ *
15
+ * **One step per job.** Each is independently retried and backed off by the Workflow runtime, and a step
16
+ * whose retries are spent is contained here, so a single bad recipient never blocks the rest of the
17
+ * batch. That sentence has been in this docblock since the file was written and the code did not do it
18
+ * until #380: the throw came out of the loop and took every job behind it, on this attempt and on every
19
+ * replay of the body.
20
+ *
21
+ * **The batch is the unit of liveness, and it says so by existing.** The scheduler claims a whole batch
22
+ * up front, stamps every row with the batch's id — which is this Workflow instance's id — and dispatches
23
+ * one instance for it. So a job this body has not reached is queued, not stranded, and the scheduler
24
+ * establishes that by asking the runtime whether the instance is still running.
25
+ *
26
+ * That is why nothing here writes to a job it is not sending. The previous answer had each step renew the
27
+ * claim on every job behind it (pithy-sh/pithy#340), which is correct and costs N(N-1)/2 row updates for
28
+ * a batch of N — 1,225 at the shipped batch size of 50, on a setting with no ceiling — and still could
29
+ * not speak for a step waiting out its retry backoff, because a body that is not running renews nothing
30
+ * (pithy-sh/pithy#342). One question to the runtime answers both, and writes nothing.
31
+ */
32
+
33
+ /** The durable step runner, structurally. Injected, so a test can drive an interrupt and a resume. */
34
+ export interface SendBatchStep {
35
+ /** Run a named step, or return its journaled result if this instance already completed it. */
36
+ do<T>(name: string, fn: () => Promise<T>): Promise<T>;
37
+ }
38
+
39
+ /**
40
+ * What a batch needs: everything one send needs, minus the pass instant it journals for itself.
41
+ *
42
+ * `heartbeatAt` is the clock, and it stays a thunk all the way down — `runSend` reads it afresh on every
43
+ * patch. The pass instant is *one journaled read of the same clock*, which is the whole design in a
44
+ * sentence: one source, two lifetimes, and neither of them able to answer for the other.
45
+ */
46
+ export type SendBatchDeps = Omit<SendDeps, "passStartedAt">;
47
+
48
+ /**
49
+ * One job's place in the batch — **two states, and the outcome lives behind the one that has it**
50
+ * (#380).
51
+ *
52
+ * A step exhausts its retries and throws, and the docblock above promises that never blocks the rest of
53
+ * the batch. It did not: the throw propagated out of the loop and every job behind it went unsent, on
54
+ * this attempt and on every retry of the body, because a re-execution replays the journal and reaches
55
+ * the same failing step again. A batch of fifty lost forty-seven messages to one bad recipient.
56
+ *
57
+ * The two states share no field, so a caller cannot reach an outcome without narrowing, and a job the
58
+ * batch could not finish cannot be read as one that was skipped for a reason `runSend` names. What
59
+ * happens to that job next is the scheduler's: its row is still `sending` or `failed`, and the
60
+ * `stuckMs` re-drive is what picks it up.
61
+ */
62
+ export type BatchJobResult =
63
+ | {
64
+ /** The send ran to a conclusion — sent, suppressed, canceled or terminally failed. */
65
+ state: "attempted";
66
+ /** The job this is about. */
67
+ jobId: string;
68
+ /** What the send concluded. Present here alone. */
69
+ outcome: SendOutcome;
70
+ }
71
+ | {
72
+ /**
73
+ * The step did not finish: it threw, and its retries within this instance are spent. Nothing is
74
+ * claimed about the job beyond that — it may have been rendered, it may have been sent and the
75
+ * write lost.
76
+ */
77
+ state: "unfinished";
78
+ /** The job this is about — the only fact this state carries. */
79
+ jobId: string;
80
+ };
81
+
82
+ /**
83
+ * What one batch did, one entry per job in dispatch order.
84
+ *
85
+ * Returned rather than logged because this capability has no logger seam and a Workflow's return value
86
+ * *is* its instance output — so the record lands where an operator already looks when a batch is in
87
+ * question, next to the step journal that shows which step failed.
88
+ */
89
+ export interface BatchSendReport {
90
+ /** One entry per job id the batch was dispatched with, in that order. */
91
+ jobs: BatchJobResult[];
92
+ }
93
+
94
+ /** Send one batch of jobs, one durable step each. */
95
+ export async function runSendBatch(
96
+ deps: SendBatchDeps,
97
+ step: SendBatchStep,
98
+ jobIds: readonly string[],
99
+ ): Promise<BatchSendReport> {
100
+ /**
101
+ * The pass instant, journaled (pithy-sh/pithy#327).
102
+ *
103
+ * One read of `heartbeatAt`, taken inside a step so a resume reads back the instant the batch began
104
+ * rather than the instant it came back. It dates the work — `sentAt`, the redaction stamp, the events,
105
+ * and every tracked link's expiry.
106
+ *
107
+ * **What is deliberately not journaled is the clock itself.** `deps.heartbeatAt` goes through to
108
+ * `runSend` as a thunk, because `updatedAt` is what decides a `sending` job is old enough to ask about
109
+ * at all, and a frozen one puts a job the batch is mid-flight on in front of that question every tick.
110
+ * The batch's own liveness answers it correctly either way; a clock that lies is still a clock that
111
+ * lies, and `sentAt` is not the only thing reading this.
112
+ *
113
+ * Epoch milliseconds rather than a `Date`, because a journal round-trips JSON: a `Date` would come back
114
+ * a string on the resume and an object on the first pass.
115
+ */
116
+ const passStartedAtMs: number = await step.do("pass-instant", async () => deps.heartbeatAt().getTime());
117
+ const sendDeps: SendDeps = { ...deps, passStartedAt: new Date(passStartedAtMs) };
118
+ const jobs: BatchJobResult[] = [];
119
+ for (const jobId of jobIds) {
120
+ // Contained per job, which is what the docblock above has always claimed (#380). A step that has
121
+ // spent its retries throws, and the throw used to end the batch — so one recipient whose template
122
+ // will not render, or whose row was deleted mid-flight, cost every job behind it its send.
123
+ //
124
+ // `try`/`catch` rather than `.catch()`: `step.do` is handed a function, and a runner that throws
125
+ // before it returns a promise is not a rejected promise (#371).
126
+ //
127
+ // **The guard takes no binding.** What a send throws carries a recipient's address, a provider's
128
+ // response, and sometimes the rendered link itself — none of which may travel into a value the
129
+ // Workflow instance publishes as its output. The job id is the actionable fact and it is already
130
+ // here. The failure itself stays exactly where it is visible and belongs: the failed step in the
131
+ // instance's own journal.
132
+ //
133
+ // **The step runner is not one of the contributors, and `began` is what tells them apart.** A send
134
+ // that ran and failed is this job's failure and is contained. A runner that will not start the step
135
+ // at all is the durable mechanism itself refusing — an instance being torn down — and there is no
136
+ // batch left to carry on: every job behind would fail identically, and a body that keeps calling a
137
+ // runner which has refused is a body that has not noticed it is being killed. So that one is
138
+ // rethrown, exactly as `readProjectLedger` still throws when the databases cannot be enumerated.
139
+ let began = false;
140
+ try {
141
+ const outcome = await step.do(`send-${jobId}`, async () => {
142
+ began = true;
143
+ return await runSend(sendDeps, jobId);
144
+ });
145
+ jobs.push({ state: "attempted", jobId, outcome });
146
+ } catch (interrupted) {
147
+ // The binding exists only to rethrow the same object, unchanged. Nothing derived from it reaches
148
+ // the report — that is what `unfinished` carrying only a job id means.
149
+ if (!began) throw interrupted;
150
+ jobs.push({ state: "unfinished", jobId });
151
+ }
152
+ }
153
+ return { jobs };
154
+ }
@@ -0,0 +1,203 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
5
+ import { NonRetryableError } from "cloudflare:workflows";
6
+ import type { D1Database, ExecutionContext } from "@cloudflare/workers-types";
7
+ import { classifiedSteps } from "@pithy-sh/core/src/workflow/faults";
8
+ import { requireHostEnv } from "@pithy-sh/core/src/workflow/hostEnv";
9
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
10
+ import { configureSharedSecrets } from "@pithy-sh/secrets/src/sharedSecretsStore";
11
+ import { emailSigningRegistry, resolveSigningKeys } from "../crypto/signingKey";
12
+ import { emailDatabase, emailSuppressionDatabase } from "../data/tables";
13
+ import { mintBatchId } from "../send/batchIdentity";
14
+ import type { SendWorkflowBinding } from "../send/enqueue";
15
+ import { emailWorkflowRetry } from "../send/retryPolicy";
16
+ import type { EmailSender } from "../send/sender";
17
+ import { catalogLayers, catalogsFromEnv } from "../templates/messages";
18
+ import { createEmailHostApp } from "./hostApp";
19
+ import { type EmailHostEnv, emailHostEnv } from "./hostEnv";
20
+ import { isLiveInstanceStatus } from "./instanceLiveness";
21
+ import type { SendWorkflowInstances } from "./instances";
22
+ import { runScheduler, type SchedulerDeps } from "./scheduler";
23
+ import { type BatchSendReport, runSendBatch, type SendBatchDeps } from "./sendBatch";
24
+
25
+ /**
26
+ * The prebuilt email worker. `pithy add email` deploys one per environment (`pithy-email-staging`,
27
+ * `pithy-email-prod`); the user authors no code for it. It hosts:
28
+ *
29
+ * - `EmailSendWorkflow` — sends a batch of jobs durably (dispatch target for immediate sends and
30
+ * the scheduler's fan-out).
31
+ * - `EmailSchedulerWorkflow` — finds due jobs and fans them out into send batches.
32
+ * - `scheduled()` — the every-minute cron that fires the scheduler Workflow.
33
+ * - `fetch()` — the loopback dispatch door, served only in `dev` (see {@link createEmailHostApp}).
34
+ *
35
+ * The bodies (`runSendBatch`, `runScheduler`, `runSend`) are tested against Miniflare; these classes are
36
+ * the thin durable shells. This module imports `cloudflare:workers`, so it runs only in the Workers
37
+ * runtime (excluded from the node meta-test).
38
+ *
39
+ * **Every entry validates the env first** (pithy-sh/pithy#410). Fourteen settings arrived here from a
40
+ * provisioning run and none of them was checked: a missing `BASE_URL` became a magic link to
41
+ * `undefined/…`, an unparseable `EMAIL_THEME` threw inside a render step, and a `SCHEDULER_BATCH_SIZE`
42
+ * of `"fifty"` became `NaN` and the scheduler claimed nothing, forever, in silence. Now
43
+ * {@link emailHostEnv} is parsed before anything reads a value, the coercions and defaults live in
44
+ * that one schema rather than at each reader, and a host that cannot work says so in one block and
45
+ * refuses.
46
+ */
47
+
48
+ /**
49
+ * The email worker's env as the runtime hands it over — bindings as objects, every var as a string.
50
+ *
51
+ * The *shape the host runs on* is {@link EmailHostEnv}, which is this parsed: numbers as numbers, the
52
+ * theme as a validated `EmailTheme`, `SCHEDULER_ENABLED` as a boolean. This type stays because it is
53
+ * what a `WorkflowEntrypoint` is generic over and what the platform actually binds.
54
+ */
55
+ export interface EmailWorkerEnv extends SecretsStoreEnv {
56
+ /** The app database the per-environment jobs/events tables live in. */
57
+ DB: D1Database;
58
+ /** The shared, durable suppression database. */
59
+ EMAIL_SUPPRESSIONS: D1Database;
60
+ /** The Cloudflare Email Service send binding. */
61
+ EMAIL: EmailSender;
62
+ /** The send Workflow (self) — the scheduler creates batches against it, and asks after them. */
63
+ EMAIL_SENDER: SendWorkflowBinding & SendWorkflowInstances;
64
+ /** The scheduler Workflow (self) — fired by the cron. */
65
+ EMAIL_SCHEDULER: { create(): Promise<unknown> };
66
+ /** The resolved brand theme as a JSON string (the full `EmailTheme`), set at provision from the app config. */
67
+ EMAIL_THEME?: string;
68
+ /**
69
+ * The project's catalogs arrive as one variable per locale — `EMAIL_MESSAGES_ES` and friends — read
70
+ * through `catalogsFromEnv`. Not declared here, because the names are the project's locales.
71
+ */
72
+ [catalogVar: `EMAIL_MESSAGES_${string}`]: unknown;
73
+ BASE_URL: string;
74
+ // ENVIRONMENT is inherited from SecretsStoreEnv (a `ManagedEnvironment`); never redeclare it as a plain string.
75
+ LINK_TTL_DAYS?: string;
76
+ MAX_ATTEMPTS?: string;
77
+ SCHEDULER_ENABLED?: string;
78
+ SCHEDULER_BATCH_SIZE?: string;
79
+ SCHEDULER_MAX_JOBS?: string;
80
+ SCHEDULER_GRACE_MS?: string;
81
+ SCHEDULER_STUCK_MS?: string;
82
+ }
83
+
84
+ // This is a standalone worker, not assembled by `createBackend`, so the secrets capability's `compose`
85
+ // hook never runs here. Configure the shared per-invocation accessor directly from email's own slice so
86
+ // `resolveSigningKeys` reads the signing key through the one cached path.
87
+ configureSharedSecrets({ registry: emailSigningRegistry });
88
+
89
+ /** The dispatch door. Built once per isolate; the environment gate reads its answer per request. */
90
+ const app = createEmailHostApp();
91
+
92
+ /**
93
+ * The env, parsed — or one legible block naming every unusable setting and what fills it, then a
94
+ * refusal. Called at the top of every entry, and the block is written once per env object.
95
+ */
96
+ function hostConfig(env: EmailWorkerEnv): EmailHostEnv {
97
+ return requireHostEnv(emailHostEnv, env);
98
+ }
99
+
100
+ /** Assemble the send dependencies, resolving the current signing key from the secrets store. */
101
+ async function buildSendDeps(env: EmailWorkerEnv): Promise<SendBatchDeps> {
102
+ const config = hostConfig(env);
103
+ const keys = await resolveSigningKeys(env);
104
+ const key = keys.versions[keys.currentVersion];
105
+ return {
106
+ db: emailDatabase(env.DB),
107
+ suppressionDb: emailSuppressionDatabase(env.EMAIL_SUPPRESSIONS),
108
+ sender: env.EMAIL,
109
+ theme: config.EMAIL_THEME,
110
+ // The catalogs, as a seam over the one JSON var. A body renders in the job's own locale from here;
111
+ // with the var absent this resolves to the kit's English, unchanged from before #441.
112
+ layersFor: catalogLayers(catalogsFromEnv(env as unknown as Record<string, unknown>)),
113
+ baseUrl: config.BASE_URL,
114
+ signing: key ? { key, kid: keys.currentVersion } : undefined,
115
+ linkTtlDays: config.LINK_TTL_DAYS,
116
+ maxAttempts: config.MAX_ATTEMPTS,
117
+ environment: config.ENVIRONMENT,
118
+ heartbeatAt: () => new Date(),
119
+ };
120
+ }
121
+
122
+ /** Assemble the scheduler dependencies, dispatching each batch as a send Workflow. */
123
+ function buildSchedulerDeps(env: EmailWorkerEnv): SchedulerDeps {
124
+ const config = hostConfig(env);
125
+ return {
126
+ db: emailDatabase(env.DB),
127
+ now: new Date(),
128
+ graceMs: config.SCHEDULER_GRACE_MS,
129
+ stuckMs: config.SCHEDULER_STUCK_MS,
130
+ batchSize: config.SCHEDULER_BATCH_SIZE,
131
+ maxJobs: config.SCHEDULER_MAX_JOBS,
132
+ // The same mint as the other two dispatchers, so the three cannot drift into three id schemes.
133
+ newBatchId: mintBatchId,
134
+ // The batch's id is the instance's id, so this is the whole of the lookup. A rejection means the
135
+ // instance is not there to ask — a dispatch that never landed — and that is stranded, not alive: the
136
+ // answer may only ever veto a re-drive, so the cautious reading is the one that keeps recovering.
137
+ batchIsAlive: async (batchId) => {
138
+ try {
139
+ const instance = await env.EMAIL_SENDER.get(batchId);
140
+ const { status } = await instance.status();
141
+ return isLiveInstanceStatus(status);
142
+ } catch {
143
+ return false;
144
+ }
145
+ },
146
+ dispatch: async (batchId, jobIds) => {
147
+ await env.EMAIL_SENDER.create({ id: batchId, params: { jobIds } });
148
+ },
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Sends a batch of jobs durably. Started for immediate sends and by the scheduler's fan-out.
154
+ *
155
+ * Every step runs under {@link emailWorkflowRetry}, which agrees with `errorMapping.ts` by
156
+ * construction: a rate limit and a transient provider fault re-drive the job, and a template that does
157
+ * not exist, a payload that will not render, or a job row that is gone fail it at once. See
158
+ * `send/retryPolicy.ts`.
159
+ */
160
+ export class EmailSendWorkflow extends WorkflowEntrypoint<EmailWorkerEnv, { jobIds: string[] }> {
161
+ // The batch report is the instance's output (#380). A job whose step spent its retries is contained
162
+ // so the rest of the batch still sends, and this is where an operator reads which ones those were —
163
+ // beside the failed step in the same instance. It carries job ids and outcomes, never a recipient.
164
+ override async run(event: WorkflowEvent<{ jobIds: string[] }>, step: WorkflowStep): Promise<BatchSendReport> {
165
+ return await runSendBatch(
166
+ await buildSendDeps(this.env),
167
+ classifiedSteps(step, emailWorkflowRetry, NonRetryableError),
168
+ event.payload.jobIds,
169
+ );
170
+ }
171
+ }
172
+
173
+ /** Finds due jobs and fans them out into send batches. Fired by the every-minute cron. */
174
+ export class EmailSchedulerWorkflow extends WorkflowEntrypoint<EmailWorkerEnv, unknown> {
175
+ override async run(_event: WorkflowEvent<unknown>, step: WorkflowStep): Promise<void> {
176
+ await classifiedSteps(step, emailWorkflowRetry, NonRetryableError).do("dispatch-due", async () => {
177
+ await runScheduler(buildSchedulerDeps(this.env));
178
+ });
179
+ }
180
+ }
181
+
182
+ export default {
183
+ /** Cron entry: fire the scheduler Workflow every minute, unless disabled. */
184
+ async scheduled(_controller: unknown, env: EmailWorkerEnv): Promise<void> {
185
+ if (hostConfig(env).SCHEDULER_ENABLED) {
186
+ await env.EMAIL_SCHEDULER.create();
187
+ }
188
+ },
189
+
190
+ /**
191
+ * The loopback dispatch door — how a sibling worker under `pithy dev` starts a send batch on this
192
+ * host's own same-script Workflow binding (pithy-sh/pithy#410). Refused in every other environment,
193
+ * where the cross-script binding is the only path in.
194
+ *
195
+ * The env is validated before the router sees the request: a host that cannot work must not accept
196
+ * a dispatch and then lose it. The refusal is `core/internal` and the block is already in the log,
197
+ * which is what its action line points the operator at.
198
+ */
199
+ async fetch(request: Request, env: EmailWorkerEnv, ctx: ExecutionContext): Promise<Response> {
200
+ hostConfig(env);
201
+ return await app.fetch(request, env, ctx);
202
+ },
203
+ };