@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
package/src/http/view.ts
ADDED
|
@@ -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";
|
package/src/jobs/read.ts
ADDED
|
@@ -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
|
+
}
|