@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,214 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { normalizeAddress } from "@pithy-sh/core/src/address/address";
5
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
6
+ import { ConflictError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
7
+ import type { EmailJob } from "../data/emailJob";
8
+ import type { EmailDatabase, EmailSuppressionDatabase } from "../data/tables";
9
+ import { EmailSuppressedError } from "../error/errors";
10
+ import { mintBatchId } from "../send/batchIdentity";
11
+ import type { SendWorkflowBinding } from "../send/enqueue";
12
+ import { blockingSuppression } from "../send/suppression";
13
+ import { templateKind } from "../templates/engine";
14
+ import { getJob } from "./read";
15
+
16
+ /**
17
+ * Putting a failed job back in the queue — the body behind `POST /email/jobs/:id/retry`.
18
+ *
19
+ * ## A handler never sends
20
+ *
21
+ * `runSend` is Workflow-only and is never called from here. A retry does what `enqueueEmail` does: it
22
+ * puts the row back into a sendable state and dispatches the send Workflow. That keeps the one place
23
+ * that talks to the Email Service inside a durable step with retries and history, which is the whole
24
+ * reason email is a job table rather than a function call.
25
+ *
26
+ * A failed dispatch is safe and deliberately swallowed. The row is `pending` before the Workflow is
27
+ * started, so the every-minute scheduler's safety net re-drives it on the next tick — the same contract
28
+ * `enqueueEmail` relies on. Losing the dispatch costs a minute; failing the request after the row was
29
+ * already reset would tell an operator nothing happened when something did.
30
+ *
31
+ * ## Only a `failed` job may be retried
32
+ *
33
+ * Every other state is refused with `core/conflict`, and each refusal is a different thing going wrong:
34
+ *
35
+ * - `sent` — already delivered. Retrying is a duplicate email to a real person. It is also the one
36
+ * state whose inputs are gone: a transactional job's payload is dropped when the message goes out,
37
+ * which is safe precisely because this refusal is what makes `sent` terminal.
38
+ * - `pending` / `scheduled` / `sending` — still in flight. Resetting it races the scheduler, and
39
+ * `sending` in particular is a job a Workflow is holding right now.
40
+ * - `suppressed` — the address is on the block list. Retrying is the one send this capability must
41
+ * never make.
42
+ * - `bounced` — the recipient's server said permanently no, and the bounce handler only sets this
43
+ * state while suppressing the address, so a retry is the previous case wearing a different label.
44
+ * - `canceled` — somebody withdrew it. Reviving a withdrawal is a separate decision, not a retry, and
45
+ * it should have to be made somewhere it is named.
46
+ *
47
+ * ## The attempt budget is reset, and it has to be
48
+ *
49
+ * `runSend` gives up when `attempts >= maxAttempts`. A job that failed did so having spent its budget,
50
+ * so a retry that left `attempts` alone would take one retryable error to fail terminally again — the
51
+ * button would appear to work and change nothing. `attempts` returns to zero; `error` does not, because
52
+ * it is the record of what went wrong last time and a successful send clears it anyway.
53
+ *
54
+ * ## The batch id is re-minted, and the old one must not survive
55
+ *
56
+ * A `failed` row still names the batch that failed it (pithy-sh/pithy#342), and that id is about to
57
+ * become wrong in both directions at once. `batchId` means *the instance coming for this row* — see
58
+ * `send/batchIdentity.ts` — and after a retry the instance coming for it is the one this function
59
+ * starts, not the one that gave up on it.
60
+ *
61
+ * Leaving the old id there is not a stale label. It hands the scheduler's veto the wrong Workflow to ask
62
+ * about, and a failure inside a batch is exactly the case where that Workflow is *still running*: a
63
+ * batch of fifty that failed job seven walks on to job fifty. So the tick that should re-drive the retry
64
+ * asks about the batch that abandoned it, is told "alive", and holds — the operator's click sends
65
+ * nothing for as long as the old batch runs. Then the old batch ends, the same row reads as stranded,
66
+ * and the tick starts a second Workflow behind the one this function already started. Held when it
67
+ * should send, then sent twice: one wrong id, both failures.
68
+ *
69
+ * So a retry mints its own, writes it in the same statement that makes the row queryable again, and
70
+ * creates the instance under it. Null when there is no binding to dispatch on, because then nothing is
71
+ * coming for the row and the scheduler should claim it on the next tick — and the row is `undispatched`
72
+ * rather than `pending` there, for the reason the write itself states.
73
+ */
74
+
75
+ /** What a retry needs: both databases, the send Workflow binding, and the clock. */
76
+ export interface RetryDeps {
77
+ /** The per-environment jobs database. */
78
+ db: EmailDatabase;
79
+ /** The global suppression database — checked before the row is touched. */
80
+ suppressionDb: EmailSuppressionDatabase;
81
+ /**
82
+ * The send Workflow binding. Optional so a Worker that somehow lacks it still resets the row and
83
+ * leaves the scheduler to re-drive, rather than failing an operator's click outright.
84
+ */
85
+ sender?: SendWorkflowBinding;
86
+ now: Date;
87
+ /**
88
+ * Mint the id of the batch this retry dispatches — **the send Workflow instance's id**
89
+ * (pithy-sh/pithy#342). Defaults to {@link mintBatchId}; injected only so a test can name it.
90
+ */
91
+ newBatchId?: () => string;
92
+ }
93
+
94
+ /** What a retry did. */
95
+ export interface RetryResult {
96
+ /** The job that was re-queued. */
97
+ job: EmailJob;
98
+ /**
99
+ * Whether the send Workflow actually started. False means the scheduler will pick the row up within
100
+ * the minute — the mail is not lost, it is merely not immediate.
101
+ */
102
+ dispatched: boolean;
103
+ }
104
+
105
+ /**
106
+ * Re-queue one failed job.
107
+ *
108
+ * Reads before it writes: the job must exist, be `failed`, and its recipient must not have been
109
+ * suppressed in the meantime. That last check is not redundant with `runSend`'s — `runSend` would mark
110
+ * the row `suppressed` and skip, which reports to the operator as a successful retry that silently sent
111
+ * nothing. Refusing here says what actually happened.
112
+ */
113
+ export async function retryJob(deps: RetryDeps, jobId: string): Promise<RetryResult> {
114
+ const existing = await getJob(deps.db, jobId);
115
+ if (!existing) {
116
+ throw new NotFoundError({
117
+ message: "No such email job.",
118
+ action: "Check the job id against the send log.",
119
+ detail: `email job '${jobId}' not found`,
120
+ });
121
+ }
122
+
123
+ if (existing.status !== "failed") {
124
+ throw new ConflictError({
125
+ message: `Only a failed job can be retried. This one is ${existing.status}.`,
126
+ action: "Retry a job whose status is failed.",
127
+ detail: `email job '${jobId}' is ${existing.status}`,
128
+ });
129
+ }
130
+
131
+ const recipient = normalizeAddress(existing.toAddress);
132
+ // Asked the same way `runSend` asks it, kind included. An operator retrying a failed magic link to
133
+ // somebody who unsubscribed from a newsletter must not be told the address is unreachable — the send
134
+ // would go through, so refusing it here would be this capability inventing a block of its own.
135
+ const blocked = await blockingSuppression(deps.suppressionDb, recipient, deps.now, templateKind(existing.template));
136
+ if (blocked) {
137
+ throw new EmailSuppressedError({
138
+ message: `That recipient is on the suppression list (${blocked}), so this job cannot be retried.`,
139
+ action: "Remove the address from the suppression list first, if that is what you mean to do.",
140
+ detail: `recipient of email job '${jobId}' is suppressed: ${blocked}`,
141
+ });
142
+ }
143
+
144
+ // The batch this retry will start, named before the row is written so the same statement that makes
145
+ // the row queryable again also says who is coming for it. Null with no binding: nothing is.
146
+ const batchId = deps.sender ? (deps.newBatchId ?? mintBatchId)() : null;
147
+
148
+ const updated = await deps.db
149
+ .updateTable("pithyEmailJobs")
150
+ .set({
151
+ // **The failed batch's id does not survive the retry** — see the note above. Whether it is
152
+ // re-minted or nulled, what must not happen is the row keeping the id of the batch that gave up on
153
+ // it, because that batch is usually still running and its liveness would be read as this row's.
154
+ batchId,
155
+ // `pending`, whatever the original mode was. A retry is an operator asking for this to go now;
156
+ // re-arming a `scheduled` job to a `sendAt` that is already in the past would say the same thing
157
+ // less clearly, and re-deriving a `timezone` job's local slot would move the send to tomorrow.
158
+ //
159
+ // With no binding it is `undispatched` instead — the same word `enqueueEmail` writes for the very
160
+ // same env (pithy-sh/pithy#410). One deployment must not have two names for one configuration
161
+ // fact: `pending` here would tell an operator their click queued a send while an enqueue two
162
+ // lines away was recording that this composition can start none. The scheduler claims either, so
163
+ // the retry is deferred and not dropped.
164
+ status: deps.sender ? "pending" : "undispatched",
165
+ sendAt: SQLiteDate.encode(deps.now),
166
+ attempts: 0,
167
+ updatedAt: SQLiteDate.encode(deps.now),
168
+ // **`createdAt` is reset too, and it is load-bearing.** The scheduler's safety net for an
169
+ // immediate job is `status = 'pending' AND createdAt <= now - graceMs`, and that grace exists so a
170
+ // job that has just been dispatched by whoever wrote it is not re-dispatched while its own
171
+ // Workflow is still starting. A retried row's original `createdAt` is by definition old, so
172
+ // leaving it would give the row *zero* grace: the next cron tick would claim it and dispatch a
173
+ // second send Workflow for a job this function had already dispatched successfully. `runSend`
174
+ // short-circuits only on `sent`/`canceled`, so both instances would render and send, and the
175
+ // recipient would get two copies. Re-stamping it puts the retry inside the same window a fresh
176
+ // enqueue gets, which is exactly what a retry is.
177
+ createdAt: SQLiteDate.encode(deps.now),
178
+ })
179
+ .where("id", "=", jobId)
180
+ // Status is in the predicate, not merely checked above, which makes this a compare-and-set rather
181
+ // than a read followed by a hopeful write. Two people pressing retry on the same visible failure is
182
+ // the ordinary case here, and without it the loser would reset a row the winner's Workflow had
183
+ // already claimed and then dispatch a second send of the same email.
184
+ .where("status", "=", "failed")
185
+ .executeTakeFirst();
186
+
187
+ if ((updated.numUpdatedRows ?? 0n) === 0n) {
188
+ // Somebody else re-queued it between the read and the write. Refused with the same words the state
189
+ // check above uses, because it is the same refusal — the job is simply no longer failed.
190
+ const current = await getJob(deps.db, jobId);
191
+ throw new ConflictError({
192
+ message: `Only a failed job can be retried. This one is ${current?.status ?? "no longer there"}.`,
193
+ action: "Reload the send log — somebody may have retried it already.",
194
+ detail: `email job '${jobId}' left the failed state between the read and the write`,
195
+ });
196
+ }
197
+
198
+ let dispatched = false;
199
+ if (deps.sender && batchId) {
200
+ try {
201
+ // Under the id the row now carries. The claim is written first, so the instance can never be alive
202
+ // before the row can name it; the reverse order would leave a window in which a tick re-drives a
203
+ // job whose Workflow has already started.
204
+ await deps.sender.create({ id: batchId, params: { jobIds: [jobId] } });
205
+ dispatched = true;
206
+ } catch {
207
+ // Swallowed deliberately: the row is `pending` naming an instance that is not there, the runtime
208
+ // disowns it, and the every-minute scheduler owns recovery.
209
+ }
210
+ }
211
+
212
+ const job = (await getJob(deps.db, jobId)) ?? existing;
213
+ return { job, dispatched };
214
+ }
@@ -0,0 +1,174 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * Create the per-environment email tables in the app database: `pithy_email_jobs` and
9
+ * `pithy_email_events`. Both carry the `pithy_email_` prefix so they never clash with an adopter's own
10
+ * tables. (Suppression lives in its own durable database — see `0001_suppressions.ts`.)
11
+ *
12
+ * Identifiers are declared in **camelCase**: the runner installs `CamelCasePlugin`, which snake-cases
13
+ * every identifier in the emitted DDL (CLAUDE.md §Data layer). `down` is the tested inverse — indexes
14
+ * then tables, in reverse creation order (D1 has no transactional DDL).
15
+ *
16
+ * **This is the capability's whole schema for this database, in one migration, and that is the rule
17
+ * while nothing is published.** CONTRIBUTING.md §Migrations states it and
18
+ * `packages/cli/src/migrations/oneMigration.test.ts` enforces it: a chain buys exactly one thing —
19
+ * walking a database that already holds rows from an old shape to a new one — and there is no such
20
+ * database, so a `0002` would be a step from a shape that never ran to a shape that never shipped.
21
+ *
22
+ * **Amended in place on 2026-08-23** for `locale` (pithy-sh/pithy#441), and **on 2026-08-16** for
23
+ * `correlation` (pithy-sh/pithy#382). The condition was re-checked on each occasion rather than
24
+ * assumed, because CONTRIBUTING.md asks a later reader to check it:
25
+ *
26
+ * - `@pithy-sh/email` is at `0.0.0` and `npm view @pithy-sh/email version` is a 404. Nothing has been
27
+ * released, so no `0200_email_0001_init` has run anywhere a chain would be replayed against.
28
+ * - The only adopter is `pithy-sh/dashboard`, and `GET /accounts/602df2e6ce74e98b4c7ac5e90a3af5c8/d1/database`
29
+ * — the account `apps/board/pithy.config.ts` pins, not whatever wrangler is logged in to — returns an
30
+ * **empty list**. There is no deployed D1 at all, so none holds a `pithy_email_jobs` row.
31
+ *
32
+ * **The moment either stops being true this file is history, and the chain is append-only.** A version
33
+ * cut, or one database on that account, and the next column is a `0002`. Nothing about a tidy `0001`
34
+ * tells a reader which side of that line they are on — re-run the two checks, do not infer them.
35
+ */
36
+ export const email_0001_init: Migration = {
37
+ up: async (db: Kysely<unknown>): Promise<void> => {
38
+ await db.schema
39
+ .createTable("pithyEmailJobs")
40
+ .addColumn("id", "text", (c) => c.primaryKey())
41
+ .addColumn("toAddress", "text", (c) => c.notNull())
42
+ // The recipient under `normalizeAddress`, stored rather than derived. `toAddress` keeps the
43
+ // string the caller typed; this is what anything matching a recipient compares against, and
44
+ // `lower(to_address)` is not a substitute — SQLite's `lower()` folds ASCII only.
45
+ .addColumn("recipientKey", "text", (c) => c.notNull())
46
+ .addColumn("fromAddress", "text", (c) => c.notNull())
47
+ .addColumn("fromName", "text", (c) => c.notNull())
48
+ .addColumn("subject", "text", (c) => c.notNull())
49
+ .addColumn("template", "text", (c) => c.notNull())
50
+ .addColumn("category", "text", (c) => c.notNull())
51
+ .addColumn("payload", "text", (c) => c.notNull())
52
+ // Null while the job still holds its inputs; stamped when they are dropped. A separate column
53
+ // rather than an inference off `payload = '{}'`, because a job enqueued with no variables and a
54
+ // job whose variables were spent are different facts and an operator reading a blank render needs
55
+ // to tell them apart.
56
+ .addColumn("payloadRedactedAt", "integer")
57
+ .addColumn("status", "text", (c) => c.notNull())
58
+ .addColumn("mode", "text", (c) => c.notNull())
59
+ .addColumn("attempts", "integer", (c) => c.notNull().defaultTo(0))
60
+ // The send batch holding this job, which is the id of the send Workflow instance dispatched for
61
+ // it. Null until something claims the job. Unindexed on purpose: nothing queries by it — the
62
+ // scheduler reads it off rows it has already selected and asks the Workflow runtime about them.
63
+ .addColumn("batchId", "text")
64
+ .addColumn("sendAt", "integer", (c) => c.notNull())
65
+ .addColumn("timezone", "text")
66
+ .addColumn("localTime", "text")
67
+ .addColumn("campaignId", "text")
68
+ // The language this message is written in, as a BCP-47 tag. Null means nobody chose one, and the
69
+ // render falls back to the kit's English — which is not the same statement as `en`, exactly as
70
+ // `pithy_auth_users.locale` distinguishes them.
71
+ //
72
+ // On the row rather than re-derived at send, and that is the whole of pithy-sh/pithy#441 for this
73
+ // table. The subject is rendered at enqueue, inside a request that knows the reader; the body is
74
+ // rendered hours later inside a Workflow that has no request at all. Two renders, two chances to
75
+ // choose a language, and until this column they could only agree by accident. It is also what a
76
+ // support pane needs to answer "why did this letter arrive in Spanish" — an operator reading a
77
+ // subject they cannot parse otherwise has nothing on the row that explains it.
78
+ .addColumn("locale", "text")
79
+ // What the message was *about*, as the caller names it — the discriminator for a template that
80
+ // carries more than one kind of message (pithy-sh/pithy#382). Six of the dashboard's account
81
+ // notices ride one `operationalNotice` to the same addresses, so `(recipient_key, template)`
82
+ // cannot separate them and `sentSince` could not answer the question it was built for. And the
83
+ // failure was not a duplicate: that caller uses the answer *positively* — the correction letter
84
+ // goes out only when the letter it corrects already did — so an under-report withholds the
85
+ // correction from somebody holding a letter that has stopped being true.
86
+ //
87
+ // Nullable with no default, the shape `pithy_audit_events.tenant` takes for an action that was
88
+ // not tenant-scoped: most templates say what they are by their id alone, and null is the true
89
+ // statement about those. Deliberately **not** `campaign_id`, which is marketing attribution and
90
+ // leaves this row — onto every event, into `campaignStats`, and signed into the tracking token
91
+ // that travels in a delivered email's URLs. This column goes nowhere but here.
92
+ .addColumn("correlation", "text")
93
+ .addColumn("openTracking", "integer", (c) => c.notNull().defaultTo(0))
94
+ .addColumn("clickTracking", "integer", (c) => c.notNull().defaultTo(0))
95
+ .addColumn("messageId", "text")
96
+ .addColumn("error", "text")
97
+ .addColumn("bounceCode", "text")
98
+ .addColumn("bounceType", "text")
99
+ // Threading, for a reply that answers an existing conversation — `@pithy-sh/support` is what
100
+ // needs them. A column per field rather than a generic headers bag (CLAUDE.md §Email): a bag
101
+ // would let any caller set `Bcc` or `From` on a message the adopter's domain signs, which turns
102
+ // an enqueue into a header-injection surface. Three named columns can only mean three things.
103
+ .addColumn("replyTo", "text")
104
+ .addColumn("inReplyTo", "text")
105
+ .addColumn("references", "text")
106
+ .addColumn("createdAt", "integer", (c) => c.notNull())
107
+ .addColumn("updatedAt", "integer", (c) => c.notNull())
108
+ .addColumn("sentAt", "integer")
109
+ .execute();
110
+
111
+ // The scheduler scans for due rows by (status, sendAt); the bounce handler looks a job up by its
112
+ // Email Service messageId.
113
+ await db.schema.createIndex("pithyEmailJobsDueIdx").on("pithyEmailJobs").columns(["status", "sendAt"]).execute();
114
+ await db.schema.createIndex("pithyEmailJobsMessageIdIdx").on("pithyEmailJobs").column("messageId").execute();
115
+
116
+ await db.schema
117
+ .createTable("pithyEmailEvents")
118
+ .addColumn("id", "integer", (c) => c.primaryKey().autoIncrement())
119
+ .addColumn("jobId", "text", (c) => c.notNull())
120
+ .addColumn("recipient", "text", (c) => c.notNull())
121
+ .addColumn("type", "text", (c) => c.notNull())
122
+ .addColumn("linkLabel", "text")
123
+ .addColumn("linkUrl", "text")
124
+ .addColumn("campaignId", "text")
125
+ .addColumn("detail", "text")
126
+ .addColumn("createdAt", "integer", (c) => c.notNull())
127
+ .execute();
128
+
129
+ await db.schema.createIndex("pithyEmailEventsJobIdIdx").on("pithyEmailEvents").column("jobId").execute();
130
+
131
+ // The control-plane job listing pages newest-first, optionally filtered by status. `(status,
132
+ // createdAt)` serves the filtered page and `createdAt` alone the unfiltered one — without them an
133
+ // operator opening the pane scans every job the project has ever queued.
134
+ await db.schema
135
+ .createIndex("pithyEmailJobsStatusCreatedIdx")
136
+ .on("pithyEmailJobs")
137
+ .columns(["status", "createdAt"])
138
+ .execute();
139
+ await db.schema.createIndex("pithyEmailJobsCreatedIdx").on("pithyEmailJobs").column("createdAt").execute();
140
+
141
+ // `sentSince` asks one question — has this template already gone to this person since a given
142
+ // instant — and asks it of a table holding every email the project ever queued. Without this the
143
+ // question is a full scan, and it is asked on the path that decides whether to send another one.
144
+ // Leading with the recipient rather than the template is what makes it selective: a project has a
145
+ // handful of templates and an unbounded number of recipients.
146
+ await db.schema
147
+ .createIndex("pithyEmailJobsRecipientTemplateIdx")
148
+ .on("pithyEmailJobs")
149
+ .columns(["recipientKey", "template", "createdAt"])
150
+ .execute();
151
+
152
+ // The other axis of the same read, and the same argument. `sentSince` also asks *what has been said
153
+ // about this thing since an instant* — the question a template carrying six different messages needs
154
+ // — and without an index that is the same full scan on the same decision path. It leads with
155
+ // `correlation` because a correlation names one subject and is selective by construction, where a
156
+ // template id is one of a handful a project has.
157
+ await db.schema
158
+ .createIndex("pithyEmailJobsCorrelationIdx")
159
+ .on("pithyEmailJobs")
160
+ .columns(["correlation", "createdAt"])
161
+ .execute();
162
+ },
163
+ down: async (db: Kysely<unknown>): Promise<void> => {
164
+ await db.schema.dropIndex("pithyEmailJobsCorrelationIdx").execute();
165
+ await db.schema.dropIndex("pithyEmailJobsRecipientTemplateIdx").execute();
166
+ await db.schema.dropIndex("pithyEmailJobsCreatedIdx").execute();
167
+ await db.schema.dropIndex("pithyEmailJobsStatusCreatedIdx").execute();
168
+ await db.schema.dropIndex("pithyEmailEventsJobIdIdx").execute();
169
+ await db.schema.dropTable("pithyEmailEvents").execute();
170
+ await db.schema.dropIndex("pithyEmailJobsMessageIdIdx").execute();
171
+ await db.schema.dropIndex("pithyEmailJobsDueIdx").execute();
172
+ await db.schema.dropTable("pithyEmailJobs").execute();
173
+ },
174
+ };
@@ -0,0 +1,40 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * Create the global suppression table in the dedicated, durable `EMAIL_SUPPRESSIONS` database —
9
+ * distinct from the per-environment app `DB`, because an address that hard-bounced, complained, or
10
+ * unsubscribed must never be emailed from *any* environment. Mirrors the shared-DB pattern of
11
+ * `@pithy-sh/secrets`. The single inbound bounce worker and the unsubscribe callbacks write it; every
12
+ * environment's send path reads it.
13
+ */
14
+ export const email_0001_suppressions: Migration = {
15
+ up: async (db: Kysely<unknown>): Promise<void> => {
16
+ await db.schema
17
+ .createTable("pithyEmailSuppressions")
18
+ .addColumn("id", "integer", (c) => c.primaryKey().autoIncrement())
19
+ .addColumn("email", "text", (c) => c.notNull().unique())
20
+ .addColumn("reason", "text", (c) => c.notNull())
21
+ .addColumn("jobId", "text")
22
+ .addColumn("environment", "text")
23
+ .addColumn("detail", "text")
24
+ .addColumn("createdAt", "integer", (c) => c.notNull())
25
+ .addColumn("expiresAt", "integer")
26
+ .execute();
27
+
28
+ // The control-plane suppression listing pages newest-first. The unique index on `email` already
29
+ // serves the single-address lookup; this serves the walk.
30
+ await db.schema
31
+ .createIndex("pithyEmailSuppressionsCreatedIdx")
32
+ .on("pithyEmailSuppressions")
33
+ .column("createdAt")
34
+ .execute();
35
+ },
36
+ down: async (db: Kysely<unknown>): Promise<void> => {
37
+ await db.schema.dropIndex("pithyEmailSuppressionsCreatedIdx").execute();
38
+ await db.schema.dropTable("pithyEmailSuppressions").execute();
39
+ },
40
+ };
@@ -0,0 +1,47 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * How mail leaves the email host worker when that worker is running on a developer's machine.
8
+ *
9
+ * The point of the local loop is a magic link that **arrives** (pithy-sh/pithy#410). Until now it
10
+ * could not: `pithy dev` never ran the email host at all, so a sign-in wrote a row that said
11
+ * `pending` and a screen that said "check your inbox". With the host in the dev set, one flag decides
12
+ * what its `send_email` binding does — and the default is the one that ends the loop.
13
+ *
14
+ * Both settings run the Worker locally. Neither is a transport of ours: `wrangler dev` implements the
15
+ * binding either way, and there is no REST or SMTP sender in this kit (that stays out of scope until
16
+ * a context with no binding at all needs one).
17
+ */
18
+
19
+ /** What the host's `send_email` binding does under `pithy dev`. */
20
+ export const DevMailDelivery = z
21
+ .enum(["remote", "simulator"])
22
+ .describe(
23
+ "How email is delivered when the host worker runs under `pithy dev`. **`remote` is the default, and it sends real mail from the developer's machine** — `remote: true` on the `send_email` binding runs the Worker locally and delivers through Cloudflare Email Service, so the message lands in a real inbox with the same DKIM and the same delivery logs as production. It needs a Cloudflare login `wrangler dev` can use and a sending domain already onboarded onto Email Service. `simulator` sends 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. Choose `simulator` deliberately — the cost of `remote` is that a test sign-in really does reach whatever address it was given.",
24
+ );
25
+ export type DevMailDelivery = z.infer<typeof DevMailDelivery>;
26
+
27
+ /** The local environment. The only one this flag governs; every other one deploys and delivers for real. */
28
+ const DEV_ENVIRONMENT = "dev";
29
+
30
+ /** The `send_email` binding name the committed host template declares. */
31
+ const SEND_BINDING = "EMAIL";
32
+
33
+ /**
34
+ * Which bindings the resolved host config must mark `remote: true`, for one environment.
35
+ *
36
+ * The committed template no longer hardcodes the flag, because a hardcoded `true` cannot be turned
37
+ * off: `resolveWorkflowHost` only ever *adds* `remote`, so a template that already carried it left no
38
+ * way to select the simulator. The decision moved here, where the capability's config can reach it.
39
+ *
40
+ * Outside `dev` the answer is always `remote: true`. A deployed Worker ignores the flag entirely —
41
+ * it is a `wrangler dev` instruction — so the resolved config for `staging` and `prod` is byte-
42
+ * identical whatever an adopter chose for their laptop.
43
+ */
44
+ export function emailRemoteBindings(env: string, delivery: DevMailDelivery): readonly string[] {
45
+ if (env === DEV_ENVIRONMENT && delivery === "simulator") return [];
46
+ return [SEND_BINDING];
47
+ }
@@ -0,0 +1,107 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { KitErrorPayload } from "@pithy-sh/core/src/error/payload";
5
+ import type { LocaleCatalogs, MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
6
+ import { EMAIL_MESSAGES, type EmailMessageLayers } from "../templates/messages";
7
+
8
+ /**
9
+ * The catalogs the prebuilt email host worker is deployed with — the provision-time half of
10
+ * `catalogLayers`.
11
+ *
12
+ * ## Why the host needs data where the app worker needs a seam
13
+ *
14
+ * An app worker composes capabilities, so `@pithy-sh/email`'s enqueue reaches a composed project's
15
+ * `layersFor` through the `compose` hook and renders a subject in whatever language the project
16
+ * speaks. The host worker composes nothing: `pithy email provision` deploys it from a committed
17
+ * template, and no `pithy.config.ts` of the adopter's is ever evaluated inside it. So the layers
18
+ * cannot travel as a function; they travel as one JSON var, and this is what flattens them.
19
+ *
20
+ * Nothing wrote that var before pithy-sh/pithy#441's remediation, which made the whole translated-body
21
+ * path dead in any real deployment: the send Workflow rendered the kit's English whatever tag was on
22
+ * the row, and — because `runSend` overwrites the stored subject with its own fresh render — discarded
23
+ * the translated subject the app worker had computed at enqueue as well. Two Workers, one message, and
24
+ * they disagreed about its language.
25
+ *
26
+ * ## What is dropped, and why dropping it changes nothing
27
+ *
28
+ * **Only `email/` keys.** The host renders email templates and nothing else, so a screen's copy or an
29
+ * error's translation in this var is weight against a hard 5 KB ceiling (see `resolveEmailConfig`) for
30
+ * a key no template will ever ask for.
31
+ *
32
+ * **And only keys that say something the host does not already have.** `catalogLayers` ends every
33
+ * lookup at `EMAIL_MESSAGES` — this package's own copy, in every language it is written in, bundled
34
+ * into the host — so a key whose merged value is byte-identical to what the host already resolves for
35
+ * that locale renders the same string whether it is in the var or not. Leaving it out is invisible in
36
+ * the rendered mail, and it is what makes adding a language cost no configuration at all: a project
37
+ * on the kit's own locales that overrode nothing deploys no catalog variable.
38
+ *
39
+ * An adopter who overrode a sentence is unaffected, including one who overrode a translation back to
40
+ * the English wording: the comparison is against the locale's own bundled value, which is not that.
41
+ */
42
+
43
+ /** The domain every key this capability renders lives under — `composeMessages` enforces it too. */
44
+ const EMAIL_KEY_PREFIX = "email/";
45
+
46
+ /**
47
+ * The `email/*` keys that are **error codes**, not template copy.
48
+ *
49
+ * The prefix alone is not the rule the doc below states. `email/send_failed` and its five siblings are
50
+ * catalog keys under this capability's domain because for an error the key *is* the code — but no
51
+ * template ever asks for one, and the host is the Worker that renders templates. A client translates
52
+ * an error from a catalog it already holds; the send Workflow never renders one.
53
+ *
54
+ * Worth the derivation rather than a list: it is 416 bytes of a 5120-byte ceiling that a real project
55
+ * already fills to 69%, so it is roughly 8% of the room a second locale would need. Read off
56
+ * `KitErrorPayload` so a seventh `email/*` code drops out on its own.
57
+ */
58
+ const EMAIL_ERROR_CODES: ReadonlySet<string> = new Set(
59
+ KitErrorPayload.options.map((member) => member.shape.code.value).filter((code) => code.startsWith(EMAIL_KEY_PREFIX)),
60
+ );
61
+
62
+ /** Whether a key is copy this host will actually render. */
63
+ function rendersOnTheHost(key: string): boolean {
64
+ return key.startsWith(EMAIL_KEY_PREFIX) && !EMAIL_ERROR_CODES.has(key);
65
+ }
66
+
67
+ /**
68
+ * Flatten a composed project's layers into the `EMAIL_MESSAGES` var's value, one catalog per locale.
69
+ *
70
+ * `locales` is the project's `supportedLocales`; a project that composed no i18n capability passes
71
+ * none, gets `{}`, and deploys a host with no var at all — which is the same English it always sent.
72
+ */
73
+ export function emailHostCatalogs(locales: readonly string[], layersFor: EmailMessageLayers): LocaleCatalogs {
74
+ const catalogs: LocaleCatalogs = {};
75
+ for (const locale of locales) {
76
+ const merged: MessageCatalog = {};
77
+ // The layers arrive most-specific first, because that is the order `lookupMessage` walks them in.
78
+ // Flattening runs the other way: the least specific lands first and the adopter's override writes
79
+ // over it, so the value left standing is the one `t()` would have found.
80
+ for (const layer of [...layersFor(locale)].reverse()) {
81
+ for (const [key, value] of Object.entries(layer ?? {})) {
82
+ if (rendersOnTheHost(key)) merged[key] = value;
83
+ }
84
+ }
85
+ // **What travels is the adopter's diff, and nothing else (#442).**
86
+ //
87
+ // The host is deployed carrying `EMAIL_MESSAGES` — the kit's own copy in every language it is
88
+ // written in — so a key whose merged value is already what the host holds for this locale is a
89
+ // sentence it would render identically with no variable at all. Sending it is sending the Worker
90
+ // words it was built with.
91
+ //
92
+ // It used to be compared against the English alone, which meant the kit's own Spanish rode along:
93
+ // static data through a configuration channel, every provision run, against a 5 KB per-variable
94
+ // ceiling that a language pack filled to 61%. Compared against the locale's own bundled copy, a
95
+ // project that overrides nothing deploys no variable, and adding a language the kit ships costs no
96
+ // configuration growth at all.
97
+ //
98
+ // The English fallback stays in the comparison because that is what the host renders for a key no
99
+ // translation covers — dropping only against the locale would send back every untranslated key.
100
+ const bundled = { ...(EMAIL_MESSAGES.en ?? {}), ...(EMAIL_MESSAGES[locale] ?? {}) };
101
+ for (const [key, value] of Object.entries(merged)) {
102
+ if (bundled[key] === value) delete merged[key];
103
+ }
104
+ if (Object.keys(merged).length > 0) catalogs[locale] = merged;
105
+ }
106
+ return catalogs;
107
+ }