@pithy-sh/email 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/package.json +55 -0
  4. package/pithy.manifest.json +73 -0
  5. package/src/analytics.ts +39 -0
  6. package/src/audit/actions.ts +48 -0
  7. package/src/bounce/classify.ts +103 -0
  8. package/src/bounce/handler.ts +136 -0
  9. package/src/capability.ts +385 -0
  10. package/src/cloudflare-test.d.ts +19 -0
  11. package/src/crypto/signingKey.ts +44 -0
  12. package/src/crypto/token.ts +148 -0
  13. package/src/data/emailEvent.ts +42 -0
  14. package/src/data/emailJob.ts +138 -0
  15. package/src/data/emailSuppression.ts +40 -0
  16. package/src/data/enums.ts +75 -0
  17. package/src/data/tables.ts +47 -0
  18. package/src/error/errors.ts +129 -0
  19. package/src/http/callbacks.ts +200 -0
  20. package/src/http/guards.ts +154 -0
  21. package/src/http/responses.ts +192 -0
  22. package/src/http/routes.ts +467 -0
  23. package/src/http/schemas.ts +203 -0
  24. package/src/http/view.ts +139 -0
  25. package/src/index.ts +73 -0
  26. package/src/jobs/read.ts +273 -0
  27. package/src/jobs/retry.ts +214 -0
  28. package/src/migrations/0001_init.ts +174 -0
  29. package/src/migrations/0001_suppressions.ts +40 -0
  30. package/src/provision/devDelivery.ts +47 -0
  31. package/src/provision/hostCatalogs.ts +107 -0
  32. package/src/provision/provisionEmail.ts +179 -0
  33. package/src/provision/resolveEmailConfig.ts +225 -0
  34. package/src/provision/settingsCheck.ts +212 -0
  35. package/src/send/batchIdentity.ts +47 -0
  36. package/src/send/enqueue.ts +391 -0
  37. package/src/send/errorMapping.ts +73 -0
  38. package/src/send/events.ts +34 -0
  39. package/src/send/fromComposition.ts +57 -0
  40. package/src/send/retryPolicy.ts +42 -0
  41. package/src/send/runSend.ts +320 -0
  42. package/src/send/sendAt.ts +77 -0
  43. package/src/send/sender.ts +44 -0
  44. package/src/send/senderBinding.ts +56 -0
  45. package/src/send/suppression.ts +194 -0
  46. package/src/templates/engine.ts +392 -0
  47. package/src/templates/messages.es.ts +109 -0
  48. package/src/templates/messages.ts +315 -0
  49. package/src/templates/partials.ts +88 -0
  50. package/src/templates/precompiled.generated.ts +1342 -0
  51. package/src/templates/registry.ts +550 -0
  52. package/src/templates/samples.ts +75 -0
  53. package/src/templates/severity.ts +102 -0
  54. package/src/templates/theme.ts +212 -0
  55. package/src/version.generated.ts +16 -0
  56. package/src/workflows/hostApp.ts +54 -0
  57. package/src/workflows/hostEnv.ts +219 -0
  58. package/src/workflows/instanceLiveness.ts +39 -0
  59. package/src/workflows/instances.ts +16 -0
  60. package/src/workflows/params.ts +35 -0
  61. package/src/workflows/scheduler.ts +220 -0
  62. package/src/workflows/sendBatch.ts +154 -0
  63. package/src/workflows/worker.ts +203 -0
  64. package/src/workflows/wrangler.jsonc +75 -0
@@ -0,0 +1,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
+ }