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