@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,138 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteBoolean, SQLiteDate, sqliteJson } from "@pithy-sh/core/src/data/codecs";
5
+ import { Locale } from "@pithy-sh/core/src/i18n/locale";
6
+ import { z } from "zod";
7
+ import { EmailJobStatus, SendMode, TemplateCategory } from "./enums";
8
+
9
+ /**
10
+ * The template input variables, stored as a JSON column. The per-template Zod payload schema
11
+ * validates these before a job is enqueued; the row keeps the validated object so the send Workflow
12
+ * can re-render without the caller present. A bag of strings/unknowns at the row level — the template
13
+ * id plus this payload reconstruct the email.
14
+ */
15
+ export const EmailJobPayload = z
16
+ .record(z.string(), z.unknown())
17
+ .describe(
18
+ "The validated template input variables for this job, re-rendered by the send Workflow. Empty once `payloadRedactedAt` is set — a transactional job's inputs are dropped when the message goes out.",
19
+ );
20
+ export type EmailJobPayload = z.output<typeof EmailJobPayload>;
21
+
22
+ /**
23
+ * What the `payload` column holds once a job's inputs are spent.
24
+ *
25
+ * An empty object, encoded through the same codec every other write uses rather than written as a
26
+ * `"{}"` literal — a column this schema could not read back would turn a delivered job into a row that
27
+ * throws when an operator opens it. The fact that it *was* redacted is `payloadRedactedAt`; this is
28
+ * only what is left where the variables were.
29
+ */
30
+ export const SPENT_PAYLOAD = sqliteJson(EmailJobPayload).encode({});
31
+
32
+ /**
33
+ * One row in `pithy_email_jobs` — the spine of the capability. Every email is a row first: a request
34
+ * handler only ever enqueues one, and the actual send always runs inside a Workflow. `z.output` is the
35
+ * app shape (Dates, booleans); `z.input` is the SQLite row (ms-epoch, 0|1, JSON string).
36
+ */
37
+ export const EmailJob = z
38
+ .object({
39
+ id: z
40
+ .string()
41
+ .describe(
42
+ "UUID primary key. Text, not autoincrement, so a job id embedded in a tracking link cannot be enumerated.",
43
+ ),
44
+ toAddress: z
45
+ .string()
46
+ .describe("The recipient's email address. Lowercased and checked against the suppression list before sending."),
47
+ recipientKey: z
48
+ .string()
49
+ .describe(
50
+ "The recipient under `normalizeAddress` — the one form every comparison in the kit is against, and the only column anything matches a recipient on. `toAddress` deliberately keeps what the caller typed, because an operator diagnosing a send needs to see the string that was actually addressed; that makes it useless as a key, since `Ada@example.com` and `ada@example.com` are one mailbox and two rows. Doing the folding in SQL instead would not work: SQLite's `lower()` is ASCII-only while `normalizeAddress` is `toLowerCase()`, so the two disagree on exactly the addresses nobody tests with. The same value `pithy_email_events.recipient` is keyed on, so a job and its events agree on who the person is.",
51
+ ),
52
+ fromAddress: z.string().describe("The sender address. Must use a domain onboarded onto Cloudflare Email Service."),
53
+ fromName: z.string().describe("The sender display name recipients see in their inbox."),
54
+ subject: z.string().describe("The rendered subject line."),
55
+ template: z.string().describe("The template id (e.g. `magicLink`, `newsletter`) used to render this email."),
56
+ category: TemplateCategory.describe(
57
+ "The template's category — drives unsubscribe enforcement and tracking defaults.",
58
+ ),
59
+ payload: sqliteJson(EmailJobPayload).describe(
60
+ "The validated template input variables, as a JSON column. Emptied when the message is delivered, for every template whose category is `transactional` — see `payloadRedactedAt`.",
61
+ ),
62
+ payloadRedactedAt: SQLiteDate.nullish().describe(
63
+ "When this job's inputs were dropped, or null while it still holds them. A magic link's payload *is* the sign-in link, so keeping it after delivery is a second, permanent copy of a credential in a table nobody thinks of as holding secrets. Null and an empty payload mean different things — the first is a job enqueued with no variables, the second is one whose variables were spent — which is why this is a timestamp and not the absence of data.",
64
+ ),
65
+ status: EmailJobStatus.describe("The job's lifecycle state."),
66
+ mode: SendMode.describe("How this job's send time was determined."),
67
+ attempts: z.number().int().describe("How many send attempts have been made; incremented on each Workflow try."),
68
+ batchId: z
69
+ .string()
70
+ .nullish()
71
+ .describe(
72
+ "The send batch holding this job — the id of the send Workflow instance dispatched for it, minted by whoever claimed it. Null for a job nothing has claimed. This *is* the claim: liveness belongs to the batch, not to a row, so the scheduler asks the Workflow runtime whether this instance is still running rather than inferring it from how long ago the row was written.",
73
+ ),
74
+ sendAt: SQLiteDate.describe("The absolute time this job should send. Equal to creation time for immediate sends."),
75
+ timezone: z.string().nullish().describe("The recipient's IANA timezone for `timezone` mode; null otherwise."),
76
+ localTime: z
77
+ .string()
78
+ .nullish()
79
+ .describe("The recipient-local time-of-day (e.g. `10:00`) for `timezone` mode; null otherwise."),
80
+ campaignId: z
81
+ .string()
82
+ .nullish()
83
+ .describe("The marketing campaign this job belongs to, for click/open attribution; null for transactional."),
84
+ locale: Locale.nullish().describe(
85
+ "The language this message is written in, as a BCP-47 tag; null when the recipient never chose one. **Null is not `en`** — it means nothing was chosen, so the render falls back to the kit's English rather than asserting English was picked, the same distinction `pithy_auth_users.locale` draws. It lives on the row because the two renders happen in different places at different times: the subject at enqueue, inside a request that knows the reader, and the body at send, inside a Workflow with no request on it at all. Without a stored locale those two could agree only by accident, and an operator opening a send log had nothing that explained why a subject read the way it did.",
86
+ ),
87
+ correlation: z
88
+ .string()
89
+ .nullish()
90
+ .describe(
91
+ "What this message was *about*, as the caller names it — the discriminator for a template that carries more than one kind of message. Opaque here: this capability never parses it, never renders it, and never puts it on a header or a link. Set at enqueue, matched by `sentSince`, indexed with `createdAt`. **Not `campaignId`**, which is documented marketing-only and is not merely a naming preference: `campaignId` is copied onto every `pithy_email_events` row, grouped by `campaignStats`, and signed into the click/open tracking token that travels in the URL of a delivered email — so a transactional discriminator put there would land in campaign analytics and in a string the recipient's mail client fetches. This column goes nowhere but this row. Null for a template whose id already says everything about what the message is, which is most of them.",
92
+ ),
93
+ openTracking: SQLiteBoolean.describe("Whether an open-tracking pixel was injected into the rendered HTML."),
94
+ clickTracking: SQLiteBoolean.describe("Whether links were rewritten to tracked click-callback URLs."),
95
+ messageId: z
96
+ .string()
97
+ .nullish()
98
+ .describe(
99
+ "The Email Service message id returned on send — the handle that ties an inbound bounce back to this job.",
100
+ ),
101
+ error: z
102
+ .string()
103
+ .nullish()
104
+ .describe("The last error code/message recorded on a failed or retried send; null when healthy."),
105
+ bounceCode: z
106
+ .string()
107
+ .nullish()
108
+ .describe("The SMTP or Email Service code from a bounce/complaint; null unless the job bounced."),
109
+ bounceType: z
110
+ .string()
111
+ .nullish()
112
+ .describe(
113
+ "The classification of a bounce (`hard`/`soft`/`complaint`/`auto_reply`); null unless the job bounced.",
114
+ ),
115
+ replyTo: z
116
+ .string()
117
+ .nullish()
118
+ .describe(
119
+ "The `Reply-To` address, when this job should be answered somewhere other than `fromAddress` — a support inbox replying from a no-reply sender is the case this exists for. Null for an ordinary send.",
120
+ ),
121
+ inReplyTo: z
122
+ .string()
123
+ .nullish()
124
+ .describe(
125
+ "The `In-Reply-To` header value, angle brackets included — the message this one answers. Null unless this job is a reply.",
126
+ ),
127
+ references: z
128
+ .string()
129
+ .nullish()
130
+ .describe(
131
+ "The `References` header value, angle-bracketed and space-separated. Stored as the wire string rather than a JSON array because that is exactly what goes on the header, and re-deriving it at send time is a second chance to get threading wrong. Null unless this job is a reply.",
132
+ ),
133
+ createdAt: SQLiteDate.describe("When the job row was created."),
134
+ updatedAt: SQLiteDate.describe("When the job row was last written."),
135
+ sentAt: SQLiteDate.nullish().describe("When the send succeeded; null until then."),
136
+ })
137
+ .describe("One email job in `pithy_email_jobs` — the auditable, retryable record of a single email.");
138
+ export type EmailJob = z.output<typeof EmailJob>;
@@ -0,0 +1,40 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+ import { SuppressionReason } from "./enums";
7
+
8
+ /**
9
+ * One row in `pithy_email_suppressions` — an address that must not be sent to, fed by hard bounces,
10
+ * complaints, and unsubscribes. The send path checks this list before every send and skips a match,
11
+ * recording why. `z.output` is the app shape; `z.input` is the SQLite row.
12
+ */
13
+ export const EmailSuppression = z
14
+ .object({
15
+ id: z.number().int().describe("Surrogate primary key, autoincremented by SQLite."),
16
+ email: z.string().describe("The suppressed address, lowercased. Uniquely indexed — one row per address."),
17
+ reason: SuppressionReason.describe(
18
+ "Why the address is suppressed: hard bounce, complaint, unsubscribe, or manual.",
19
+ ),
20
+ jobId: z
21
+ .string()
22
+ .nullish()
23
+ .describe(
24
+ "The job that triggered the suppression (a bounce/complaint/unsubscribe), if any. Job ids are per-environment, so pair it with `environment` for context.",
25
+ ),
26
+ environment: z
27
+ .string()
28
+ .nullish()
29
+ .describe(
30
+ "The environment the triggering job came from (e.g. `prod`, `feature-27`). The suppression itself is global; this records where it originated.",
31
+ ),
32
+ detail: z
33
+ .string()
34
+ .nullish()
35
+ .describe("Free-form context — the bounce code or complaint source; null when not applicable."),
36
+ createdAt: SQLiteDate.describe("When the address was suppressed."),
37
+ expiresAt: SQLiteDate.nullish().describe("When a temporary suppression lifts; null for a permanent suppression."),
38
+ })
39
+ .describe("One suppressed address in `pithy_email_suppressions`.");
40
+ export type EmailSuppression = z.output<typeof EmailSuppression>;
@@ -0,0 +1,75 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * The closed enums of the email capability. Each is the documented vocabulary for one column, shared
8
+ * across the row schemas, the send path, and the callback/bounce handlers. Stored as text in SQLite.
9
+ */
10
+
11
+ export const TemplateCategory = z
12
+ .enum(["transactional", "marketing"])
13
+ .describe(
14
+ "What a template's message *is*. `transactional` is triggered by a user action (magic link, receipt); `marketing` is promotional, and cannot render at all without an unsubscribe link. The category drives tracking defaults and that hard requirement. Whether a person may *refuse* the message is a separate question, answered by `EmailKind` — a testing-program nudge is transactional in style and elective in consent.",
15
+ );
16
+ export type TemplateCategory = z.output<typeof TemplateCategory>;
17
+
18
+ export const EmailKind = z
19
+ .enum(["transactional", "elective"])
20
+ .describe(
21
+ "Whether a recipient may refuse a message. `transactional` answers something the person just did — a sign-in link, an invitation they are waiting on, a security notice — and carries no unsubscribe affordance and no `List-Unsubscribe` header; `elective` is mail somebody chose to receive and carries both. The kind is declared by the template, never passed by a caller, and it decides how the suppression list is consulted: an unsubscribe blocks elective mail only, while a bounce or a complaint blocks everything.",
22
+ );
23
+ export type EmailKind = z.output<typeof EmailKind>;
24
+
25
+ export const SendMode = z
26
+ .enum(["immediate", "scheduled", "timezone"])
27
+ .describe(
28
+ "How a job's send time is determined. `immediate` sends now (a Workflow starts at enqueue); `scheduled` sends at a fixed absolute time; `timezone` resolves a recipient's local time-of-day to an absolute time using their IANA zone.",
29
+ );
30
+ export type SendMode = z.output<typeof SendMode>;
31
+
32
+ /**
33
+ * **`canceled` has one `l` here, and `@pithy-sh/payments` spells it the same way for a different reason.**
34
+ *
35
+ * The kit writes American English (pithy-sh/pithy#434). This enum is the kit's own vocabulary — nobody
36
+ * else names these states, no wire format outside this package carries them — so the spelling was ours to
37
+ * pick, and we picked the one the rest of the prose uses. `PurchaseStatus` in `payments/src/data/status.ts`
38
+ * arrives at the same string from the other direction: `canceled` is Paddle's wire value, and rewriting a
39
+ * vendor's own string would be inventing a translation layer over somebody else's API.
40
+ *
41
+ * Two capabilities, two arguments, one spelling. That is the point — the kit stopped spelling one concept
42
+ * both ways, and neither side has to be re-derived by the next reader.
43
+ *
44
+ * **This was settled before the first publish, and that window is closed.** Every package was `0.0.0`,
45
+ * nothing was on npm, and `0001_init.ts` declares `status` as bare `text` with no CHECK, so no row and no
46
+ * migration ever held the old value. Changing one now would be a breaking change with a real migration
47
+ * behind it.
48
+ */
49
+ export const EmailJobStatus = z
50
+ .enum(["pending", "scheduled", "sending", "sent", "failed", "suppressed", "bounced", "undispatched", "canceled"])
51
+ .describe(
52
+ "The lifecycle state of an email job. `pending` (immediate, awaiting dispatch) and `scheduled` (future sendAt) are pre-send; `sending` is in-flight; `sent` succeeded; `failed` exhausted retries; `suppressed` was skipped because the address is on the suppression list; `bounced` was reported undeliverable; `undispatched` means this composition binds no send Workflow, so nothing was started and nothing is coming while that holds — a configuration fact, not a transient one, and the scheduler claims those rows once a host worker exists; `canceled` was withdrawn before sending.",
53
+ );
54
+ export type EmailJobStatus = z.output<typeof EmailJobStatus>;
55
+
56
+ export const EmailEventType = z
57
+ .enum(["sent", "open", "click", "bounce", "complaint", "unsubscribe", "suppressed", "failed"])
58
+ .describe(
59
+ "A per-recipient event recorded for history and campaign attribution: `sent` on a successful send, `open` from the tracking pixel, `click` from a tracked link, `bounce`/`complaint` from inbound mail, `unsubscribe` from the opt-out callback, `suppressed` when a send was skipped, `failed` on a terminal send error.",
60
+ );
61
+ export type EmailEventType = z.output<typeof EmailEventType>;
62
+
63
+ export const BounceType = z
64
+ .enum(["hard", "soft", "complaint", "auto_reply"])
65
+ .describe(
66
+ "How an inbound delivery-status or complaint message was classified: `hard` (permanent — suppress), `soft` (transient — Cloudflare auto-retries), `complaint` (recipient reported spam — suppress), `auto_reply` (vacation responder — a no-op, never suppressed).",
67
+ );
68
+ export type BounceType = z.output<typeof BounceType>;
69
+
70
+ export const SuppressionReason = z
71
+ .enum(["hard_bounce", "complaint", "unsubscribe", "manual"])
72
+ .describe(
73
+ "Why an address is on the suppression list and will not be sent to: `hard_bounce` (permanent delivery failure), `complaint` (spam report), `unsubscribe` (recipient opted out), `manual` (added by an operator).",
74
+ );
75
+ export type SuppressionReason = z.output<typeof SuppressionReason>;
@@ -0,0 +1,47 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
6
+ import type { Kysely } from "kysely";
7
+ import { EmailEvent } from "./emailEvent";
8
+ import { EmailJob } from "./emailJob";
9
+ import { EmailSuppression } from "./emailSuppression";
10
+
11
+ /**
12
+ * The email capability spans two databases. Jobs and events are **per-environment** — they live in the
13
+ * app `DB`, alongside the app's own data, scoped to the environment that sent them. Suppression is
14
+ * **global** — an address that hard-bounced, complained, or unsubscribed must never be emailed from any
15
+ * environment — so it lives in a dedicated, durable `EMAIL_SUPPRESSIONS` database (the same shared-DB
16
+ * pattern `@pithy-sh/secrets` uses), written by the single inbound bounce worker and the unsubscribe
17
+ * callbacks and read by every environment's send path.
18
+ */
19
+
20
+ /** The per-environment email tables, on the app `DB`. */
21
+ export const emailTables = {
22
+ pithyEmailJobs: EmailJob,
23
+ pithyEmailEvents: EmailEvent,
24
+ };
25
+ export type EmailTables = typeof emailTables;
26
+
27
+ /** The global suppression table, on the dedicated durable `EMAIL_SUPPRESSIONS` database. */
28
+ export const emailSuppressionTables = {
29
+ pithyEmailSuppressions: EmailSuppression,
30
+ };
31
+ export type EmailSuppressionTables = typeof emailSuppressionTables;
32
+
33
+ /** The typed Kysely database over the per-environment email tables (jobs + events). */
34
+ export type EmailDatabase = Kysely<DatabaseSchema<EmailTables>>;
35
+
36
+ /** The typed Kysely database over the global suppression table. */
37
+ export type EmailSuppressionDatabase = Kysely<DatabaseSchema<EmailSuppressionTables>>;
38
+
39
+ /** Build the per-environment email database from the app `DB` binding (CamelCasePlugin installed). */
40
+ export function emailDatabase(d1: D1Database): EmailDatabase {
41
+ return createDatabase(d1, emailTables);
42
+ }
43
+
44
+ /** Build the global suppression database from the `EMAIL_SUPPRESSIONS` binding. */
45
+ export function emailSuppressionDatabase(d1: D1Database): EmailSuppressionDatabase {
46
+ return createDatabase(d1, emailSuppressionTables);
47
+ }
@@ -0,0 +1,129 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
6
+
7
+ /**
8
+ * `@pithy-sh/email` throw sugar. The `email/*` codes live in core's closed `KitErrorPayload` union
9
+ * (CLAUDE.md §Errors: capabilities add their codes to the one union); these subclasses are the
10
+ * package-local vehicles that set one of those members — the same pattern as `@pithy-sh/secrets`.
11
+ * Runtime code in this package throws one of these, never a plain `new Error`.
12
+ */
13
+
14
+ /** Variable parts each subclass accepts; `code`/`status` are fixed by the subclass. */
15
+ interface EmailErrorArgs {
16
+ /** Override the public, safe-to-expose message. */
17
+ message?: string;
18
+ /** A remediation hint (CLI action line). */
19
+ action?: string;
20
+ /** Internal context for logs + audit. Never serialized to clients. */
21
+ detail?: string;
22
+ /**
23
+ * Values a translating client interpolates into its own wording for this code. Client-facing, so —
24
+ * unlike `action` and `detail` — these cross the boundary with `message`.
25
+ */
26
+ params?: MessageParams;
27
+ }
28
+
29
+ /** The requested template id is not registered. */
30
+ export class EmailTemplateNotFoundError extends PithyError {
31
+ constructor(args: EmailErrorArgs = {}, options?: { cause?: unknown }) {
32
+ super(
33
+ {
34
+ code: "email/template_not_found",
35
+ status: 404,
36
+ message: args.message ?? "Email template not found.",
37
+ action: args.action,
38
+ detail: args.detail,
39
+ params: args.params,
40
+ },
41
+ options,
42
+ );
43
+ }
44
+ }
45
+
46
+ /** A template's input variables failed validation against its payload schema. */
47
+ export class EmailInvalidPayloadError extends PithyError {
48
+ constructor(args: EmailErrorArgs = {}, options?: { cause?: unknown }) {
49
+ super(
50
+ {
51
+ code: "email/invalid_payload",
52
+ status: 400,
53
+ message: args.message ?? "Email template payload failed validation.",
54
+ action: args.action,
55
+ detail: args.detail,
56
+ params: args.params,
57
+ },
58
+ options,
59
+ );
60
+ }
61
+ }
62
+
63
+ /** A callback token (click/open/unsubscribe) is malformed, expired, forged, or signed by an unknown key. */
64
+ export class EmailInvalidTokenError extends PithyError {
65
+ constructor(args: EmailErrorArgs = {}, options?: { cause?: unknown }) {
66
+ super(
67
+ {
68
+ code: "email/invalid_token",
69
+ status: 400,
70
+ message: args.message ?? "This link is invalid or has expired.",
71
+ action: args.action,
72
+ detail: args.detail,
73
+ params: args.params,
74
+ },
75
+ options,
76
+ );
77
+ }
78
+ }
79
+
80
+ /** The recipient address is on the suppression list and cannot be sent to. */
81
+ export class EmailSuppressedError extends PithyError {
82
+ constructor(args: EmailErrorArgs = {}, options?: { cause?: unknown }) {
83
+ super(
84
+ {
85
+ code: "email/suppressed",
86
+ status: 409,
87
+ message: args.message ?? "This address is suppressed and cannot be emailed.",
88
+ action: args.action,
89
+ detail: args.detail,
90
+ params: args.params,
91
+ },
92
+ options,
93
+ );
94
+ }
95
+ }
96
+
97
+ /** The Email Service rejected the send for a rate or daily-quota limit; the send is retryable. */
98
+ export class EmailRateLimitedError extends PithyError {
99
+ constructor(args: EmailErrorArgs = {}, options?: { cause?: unknown }) {
100
+ super(
101
+ {
102
+ code: "email/rate_limited",
103
+ status: 429,
104
+ message: args.message ?? "The email rate limit was exceeded.",
105
+ action: args.action ?? "Retry with backoff.",
106
+ detail: args.detail,
107
+ params: args.params,
108
+ },
109
+ options,
110
+ );
111
+ }
112
+ }
113
+
114
+ /** A send through the Email Service binding failed for a non-retryable reason. */
115
+ export class EmailSendFailedError extends PithyError {
116
+ constructor(args: EmailErrorArgs = {}, options?: { cause?: unknown }) {
117
+ super(
118
+ {
119
+ code: "email/send_failed",
120
+ status: 502,
121
+ message: args.message ?? "The email could not be sent.",
122
+ action: args.action,
123
+ detail: args.detail,
124
+ params: args.params,
125
+ },
126
+ options,
127
+ );
128
+ }
129
+ }
@@ -0,0 +1,200 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { zValidator } from "@hono/zod-validator";
6
+ import { normalizeAddress } from "@pithy-sh/core/src/address/address";
7
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
8
+ import { validationHook } from "@pithy-sh/core/src/http/validation";
9
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
10
+ import type { Hono } from "hono";
11
+ import { resolveSigningKeys } from "../crypto/signingKey";
12
+ import { type CallbackToken, type TokenKind, verifyToken } from "../crypto/token";
13
+ import {
14
+ type EmailDatabase,
15
+ type EmailSuppressionDatabase,
16
+ emailDatabase,
17
+ emailSuppressionDatabase,
18
+ } from "../data/tables";
19
+ import { EmailInvalidTokenError } from "../error/errors";
20
+ import { recordEvent } from "../send/events";
21
+ import { suppress } from "../send/suppression";
22
+ import { CALLBACK_BASE } from "../templates/engine";
23
+ import { CallbackTokenParam, UnsubscribeQuery } from "./schemas";
24
+
25
+ /**
26
+ * The callback routes — click, open, and unsubscribe. Every link in a tracked email points here with
27
+ * an HMAC-signed token (see `crypto/token`). The routes are **public + signed-token**: they take no
28
+ * session, and the token's signature is the only gate. A forged, altered, or expired token is rejected
29
+ * (`email/invalid_token` → 400) before anything is recorded. The click route only ever 302-redirects to
30
+ * an `http(s)` destination carried inside the signed token, so it cannot be turned into an open redirect.
31
+ *
32
+ * | Method | Path | Strategy | Validates |
33
+ * |--------|-------------------------------|---------------|-----------|
34
+ * | GET | /_pithy/email/c/:token | signed token | param `CallbackTokenParam` |
35
+ * | GET | /_pithy/email/o/:token | signed token | param `CallbackTokenParam` |
36
+ * | GET | /_pithy/email/u/:token | signed token | param `CallbackTokenParam`, query `UnsubscribeQuery` |
37
+ * | POST | /_pithy/email/u/:token | signed token | param `CallbackTokenParam`, query `UnsubscribeQuery` |
38
+ *
39
+ * The unsubscribe route answers POST as well as GET because elective mail carries
40
+ * `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058) — the mail client posts to the URL
41
+ * itself, with no human ever loading the page, and Gmail's and Yahoo's bulk-sender rules require that to
42
+ * work. Same handler: the underlying write is an upsert, so the two methods cannot disagree and a
43
+ * client that retries costs nothing.
44
+ *
45
+ * The validators bound shape and size only — the signature check stays in the handler, so a well-formed
46
+ * token that is forged or expired is still `email/invalid_token` (400), not `validation/invalid_input`.
47
+ * No route reads a body, the one-click POST included: RFC 8058 fixes its body to a constant, so parsing
48
+ * it could only ever restate what the method and the signed token already say.
49
+ *
50
+ * The per-request handlers take the signing-key version set directly so they are unit-testable without
51
+ * standing up the secrets store; `registerCallbacks` is the thin shell that resolves the keys from the
52
+ * worker env (via `@pithy-sh/secrets`) and the database from the `DB` binding.
53
+ */
54
+
55
+ /** The env the callbacks read: the app + shared suppression databases plus the secrets bindings for the signing key. */
56
+ type CallbackEnv = SecretsStoreEnv & { DB: D1Database; EMAIL_SUPPRESSIONS: D1Database };
57
+
58
+ /** The signing-key version set a handler verifies against. */
59
+ export interface SigningKeys {
60
+ versions: Record<string, string>;
61
+ }
62
+
63
+ /** A 1×1 transparent PNG — the open-tracking pixel body. */
64
+ const PIXEL = Uint8Array.from(
65
+ atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg=="),
66
+ (c) => c.charCodeAt(0),
67
+ );
68
+
69
+ /** Verify a token and assert its kind, or throw `email/invalid_token`. */
70
+ async function verify(keys: SigningKeys, token: string, expected: TokenKind, now: Date): Promise<CallbackToken> {
71
+ const claims = await verifyToken(token, keys, now);
72
+ if (claims.kind !== expected) {
73
+ throw new EmailInvalidTokenError({ detail: `token kind '${claims.kind}' does not match callback '${expected}'` });
74
+ }
75
+ return claims;
76
+ }
77
+
78
+ /** Handle a click: record it, then 302-redirect to the signed http(s) destination. */
79
+ export async function handleClick(db: EmailDatabase, keys: SigningKeys, token: string, now: Date): Promise<Response> {
80
+ const claims = await verify(keys, token, "click", now);
81
+ const destination = claims.destination ?? "";
82
+ if (!/^https?:\/\//i.test(destination)) {
83
+ throw new EmailInvalidTokenError({ detail: "click token has no http(s) destination" });
84
+ }
85
+ await recordEvent(
86
+ db,
87
+ {
88
+ jobId: claims.jobId,
89
+ recipient: normalizeAddress(claims.recipient),
90
+ type: "click",
91
+ linkLabel: claims.linkLabel ?? null,
92
+ linkUrl: destination,
93
+ campaignId: claims.campaignId ?? null,
94
+ },
95
+ now,
96
+ );
97
+ return new Response(null, { status: 302, headers: { location: destination } });
98
+ }
99
+
100
+ /** Handle an open: record it, return the 1×1 pixel. */
101
+ export async function handleOpen(db: EmailDatabase, keys: SigningKeys, token: string, now: Date): Promise<Response> {
102
+ const claims = await verify(keys, token.replace(/\.png$/i, ""), "open", now);
103
+ await recordEvent(
104
+ db,
105
+ {
106
+ jobId: claims.jobId,
107
+ recipient: normalizeAddress(claims.recipient),
108
+ type: "open",
109
+ campaignId: claims.campaignId ?? null,
110
+ },
111
+ now,
112
+ );
113
+ return new Response(PIXEL, {
114
+ status: 200,
115
+ headers: { "content-type": "image/png", "cache-control": "no-store, max-age=0" },
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Handle an unsubscribe: suppress the address (shared DB), record the opt-out event (app DB), confirm.
121
+ * An optional `reason` — supplied by the app's own unsubscribe/preferences flow via the `?reason=` query
122
+ * param — is stored on the suppression `detail` and the event, so a project can capture *why* someone
123
+ * opted out using its own logic. It is length-bounded so the link can't be used to stuff the column.
124
+ */
125
+ export async function handleUnsubscribe(
126
+ db: EmailDatabase,
127
+ suppressionDb: EmailSuppressionDatabase,
128
+ keys: SigningKeys,
129
+ token: string,
130
+ now: Date,
131
+ reason?: string,
132
+ environment?: string,
133
+ ): Promise<Response> {
134
+ const claims = await verify(keys, token, "unsubscribe", now);
135
+ const recipient = normalizeAddress(claims.recipient);
136
+ const detail = reason ? reason.slice(0, 200) : "unsubscribe link";
137
+ await suppress(
138
+ suppressionDb,
139
+ { email: recipient, reason: "unsubscribe", jobId: claims.jobId, environment, detail },
140
+ now,
141
+ );
142
+ await recordEvent(
143
+ db,
144
+ { jobId: claims.jobId, recipient, type: "unsubscribe", campaignId: claims.campaignId ?? null, detail },
145
+ now,
146
+ );
147
+ // The copy states the scope, because the scope is not what "unsubscribe" implies to most people and
148
+ // getting it wrong in either direction is alarming: somebody who believes this killed their sign-in
149
+ // link, or somebody who believes it stopped everything and then receives a security notice.
150
+ const body =
151
+ "<p>You've been unsubscribed. Messages you ask for — a sign-in link, a security notice — still arrive. Nothing else will.</p>";
152
+ return new Response(body, {
153
+ status: 200,
154
+ headers: { "content-type": "text/html; charset=utf-8" },
155
+ });
156
+ }
157
+
158
+ /** Mount the click/open/unsubscribe callback routes under `${CALLBACK_BASE}` on the app's Hono router. */
159
+ export function registerCallbacks(app: Hono<PithyHonoEnv>): void {
160
+ const setup = async (
161
+ env: CallbackEnv,
162
+ ): Promise<{ db: EmailDatabase; suppressionDb: EmailSuppressionDatabase; keys: SigningKeys }> => {
163
+ const keys = await resolveSigningKeys(env);
164
+ return {
165
+ db: emailDatabase(env.DB),
166
+ suppressionDb: emailSuppressionDatabase(env.EMAIL_SUPPRESSIONS),
167
+ keys: { versions: keys.versions },
168
+ };
169
+ };
170
+
171
+ app.get(`${CALLBACK_BASE}/c/:token`, zValidator("param", CallbackTokenParam, validationHook), async (c) => {
172
+ const { db, keys } = await setup(c.env as unknown as CallbackEnv);
173
+ return handleClick(db, keys, c.req.valid("param").token, new Date());
174
+ });
175
+
176
+ app.get(`${CALLBACK_BASE}/o/:token`, zValidator("param", CallbackTokenParam, validationHook), async (c) => {
177
+ const { db, keys } = await setup(c.env as unknown as CallbackEnv);
178
+ return handleOpen(db, keys, c.req.valid("param").token, new Date());
179
+ });
180
+
181
+ app.on(
182
+ ["GET", "POST"],
183
+ `${CALLBACK_BASE}/u/:token`,
184
+ zValidator("param", CallbackTokenParam, validationHook),
185
+ zValidator("query", UnsubscribeQuery, validationHook),
186
+ async (c) => {
187
+ const env = c.env as unknown as CallbackEnv;
188
+ const { db, suppressionDb, keys } = await setup(env);
189
+ return handleUnsubscribe(
190
+ db,
191
+ suppressionDb,
192
+ keys,
193
+ c.req.valid("param").token,
194
+ new Date(),
195
+ c.req.valid("query").reason,
196
+ env.ENVIRONMENT,
197
+ );
198
+ },
199
+ );
200
+ }