@pithy-sh/email 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +55 -0
- package/pithy.manifest.json +73 -0
- package/src/analytics.ts +39 -0
- package/src/audit/actions.ts +48 -0
- package/src/bounce/classify.ts +103 -0
- package/src/bounce/handler.ts +136 -0
- package/src/capability.ts +385 -0
- package/src/cloudflare-test.d.ts +19 -0
- package/src/crypto/signingKey.ts +44 -0
- package/src/crypto/token.ts +148 -0
- package/src/data/emailEvent.ts +42 -0
- package/src/data/emailJob.ts +138 -0
- package/src/data/emailSuppression.ts +40 -0
- package/src/data/enums.ts +75 -0
- package/src/data/tables.ts +47 -0
- package/src/error/errors.ts +129 -0
- package/src/http/callbacks.ts +200 -0
- package/src/http/guards.ts +154 -0
- package/src/http/responses.ts +192 -0
- package/src/http/routes.ts +467 -0
- package/src/http/schemas.ts +203 -0
- package/src/http/view.ts +139 -0
- package/src/index.ts +73 -0
- package/src/jobs/read.ts +273 -0
- package/src/jobs/retry.ts +214 -0
- package/src/migrations/0001_init.ts +174 -0
- package/src/migrations/0001_suppressions.ts +40 -0
- package/src/provision/devDelivery.ts +47 -0
- package/src/provision/hostCatalogs.ts +107 -0
- package/src/provision/provisionEmail.ts +179 -0
- package/src/provision/resolveEmailConfig.ts +225 -0
- package/src/provision/settingsCheck.ts +212 -0
- package/src/send/batchIdentity.ts +47 -0
- package/src/send/enqueue.ts +391 -0
- package/src/send/errorMapping.ts +73 -0
- package/src/send/events.ts +34 -0
- package/src/send/fromComposition.ts +57 -0
- package/src/send/retryPolicy.ts +42 -0
- package/src/send/runSend.ts +320 -0
- package/src/send/sendAt.ts +77 -0
- package/src/send/sender.ts +44 -0
- package/src/send/senderBinding.ts +56 -0
- package/src/send/suppression.ts +194 -0
- package/src/templates/engine.ts +392 -0
- package/src/templates/messages.es.ts +109 -0
- package/src/templates/messages.ts +315 -0
- package/src/templates/partials.ts +88 -0
- package/src/templates/precompiled.generated.ts +1342 -0
- package/src/templates/registry.ts +550 -0
- package/src/templates/samples.ts +75 -0
- package/src/templates/severity.ts +102 -0
- package/src/templates/theme.ts +212 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/hostApp.ts +54 -0
- package/src/workflows/hostEnv.ts +219 -0
- package/src/workflows/instanceLiveness.ts +39 -0
- package/src/workflows/instances.ts +16 -0
- package/src/workflows/params.ts +35 -0
- package/src/workflows/scheduler.ts +220 -0
- package/src/workflows/sendBatch.ts +154 -0
- package/src/workflows/worker.ts +203 -0
- package/src/workflows/wrangler.jsonc +75 -0
|
@@ -0,0 +1,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>;
|