@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,320 @@
|
|
|
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 type { DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
7
|
+
import { NotFoundError } from "@pithy-sh/core/src/error/pithyError";
|
|
8
|
+
import type { Updateable } from "kysely";
|
|
9
|
+
import { EmailJob, SPENT_PAYLOAD } from "../data/emailJob";
|
|
10
|
+
import type { EmailJobStatus, SuppressionReason } from "../data/enums";
|
|
11
|
+
import type { EmailDatabase, EmailSuppressionDatabase, EmailTables } from "../data/tables";
|
|
12
|
+
import { type RenderTracking, redactsPayloadOnDelivery, renderEmail, templateKind } from "../templates/engine";
|
|
13
|
+
import { type EmailMessageLayers, emailTranslator, kitEmailLayers } from "../templates/messages";
|
|
14
|
+
import type { EmailTheme } from "../templates/theme";
|
|
15
|
+
import { classifySendError } from "./errorMapping";
|
|
16
|
+
import { recordEvent } from "./events";
|
|
17
|
+
import type { EmailSender } from "./sender";
|
|
18
|
+
import { blockingSuppression, suppress } from "./suppression";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Send one job — the body the send Workflow runs inside a durable step. Never called from a request
|
|
22
|
+
* handler. It loads the job, skips a suppressed recipient, renders (with tracking per the job's flags),
|
|
23
|
+
* sends through the binding, and records the outcome on the row plus an event. On a retryable failure
|
|
24
|
+
* it throws (the Workflow step retries with backoff) until `maxAttempts`, then marks the job failed.
|
|
25
|
+
*
|
|
26
|
+
* **Whether a suppression applies depends on the message.** The kind comes from the template, not from
|
|
27
|
+
* the job row and not from a caller, so a magic link is transactional by declaration and an unrelated
|
|
28
|
+
* unsubscribe cannot swallow it.
|
|
29
|
+
*
|
|
30
|
+
* **A delivered job's inputs are dropped here, and "delivered" is the only place they can be.** The
|
|
31
|
+
* payload of a magic link *is* the sign-in link, so a row that keeps it after the message is out is a
|
|
32
|
+
* second, permanent copy of a live credential in a table nobody thinks of as holding secrets — which is
|
|
33
|
+
* what made an adopter's digested invitation token worth nothing the moment it was mailed. The line is
|
|
34
|
+
* `sent`, and every other outcome keeps its payload on purpose: `failed` can be retried, `suppressed`
|
|
35
|
+
* can be unblocked, and a payload dropped before the last attempt is a message that cannot be resent —
|
|
36
|
+
* a worse failure than the one being fixed. `sent` is the one status a retry is *already* refused for,
|
|
37
|
+
* because the mail has gone to a real person, so nothing here can ever need those inputs again.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** The current signing key plus the valid version set, for minting tracking/unsubscribe links. */
|
|
41
|
+
export interface SendSigning {
|
|
42
|
+
/** The current signing key value. */
|
|
43
|
+
key: string;
|
|
44
|
+
/** The current signing key version, recorded as the token `kid`. */
|
|
45
|
+
kid: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Everything a send needs that isn't on the job row. */
|
|
49
|
+
export interface SendDeps {
|
|
50
|
+
/** The per-environment jobs/events database. */
|
|
51
|
+
db: EmailDatabase;
|
|
52
|
+
/** The global, shared suppression database — checked before every send, fed by bounces/complaints. */
|
|
53
|
+
suppressionDb: EmailSuppressionDatabase;
|
|
54
|
+
sender: EmailSender;
|
|
55
|
+
theme: EmailTheme;
|
|
56
|
+
/**
|
|
57
|
+
* Where the words for a locale come from.
|
|
58
|
+
*
|
|
59
|
+
* The host that runs this is a standalone Worker — nothing composes capabilities inside it — so it
|
|
60
|
+
* cannot be handed a composed project's layers the way the app worker's enqueue is. It reads the
|
|
61
|
+
* catalogs off its own env instead (`EMAIL_MESSAGES`) and turns them into this. Absent walks the
|
|
62
|
+
* kit's English, which is what a project that never opted into a second language gets.
|
|
63
|
+
*/
|
|
64
|
+
layersFor?: EmailMessageLayers;
|
|
65
|
+
/** The public base URL for callback links. */
|
|
66
|
+
baseUrl: string;
|
|
67
|
+
/** The signing key for tracking/unsubscribe links. Absent disables tracking (and blocks marketing sends). */
|
|
68
|
+
signing?: SendSigning;
|
|
69
|
+
/** How long a tracked link stays valid. */
|
|
70
|
+
linkTtlDays: number;
|
|
71
|
+
/** The most attempts before a retryable failure becomes terminal. */
|
|
72
|
+
maxAttempts: number;
|
|
73
|
+
/** This worker's environment name, stamped on each send (X-Pithy-Env) so the single inbound worker can route. */
|
|
74
|
+
environment?: string;
|
|
75
|
+
/**
|
|
76
|
+
* When this pass began — **stable across a Workflow resume** (pithy-sh/pithy#327).
|
|
77
|
+
*
|
|
78
|
+
* Everything that dates the work reads this: `sentAt`, the redaction stamp, the events, and how long a
|
|
79
|
+
* tracked link stays valid. A driver body re-executes from the top on a resume, so a clock read there
|
|
80
|
+
* answers with the resume — and a batch that backed off overnight dated its remaining jobs, and the
|
|
81
|
+
* expiry of every link in them, a day late. `runSendBatch` journals this in a step so a resume reads
|
|
82
|
+
* back the instant the batch began.
|
|
83
|
+
*
|
|
84
|
+
* A link minted on the resumed half expires from the pass, not from the mint. Two people in one batch
|
|
85
|
+
* are promised the same window; which attempt happened to render their message is not something they
|
|
86
|
+
* can see and not something their link should depend on.
|
|
87
|
+
*/
|
|
88
|
+
passStartedAt: Date;
|
|
89
|
+
/**
|
|
90
|
+
* "This job is being worked on, now" — **read fresh on every call**, and never journaled.
|
|
91
|
+
*
|
|
92
|
+
* This is not a stamp with a tidier name. `updatedAt` is the scheduler's only evidence that a `sending`
|
|
93
|
+
* job is alive: `runScheduler` claims and re-drives anything in `sending` older than `stuckMs`, on the
|
|
94
|
+
* assumption its dispatch died. Freeze it and a batch that resumes past that window reports the job it
|
|
95
|
+
* is actively retrying as stranded, so the next tick starts a second send Workflow against it — and
|
|
96
|
+
* `runSend` short-circuits only a job already `sent`, so both attempts render and both call `send`. One
|
|
97
|
+
* person, two emails. A sweep journaled the single `now` this replaced and was reverted for exactly
|
|
98
|
+
* that.
|
|
99
|
+
*
|
|
100
|
+
* The suppression liveness read takes it too, because "is this address blocked *right now*" is a
|
|
101
|
+
* present-tense question and an hour-old answer to it is simply wrong.
|
|
102
|
+
*/
|
|
103
|
+
heartbeatAt: () => Date;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The outcome of a send attempt. */
|
|
107
|
+
export interface SendOutcome {
|
|
108
|
+
jobId: string;
|
|
109
|
+
status: EmailJobStatus;
|
|
110
|
+
messageId?: string;
|
|
111
|
+
skipped?: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Why nothing was sent, when the recipient was on the suppression list.
|
|
114
|
+
*
|
|
115
|
+
* A skipped send has to be reported, not swallowed. Without this the caller sees an outcome that is
|
|
116
|
+
* not `failed` and the person sees an empty inbox, which is what made the whole class of bug invisible
|
|
117
|
+
* from both ends. "Suppressed" alone is not enough either — an operator deciding what to do next needs
|
|
118
|
+
* to know whether the address bounced, complained, or opted out.
|
|
119
|
+
*/
|
|
120
|
+
suppressionReason?: SuppressionReason;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
124
|
+
|
|
125
|
+
/** A partial update to the jobs table, in its SQLite row shape. */
|
|
126
|
+
type JobUpdate = Updateable<DatabaseSchema<EmailTables>["pithyEmailJobs"]>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Patch a job row by id, always bumping `updatedAt` — **on the heartbeat clock, not the pass instant.**
|
|
130
|
+
*
|
|
131
|
+
* Every write this function makes is also a statement that the job is still being worked on, because
|
|
132
|
+
* `updatedAt` is what the scheduler's `sending` re-drive reads. That is why the bump is here rather than
|
|
133
|
+
* at each call site: a patch that did not say "still alive" would be a patch that invited a second send
|
|
134
|
+
* Workflow. See {@link SendDeps.heartbeatAt}.
|
|
135
|
+
*/
|
|
136
|
+
async function patchJob(deps: SendDeps, jobId: string, patch: JobUpdate): Promise<void> {
|
|
137
|
+
await deps.db
|
|
138
|
+
.updateTable("pithyEmailJobs")
|
|
139
|
+
.set({ ...patch, updatedAt: SQLiteDate.encode(deps.heartbeatAt()) })
|
|
140
|
+
.where("id", "=", jobId)
|
|
141
|
+
.execute();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function runSend(deps: SendDeps, jobId: string): Promise<SendOutcome> {
|
|
145
|
+
const row = await deps.db.selectFrom("pithyEmailJobs").selectAll().where("id", "=", jobId).executeTakeFirst();
|
|
146
|
+
if (!row) throw new NotFoundError({ detail: `email job '${jobId}' not found` });
|
|
147
|
+
const job = EmailJob.parse(row);
|
|
148
|
+
|
|
149
|
+
// Idempotent: a job already sent or canceled is a no-op (a Workflow can re-run a step).
|
|
150
|
+
if (job.status === "sent") return { jobId, status: "sent", messageId: job.messageId ?? undefined };
|
|
151
|
+
if (job.status === "canceled") return { jobId, status: "canceled", skipped: true };
|
|
152
|
+
|
|
153
|
+
const recipient = normalizeAddress(job.toAddress);
|
|
154
|
+
|
|
155
|
+
// A job whose inputs were dropped cannot be re-rendered. Unreachable today — only a `sent` job is
|
|
156
|
+
// redacted and `sent` short-circuits two lines up — and it is here because the invariant is worth
|
|
157
|
+
// more executable than commented. It fails terminally rather than throwing: a throw would retry, and
|
|
158
|
+
// every attempt would render the same empty payload. What it prevents is the worse outcome, which is
|
|
159
|
+
// a real person receiving a magic-link email with no link in it.
|
|
160
|
+
if (job.payloadRedactedAt) {
|
|
161
|
+
await patchJob(deps, jobId, { status: "failed", error: "payload dropped after delivery" });
|
|
162
|
+
await recordEvent(
|
|
163
|
+
deps.db,
|
|
164
|
+
{ jobId, recipient, type: "failed", detail: "payload already redacted" },
|
|
165
|
+
deps.passStartedAt,
|
|
166
|
+
);
|
|
167
|
+
return { jobId, status: "failed" };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The template's own declaration, not `job.category` and not anything the enqueuing call site chose.
|
|
171
|
+
// Deriving it here means a template whose kind is corrected in a later release corrects the jobs
|
|
172
|
+
// already queued against it, and that a stored value can never drift from the template it names.
|
|
173
|
+
const kind = templateKind(job.template);
|
|
174
|
+
// The heartbeat clock: whether an address is blocked is a question about now, and a suppression that
|
|
175
|
+
// expired while this batch was backed off must not still be blocking.
|
|
176
|
+
const blocked = await blockingSuppression(deps.suppressionDb, recipient, deps.heartbeatAt(), kind);
|
|
177
|
+
if (blocked) {
|
|
178
|
+
await patchJob(deps, jobId, { status: "suppressed", error: `recipient suppressed: ${blocked}` });
|
|
179
|
+
await recordEvent(deps.db, { jobId, recipient, type: "suppressed", detail: blocked }, deps.passStartedAt);
|
|
180
|
+
return { jobId, status: "suppressed", skipped: true, suppressionReason: blocked };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A marketing send needs a signing key for its unsubscribe link; without one it cannot render. This
|
|
184
|
+
// is a configuration fault, not a transient one, so fail **terminally** — return, don't throw. A
|
|
185
|
+
// throw would make the Workflow step retry, and since a `failed` row isn't short-circuited above it
|
|
186
|
+
// would re-enter and re-throw, burning the whole retry budget re-failing.
|
|
187
|
+
if (job.category === "marketing" && !deps.signing) {
|
|
188
|
+
await patchJob(deps, jobId, { status: "failed", error: "no signing key for marketing unsubscribe link" });
|
|
189
|
+
await recordEvent(deps.db, { jobId, recipient, type: "failed", detail: "no signing key" }, deps.passStartedAt);
|
|
190
|
+
return { jobId, status: "failed" };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const tracking: RenderTracking | undefined = deps.signing
|
|
194
|
+
? {
|
|
195
|
+
baseUrl: deps.baseUrl,
|
|
196
|
+
jobId,
|
|
197
|
+
recipient,
|
|
198
|
+
campaignId: job.campaignId ?? undefined,
|
|
199
|
+
key: deps.signing.key,
|
|
200
|
+
kid: deps.signing.kid,
|
|
201
|
+
expiresAt: new Date(deps.passStartedAt.getTime() + deps.linkTtlDays * DAY_MS),
|
|
202
|
+
openTracking: job.openTracking,
|
|
203
|
+
clickTracking: job.clickTracking,
|
|
204
|
+
}
|
|
205
|
+
: undefined;
|
|
206
|
+
|
|
207
|
+
const attempts = job.attempts + 1;
|
|
208
|
+
await patchJob(deps, jobId, { status: "sending", attempts });
|
|
209
|
+
|
|
210
|
+
// The job's own locale, not a request's — there is no request here, which is exactly why the tag is
|
|
211
|
+
// on the row. This is the second of the two render sites, and the column is what makes it agree with
|
|
212
|
+
// the first: the subject stored at enqueue and the body composed now are one language by
|
|
213
|
+
// construction rather than by two call sites happening to choose alike.
|
|
214
|
+
const rendered = await renderEmail(
|
|
215
|
+
job.template,
|
|
216
|
+
job.payload,
|
|
217
|
+
deps.theme,
|
|
218
|
+
tracking,
|
|
219
|
+
emailTranslator(job.locale, deps.layersFor ?? kitEmailLayers),
|
|
220
|
+
);
|
|
221
|
+
// Stamp the origin job and environment so the single (production) inbound worker can attribute an
|
|
222
|
+
// async bounce/complaint back to where it came from. Suppression itself is global, so it applies
|
|
223
|
+
// regardless; these headers carry the per-job/per-env context the platform send response cannot.
|
|
224
|
+
const headers: Record<string, string> = { "X-Pithy-Job": jobId };
|
|
225
|
+
if (deps.environment) headers["X-Pithy-Env"] = deps.environment;
|
|
226
|
+
// Threading, when the job carries it. Both headers or neither: a client threads on `In-Reply-To`
|
|
227
|
+
// and falls back to `References`, so sending one without the other is how a conversation stays
|
|
228
|
+
// together in some mail clients and splits in others.
|
|
229
|
+
if (job.inReplyTo) headers["In-Reply-To"] = job.inReplyTo;
|
|
230
|
+
if (job.references) headers.References = job.references;
|
|
231
|
+
// One-click opt-out, and **only** where the body already offers one — `rendered.unsubscribeUrl` is
|
|
232
|
+
// present exactly for an elective template, so this cannot appear on a sign-in link. That restraint is
|
|
233
|
+
// the point: Gmail's and Yahoo's bulk-sender rules require one-click unsubscribe for *promotional*
|
|
234
|
+
// mail, and some clients surface the header as prominently as the message body. On a magic link it
|
|
235
|
+
// would publish a mechanism for disabling authentication, one tap from the mail the account depends on.
|
|
236
|
+
//
|
|
237
|
+
// Both headers together, per RFC 8058: `List-Unsubscribe-Post` is what tells a client the URL will
|
|
238
|
+
// honor a POST, and the callback accepts one for that reason. Sending it without a POST route would
|
|
239
|
+
// advertise an opt-out that silently does nothing.
|
|
240
|
+
if (rendered.unsubscribeUrl) {
|
|
241
|
+
headers["List-Unsubscribe"] = `<${rendered.unsubscribeUrl}>`;
|
|
242
|
+
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
|
|
243
|
+
}
|
|
244
|
+
const message = {
|
|
245
|
+
to: recipient,
|
|
246
|
+
from: { email: job.fromAddress, name: job.fromName },
|
|
247
|
+
...(job.replyTo ? { replyTo: job.replyTo } : {}),
|
|
248
|
+
subject: rendered.subject,
|
|
249
|
+
html: rendered.html,
|
|
250
|
+
text: rendered.text,
|
|
251
|
+
headers,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const result = await deps.sender.send(message);
|
|
256
|
+
// The status and the redaction are one write. Two statements would leave a window in which the row
|
|
257
|
+
// says `sent` and still holds the link, and the window would be exactly as long as whatever went
|
|
258
|
+
// wrong between them.
|
|
259
|
+
const spent = redactsPayloadOnDelivery(job.template);
|
|
260
|
+
await patchJob(deps, jobId, {
|
|
261
|
+
status: "sent",
|
|
262
|
+
sentAt: SQLiteDate.encode(deps.passStartedAt),
|
|
263
|
+
messageId: result.messageId ?? null,
|
|
264
|
+
// The subject that actually went out, which is not necessarily the one enqueue stored.
|
|
265
|
+
// `runSend` sends a *fresh* render rather than `job.subject`, so the column was only ever the
|
|
266
|
+
// enqueue's guess at it — and a template corrected, a theme renamed, or a catalog sentence
|
|
267
|
+
// retranslated between the queue and the send made the send log describe a message nobody
|
|
268
|
+
// received. An operator reading the row is reading the delivered subject now.
|
|
269
|
+
subject: rendered.subject,
|
|
270
|
+
error: null,
|
|
271
|
+
...(spent ? { payload: SPENT_PAYLOAD, payloadRedactedAt: SQLiteDate.encode(deps.passStartedAt) } : {}),
|
|
272
|
+
});
|
|
273
|
+
await recordEvent(deps.db, { jobId, recipient, type: "sent", campaignId: job.campaignId }, deps.passStartedAt);
|
|
274
|
+
|
|
275
|
+
// Synchronous permanent bounces (batch/REST shape) feed suppression immediately.
|
|
276
|
+
for (const bounced of result.permanentBounces ?? []) {
|
|
277
|
+
await suppress(
|
|
278
|
+
deps.suppressionDb,
|
|
279
|
+
{
|
|
280
|
+
email: bounced,
|
|
281
|
+
reason: "hard_bounce",
|
|
282
|
+
jobId,
|
|
283
|
+
environment: deps.environment,
|
|
284
|
+
detail: "permanent bounce on send",
|
|
285
|
+
},
|
|
286
|
+
deps.passStartedAt,
|
|
287
|
+
);
|
|
288
|
+
await recordEvent(
|
|
289
|
+
deps.db,
|
|
290
|
+
{ jobId, recipient: normalizeAddress(bounced), type: "bounce", detail: "permanent bounce on send" },
|
|
291
|
+
deps.passStartedAt,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { jobId, status: "sent", messageId: result.messageId };
|
|
296
|
+
} catch (err) {
|
|
297
|
+
const classified = classifySendError(err);
|
|
298
|
+
|
|
299
|
+
if (classified.suppressed) {
|
|
300
|
+
await patchJob(deps, jobId, { status: "suppressed", error: classified.code, bounceCode: classified.code });
|
|
301
|
+
await suppress(
|
|
302
|
+
deps.suppressionDb,
|
|
303
|
+
{ email: recipient, reason: "hard_bounce", jobId, environment: deps.environment, detail: classified.code },
|
|
304
|
+
deps.passStartedAt,
|
|
305
|
+
);
|
|
306
|
+
await recordEvent(deps.db, { jobId, recipient, type: "suppressed", detail: classified.code }, deps.passStartedAt);
|
|
307
|
+
return { jobId, status: "suppressed", skipped: true };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (classified.retryable && attempts < deps.maxAttempts) {
|
|
311
|
+
// Persist the last error, then throw so the Workflow step retries with backoff (row stays `sending`).
|
|
312
|
+
await patchJob(deps, jobId, { error: classified.code });
|
|
313
|
+
throw classified.error;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await patchJob(deps, jobId, { status: "failed", error: classified.code });
|
|
317
|
+
await recordEvent(deps.db, { jobId, recipient, type: "failed", detail: classified.code }, deps.passStartedAt);
|
|
318
|
+
return { jobId, status: "failed" };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { normalizeIanaTimezone } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { EmailInvalidPayloadError } from "../error/errors";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a job's absolute send time from its mode. `immediate` is now; `scheduled` is the given
|
|
9
|
+
* absolute instant; `timezone` resolves a recipient-local time-of-day (e.g. `10:00`) in their IANA
|
|
10
|
+
* zone to the next absolute instant at or after now. The timezone math uses `Intl` only — no library —
|
|
11
|
+
* by measuring the zone's offset at a candidate instant and correcting for it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The offset (ms) of a timezone at a given UTC instant: zone wall-clock minus UTC. */
|
|
15
|
+
function zoneOffsetMs(timezone: string, utcMs: number): number {
|
|
16
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
17
|
+
timeZone: timezone,
|
|
18
|
+
hour12: false,
|
|
19
|
+
year: "numeric",
|
|
20
|
+
month: "2-digit",
|
|
21
|
+
day: "2-digit",
|
|
22
|
+
hour: "2-digit",
|
|
23
|
+
minute: "2-digit",
|
|
24
|
+
second: "2-digit",
|
|
25
|
+
});
|
|
26
|
+
const parts = formatter.formatToParts(new Date(utcMs));
|
|
27
|
+
const get = (type: string): number => Number(parts.find((p) => p.type === type)?.value ?? "0");
|
|
28
|
+
// `hour` can come back as 24 at midnight in some engines; normalize to 0.
|
|
29
|
+
const hour = get("hour") % 24;
|
|
30
|
+
const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second"));
|
|
31
|
+
return asUtc - utcMs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse `HH:MM` to `{ hour, minute }`, or throw `email/invalid_payload`. */
|
|
35
|
+
function parseLocalTime(localTime: string): { hour: number; minute: number } {
|
|
36
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(localTime.trim());
|
|
37
|
+
const hour = match ? Number(match[1]) : Number.NaN;
|
|
38
|
+
const minute = match ? Number(match[2]) : Number.NaN;
|
|
39
|
+
if (!match || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
40
|
+
throw new EmailInvalidPayloadError({ detail: `invalid localTime '${localTime}' — expected HH:MM` });
|
|
41
|
+
}
|
|
42
|
+
return { hour, minute };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The next absolute instant when the wall clock reads `hour:minute` in `timezone`, at or after `now`.
|
|
47
|
+
* Computes the candidate for the zone-local date of `now`, advancing a day if it has already passed.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveTimezoneSendAt(localTime: string, timezone: string, now: Date): Date {
|
|
50
|
+
const zone = normalizeIanaTimezone(timezone);
|
|
51
|
+
if (!zone) throw new EmailInvalidPayloadError({ detail: `invalid IANA timezone '${timezone}'` });
|
|
52
|
+
const { hour, minute } = parseLocalTime(localTime);
|
|
53
|
+
|
|
54
|
+
// The zone-local calendar date of `now`.
|
|
55
|
+
const dateParts = new Intl.DateTimeFormat("en-CA", {
|
|
56
|
+
timeZone: zone,
|
|
57
|
+
year: "numeric",
|
|
58
|
+
month: "2-digit",
|
|
59
|
+
day: "2-digit",
|
|
60
|
+
}).formatToParts(now);
|
|
61
|
+
const part = (type: string): number => Number(dateParts.find((p) => p.type === type)?.value ?? "0");
|
|
62
|
+
|
|
63
|
+
const candidateFor = (dayOffset: number): Date => {
|
|
64
|
+
const guessUtc = Date.UTC(part("year"), part("month") - 1, part("day") + dayOffset, hour, minute);
|
|
65
|
+
// Correct the guess by the zone's offset at that instant so the wall clock lands on hour:minute.
|
|
66
|
+
// A single correction is wrong when the offset at the guess differs from the offset at the corrected
|
|
67
|
+
// instant (a DST boundary), so apply a second pass: if the offset there differs, re-correct from it.
|
|
68
|
+
// (In the spring-forward gap, where hour:minute doesn't exist, this settles on the post-transition
|
|
69
|
+
// instant rather than landing an hour off.)
|
|
70
|
+
const first = guessUtc - zoneOffsetMs(zone, guessUtc);
|
|
71
|
+
const secondOffset = zoneOffsetMs(zone, first);
|
|
72
|
+
return new Date(guessUtc - secondOffset);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const today = candidateFor(0);
|
|
76
|
+
return today.getTime() >= now.getTime() ? today : candidateFor(1);
|
|
77
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The minimal shape of the Cloudflare Email Service `send_email` binding this capability depends on.
|
|
6
|
+
* We type only what we use, structurally — the real binding (`env.EMAIL`) satisfies it, and tests
|
|
7
|
+
* inject a fake so send outcomes are deterministic. The binding sends both `html` and `text` and
|
|
8
|
+
* returns a `messageId`; it throws on failure with an `E_*` `.code` (mapped by `errorMapping`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** An outbound message handed to the binding — html + text both, per deliverability best practice. */
|
|
12
|
+
export interface EmailMessage {
|
|
13
|
+
/** The recipient address. */
|
|
14
|
+
to: string;
|
|
15
|
+
/** The sender — address plus display name. */
|
|
16
|
+
from: { email: string; name?: string };
|
|
17
|
+
/** Optional reply-to address. */
|
|
18
|
+
replyTo?: string;
|
|
19
|
+
/** The rendered subject line. */
|
|
20
|
+
subject: string;
|
|
21
|
+
/** The rendered HTML body. */
|
|
22
|
+
html: string;
|
|
23
|
+
/** The rendered plain-text body. */
|
|
24
|
+
text: string;
|
|
25
|
+
/** Optional whitelisted headers (e.g. `List-Unsubscribe`). */
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The send response. The Workers binding returns a `messageId`; the batch/REST shape additionally
|
|
31
|
+
* reports `delivered`/`permanentBounces`/`queued`. We capture all of it so synchronous permanent
|
|
32
|
+
* bounces can feed suppression immediately, and the `messageId` ties an asynchronous bounce back.
|
|
33
|
+
*/
|
|
34
|
+
export interface EmailSendResult {
|
|
35
|
+
messageId?: string;
|
|
36
|
+
delivered?: string[];
|
|
37
|
+
permanentBounces?: string[];
|
|
38
|
+
queued?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The send seam. `env.EMAIL` satisfies it; tests pass a fake. */
|
|
42
|
+
export interface EmailSender {
|
|
43
|
+
send(message: EmailMessage): Promise<EmailSendResult>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { resolveWorkflowBinding } from "@pithy-sh/core/src/workflow/dispatch";
|
|
5
|
+
import type { SendWorkflowBinding } from "./enqueue";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where the send Workflow comes from — one answer, for the two call sites that need it.
|
|
9
|
+
*
|
|
10
|
+
* `email().enqueue` and the admin retry route both start a send, and both used to read
|
|
11
|
+
* `env.EMAIL_SENDER` straight off the request env. Under `pithy dev` that binding does not exist:
|
|
12
|
+
* it is a **cross-script** binding at `<project>-dev-email`, and no local script runs under that
|
|
13
|
+
* name, so every immediate send was born `undispatched` and the local loop was silent
|
|
14
|
+
* (pithy-sh/pithy#410). A module rather than a line in each place, because the day the two answers
|
|
15
|
+
* differ is the day one of them stops sending mail and nothing says so.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What deciding the sender reads off the env, and nothing else.
|
|
20
|
+
*
|
|
21
|
+
* Three keys, not the whole enqueue env: `DB` and the suppression list are somebody else's question,
|
|
22
|
+
* and a narrower parameter is what lets the admin routes' own env shape pass without a cast.
|
|
23
|
+
*/
|
|
24
|
+
export interface EmailSenderEnv {
|
|
25
|
+
/** The bound send Workflow. Present in every deployed environment; absent under `pithy dev`. */
|
|
26
|
+
EMAIL_SENDER?: SendWorkflowBinding;
|
|
27
|
+
/**
|
|
28
|
+
* The environment this composition was stamped for — `pithy init` writes it into every Worker's
|
|
29
|
+
* `wrangler.jsonc`. Read only to refuse: a substitution happens in `dev` and nowhere else.
|
|
30
|
+
*/
|
|
31
|
+
ENVIRONMENT?: string;
|
|
32
|
+
/**
|
|
33
|
+
* The local email host's address, written by `pithy dev` as a `--var`. Present only in `dev`, where
|
|
34
|
+
* it is what stands in for the cross-script binding no local script can provide.
|
|
35
|
+
*/
|
|
36
|
+
EMAIL_ORIGIN?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The Workflow that starts a send: the bound one, or — under `pithy dev` — a loopback dispatcher
|
|
41
|
+
* addressed at the local email host.
|
|
42
|
+
*
|
|
43
|
+
* The rules are core's ({@link resolveWorkflowBinding}) and are stated there. The one thing decided
|
|
44
|
+
* here is that email's host is the capability named `email`, which is what makes the var
|
|
45
|
+
* `EMAIL_ORIGIN`.
|
|
46
|
+
*
|
|
47
|
+
* **Absent still means absent.** A composition with no binding and no local host enqueues the row
|
|
48
|
+
* `undispatched` exactly as it did — this fills the seam, it never invents one.
|
|
49
|
+
*/
|
|
50
|
+
export function emailSenderBinding(env: EmailSenderEnv): SendWorkflowBinding | undefined {
|
|
51
|
+
// An interface is not assignable to an index signature, and core's resolver reads an env by name.
|
|
52
|
+
return resolveWorkflowBinding(env as unknown as Record<string, unknown>, {
|
|
53
|
+
binding: "EMAIL_SENDER",
|
|
54
|
+
capability: "email",
|
|
55
|
+
});
|
|
56
|
+
}
|