@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,194 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { normalizeAddress } from "@pithy-sh/core/src/address/address";
|
|
5
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
6
|
+
import { decodeCursor, type PageCursor, pageLimit, toPage } from "@pithy-sh/core/src/data/cursor";
|
|
7
|
+
import { EmailSuppression } from "../data/emailSuppression";
|
|
8
|
+
import { type EmailKind, SuppressionReason } from "../data/enums";
|
|
9
|
+
import type { EmailSuppressionDatabase } from "../data/tables";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The suppression list: addresses that must not be emailed, fed by hard bounces, complaints, and
|
|
13
|
+
* unsubscribes. The send path checks it before every send and skips a match, naming the reason it
|
|
14
|
+
* skipped for.
|
|
15
|
+
*
|
|
16
|
+
* Every address here goes through `normalizeAddress` from core, so a check and a write agree on the
|
|
17
|
+
* key — and so does `auth` matching a sign-in, `support` linking a sender, and `testers` reading this
|
|
18
|
+
* very table. A suppression written under one rule and read under another is a suppression that does
|
|
19
|
+
* not suppress, and it reports itself as "the list did not work" rather than as anything about case.
|
|
20
|
+
*
|
|
21
|
+
* The list is also the one thing in this capability a management client both reads and writes, and it
|
|
22
|
+
* is **global** — one database shared by every environment, so a row here stops mail from staging and
|
|
23
|
+
* production alike. That is why reading it, adding to it, and removing from it are three separate
|
|
24
|
+
* control-plane scopes rather than one.
|
|
25
|
+
*
|
|
26
|
+
* **The reason is part of the answer, not just a label on the row.** The list is keyed by address and
|
|
27
|
+
* holds no memory of which message a person was refusing, so treating every reason alike meant one
|
|
28
|
+
* unsubscribe from a weekly digest also withheld that person's sign-in link — and passwordless has no
|
|
29
|
+
* password to fall back on. `suppressionBlocks` is where the four reasons stop being interchangeable.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether a live suppression for this reason blocks a message of this kind.
|
|
34
|
+
*
|
|
35
|
+
* | reason | elective | transactional |
|
|
36
|
+
* |---|---|---|
|
|
37
|
+
* | `hard_bounce` | block | **block** — the mailbox does not exist. Sending is futile, and hammering dead addresses damages the sending domain for every other adopter on it. |
|
|
38
|
+
* | `complaint` | block | **block** — they reported us as spam. Continuing after a complaint is how a domain gets blocked outright. |
|
|
39
|
+
* | `unsubscribe` | block | **send** — an opt-out is a statement about mail somebody chose to receive. A sign-in link is not that; withholding it respects no preference, it locks the account. |
|
|
40
|
+
* | `manual` | block | block — an operator's deliberate act, and the one reason a human can point at. Narrowing it would silently overrule the person who set it. |
|
|
41
|
+
*
|
|
42
|
+
* Bounce and complaint are facts about the mailbox; unsubscribe and manual are decisions about mail.
|
|
43
|
+
* Only the unsubscribe decision is about a *category* of mail, which is why it is the only one the kind
|
|
44
|
+
* can narrow.
|
|
45
|
+
*/
|
|
46
|
+
export function suppressionBlocks(reason: SuppressionReason, kind: EmailKind): boolean {
|
|
47
|
+
if (reason === "unsubscribe") return kind === "elective";
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The live suppression reason blocking a message of this kind, or `null` if nothing does.
|
|
53
|
+
*
|
|
54
|
+
* The kind is required rather than defaulted. A default would have to be one of the two, and the one
|
|
55
|
+
* that reads as safe — block everything — is the one that locks people out of their own accounts.
|
|
56
|
+
*/
|
|
57
|
+
export async function blockingSuppression(
|
|
58
|
+
db: EmailSuppressionDatabase,
|
|
59
|
+
email: string,
|
|
60
|
+
now: Date,
|
|
61
|
+
kind: EmailKind,
|
|
62
|
+
): Promise<SuppressionReason | null> {
|
|
63
|
+
const row = await db
|
|
64
|
+
.selectFrom("pithyEmailSuppressions")
|
|
65
|
+
.select(["reason", "expiresAt"])
|
|
66
|
+
// The same key `suppress` writes under. A read normalized differently from the write is a
|
|
67
|
+
// suppression that silently stops suppressing.
|
|
68
|
+
.where("email", "=", normalizeAddress(email))
|
|
69
|
+
.executeTakeFirst();
|
|
70
|
+
if (!row) return null;
|
|
71
|
+
const live =
|
|
72
|
+
row.expiresAt === null || row.expiresAt === undefined || SQLiteDate.parse(row.expiresAt).getTime() > now.getTime();
|
|
73
|
+
if (!live) return null;
|
|
74
|
+
// Parsed, not cast: this crosses the D1 boundary, and an unrecognized reason must not fall through the
|
|
75
|
+
// `=== "unsubscribe"` test into "send it anyway". A row nobody can read is reported as `manual`, the
|
|
76
|
+
// one reason that claims no observed fact — it blocks, and it does not pretend to know why.
|
|
77
|
+
const parsed = SuppressionReason.safeParse(row.reason);
|
|
78
|
+
if (!parsed.success) return "manual";
|
|
79
|
+
return suppressionBlocks(parsed.data, kind) ? parsed.data : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Add (or refresh) a suppression for an address. Idempotent on the unique `email` column. */
|
|
83
|
+
export async function suppress(
|
|
84
|
+
db: EmailSuppressionDatabase,
|
|
85
|
+
input: {
|
|
86
|
+
email: string;
|
|
87
|
+
reason: SuppressionReason;
|
|
88
|
+
jobId?: string | null;
|
|
89
|
+
environment?: string | null;
|
|
90
|
+
detail?: string | null;
|
|
91
|
+
expiresAt?: Date | null;
|
|
92
|
+
},
|
|
93
|
+
now: Date,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const email = normalizeAddress(input.email);
|
|
96
|
+
const jobId = input.jobId ?? null;
|
|
97
|
+
const environment = input.environment ?? null;
|
|
98
|
+
const detail = input.detail ?? null;
|
|
99
|
+
const expiresAt = input.expiresAt ? SQLiteDate.encode(input.expiresAt) : null;
|
|
100
|
+
await db
|
|
101
|
+
.insertInto("pithyEmailSuppressions")
|
|
102
|
+
.values({ email, reason: input.reason, jobId, environment, detail, expiresAt, createdAt: SQLiteDate.encode(now) })
|
|
103
|
+
.onConflict((oc) => oc.column("email").doUpdateSet({ reason: input.reason, jobId, environment, detail, expiresAt }))
|
|
104
|
+
.execute();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Remove a suppression. Returns whether a row was actually removed.
|
|
109
|
+
*
|
|
110
|
+
* The boolean matters: an operator unblocking an address that was never blocked, and one unblocking an
|
|
111
|
+
* address that was, are two different events and the audit trail should not record them as the same
|
|
112
|
+
* one. It also keeps the route idempotent — asking twice is not an error, it is simply the second one
|
|
113
|
+
* finding nothing to do.
|
|
114
|
+
*
|
|
115
|
+
* There is no soft delete. A suppression is a "do not send" flag, and a lifted flag that stays in the
|
|
116
|
+
* table is one bad query away from still being enforced.
|
|
117
|
+
*/
|
|
118
|
+
export async function unsuppress(db: EmailSuppressionDatabase, email: string): Promise<boolean> {
|
|
119
|
+
const result = await db
|
|
120
|
+
.deleteFrom("pithyEmailSuppressions")
|
|
121
|
+
.where("email", "=", normalizeAddress(email))
|
|
122
|
+
.executeTakeFirst();
|
|
123
|
+
return (result.numDeletedRows ?? 0n) > 0n;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What the suppression list filters and pages by. */
|
|
127
|
+
export interface SuppressionListFilter {
|
|
128
|
+
/** One reason, or every reason when absent. */
|
|
129
|
+
reason?: SuppressionReason;
|
|
130
|
+
/** Look one address up exactly, rather than paging the list. */
|
|
131
|
+
email?: string;
|
|
132
|
+
/** The previous page's `nextCursor`. A malformed one is a first page. */
|
|
133
|
+
cursor?: string;
|
|
134
|
+
/** How many rows to return, clamped into range. */
|
|
135
|
+
limit?: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One page of the suppression list. */
|
|
139
|
+
export interface SuppressionPage {
|
|
140
|
+
/** The suppressed addresses, most recently blocked first. */
|
|
141
|
+
items: EmailSuppression[];
|
|
142
|
+
/** Where the next page starts, or null at the end of the list. */
|
|
143
|
+
nextCursor: string | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A cursor this table can resume from.
|
|
148
|
+
*
|
|
149
|
+
* Both halves must be numbers here — `createdAt` is a ms-epoch and `id` is an autoincrement integer —
|
|
150
|
+
* and anything else is treated exactly as a malformed cursor is. A string compared against an integer
|
|
151
|
+
* column in SQLite does not fail; it orders by something nobody meant, which is worse.
|
|
152
|
+
*/
|
|
153
|
+
function suppressionCursor(raw: string | undefined): { sort: number; id: number } | undefined {
|
|
154
|
+
const cursor: PageCursor | undefined = decodeCursor(raw);
|
|
155
|
+
if (!cursor || typeof cursor.sort !== "number" || typeof cursor.id !== "number") return undefined;
|
|
156
|
+
return { sort: cursor.sort, id: cursor.id };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One page of the suppression list, most recently blocked first.
|
|
161
|
+
*
|
|
162
|
+
* Keyset, never offset — the same rule every list in this repo follows, and it applies here even though
|
|
163
|
+
* the table is quieter than the job log: a hard bounce arriving mid-scroll would otherwise skip a row
|
|
164
|
+
* for the person reading it.
|
|
165
|
+
*/
|
|
166
|
+
export async function listSuppressions(
|
|
167
|
+
db: EmailSuppressionDatabase,
|
|
168
|
+
filter: SuppressionListFilter,
|
|
169
|
+
): Promise<SuppressionPage> {
|
|
170
|
+
const limit = pageLimit(filter.limit);
|
|
171
|
+
const after = suppressionCursor(filter.cursor);
|
|
172
|
+
|
|
173
|
+
let query = db
|
|
174
|
+
.selectFrom("pithyEmailSuppressions")
|
|
175
|
+
.selectAll()
|
|
176
|
+
.orderBy("createdAt", "desc")
|
|
177
|
+
.orderBy("id", "desc")
|
|
178
|
+
.limit(limit + 1);
|
|
179
|
+
|
|
180
|
+
if (filter.reason) query = query.where("reason", "=", filter.reason);
|
|
181
|
+
// Exact equality on the normalized key, never a prefix or a LIKE: a lookup that also matched
|
|
182
|
+
// neighbors would be a way to enumerate the list one query at a time while looking like a question
|
|
183
|
+
// about a single address.
|
|
184
|
+
if (filter.email) query = query.where("email", "=", normalizeAddress(filter.email));
|
|
185
|
+
if (after) {
|
|
186
|
+
query = query.where((eb) =>
|
|
187
|
+
eb.or([eb("createdAt", "<", after.sort), eb.and([eb("createdAt", "=", after.sort), eb("id", "<", after.id)])]),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const rows = await query.execute();
|
|
192
|
+
const items = rows.map((row) => EmailSuppression.parse(row));
|
|
193
|
+
return toPage(items, limit, (row) => ({ sort: row.createdAt.getTime(), id: row.id }));
|
|
194
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
5
|
+
import type { Translator } from "@pithy-sh/core/src/i18n/translator";
|
|
6
|
+
import Handlebars from "handlebars";
|
|
7
|
+
import { mintToken } from "../crypto/token";
|
|
8
|
+
import type { EmailKind } from "../data/enums";
|
|
9
|
+
import { EmailInvalidPayloadError, EmailTemplateNotFoundError } from "../error/errors";
|
|
10
|
+
import { emailTranslator, kitEmailLayers } from "./messages";
|
|
11
|
+
import { precompiledPartials, precompiledTemplates } from "./precompiled.generated";
|
|
12
|
+
import { type EmailTemplate, templates } from "./registry";
|
|
13
|
+
import { severityColor, severityLabelKey } from "./severity";
|
|
14
|
+
import { type EmailTheme, widthPx } from "./theme";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The render engine. Templates are compiled **once at module load** — which in a Worker happens during
|
|
18
|
+
* isolate startup, where dynamic compilation is permitted — and cached, so a send never compiles or
|
|
19
|
+
* evals at request time (CLAUDE.md §Email: "precompiled, no runtime compile/eval"). Rendering is then
|
|
20
|
+
* pure string substitution.
|
|
21
|
+
*
|
|
22
|
+
* Tracking is applied here, not in the template: callback tokens are minted ahead of render (signing
|
|
23
|
+
* is async; Handlebars helpers are not) and the resulting URLs are placed into the render context.
|
|
24
|
+
* Click tracking rewrites the declared link locations; open tracking injects a pixel; and an **elective**
|
|
25
|
+
* template gets an unsubscribe link, which a marketing template additionally cannot render without.
|
|
26
|
+
*
|
|
27
|
+
* **A transactional template can never be given one.** The affordance is gated on the kind the template
|
|
28
|
+
* itself declares, so there is no argument a call site could pass to put an opt-out on a sign-in link —
|
|
29
|
+
* and `renderEmail` reports the URL it minted, so the send path sets `List-Unsubscribe` from what was
|
|
30
|
+
* actually rendered rather than from a second, drift-prone judgment of its own.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The mount prefix of the callback routes. Tracking URLs are built against `${baseUrl}${CALLBACK_BASE}`. */
|
|
34
|
+
export const CALLBACK_BASE = "/_pithy/email";
|
|
35
|
+
|
|
36
|
+
// Build the engine from the **precompiled** specs (generated by `scripts/precompile.ts`).
|
|
37
|
+
// `Handlebars.template(spec)` wraps spec functions that were defined at module-parse time — it never
|
|
38
|
+
// calls `eval`/`new Function`, so this works in the Workers runtime, which forbids runtime code
|
|
39
|
+
// generation entirely (CLAUDE.md §Email: "precompiled, no runtime compile/eval").
|
|
40
|
+
const hbs = Handlebars.create();
|
|
41
|
+
for (const [name, spec] of Object.entries(precompiledPartials)) {
|
|
42
|
+
hbs.registerPartial(name, hbs.template(spec));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The only two helpers this engine has, and both exist for one reason: a severity has to be said in
|
|
46
|
+
// three places — the subject line, the body, and the plain-text part — and a mapping repeated three
|
|
47
|
+
// times is a mapping that will disagree with itself. Handlebars has no equality test, so without them a
|
|
48
|
+
// template would carry nine `{{#if}}` blocks to render one word. Registered on this instance at module
|
|
49
|
+
// load; precompiled specs resolve a helper by name at render time, so `scripts/precompile.ts` needs to
|
|
50
|
+
// know nothing about them.
|
|
51
|
+
hbs.registerHelper("severityColor", severityColor);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The English every render falls back to — and the whole render when nothing composed an i18n
|
|
55
|
+
* capability, which is the seam's zero-config behavior everywhere else in the kit.
|
|
56
|
+
*
|
|
57
|
+
* Module-level and shared, because it holds no request state: a translator over one fixed catalog is a
|
|
58
|
+
* pure lookup table. The per-job translators the send path builds are the ones that carry a locale, and
|
|
59
|
+
* they live and die with one render.
|
|
60
|
+
*/
|
|
61
|
+
const ENGLISH: Translator = emailTranslator("en", kitEmailLayers);
|
|
62
|
+
|
|
63
|
+
/** The key the render context carries its translator under. Read by the helpers, never by a template. */
|
|
64
|
+
const TRANSLATOR_KEY = "i18n";
|
|
65
|
+
|
|
66
|
+
/** The slice of Handlebars' helper options these helpers read. Declared rather than imported, to stay off `any`. */
|
|
67
|
+
interface HelperContext {
|
|
68
|
+
/** The `key=value` arguments written on the helper call — the message's interpolation parameters. */
|
|
69
|
+
hash?: Record<string, unknown>;
|
|
70
|
+
/** Handlebars' data frame. `root` is the render context, which is where the translator rides. */
|
|
71
|
+
data?: { root?: unknown };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether a value is a translator, structurally — the seam is an interface, so there is no class to test. */
|
|
75
|
+
function isTranslator(value: unknown): value is Translator {
|
|
76
|
+
return typeof (value as { t?: unknown } | null | undefined)?.t === "function";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The translator this render is going through, or the kit's English.
|
|
81
|
+
*
|
|
82
|
+
* Read off `@root` rather than closed over, because the engine is built once per isolate and a
|
|
83
|
+
* translator belongs to one message. A module-level "current locale" would be the same hazard
|
|
84
|
+
* `z.config()` is banned repo-wide for: an isolate serves many recipients, and the one holding the
|
|
85
|
+
* locale would apply the last render's language to the next.
|
|
86
|
+
*/
|
|
87
|
+
function translatorOf(options: HelperContext): Translator {
|
|
88
|
+
const held = (options.data?.root as Record<string, unknown> | undefined)?.[TRANSLATOR_KEY];
|
|
89
|
+
return isTranslator(held) ? held : ENGLISH;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The helper's `key=value` arguments as message parameters — scalars only, like every catalog value. */
|
|
93
|
+
function messageParams(hash: Record<string, unknown> | undefined): MessageParams {
|
|
94
|
+
const params: MessageParams = {};
|
|
95
|
+
for (const [name, value] of Object.entries(hash ?? {})) {
|
|
96
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") params[name] = value;
|
|
97
|
+
}
|
|
98
|
+
return params;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `{{t "email/magic_link.heading"}}` — one catalog message, with `key=value` interpolation.
|
|
103
|
+
*
|
|
104
|
+
* It returns a plain string and **never** a `SafeString`. That is the security constraint, not a style
|
|
105
|
+
* choice: the HTML body escapes what a mustache produces, so a catalog value — the kit's, or the one
|
|
106
|
+
* sentence an adopter overrode it with — is escaped on exactly the path a payload value is. `subject`
|
|
107
|
+
* and `text` are precompiled with `noEscape`, where nothing is escaped and nothing needs to be, which
|
|
108
|
+
* is why no catalog value in this kit carries markup and why `docs/I18N.md` says so to adopters.
|
|
109
|
+
*/
|
|
110
|
+
hbs.registerHelper("t", (key: unknown, options: HelperContext): string =>
|
|
111
|
+
translatorOf(options).t(String(key), messageParams(options.hash)),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `{{tn "email/otp.expiry" count=expiresMinutes}}` — the plural form the count calls for.
|
|
116
|
+
*
|
|
117
|
+
* Separate from `t` because plural selection is the thing a second locale exposes: "It expires in 1
|
|
118
|
+
* minutes" was already wrong in English and unfixable without it, and Russian needs three forms where
|
|
119
|
+
* English needs two. The count is a hash argument rather than a positional one so that a payload field
|
|
120
|
+
* reaching a template through this helper is still visible to `engine.test.ts`'s check that every
|
|
121
|
+
* variable a template renders is declared on its payload schema.
|
|
122
|
+
*/
|
|
123
|
+
hbs.registerHelper("tn", (key: unknown, options: HelperContext): string => {
|
|
124
|
+
const params = messageParams(options.hash);
|
|
125
|
+
return translatorOf(options).plural(String(key), Number(params.count ?? 0), params);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/** `{{severityLabel severity}}` — the level's word, in the reader's language. */
|
|
129
|
+
hbs.registerHelper("severityLabel", (severity: unknown, options: HelperContext): string =>
|
|
130
|
+
translatorOf(options).t(severityLabelKey(severity)),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
interface Compiled {
|
|
134
|
+
def: EmailTemplate;
|
|
135
|
+
subject: HandlebarsTemplateDelegate;
|
|
136
|
+
html: HandlebarsTemplateDelegate;
|
|
137
|
+
text: HandlebarsTemplateDelegate;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function delegate(key: string): HandlebarsTemplateDelegate {
|
|
141
|
+
const spec = (precompiledTemplates as Record<string, unknown>)[key];
|
|
142
|
+
if (!spec) throw new Error(`missing precompiled template spec '${key}' — run \`bun run precompile\``);
|
|
143
|
+
return hbs.template(spec);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const compiled = new Map<string, Compiled>(
|
|
147
|
+
Object.values(templates).map((def) => [
|
|
148
|
+
def.id,
|
|
149
|
+
{ def, subject: delegate(`${def.id}:subject`), html: delegate(`${def.id}:html`), text: delegate(`${def.id}:text`) },
|
|
150
|
+
]),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
/** The signing + addressing context a tracked render needs. Shaped to plug straight into the send path. */
|
|
154
|
+
export interface RenderTracking {
|
|
155
|
+
/** The public base URL of the app worker; callback links are built against it. */
|
|
156
|
+
baseUrl: string;
|
|
157
|
+
/** The job this render belongs to — embedded in every token. */
|
|
158
|
+
jobId: string;
|
|
159
|
+
/** The recipient address — embedded in every token. */
|
|
160
|
+
recipient: string;
|
|
161
|
+
/** The marketing campaign id, for click/open attribution. */
|
|
162
|
+
campaignId?: string;
|
|
163
|
+
/** The current signing key value (from `@pithy-sh/secrets`). */
|
|
164
|
+
key: string;
|
|
165
|
+
/** The current signing key version, recorded as the token `kid`. */
|
|
166
|
+
kid: string;
|
|
167
|
+
/** When the tokens (and so the links) expire. */
|
|
168
|
+
expiresAt: Date;
|
|
169
|
+
/** Whether to inject the open-tracking pixel. */
|
|
170
|
+
openTracking: boolean;
|
|
171
|
+
/** Whether to rewrite links to tracked click callbacks. */
|
|
172
|
+
clickTracking: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A rendered email: the three parts a send needs, plus the opt-out URL it actually carries. */
|
|
176
|
+
export interface RenderResult {
|
|
177
|
+
subject: string;
|
|
178
|
+
html: string;
|
|
179
|
+
text: string;
|
|
180
|
+
/**
|
|
181
|
+
* The unsubscribe URL rendered into the body, when one was. Present only for an elective template with
|
|
182
|
+
* a signing context; the send path turns it into the `List-Unsubscribe` header, so the header and the
|
|
183
|
+
* link in the body can never disagree about whether this message can be opted out of.
|
|
184
|
+
*/
|
|
185
|
+
unsubscribeUrl?: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Look up a template by id, or throw `email/template_not_found`. */
|
|
189
|
+
export function getTemplate(id: string): EmailTemplate {
|
|
190
|
+
const found = compiled.get(id);
|
|
191
|
+
if (!found) throw new EmailTemplateNotFoundError({ detail: `no template registered with id '${id}'` });
|
|
192
|
+
return found.def;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The kind a template declares — the one answer to "may this message be refused".
|
|
197
|
+
*
|
|
198
|
+
* An id nobody registered is reported as `elective`, deliberately. It cannot be *proved* transactional,
|
|
199
|
+
* and the alternative reading would let a template deleted in a later release quietly reopen sending to
|
|
200
|
+
* addresses that opted out. Nothing is locked out by the choice: a job naming an unknown template fails
|
|
201
|
+
* at render moments later either way, so this only decides which of two failures gets reported.
|
|
202
|
+
*/
|
|
203
|
+
export function templateKind(templateId: string): EmailKind {
|
|
204
|
+
return compiled.get(templateId)?.def.kind ?? "elective";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Whether this template's payload is dropped once the message is delivered.
|
|
209
|
+
*
|
|
210
|
+
* **Keyed on the category, not the kind.** The kind answers "may this be refused"; this question is
|
|
211
|
+
* "are these inputs a one-time credential". `testerNudge` is the template that proves they are
|
|
212
|
+
* different axes — it is *elective*, because somebody may say stop chasing me, and its payload carries
|
|
213
|
+
* an opt-in URL that authenticates a tester. Keying on the kind would have left exactly that one live.
|
|
214
|
+
*
|
|
215
|
+
* A transactional message is a reply to something one person just did, and its payload is that
|
|
216
|
+
* person's one-time input: a sign-in URL, a code, an invitation token. A marketing message's payload is
|
|
217
|
+
* copy authored for a batch, carrying no per-recipient credential and answering the real question
|
|
218
|
+
* "what did the forty thousand of them actually receive". So the category is the line, and it falls in
|
|
219
|
+
* the right place on every template in the registry.
|
|
220
|
+
*
|
|
221
|
+
* There is no per-template override, because no template needs one today and an escape hatch nobody
|
|
222
|
+
* uses is where the bug comes back. The day a marketing template carries a per-recipient credential,
|
|
223
|
+
* the override is the change to make — with a reason written beside it.
|
|
224
|
+
*
|
|
225
|
+
* An id nobody registered redacts, which is the safe direction and, like `templateKind`, decides
|
|
226
|
+
* nothing real: a job naming an unknown template fails at render and never reaches a delivery.
|
|
227
|
+
*/
|
|
228
|
+
export function redactsPayloadOnDelivery(templateId: string): boolean {
|
|
229
|
+
return compiled.get(templateId)?.def.category !== "marketing";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The registered template ids, for introspection and the CLI. */
|
|
233
|
+
export function listTemplates(): EmailTemplate[] {
|
|
234
|
+
return Object.values(templates);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Validate a payload against its template and render just the subject line. Used at enqueue to fail
|
|
239
|
+
* fast on a bad payload and to store the job's subject without rendering the whole body. Throws
|
|
240
|
+
* `email/template_not_found` or `email/invalid_payload`.
|
|
241
|
+
*/
|
|
242
|
+
export function renderSubject(
|
|
243
|
+
templateId: string,
|
|
244
|
+
payload: unknown,
|
|
245
|
+
theme: EmailTheme,
|
|
246
|
+
translator: Translator = ENGLISH,
|
|
247
|
+
): string {
|
|
248
|
+
const entry = compiled.get(templateId);
|
|
249
|
+
if (!entry) throw new EmailTemplateNotFoundError({ detail: `no template registered with id '${templateId}'` });
|
|
250
|
+
const parsed = entry.def.payload.safeParse(payload);
|
|
251
|
+
if (!parsed.success) {
|
|
252
|
+
const summary = parsed.error.issues.map((i) => `${i.path.join(".") || "<root>"}:${i.code}`).join(", ");
|
|
253
|
+
throw new EmailInvalidPayloadError({ detail: `template '${templateId}' payload invalid: ${summary}` });
|
|
254
|
+
}
|
|
255
|
+
return entry.subject(renderContext(parsed.data as Record<string, unknown>, entry, theme, translator)).trim();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The render context: the parsed payload, the theme, and what the shell needs to be written in a
|
|
260
|
+
* language.
|
|
261
|
+
*
|
|
262
|
+
* One builder for both render sites, because the two used to assemble it separately and the *subject*
|
|
263
|
+
* is the field they were most able to disagree about — `renderSubject` runs at enqueue and `renderEmail`
|
|
264
|
+
* runs at send, and until the locale landed on the row there was nothing tying them to one language.
|
|
265
|
+
*/
|
|
266
|
+
function renderContext(
|
|
267
|
+
payload: Record<string, unknown>,
|
|
268
|
+
entry: Compiled,
|
|
269
|
+
theme: EmailTheme,
|
|
270
|
+
translator: Translator,
|
|
271
|
+
): Record<string, unknown> {
|
|
272
|
+
return {
|
|
273
|
+
...payload,
|
|
274
|
+
theme,
|
|
275
|
+
layoutWidth: widthPx(entry.def.width),
|
|
276
|
+
// What the document declares itself as. `lang` is the catalog locale — the language the words are
|
|
277
|
+
// actually in — and `dir` is what an RTL reader's client needs before it lays anything out.
|
|
278
|
+
lang: translator.catalogLocale,
|
|
279
|
+
dir: translator.direction,
|
|
280
|
+
[TRANSLATOR_KEY]: translator,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function trimTrailingSlash(value: string): string {
|
|
285
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Rewrite one declared link path (`"url"` or `"articles[].link"`) in the context through `makeUrl`. */
|
|
289
|
+
async function rewriteLink(
|
|
290
|
+
context: Record<string, unknown>,
|
|
291
|
+
path: string,
|
|
292
|
+
label: string,
|
|
293
|
+
makeUrl: (destination: string, label: string) => Promise<string>,
|
|
294
|
+
): Promise<void> {
|
|
295
|
+
const arrayMatch = path.split("[].");
|
|
296
|
+
if (arrayMatch.length === 2) {
|
|
297
|
+
const arrayKey = arrayMatch[0];
|
|
298
|
+
const field = arrayMatch[1];
|
|
299
|
+
if (!arrayKey || !field) return;
|
|
300
|
+
const items = context[arrayKey];
|
|
301
|
+
if (Array.isArray(items)) {
|
|
302
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
303
|
+
const item = items[i] as Record<string, unknown>;
|
|
304
|
+
if (item && typeof item[field] === "string")
|
|
305
|
+
item[field] = await makeUrl(item[field] as string, `${label}-${i}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (typeof context[path] === "string") context[path] = await makeUrl(context[path] as string, label);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Render a template to `{ subject, html, text }`. Validates the payload against the template's schema
|
|
315
|
+
* (throws `email/invalid_payload`), applies click/open tracking and — for an elective template — the
|
|
316
|
+
* unsubscribe link, then substitutes. A marketing template with no tracking context throws: it cannot
|
|
317
|
+
* render without an unsubscribe link.
|
|
318
|
+
*
|
|
319
|
+
* The opt-out rule is two-tier on purpose. Every elective template carries the link *when the engine can
|
|
320
|
+
* mint one*; a marketing template additionally *refuses to render* without it. Tying the hard refusal to
|
|
321
|
+
* the kind instead would mean a project that has not configured a link-signing key suddenly cannot send
|
|
322
|
+
* its testing-cohort nudges — a new outage, in a change whose whole subject is mail that fails silently.
|
|
323
|
+
*/
|
|
324
|
+
export async function renderEmail(
|
|
325
|
+
templateId: string,
|
|
326
|
+
payload: unknown,
|
|
327
|
+
theme: EmailTheme,
|
|
328
|
+
tracking?: RenderTracking,
|
|
329
|
+
translator: Translator = ENGLISH,
|
|
330
|
+
): Promise<RenderResult> {
|
|
331
|
+
const entry = compiled.get(templateId);
|
|
332
|
+
if (!entry) throw new EmailTemplateNotFoundError({ detail: `no template registered with id '${templateId}'` });
|
|
333
|
+
|
|
334
|
+
const parsed = entry.def.payload.safeParse(payload);
|
|
335
|
+
if (!parsed.success) {
|
|
336
|
+
const summary = parsed.error.issues.map((i) => `${i.path.join(".") || "<root>"}:${i.code}`).join(", ");
|
|
337
|
+
throw new EmailInvalidPayloadError({ detail: `template '${templateId}' payload invalid: ${summary}` });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const isMarketing = entry.def.category === "marketing";
|
|
341
|
+
const isElective = entry.def.kind === "elective";
|
|
342
|
+
if (isMarketing && !tracking) {
|
|
343
|
+
throw new EmailInvalidPayloadError({
|
|
344
|
+
message: "Marketing emails require an unsubscribe link.",
|
|
345
|
+
detail: `template '${templateId}' is marketing but no signing/tracking context was provided`,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const context: Record<string, unknown> = renderContext(
|
|
350
|
+
parsed.data as Record<string, unknown>,
|
|
351
|
+
entry,
|
|
352
|
+
theme,
|
|
353
|
+
translator,
|
|
354
|
+
);
|
|
355
|
+
let unsubscribeUrl: string | undefined;
|
|
356
|
+
|
|
357
|
+
if (tracking) {
|
|
358
|
+
const base = trimTrailingSlash(tracking.baseUrl) + CALLBACK_BASE;
|
|
359
|
+
const mint = (kind: "click" | "open" | "unsubscribe", extra: { destination?: string; linkLabel?: string }) =>
|
|
360
|
+
mintToken(
|
|
361
|
+
{ kind, jobId: tracking.jobId, recipient: tracking.recipient, campaignId: tracking.campaignId, ...extra },
|
|
362
|
+
{ key: tracking.key, kid: tracking.kid, expiresAt: tracking.expiresAt },
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
if (tracking.clickTracking) {
|
|
366
|
+
for (const link of entry.def.links) {
|
|
367
|
+
await rewriteLink(context, link.path, link.label, async (destination, label) => {
|
|
368
|
+
const token = await mint("click", { destination, linkLabel: label });
|
|
369
|
+
return `${base}/c/${token}`;
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (tracking.openTracking) {
|
|
375
|
+
context.openPixelUrl = `${base}/o/${await mint("open", {})}.png`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Elective mail carries an unsubscribe link; transactional mail never does. Keyed on the kind the
|
|
379
|
+
// template declared, so this cannot be reached for a sign-in link by any argument a caller passes.
|
|
380
|
+
if (isElective) {
|
|
381
|
+
unsubscribeUrl = `${base}/u/${await mint("unsubscribe", {})}`;
|
|
382
|
+
context.unsubscribeUrl = unsubscribeUrl;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
subject: entry.subject(context).trim(),
|
|
388
|
+
html: entry.html(context),
|
|
389
|
+
text: entry.text(context),
|
|
390
|
+
...(unsubscribeUrl ? { unsubscribeUrl } : {}),
|
|
391
|
+
};
|
|
392
|
+
}
|