@pithy-sh/email 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +55 -0
- package/pithy.manifest.json +73 -0
- package/src/analytics.ts +39 -0
- package/src/audit/actions.ts +48 -0
- package/src/bounce/classify.ts +103 -0
- package/src/bounce/handler.ts +136 -0
- package/src/capability.ts +385 -0
- package/src/cloudflare-test.d.ts +19 -0
- package/src/crypto/signingKey.ts +44 -0
- package/src/crypto/token.ts +148 -0
- package/src/data/emailEvent.ts +42 -0
- package/src/data/emailJob.ts +138 -0
- package/src/data/emailSuppression.ts +40 -0
- package/src/data/enums.ts +75 -0
- package/src/data/tables.ts +47 -0
- package/src/error/errors.ts +129 -0
- package/src/http/callbacks.ts +200 -0
- package/src/http/guards.ts +154 -0
- package/src/http/responses.ts +192 -0
- package/src/http/routes.ts +467 -0
- package/src/http/schemas.ts +203 -0
- package/src/http/view.ts +139 -0
- package/src/index.ts +73 -0
- package/src/jobs/read.ts +273 -0
- package/src/jobs/retry.ts +214 -0
- package/src/migrations/0001_init.ts +174 -0
- package/src/migrations/0001_suppressions.ts +40 -0
- package/src/provision/devDelivery.ts +47 -0
- package/src/provision/hostCatalogs.ts +107 -0
- package/src/provision/provisionEmail.ts +179 -0
- package/src/provision/resolveEmailConfig.ts +225 -0
- package/src/provision/settingsCheck.ts +212 -0
- package/src/send/batchIdentity.ts +47 -0
- package/src/send/enqueue.ts +391 -0
- package/src/send/errorMapping.ts +73 -0
- package/src/send/events.ts +34 -0
- package/src/send/fromComposition.ts +57 -0
- package/src/send/retryPolicy.ts +42 -0
- package/src/send/runSend.ts +320 -0
- package/src/send/sendAt.ts +77 -0
- package/src/send/sender.ts +44 -0
- package/src/send/senderBinding.ts +56 -0
- package/src/send/suppression.ts +194 -0
- package/src/templates/engine.ts +392 -0
- package/src/templates/messages.es.ts +109 -0
- package/src/templates/messages.ts +315 -0
- package/src/templates/partials.ts +88 -0
- package/src/templates/precompiled.generated.ts +1342 -0
- package/src/templates/registry.ts +550 -0
- package/src/templates/samples.ts +75 -0
- package/src/templates/severity.ts +102 -0
- package/src/templates/theme.ts +212 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/hostApp.ts +54 -0
- package/src/workflows/hostEnv.ts +219 -0
- package/src/workflows/instanceLiveness.ts +39 -0
- package/src/workflows/instances.ts +16 -0
- package/src/workflows/params.ts +35 -0
- package/src/workflows/scheduler.ts +220 -0
- package/src/workflows/sendBatch.ts +154 -0
- package/src/workflows/worker.ts +203 -0
- package/src/workflows/wrangler.jsonc +75 -0
|
@@ -0,0 +1,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
|
+
}
|