@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,139 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { EmailJob } from "../data/emailJob";
5
+ import type { EmailSuppression } from "../data/emailSuppression";
6
+ import type { EmailJobDetail, EmailJobListItem, EmailSuppressionView } from "./responses";
7
+
8
+ /**
9
+ * What a management client is allowed to see. Nothing in this package ever returns a raw row.
10
+ *
11
+ * ## `payload` is never projected. Anywhere.
12
+ *
13
+ * `EmailJob.payload` holds the template's input variables, and that is not merely "often PII" — it is
14
+ * the most sensitive column this capability owns. A `magicLink` job's payload contains the sign-in URL,
15
+ * an `otp` job's contains the code, an order confirmation's contains a name, an address, and what
16
+ * somebody bought. Projecting it on a read scope would turn "let the dashboard show me the email log"
17
+ * into "let the dashboard sign in as any user who requested a magic link recently", which is a full
18
+ * account takeover reachable from the least privileged credential this capability defines.
19
+ *
20
+ * There is no flag to turn it on and no field that carries a redacted version of it. An operator
21
+ * diagnosing a send needs to know *which template* ran and *what went wrong*, and both are projected.
22
+ * They do not need the variables, and the one case where they would — reproducing a render — is
23
+ * reachable from the adopter's own database, where the audit trail is not a substitute for authority.
24
+ *
25
+ * ## The list masks the recipient; the detail does not
26
+ *
27
+ * `toAddress` is personal data on its own. The list is the bulk surface — a hundred rows a request,
28
+ * paged, is precisely how a compromised credential turns a job log into a customer address book — so it
29
+ * carries a masked address: enough for an operator to recognize the row they are looking for, and
30
+ * useless for harvesting. The **detail** route returns the whole address, one job at a time, with an
31
+ * audit event naming that job.
32
+ *
33
+ * This is a bulk-harvest control, not anonymization, and it is not sold as one. `ad***@example.com`
34
+ * identifies a person to anyone who already knows them. What it does is raise the cost of taking the
35
+ * whole list from one request per hundred addresses to one request per address, each individually
36
+ * recorded — which is the difference between an incident nobody can reconstruct and one whose every
37
+ * step is in the trail. The precedent is the same one testers' `resend` and `remove` follow: return the
38
+ * id, not the address, and let the audit trail hold what the response does not.
39
+ *
40
+ * The **domain survives masking**, deliberately. It is the field an operator reads a deliverability
41
+ * problem off — every failure landing on one provider is the diagnosis — and it names an organization
42
+ * rather than a person.
43
+ *
44
+ * The suppression list is the deliberate exception: an address *is* the record there, so masking it
45
+ * would leave a list of blocks nobody could act on. That is exactly why reading it is its own scope.
46
+ *
47
+ * ## Structural on the list, rendered on the detail
48
+ *
49
+ * The line the list holds is not "less data" — it is *kind* of data. `template`, `category`, `mode`,
50
+ * `bounceType` and `locale` each name what a message was, and none of them carries a character of what
51
+ * it said or of who read it, so a hundred of them in one response is a hundred facts about this
52
+ * project rather than a hundred facts about people. `subject` is the other kind. It is rendered copy,
53
+ * it routinely names the recipient's own order or their own document, and it stays beside the whole
54
+ * address on the detail route, where the disclosure is one job at a time and the audit trail names it.
55
+ *
56
+ * `locale` earns the list on exactly that test. "Why is half this account getting English" is a
57
+ * question about a page of rows, and answering it through the detail route means a hundred audited
58
+ * disclosures of a hundred addresses to render one column.
59
+ */
60
+
61
+ /**
62
+ * Mask a recipient for the list.
63
+ *
64
+ * Two characters of the local part survive, then the domain in full. Anything that does not parse as
65
+ * `local@domain` collapses to `***` rather than being echoed — a row whose address is malformed is
66
+ * usually a row whose address came from somewhere unexpected, and passing it through unchanged is how
67
+ * the one value the mask exists for escapes it.
68
+ */
69
+ export function maskAddress(address: string): string {
70
+ const at = address.lastIndexOf("@");
71
+ if (at <= 0 || at === address.length - 1) return "***";
72
+ const local = address.slice(0, at);
73
+ const domain = address.slice(at + 1);
74
+ return `${local.slice(0, 2)}***@${domain}`;
75
+ }
76
+
77
+ /**
78
+ * ## The field lists live in `responses.ts`
79
+ *
80
+ * Every view type below is `z.output` of the Zod object there, so there is one declaration of what a
81
+ * client receives rather than an interface here and a hand-written mirror of it in every management
82
+ * client. A field added to one and not the other does not compile.
83
+ */
84
+
85
+ /** Project one job for the list. */
86
+ export function jobListView(job: EmailJob): EmailJobListItem {
87
+ return {
88
+ id: job.id,
89
+ recipient: maskAddress(job.toAddress),
90
+ template: job.template,
91
+ category: job.category,
92
+ locale: job.locale ?? null,
93
+ status: job.status,
94
+ mode: job.mode,
95
+ attempts: job.attempts,
96
+ campaignId: job.campaignId ?? null,
97
+ bounceType: job.bounceType ?? null,
98
+ failed: Boolean(job.error),
99
+ sendAt: job.sendAt.toISOString(),
100
+ createdAt: job.createdAt.toISOString(),
101
+ sentAt: job.sentAt ? job.sentAt.toISOString() : null,
102
+ };
103
+ }
104
+
105
+ /** Project one job in full. */
106
+ export function jobDetailView(job: EmailJob): EmailJobDetail {
107
+ const { recipient: _masked, failed: _failed, ...common } = jobListView(job);
108
+ return {
109
+ ...common,
110
+ toAddress: job.toAddress,
111
+ subject: job.subject,
112
+ fromAddress: job.fromAddress,
113
+ fromName: job.fromName,
114
+ messageId: job.messageId ?? null,
115
+ error: job.error ?? null,
116
+ bounceCode: job.bounceCode ?? null,
117
+ timezone: job.timezone ?? null,
118
+ localTime: job.localTime ?? null,
119
+ openTracking: job.openTracking,
120
+ clickTracking: job.clickTracking,
121
+ updatedAt: job.updatedAt.toISOString(),
122
+ };
123
+ }
124
+
125
+ /** Project one suppression row, resolving `active` against `now`. */
126
+ export function suppressionView(row: EmailSuppression, now: Date): EmailSuppressionView {
127
+ const expiresAt = row.expiresAt ?? null;
128
+ return {
129
+ id: row.id,
130
+ email: row.email,
131
+ reason: row.reason,
132
+ environment: row.environment ?? null,
133
+ jobId: row.jobId ?? null,
134
+ detail: row.detail ?? null,
135
+ createdAt: row.createdAt.toISOString(),
136
+ expiresAt: expiresAt ? expiresAt.toISOString() : null,
137
+ active: expiresAt === null || expiresAt.getTime() > now.getTime(),
138
+ };
139
+ }
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The package entrypoint — the surface `pithy add email` wires into `pithy.config.ts`. Deliberately
6
+ * narrow: the capability factory plus the enqueue API and the types an app needs to send mail. Every
7
+ * other module is imported by deep path (`@pithy-sh/email/src/...`); this is the documented contract,
8
+ * not a barrel over the package.
9
+ */
10
+
11
+ export { type CampaignStats, campaignStats } from "./analytics";
12
+ export { type EmailCapability, type EmailConfigInput, email, isEmailCapability } from "./capability";
13
+ export type { EmailKind, SuppressionReason } from "./data/enums";
14
+ export type { EmailSuppressionDatabase } from "./data/tables";
15
+ // The control-plane scopes, exported because they are the join key with what `pithy dashboard connect`
16
+ // offers an adopter to grant. A doc or a tool naming one of these should read the constant, not retype
17
+ // the string — a scope that differs by a character is a gate nothing ever satisfies.
18
+ export {
19
+ EMAIL_CONTROL_PLANE_SCOPES,
20
+ EMAIL_JOBS_READ_SCOPE,
21
+ EMAIL_JOBS_RETRY_SCOPE,
22
+ EMAIL_SUPPRESSIONS_DELETE_SCOPE,
23
+ EMAIL_SUPPRESSIONS_READ_SCOPE,
24
+ EMAIL_SUPPRESSIONS_WRITE_SCOPE,
25
+ } from "./http/guards";
26
+ /**
27
+ * Has this template already gone to this person (pithy-sh/pithy#354).
28
+ *
29
+ * Exported for the same reason the suppression readers are: **it is the adopter's database**, and the
30
+ * alternative to publishing this read is every adopter querying `pithy_email_jobs` through the handle
31
+ * `emailDatabase(d1)` already hands them — a second definition of this capability's schema in somebody
32
+ * else's repository, drifting in whichever direction nobody is looking.
33
+ *
34
+ * A notice that must not repeat, and must be corrected if what it announced stops being true, is the
35
+ * caller this exists for. Do not answer it with a column of your own: a flag saying "we sent it" is a
36
+ * second answer to a question this table already holds the first of, and the two disagree the first time
37
+ * a send fails after the flag is written.
38
+ */
39
+ export { type SentFilter, type SentLog, SentSummary, sentSince } from "./jobs/read";
40
+ export { type EnqueueInput, type EnqueueResult, enqueueEmail } from "./send/enqueue";
41
+ // How a Workflow sends mail (pithy-sh/pithy#356). A route holds the bound `enqueue` its `compose` hook
42
+ // handed it; a durable step has no such hook, and this is the seam that gets it there without restating
43
+ // the sending identity `pithy.config.ts` already resolved.
44
+ export { composedEmail, enqueueFromEnv } from "./send/fromComposition";
45
+ /**
46
+ * The suppression list, for the adopter who wants to look (pithy-sh/pithy#355).
47
+ *
48
+ * Nobody needs these to be protected — `enqueue` consults the list on its own and `runSend` refuses a
49
+ * blocked recipient regardless. They are here because **it is the adopter's database**: an operator
50
+ * un-suppressing an address a customer has fixed, a support screen explaining why a letter did not go,
51
+ * a report of who a notice could not reach. Pair them with `EmailCapability.suppressions(env)`, which
52
+ * hands back the handle without anybody naming a binding.
53
+ *
54
+ * `blockingSuppression` takes the message's kind, and the kind belongs to the template — read it with
55
+ * `templateKind`, never type a literal. A restated `"transactional"` is a claim about somebody else's
56
+ * template, and it is the claim that starts withholding invitations from people who unsubscribed from
57
+ * a newsletter.
58
+ */
59
+ export {
60
+ blockingSuppression,
61
+ listSuppressions,
62
+ type SuppressionListFilter,
63
+ type SuppressionPage,
64
+ suppress,
65
+ suppressionBlocks,
66
+ unsuppress,
67
+ } from "./send/suppression";
68
+ export { listTemplates, templateKind } from "./templates/engine";
69
+ // The one payload contract exported by name. Every other template is called by a capability in this
70
+ // repo, which deep-imports; an operational notice is the template an *adopter* sends, about their own
71
+ // infrastructure, and it is the one where getting the severity wrong should not wait for runtime.
72
+ export { OperationalNoticePayload } from "./templates/registry";
73
+ export { NoticeSeverity } from "./templates/severity";
@@ -0,0 +1,273 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { normalizeAddress } from "@pithy-sh/core/src/address/address";
5
+ import { decodeCursor, type PageCursor, pageLimit, toPage } from "@pithy-sh/core/src/data/cursor";
6
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
7
+ import type { z } from "zod";
8
+ import { EmailJob } from "../data/emailJob";
9
+ import type { EmailJobStatus } from "../data/enums";
10
+ import type { EmailDatabase } from "../data/tables";
11
+
12
+ /**
13
+ * Reading the send log: the queries behind the two `email:jobs:read` routes, and `sentSince` below.
14
+ *
15
+ * The two are different questions and deliberately not one function. The routes serve an operator
16
+ * walking the log — every job, newest first, filtered by status, paged, and gated by a control-plane
17
+ * scope. `sentSince` answers a single question for the adopter's own code, in their own Worker, about a
18
+ * message they are about to send: has this one already gone out. It is not exposed over HTTP, because
19
+ * nothing calls it over HTTP — see its own note.
20
+ *
21
+ * **Keyset pagination, never offset.** `pithy_email_jobs` is written to on every single send, which
22
+ * makes it the worst possible table to page with `OFFSET`: a row inserted at the head while somebody is
23
+ * on page two pushes one row from page one down, so they see it twice and miss another by luck. The
24
+ * cursor names the last row's `(createdAt, id)` position, so the next page starts exactly where the
25
+ * previous ended whatever arrived in between. The helper is core's — one implementation, one decode,
26
+ * one definition of what a malformed cursor means.
27
+ *
28
+ * `createdAt` is the sort key rather than `sendAt`: a scheduled job's `sendAt` is a *future* time that a
29
+ * retry then moves, so ordering on it would shuffle rows around under a reader for reasons that have
30
+ * nothing to do with when anything happened. `createdAt` never moves.
31
+ *
32
+ * Rows are decoded through `EmailJob.parse` before they leave this module, so callers get Dates and
33
+ * booleans rather than ms-epochs and `0|1` — and a corrupt row fails here rather than three layers up.
34
+ */
35
+
36
+ /** What the list filters and pages by. */
37
+ export interface JobListFilter {
38
+ /** One lifecycle state, or every state when absent. */
39
+ status?: EmailJobStatus;
40
+ /** The previous page's `nextCursor`. A malformed one is a first page. */
41
+ cursor?: string;
42
+ /** How many rows to return, clamped into range. */
43
+ limit?: number;
44
+ }
45
+
46
+ /** One page of the send log. */
47
+ export interface JobPage {
48
+ /** The jobs, newest first. */
49
+ items: EmailJob[];
50
+ /** Where the next page starts, or null at the end of the list. */
51
+ nextCursor: string | null;
52
+ }
53
+
54
+ /**
55
+ * A cursor this table can actually resume from.
56
+ *
57
+ * `PageCursor.sort` is a union because Better Auth stores ISO text where Pithy's own tables store
58
+ * ms-epoch numbers, and comparing a string against an integer column in SQLite silently orders by
59
+ * something nobody meant. Jobs store a number, so anything else is treated exactly as a malformed
60
+ * cursor is: undefined, and the caller gets a first page.
61
+ */
62
+ function jobCursor(raw: string | undefined): { sort: number; id: string } | undefined {
63
+ const cursor: PageCursor | undefined = decodeCursor(raw);
64
+ if (!cursor || typeof cursor.sort !== "number") return undefined;
65
+ return { sort: cursor.sort, id: String(cursor.id) };
66
+ }
67
+
68
+ /** One page of jobs, newest first, optionally filtered to one status. */
69
+ export async function listJobs(db: EmailDatabase, filter: JobListFilter): Promise<JobPage> {
70
+ const limit = pageLimit(filter.limit);
71
+ const after = jobCursor(filter.cursor);
72
+
73
+ let query = db
74
+ .selectFrom("pithyEmailJobs")
75
+ .selectAll()
76
+ .orderBy("createdAt", "desc")
77
+ .orderBy("id", "desc")
78
+ // One more than asked for, so "is there another page" is answerable without a count query — which
79
+ // on a table holding every email a project ever sent is the difference between a page and a scan.
80
+ .limit(limit + 1);
81
+
82
+ if (filter.status) query = query.where("status", "=", filter.status);
83
+ if (after) {
84
+ query = query.where((eb) =>
85
+ eb.or([eb("createdAt", "<", after.sort), eb.and([eb("createdAt", "=", after.sort), eb("id", "<", after.id)])]),
86
+ );
87
+ }
88
+
89
+ const rows = await query.execute();
90
+ const jobs = rows.map((row) => EmailJob.parse(row));
91
+ return toPage(jobs, limit, (job) => ({ sort: job.createdAt.getTime(), id: job.id }));
92
+ }
93
+
94
+ /** One job by id, or undefined. The caller decides what a miss means. */
95
+ export async function getJob(db: EmailDatabase, jobId: string): Promise<EmailJob | undefined> {
96
+ const row = await db.selectFrom("pithyEmailJobs").selectAll().where("id", "=", jobId).executeTakeFirst();
97
+ return row ? EmailJob.parse(row) : undefined;
98
+ }
99
+
100
+ /**
101
+ * ## `sentSince` — has this message already gone out
102
+ *
103
+ * The narrow read, for the caller deciding whether to send. A transactional notice that must not repeat,
104
+ * and must be *corrected* if the thing it announced stops being true, cannot be decided from a flag of
105
+ * the caller's own: a `cancellationNoticeSentAt` column is a second answer to a question this table
106
+ * already holds the first of, and the two disagree the first time a send fails after the flag is
107
+ * written. `pithy_email_jobs` is the record; this is the read over it, so nobody has to define its shape
108
+ * a second time in their own repository.
109
+ *
110
+ * ## Two axes, because a template is not always one message
111
+ *
112
+ * It was `(to, template)` alone, and that could not finish its only intended consumer (pithy-sh/pithy#382).
113
+ * Six account notices ride one `operationalNotice` to the same addresses, so the template id does not
114
+ * separate them; `correlation` is the enqueue-side discriminator that does. See {@link SentSubject} for
115
+ * why the two are a union and not three optional fields.
116
+ *
117
+ * **The direction of the failure is why this was worth a column.** The dashboard uses the answer
118
+ * *positively*: the correction letter goes out only when the letter it corrects already did. An
119
+ * under-report there sends nothing at all — it withholds the correction from somebody holding a letter
120
+ * that has stopped being true. That is silence, to the one person owed the message, and silence is the
121
+ * failure nobody finds in production.
122
+ *
123
+ * ## Four columns, and every other one is a deliberate no
124
+ *
125
+ * `SentSummary` is `EmailJob.pick(…)`, so the columns it carries are the columns it selects and a
126
+ * projection cannot leak one it never loaded. What is not on it:
127
+ *
128
+ * - **`toAddress`.** The caller passed the address in; handing it back means a read answering a question
129
+ * about one person returns a copy of them in every row, and any log of the result is an address list.
130
+ * - **`payload`.** The sign-in link and the OTP. `view.ts` argues this at length and the argument is the
131
+ * same here, except that this reader is reachable from ordinary application code rather than only from
132
+ * a scoped credential, which makes it stronger rather than weaker.
133
+ * - **`subject`.** Rendered content, and the temptation is specific: it is the only per-row string that
134
+ * distinguishes two messages sharing a template, so a caller needing that discrimination would match
135
+ * on it. That match breaks on a copy edit, silently, in the direction of sending again. Discriminating
136
+ * two notices was the enqueue side's problem and now has an enqueue-side answer — `correlation`
137
+ * (#382) — rather than a rendered string exported to be string-matched.
138
+ * - **`messageId`, `error`, `bounceCode`.** The provider's own words. A provider error routinely embeds
139
+ * the recipient, which is why the list view carries `failed` rather than the text.
140
+ *
141
+ * `status` rather than a boolean, because the statuses are the answer: a `failed` or `suppressed` row is
142
+ * an email nobody read, and a reader that collapsed the log to "yes" would treat it as one they did.
143
+ *
144
+ * ## Bounded, and it says when the bound bit
145
+ *
146
+ * The log is append-only and unbounded by nature, so the read is capped — and a cap that silently
147
+ * truncates is the failure mode, not the bound. `truncated` says the cap was reached, the same word
148
+ * `@pithy-sh/auth`'s bounded sub-lists use. A caller counting rows must read it; a caller asking only
149
+ * "did anything go out" may ignore it, because a truncated page is still a non-empty one.
150
+ */
151
+
152
+ /**
153
+ * One job as the send-decision reader sees it: the handle, the outcome, and the two instants.
154
+ *
155
+ * Derived from `EmailJob` with `.pick()` rather than restated, so the codecs and the field descriptions
156
+ * come from the one table definition and a column added to the row does not silently appear here.
157
+ * Re-described because `.pick()` builds a new schema and the top-level description does not follow it.
158
+ */
159
+ export const SentSummary = EmailJob.pick({ id: true, status: true, createdAt: true, sentAt: true }).describe(
160
+ "One previously enqueued message, as the caller deciding whether to send another one sees it. No recipient, no subject, no payload, no provider text — the id is the handle into the full record for anyone holding the scope to read it.",
161
+ );
162
+ export type SentSummary = z.output<typeof SentSummary>;
163
+
164
+ /**
165
+ * Which messages are being asked about — one of the two indexed axes, and the type admits no third
166
+ * answer.
167
+ *
168
+ * **`(to, template)`** is the original question: has this template already gone to this person. It runs
169
+ * on `(recipientKey, template, createdAt)`.
170
+ *
171
+ * **`correlation`** is the question a template carrying more than one kind of message needs
172
+ * (pithy-sh/pithy#382): has *this thing* already been said. Six account notices ride one
173
+ * `operationalNotice` to the same addresses, so the template id cannot separate them and the address is
174
+ * a proxy for the account only while one person belongs to one account. It runs on
175
+ * `(correlation, createdAt)`.
176
+ *
177
+ * Written as a union rather than three optional fields because the shape a union forbids is the one that
178
+ * matters: a filter naming *neither* axis is an unbounded scan of every email the project ever queued,
179
+ * asked on the path that decides whether to send. It cannot be constructed. Both axes together is
180
+ * allowed and narrows further.
181
+ */
182
+ export type SentSubject =
183
+ | {
184
+ /** The recipient. Matched under `normalizeAddress`, the same rule the row was keyed under. */
185
+ readonly to: string;
186
+ /** The template id, exactly. */
187
+ readonly template: string;
188
+ /** Optionally narrower still: which of this template's messages. */
189
+ readonly correlation?: string;
190
+ }
191
+ | {
192
+ /** What the message was about, exactly as the enqueue stated it. */
193
+ readonly correlation: string;
194
+ /** Optional here — the correlation already bounds the read. */
195
+ readonly to?: string;
196
+ /** Optional here — the correlation already bounds the read. */
197
+ readonly template?: string;
198
+ };
199
+
200
+ /** What `sentSince` asks: a subject, a floor, and a bound. The bound is the only one clamped. */
201
+ export type SentFilter = SentSubject & {
202
+ /**
203
+ * The earliest `createdAt` to consider, inclusive.
204
+ *
205
+ * Required, with no default. An unbounded question against an append-only send log is a table scan
206
+ * whose cost grows with the project's age, asked on the path that decides whether to send — and a
207
+ * default would be this module choosing how far back "already" reaches, which is the caller's
208
+ * decision and differs per notice.
209
+ */
210
+ readonly since: Date;
211
+ /** How many rows to return, clamped into range. The default is `DEFAULT_PAGE_SIZE`. */
212
+ readonly limit?: number;
213
+ };
214
+
215
+ /** What went out, and whether the bound cut the answer short. */
216
+ export interface SentLog {
217
+ /** The matching jobs, newest first, at most the clamped limit. */
218
+ items: SentSummary[];
219
+ /** True when more rows matched than the limit allowed. A count taken off `items` is wrong when this is set. */
220
+ truncated: boolean;
221
+ }
222
+
223
+ /**
224
+ * Every job matching one subject since an instant, newest first.
225
+ *
226
+ * The subject is `(to, template)`, or a `correlation`, or both — see {@link SentSubject}. Neither is not
227
+ * a subject, and the type will not build one.
228
+ *
229
+ * **A row that will not parse throws.** It is tempting to skip it and carry on, and it is wrong here in
230
+ * a way it is not on a listing: this reader's answer decides whether a message goes out. A skipped row
231
+ * is reported as an email that never went, which means a duplicate send — or, where the caller is
232
+ * looking for who to send a *correction* to, a correction withheld from exactly the person owed one.
233
+ * Both failures are silent and neither is recoverable after the fact, so a row this schema cannot read
234
+ * stops the decision instead of quietly biasing it.
235
+ */
236
+ export async function sentSince(db: EmailDatabase, filter: SentFilter): Promise<SentLog> {
237
+ const limit = pageLimit(filter.limit);
238
+ let query = db
239
+ .selectFrom("pithyEmailJobs")
240
+ .select(["id", "status", "createdAt", "sentAt"])
241
+ .where("createdAt", ">=", filter.since.getTime())
242
+ .orderBy("createdAt", "desc")
243
+ .orderBy("id", "desc")
244
+ // One more than asked for, so "was there more" is answerable without a second count query.
245
+ .limit(limit + 1);
246
+
247
+ // `recipientKey`, never `toAddress`: the row keeps what the caller typed and this is the column every
248
+ // comparison is against. The index is `(recipientKey, template, createdAt)`.
249
+ if (filter.to !== undefined) query = query.where("recipientKey", "=", normalizeAddress(filter.to));
250
+ if (filter.template !== undefined) query = query.where("template", "=", filter.template);
251
+ // The other indexed axis, `(correlation, createdAt)`. Compared exactly and never with `like`: a prefix
252
+ // match would make one caller's correlation the ancestor of another's by accident of spelling.
253
+ if (filter.correlation !== undefined) query = query.where("correlation", "=", filter.correlation);
254
+
255
+ const rows = await query.execute();
256
+
257
+ const items = rows.slice(0, limit).map((row) => {
258
+ const parsed = SentSummary.safeParse(row);
259
+ if (!parsed.success) {
260
+ throw new InternalError({
261
+ message: "The send log could not be read.",
262
+ action: "Inspect pithy_email_jobs for a row this deployment's schema cannot decode.",
263
+ // The row id only. The values that failed to parse are the row, and the row is somebody's mail.
264
+ detail: `pithy_email_jobs row '${String(row.id)}' does not satisfy EmailJob: ${parsed.error.issues
265
+ .map((issue) => `${issue.path.join(".")}: ${issue.code}`)
266
+ .join("; ")}`,
267
+ });
268
+ }
269
+ return parsed.data;
270
+ });
271
+
272
+ return { items, truncated: rows.length > limit };
273
+ }