@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,467 @@
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 type { ControlPlaneContext } from "@pithy-sh/core/src/controlPlane/context";
9
+ import { requireControlPlane } from "@pithy-sh/core/src/controlPlane/http/guard";
10
+ import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
11
+ import { ConflictError, InternalError, NotFoundError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
12
+ import { validationHook } from "@pithy-sh/core/src/http/validation";
13
+ import type { VerificationStrategy } from "@pithy-sh/core/src/http/verification";
14
+ import type { Context, Hono } from "hono";
15
+ import { EmailAuditActions } from "../audit/actions";
16
+ import {
17
+ type EmailDatabase,
18
+ type EmailSuppressionDatabase,
19
+ emailDatabase,
20
+ emailSuppressionDatabase,
21
+ } from "../data/tables";
22
+ import { getJob, listJobs } from "../jobs/read";
23
+ import { retryJob } from "../jobs/retry";
24
+ import type { SendWorkflowBinding } from "../send/enqueue";
25
+ import { emailSenderBinding } from "../send/senderBinding";
26
+ import { listSuppressions, suppress, unsuppress } from "../send/suppression";
27
+ import {
28
+ EMAIL_JOBS_READ_SCOPE,
29
+ EMAIL_JOBS_RETRY_SCOPE,
30
+ EMAIL_SUPPRESSIONS_DELETE_SCOPE,
31
+ EMAIL_SUPPRESSIONS_READ_SCOPE,
32
+ EMAIL_SUPPRESSIONS_WRITE_SCOPE,
33
+ } from "./guards";
34
+ import type {
35
+ EmailJobResponse,
36
+ EmailJobRetryResponse,
37
+ EmailJobsResponse,
38
+ EmailSuppressionsResponse,
39
+ EmailSuppressResponse,
40
+ EmailUnsuppressResponse,
41
+ } from "./responses";
42
+ import { JobIdParam, JobsQuery, SuppressionsQuery, SuppressRequest, UnsuppressRequest } from "./schemas";
43
+ import { jobDetailView, jobListView, suppressionView } from "./view";
44
+
45
+ /**
46
+ * The email management routes, their declared verification strategies, and what each accepts:
47
+ *
48
+ * GET /email/jobs → the send log (control-plane: email:jobs:read) query
49
+ * GET /email/jobs/:id → one job in full (control-plane: email:jobs:read) param
50
+ * POST /email/jobs/:id/retry → queue it again (control-plane: email:jobs:retry) param
51
+ * GET /email/suppressions → who we won't mail (control-plane: email:suppressions:read) query
52
+ * POST /email/suppressions → block an address (control-plane: email:suppressions:write) json
53
+ * POST /email/suppressions/remove → unblock one (control-plane: email:suppressions:delete) json
54
+ *
55
+ * **Every route here is `control-plane`, and there is no end-user surface in this file.** A recipient
56
+ * interacts with email by receiving it; the three routes a recipient does call — click, open,
57
+ * unsubscribe — live in `callbacks.ts`, are public, and are gated by the signature on the token in the
58
+ * path. Nothing below has any business answering an ordinary user, and with the seam uncomposed every
59
+ * one of them answers 403 `controlplane/not_connected`.
60
+ *
61
+ * **Validators sit after the gate on every line.** A validator ahead of it turns a 403 into a 400 and
62
+ * tells an unverified caller which requests were well-formed — on this surface that is a live oracle
63
+ * for the shape of the send log and, worse, a way to learn that a given address parses as one this
64
+ * deployment would accept.
65
+ *
66
+ * **Every response has an exported schema**, in `responses.ts`, and each `c.json` below is
67
+ * `satisfies`-checked against its envelope. A management client imports the same object and validates
68
+ * with it rather than hand-writing a mirror that drifts. The check is at compile time on purpose:
69
+ * parsing every response would spend a validation pass on rows this Worker just read, and would turn a
70
+ * shape mistake into a 500 in production rather than a red build.
71
+ *
72
+ * **Removal is a POST with a body, not `DELETE /suppressions/:email`.** An address in a path is an
73
+ * address in every access log, every proxy, every trace, and every referrer between the client and the
74
+ * Worker. The record is personal data whether or not it is a person's current address, so it travels in
75
+ * a body. Testers' `POST /remove` is the same call made for the same reason.
76
+ */
77
+
78
+ /** What every route this capability mounts declares: its path, its strategy, and the scope it checks. */
79
+ export interface EmailRouteDeclaration {
80
+ readonly method: "GET" | "POST";
81
+ /** The path relative to the configured `basePath`, e.g. `/jobs`. */
82
+ readonly path: string;
83
+ readonly strategy: VerificationStrategy;
84
+ /** The control-plane scope this route checks. */
85
+ readonly scope: ControlPlaneScope;
86
+ }
87
+
88
+ /**
89
+ * Every management route, and how it is gated.
90
+ *
91
+ * Exported so a test can assert against the declaration rather than against a middleware count. A count
92
+ * proves that *something* runs before the handler — a bare `zValidator` satisfies it — and cannot prove
93
+ * *what*. `routeContract.test.ts` checks this list against the paths Hono actually registered, in both
94
+ * directions, so a route added without an entry and an entry without a route both fail.
95
+ */
96
+ export const EMAIL_ADMIN_ROUTES: readonly EmailRouteDeclaration[] = [
97
+ { method: "GET", path: "/jobs", strategy: "control-plane", scope: EMAIL_JOBS_READ_SCOPE },
98
+ { method: "GET", path: "/jobs/:id", strategy: "control-plane", scope: EMAIL_JOBS_READ_SCOPE },
99
+ { method: "POST", path: "/jobs/:id/retry", strategy: "control-plane", scope: EMAIL_JOBS_RETRY_SCOPE },
100
+ { method: "GET", path: "/suppressions", strategy: "control-plane", scope: EMAIL_SUPPRESSIONS_READ_SCOPE },
101
+ { method: "POST", path: "/suppressions", strategy: "control-plane", scope: EMAIL_SUPPRESSIONS_WRITE_SCOPE },
102
+ {
103
+ method: "POST",
104
+ path: "/suppressions/remove",
105
+ strategy: "control-plane",
106
+ scope: EMAIL_SUPPRESSIONS_DELETE_SCOPE,
107
+ },
108
+ ];
109
+
110
+ /** The bindings the management routes read off the request env. */
111
+ interface AdminEnv {
112
+ DB?: D1Database;
113
+ EMAIL_SUPPRESSIONS?: D1Database;
114
+ EMAIL_SENDER?: SendWorkflowBinding;
115
+ /** Stamped by `pithy init`. Read only to decide whether a local host may stand in for the binding. */
116
+ ENVIRONMENT?: string;
117
+ /** The local email host's address under `pithy dev`. See {@link emailSenderBinding}. */
118
+ EMAIL_ORIGIN?: string;
119
+ }
120
+
121
+ /** How the email management sub-router is built. */
122
+ export interface EmailAdminRoutesOptions {
123
+ /**
124
+ * Where the routes mount.
125
+ *
126
+ * Required, with no default of its own. The manifest advertises these paths and a client composes its
127
+ * calls from the manifest, so the mount point and the advertised path must come from one value — a
128
+ * default here and a default in the config is two, and the day they disagree every management call
129
+ * 404s against exactly the adopters who customized anything.
130
+ */
131
+ basePath: string;
132
+ /** The clock. Injected so retried timestamps are deterministic in tests. */
133
+ now?: () => Date;
134
+ }
135
+
136
+ /**
137
+ * A required D1 binding, or a stated wiring failure.
138
+ *
139
+ * `email()` declares both databases in `requiredBindings`, so an absent one is a Worker assembled
140
+ * wrong rather than a request doing anything unusual — hence a 500 that names the fix, not a 4xx that
141
+ * blames the caller.
142
+ */
143
+ function d1(c: Context<PithyHonoEnv>, binding: "DB" | "EMAIL_SUPPRESSIONS"): D1Database {
144
+ const found = (c.env as AdminEnv)[binding];
145
+ if (!found) {
146
+ throw new InternalError({
147
+ message: "Email is not fully configured on this Worker.",
148
+ action: `Bind a D1 database named ${binding} in wrangler.jsonc, then run pithy migrate.`,
149
+ detail: `the email management routes require a \`${binding}\` D1 binding; none was present on env`,
150
+ });
151
+ }
152
+ return found;
153
+ }
154
+
155
+ /** The per-environment jobs database. */
156
+ function jobs(c: Context<PithyHonoEnv>): EmailDatabase {
157
+ return emailDatabase(d1(c, "DB"));
158
+ }
159
+
160
+ /** The global suppression database. */
161
+ function suppressions(c: Context<PithyHonoEnv>): EmailSuppressionDatabase {
162
+ return emailSuppressionDatabase(d1(c, "EMAIL_SUPPRESSIONS"));
163
+ }
164
+
165
+ /**
166
+ * The verified management caller.
167
+ *
168
+ * `requireControlPlane()` has run on every route in this file, so a null here is a route mounted
169
+ * without its gate — a programming error, not an unauthenticated request, and therefore an internal
170
+ * error rather than a 401 that would imply a credential could fix it.
171
+ */
172
+ function caller(c: Context<PithyHonoEnv>): ControlPlaneContext {
173
+ const context = c.var.controlPlane;
174
+ if (!context) {
175
+ throw new InternalError({
176
+ message: "Email could not identify the management caller.",
177
+ detail: "requireControlPlane() must run before an email handler reads the caller.",
178
+ });
179
+ }
180
+ return context;
181
+ }
182
+
183
+ /** Register the email management sub-router. Composed alongside the public callbacks. */
184
+ export function registerEmailAdminRoutes(options: EmailAdminRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
185
+ const base = options.basePath;
186
+ const clock = options.now ?? (() => new Date());
187
+
188
+ return (app) => {
189
+ // ── the send log ──────────────────────────────────────────────────────────
190
+
191
+ app.get(
192
+ `${base}/jobs`,
193
+ requireControlPlane(EMAIL_JOBS_READ_SCOPE),
194
+ zValidator("query", JobsQuery, validationHook),
195
+ async (c) => {
196
+ const query = c.req.valid("query");
197
+ const who = caller(c);
198
+ const page = await listJobs(jobs(c), query);
199
+
200
+ // Reads are audited too. A page of the send log is a page of who this project mailed, and a
201
+ // credential quietly walking it is the event an adopter most needs to be able to find later.
202
+ // The filter and the count go in metadata; the rows do not.
203
+ await c.var.emit({
204
+ action: EmailAuditActions.jobsRead,
205
+ outcome: "success",
206
+ actorType: "control-plane",
207
+ actorId: who.subject,
208
+ resourceType: "email_job",
209
+ resourceId: null,
210
+ metadata: {
211
+ connectionId: who.connectionId,
212
+ status: query.status ?? null,
213
+ returned: page.items.length,
214
+ paged: Boolean(query.cursor),
215
+ },
216
+ });
217
+
218
+ return c.json({ jobs: page.items.map(jobListView), nextCursor: page.nextCursor } satisfies EmailJobsResponse);
219
+ },
220
+ );
221
+
222
+ app.get(
223
+ `${base}/jobs/:id`,
224
+ requireControlPlane(EMAIL_JOBS_READ_SCOPE),
225
+ zValidator("param", JobIdParam, validationHook),
226
+ async (c) => {
227
+ const { id } = c.req.valid("param");
228
+ const who = caller(c);
229
+ const job = await getJob(jobs(c), id);
230
+ if (!job) {
231
+ // `core/not_found`, saying nothing beyond "not here" — an id that exists and an id that does
232
+ // not get the same words, so the route is not an oracle for which jobs this project sent.
233
+ throw new NotFoundError({
234
+ message: "No such email job.",
235
+ action: "Check the job id against the send log.",
236
+ detail: `email job '${id}' not found`,
237
+ });
238
+ }
239
+
240
+ // The one route that discloses a whole address, so it is audited against the job it disclosed.
241
+ await c.var.emit({
242
+ action: EmailAuditActions.jobRead,
243
+ outcome: "success",
244
+ actorType: "control-plane",
245
+ actorId: who.subject,
246
+ resourceType: "email_job",
247
+ resourceId: job.id,
248
+ metadata: { connectionId: who.connectionId, status: job.status, template: job.template },
249
+ });
250
+
251
+ return c.json({ job: jobDetailView(job) } satisfies EmailJobResponse);
252
+ },
253
+ );
254
+
255
+ app.post(
256
+ `${base}/jobs/:id/retry`,
257
+ requireControlPlane(EMAIL_JOBS_RETRY_SCOPE),
258
+ zValidator("param", JobIdParam, validationHook),
259
+ async (c) => {
260
+ const { id } = c.req.valid("param");
261
+ const who = caller(c);
262
+ const now = clock();
263
+ const db = jobs(c);
264
+ // Read before write, and read what the row *was* — the audit event says which state this came
265
+ // out of, and after the update there is nothing left to ask.
266
+ const before = await getJob(db, id);
267
+ const result = await retryJob(
268
+ { db, suppressionDb: suppressions(c), sender: emailSenderBinding(c.env as AdminEnv), now },
269
+ id,
270
+ );
271
+
272
+ // `warning`: this is the one operation in the capability that sends mail to a real person.
273
+ await c.var.emit({
274
+ action: EmailAuditActions.jobRetried,
275
+ outcome: "success",
276
+ severity: "warning",
277
+ actorType: "control-plane",
278
+ actorId: who.subject,
279
+ resourceType: "email_job",
280
+ resourceId: result.job.id,
281
+ metadata: {
282
+ connectionId: who.connectionId,
283
+ from: before?.status ?? null,
284
+ template: result.job.template,
285
+ dispatched: result.dispatched,
286
+ },
287
+ });
288
+
289
+ return c.json({
290
+ job: jobDetailView(result.job),
291
+ dispatched: result.dispatched,
292
+ } satisfies EmailJobRetryResponse);
293
+ },
294
+ );
295
+
296
+ // ── the suppression list ──────────────────────────────────────────────────
297
+
298
+ app.get(
299
+ `${base}/suppressions`,
300
+ requireControlPlane(EMAIL_SUPPRESSIONS_READ_SCOPE),
301
+ zValidator("query", SuppressionsQuery, validationHook),
302
+ async (c) => {
303
+ const query = c.req.valid("query");
304
+ const who = caller(c);
305
+ const now = clock();
306
+ const page = await listSuppressions(suppressions(c), query);
307
+
308
+ // The heaviest read in the capability: this database is global, so a page of it is a page of
309
+ // every environment's bounces, complaints, and opt-outs. Audited with whether it was a lookup
310
+ // or a walk, because those are very different events wearing the same route.
311
+ await c.var.emit({
312
+ action: EmailAuditActions.suppressionsRead,
313
+ outcome: "success",
314
+ actorType: "control-plane",
315
+ actorId: who.subject,
316
+ resourceType: "email_suppression",
317
+ resourceId: null,
318
+ metadata: {
319
+ connectionId: who.connectionId,
320
+ reason: query.reason ?? null,
321
+ lookup: Boolean(query.email),
322
+ returned: page.items.length,
323
+ paged: Boolean(query.cursor),
324
+ },
325
+ });
326
+
327
+ return c.json({
328
+ suppressions: page.items.map((row) => suppressionView(row, now)),
329
+ nextCursor: page.nextCursor,
330
+ } satisfies EmailSuppressionsResponse);
331
+ },
332
+ );
333
+
334
+ app.post(
335
+ `${base}/suppressions`,
336
+ requireControlPlane(EMAIL_SUPPRESSIONS_WRITE_SCOPE),
337
+ zValidator("json", SuppressRequest, validationHook),
338
+ async (c) => {
339
+ const input = c.req.valid("json");
340
+ const who = caller(c);
341
+ const now = clock();
342
+ const email = normalizeAddress(input.email);
343
+ const db = suppressions(c);
344
+
345
+ // **A write must never weaken an existing block.** `suppress()` is an upsert, so without this
346
+ // guard `email:suppressions:write` could rewrite a `hard_bounce`, `complaint`, or `unsubscribe`
347
+ // row — every one of which the system writes with no expiry — into a `manual` one that lapses,
348
+ // and the send path decides purely on `expiresAt <= now`. That is precisely the act
349
+ // `email:suppressions:delete` exists to gate, reached from a scope that was never granted it.
350
+ // `scopeCovers` matches exactly and offers no protection here: the delete route is simply not
351
+ // the route being called.
352
+ //
353
+ // The upsert would also destroy the compliance record, forcing `reason` to `manual` and
354
+ // clobbering the `jobId` and `detail` that say *why* the address was blocked.
355
+ // An expiry in the past is not a block — it is an unblock wearing one, since the send path
356
+ // decides purely on `expiresAt <= now`. Checked here rather than in the schema because the
357
+ // route has an injected clock, and a schema reading the wall clock would ignore it.
358
+ const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
359
+ if (expiresAt && expiresAt.getTime() <= now.getTime()) {
360
+ throw new ValidationError({
361
+ message: "That expiry has already passed, so it would not block anything.",
362
+ action:
363
+ "Give a future instant, or omit `expiresAt` to block permanently. To lift an existing block, use the remove route — it requires `email:suppressions:delete`.",
364
+ detail: `refused suppression for ${email} with expiresAt ${expiresAt.toISOString()} at ${now.toISOString()}`,
365
+ });
366
+ }
367
+
368
+ const current = (await listSuppressions(db, { email, limit: 1 })).items[0];
369
+ if (current && current.reason !== "manual") {
370
+ throw new ConflictError({
371
+ message: `${email} is already suppressed as a ${current.reason}, which this route may not overwrite.`,
372
+ action:
373
+ "Lift it with the remove route, which requires `email:suppressions:delete` — undoing a bounce, a complaint, or someone's own opt-out is a separate decision.",
374
+ detail: `refused manual suppression over an existing ${current.reason} for ${email}`,
375
+ });
376
+ }
377
+
378
+ await suppress(
379
+ db,
380
+ {
381
+ email,
382
+ // Always `manual`. See `SuppressRequest`: the other three reasons are observations the
383
+ // system made, and a management client made none of them.
384
+ reason: "manual",
385
+ detail: input.detail ?? "blocked by a management client",
386
+ environment: (c.env as { ENVIRONMENT?: string }).ENVIRONMENT ?? null,
387
+ expiresAt,
388
+ },
389
+ now,
390
+ );
391
+
392
+ // The address is in the trail on purpose. A silent block is invisible to the person it affects
393
+ // and to everyone else; the audit event is the only record that it happened and who asked.
394
+ //
395
+ // **In `metadata`, never in `resourceId`.** `auditEventView` projects `resourceId` into the
396
+ // *listing* served under `audit:events:read`, which the audit views deliberately keep free of
397
+ // personal data — `metadata` is held back to `audit:events:read_detail` for exactly this reason.
398
+ // A raw recipient address in `resourceId` would let a credential holding only the everyday read
399
+ // scope page out every address the adopter's staff has blocked, without `audit:events:read_detail`
400
+ // and without `email:suppressions:read`. The stable row id is the correct resource handle, and it
401
+ // is what `@pithy-sh/testers` uses.
402
+ // Read back first, so the event can name the row rather than the person.
403
+ const stored = await listSuppressions(db, { email, limit: 1 });
404
+ const row = stored.items[0];
405
+
406
+ await c.var.emit({
407
+ action: EmailAuditActions.suppressionAdded,
408
+ outcome: "success",
409
+ severity: "warning",
410
+ actorType: "control-plane",
411
+ actorId: who.subject,
412
+ resourceType: "email_suppression",
413
+ resourceId: row ? String(row.id) : null,
414
+ metadata: {
415
+ connectionId: who.connectionId,
416
+ email,
417
+ reason: "manual",
418
+ expiresAt: input.expiresAt ?? null,
419
+ },
420
+ });
421
+
422
+ return c.json({ suppression: row ? suppressionView(row, now) : null } satisfies EmailSuppressResponse, 200);
423
+ },
424
+ );
425
+
426
+ app.post(
427
+ `${base}/suppressions/remove`,
428
+ requireControlPlane(EMAIL_SUPPRESSIONS_DELETE_SCOPE),
429
+ zValidator("json", UnsuppressRequest, validationHook),
430
+ async (c) => {
431
+ const input = c.req.valid("json");
432
+ const who = caller(c);
433
+ const email = normalizeAddress(input.email);
434
+ const db = suppressions(c);
435
+
436
+ // Read before write, so the trail records *what was undone* rather than only that something
437
+ // was. Lifting a hard bounce and lifting somebody's own unsubscribe are not the same act, and
438
+ // once the row is gone there is nothing left to tell them apart.
439
+ const existing = (await listSuppressions(db, { email, limit: 1 })).items[0];
440
+ const removed = await unsuppress(db, email);
441
+
442
+ // `resourceId` stays null and the address lives in `metadata` — see the add route above. The
443
+ // listing view is the bulk surface and must not carry a recipient address.
444
+ await c.var.emit({
445
+ action: EmailAuditActions.suppressionRemoved,
446
+ outcome: "success",
447
+ severity: "warning",
448
+ actorType: "control-plane",
449
+ actorId: who.subject,
450
+ resourceType: "email_suppression",
451
+ resourceId: existing?.id ? String(existing.id) : null,
452
+ metadata: {
453
+ connectionId: who.connectionId,
454
+ email,
455
+ removed,
456
+ // Null when there was nothing to remove — which is itself worth recording, because a run
457
+ // of those is somebody probing which addresses are on the list from a credential that was
458
+ // never granted permission to read it.
459
+ reason: existing?.reason ?? null,
460
+ },
461
+ });
462
+
463
+ return c.json({ email, removed } satisfies EmailUnsuppressResponse, 200);
464
+ },
465
+ );
466
+ };
467
+ }
@@ -0,0 +1,203 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { MAX_PAGE_SIZE } from "@pithy-sh/core/src/data/cursor";
5
+ import { z } from "zod";
6
+ import { EmailJobStatus, SuppressionReason } from "../data/enums";
7
+
8
+ /**
9
+ * The request schemas for every email route — the three public callbacks and the six control-plane
10
+ * admin routes. Validation happens at the HTTP boundary (CLAUDE.md §Zod), declared on the route line
11
+ * with `zValidator(target, Schema, validationHook)` — so reading `callbacks.ts` or `routes.ts` tells
12
+ * you what each route accepts without opening a handler.
13
+ *
14
+ * The callback schemas bound a value that already reaches the handler as free-form text. Neither tries
15
+ * to *authenticate* anything: the token's signature is still the only gate, checked by `verifyToken`,
16
+ * and a well-formed but forged or expired token still answers `email/invalid_token` (400).
17
+ *
18
+ * The admin schemas bound something a **verified** caller chose, which is a different job and just as
19
+ * necessary: a control-plane credential is verified, and verified is not trusted. A management client
20
+ * with a bug asks for a million rows exactly as easily as a hostile one does.
21
+ */
22
+
23
+ /**
24
+ * The characters a callback token path segment may contain: the base64url alphabet, plus `.` — the
25
+ * token's own `<payload>.<signature>` separator, and the `.png` suffix mail clients append to an
26
+ * open-pixel URL (which `handleOpen` strips before verifying). Deliberately not a strict base64url
27
+ * check: that would 400 every tracking pixel.
28
+ */
29
+ const TOKEN_SEGMENT = /^[A-Za-z0-9._-]+$/;
30
+
31
+ /**
32
+ * The `:token` path parameter every callback route carries. A shape and size bound only — the ceiling
33
+ * is generous enough for a click token whose signed claims embed a long destination URL, so no link we
34
+ * mint can exceed it, while an unbounded segment can no longer reach the verifier.
35
+ */
36
+ export const CallbackTokenParam = z
37
+ .object({
38
+ token: z
39
+ .string()
40
+ .min(1)
41
+ .max(4096)
42
+ .regex(TOKEN_SEGMENT)
43
+ .describe("The signed callback token from the path, optionally with the open-pixel `.png` suffix."),
44
+ })
45
+ .describe("The path parameter carrying a click/open/unsubscribe callback token.");
46
+ export type CallbackTokenParam = z.output<typeof CallbackTokenParam>;
47
+
48
+ /**
49
+ * The optional `?reason=` on the unsubscribe callback — supplied by the app's own preferences flow.
50
+ * The bound is a ceiling, not the storage limit: the handler still truncates to 200 characters before
51
+ * writing, so every real value keeps behaving exactly as it did and only an absurd one is refused.
52
+ */
53
+ export const UnsubscribeQuery = z
54
+ .object({
55
+ reason: z
56
+ .string()
57
+ .max(2000)
58
+ .optional()
59
+ .describe("Why the recipient opted out; truncated to 200 characters before it is stored."),
60
+ })
61
+ .describe("The optional query parameters accepted by the unsubscribe callback.");
62
+ export type UnsubscribeQuery = z.output<typeof UnsubscribeQuery>;
63
+
64
+ /**
65
+ * Where the next page starts. Opaque, and a malformed one is a first page rather than a 400 — the
66
+ * decode lives in `@pithy-sh/core/src/data/cursor` and collapses every failure mode to the same
67
+ * undefined, so this bounds the size and nothing else.
68
+ */
69
+ const Cursor = z
70
+ .string()
71
+ .max(512)
72
+ .optional()
73
+ .describe("Where to resume, from the previous page's `nextCursor`. Opaque; a malformed one is a first page.");
74
+
75
+ /** How many rows one page may carry. Bounded, because a verified client can still have a bug. */
76
+ const Limit = z.coerce
77
+ .number()
78
+ .int()
79
+ .min(1)
80
+ .max(MAX_PAGE_SIZE)
81
+ .optional()
82
+ .describe("How many rows to return. Clamped into range — an unbounded page is a table scan.");
83
+
84
+ /**
85
+ * An address as an admin route accepts it for a **write**.
86
+ *
87
+ * Validated as a real address, because blocking a typo blocks nothing and the caller never finds out:
88
+ * a suppression is silent to everyone, so a malformed one is a mistake that only surfaces as mail
89
+ * still arriving. Lowercasing and trimming happen in the handler, through core's `normalizeAddress`,
90
+ * so the stored key always matches what the send path checks — and what every other capability that
91
+ * compares an address arrives at.
92
+ */
93
+ const WritableAddress = z
94
+ .email()
95
+ .max(254)
96
+ .describe("The address to block. Normalized (trimmed, lowercased) before it is stored or compared.");
97
+
98
+ /**
99
+ * An address as an admin route accepts it for a **read or an undo**, deliberately looser than
100
+ * {@link WritableAddress}.
101
+ *
102
+ * Bounces and inbound complaints write whatever address the remote server reported, and some of those
103
+ * are not addresses a validator would accept. A rule strict enough to keep bad data out of a manual
104
+ * block would also make the row it wrote unreachable — an operator could see a malformed suppression
105
+ * in the list and have no way to lift it. An undo has to be able to reach anything already written.
106
+ */
107
+ const AddressFilter = z
108
+ .string()
109
+ .min(3)
110
+ .max(254)
111
+ .includes("@")
112
+ .describe("An address to match exactly, normalized the same way the stored key is.");
113
+
114
+ /**
115
+ * The `:id` of one job.
116
+ *
117
+ * Shape and size, not `.uuid()`. Production ids are `crypto.randomUUID()`, but `newId` is an injected
118
+ * dependency of `enqueueEmail`, so a consumer supplying its own generator would find every one of its
119
+ * jobs answering 400 at a route that never reached the lookup. A param schema constrains the string;
120
+ * the handler still does the lookup and still raises its own 404.
121
+ */
122
+ export const JobIdParam = z
123
+ .object({
124
+ id: z
125
+ .string()
126
+ .min(1)
127
+ .max(128)
128
+ .regex(/^[A-Za-z0-9._:-]+$/)
129
+ .describe("The job's id, as `pithy_email_jobs.id` stores it."),
130
+ })
131
+ .describe("The path parameter of every single-job admin route.");
132
+ export type JobIdParam = z.output<typeof JobIdParam>;
133
+
134
+ /** The job log query. */
135
+ export const JobsQuery = z
136
+ .object({
137
+ status: EmailJobStatus.optional().describe(
138
+ "Filter to one lifecycle state — `failed` is the pane this capability exists for. Absent lists every state.",
139
+ ),
140
+ cursor: Cursor,
141
+ limit: Limit,
142
+ })
143
+ .describe("The send-log query: which state to filter by, and where to resume.");
144
+ export type JobsQuery = z.output<typeof JobsQuery>;
145
+
146
+ /**
147
+ * The suppression list query.
148
+ *
149
+ * `email` is an exact-match lookup rather than a search, and that is the point: answering "is this one
150
+ * address blocked" should not require paging a list of every other person who ever unsubscribed. A
151
+ * prefix or substring search would turn the endpoint into a way to enumerate the list a page at a time
152
+ * while looking like a lookup.
153
+ */
154
+ export const SuppressionsQuery = z
155
+ .object({
156
+ reason: SuppressionReason.optional().describe(
157
+ "Filter to one reason — hard bounce, complaint, unsubscribe, manual.",
158
+ ),
159
+ email: AddressFilter.optional().describe(
160
+ "Look one address up, exactly. Answers `is this person blocked` without disclosing anybody else.",
161
+ ),
162
+ cursor: Cursor,
163
+ limit: Limit,
164
+ })
165
+ .describe("The suppression-list query: what to filter by, which address to look up, and where to resume.");
166
+ export type SuppressionsQuery = z.output<typeof SuppressionsQuery>;
167
+
168
+ /**
169
+ * Block an address by hand.
170
+ *
171
+ * **There is no `reason` field, on purpose.** The column records *why an address is blocked*, and three
172
+ * of its four values (`hard_bounce`, `complaint`, `unsubscribe`) are facts the system observed: a
173
+ * bounce arrived, a complaint arrived, a recipient followed their own opt-out link. A management client
174
+ * observed none of them, so letting it name one would let an operator's assertion enter the record as
175
+ * an observation, and the deliverability decisions read off that column would be made on fiction. Every
176
+ * address blocked through this route is `manual`, and what the operator knows goes in `detail`.
177
+ */
178
+ export const SuppressRequest = z
179
+ .object({
180
+ email: WritableAddress,
181
+ detail: z
182
+ .string()
183
+ .min(1)
184
+ .max(200)
185
+ .optional()
186
+ .describe("Why you are blocking it, in your own words. Stored verbatim on the row and in the audit trail."),
187
+ expiresAt: z.iso
188
+ .datetime()
189
+ .optional()
190
+ .describe(
191
+ "When the block lifts, as an ISO-8601 instant. **Must be in the future** — checked in the handler against its injected clock rather than here, because a schema reading the wall clock would ignore the clock the route was given. Absent blocks permanently; a time-boxed block expires on its own rather than waiting for somebody to remember it. A past instant is refused because the send path decides purely on `expiresAt <= now`, so backdating one is exactly equivalent to lifting the block — an act this route's scope deliberately does not confer.",
192
+ ),
193
+ })
194
+ .describe("Add one address to the global suppression list, as a manual block.");
195
+ export type SuppressRequest = z.output<typeof SuppressRequest>;
196
+
197
+ /** Unblock an address — the undo of {@link SuppressRequest}, and of a bounce, complaint, or opt-out. */
198
+ export const UnsuppressRequest = z
199
+ .object({
200
+ email: AddressFilter.describe("The address to unblock, matched exactly against the stored key."),
201
+ })
202
+ .describe("Remove one address from the global suppression list, re-opening sending to it.");
203
+ export type UnsuppressRequest = z.output<typeof UnsuppressRequest>;