@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,550 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import type { EmailKind, TemplateCategory } from "../data/enums";
6
+ import { NoticeSeverity } from "./severity";
7
+ import type { ContentWidth } from "./theme";
8
+
9
+ /**
10
+ * The template set. The **typed input contract is the real deliverable**: each template declares a Zod
11
+ * payload schema — its documented, validated variable set — plus a `category` that drives tracking
12
+ * defaults and a `kind` that decides whether the message can be refused. Visual polish is explicitly
13
+ * secondary. Bodies are Handlebars
14
+ * (`{{var}}`, `{{#each}}`, `{{#if}}`) and include the shared `{{> emailHead}}` / `{{> emailFoot}}`
15
+ * partials, so every template inherits the light/dark theme and the Gmail-safe shell; `links` names the
16
+ * URL locations the engine rewrites for click tracking.
17
+ *
18
+ * ## This map is closed to adopters, and that is a decision rather than an omission
19
+ *
20
+ * There is no `registerTemplate`. An adopter composing `email` sends what is in this file, and the
21
+ * argument for that — with what it costs, and what the kit owes in exchange — is in this package's
22
+ * template model at https://pithy.sh/docs/capabilities/email/template-model. The short form is three things, of which the first is not
23
+ * negotiable by design taste:
24
+ *
25
+ * 1. **The Workers runtime forbids code generation**, so a template cannot be compiled where it runs.
26
+ * Every body here is turned into a spec by `scripts/precompile.ts` at build time. Accepting an
27
+ * adopter's template means accepting a *precompiled spec* built by their own Handlebars, and
28
+ * Handlebars refuses a spec whose compiler revision differs from the runtime's — a version skew
29
+ * nobody would see until every email failed to render at once.
30
+ * 2. **The kind would go back to being a claim.** #281's fix rests on a call site being unable to
31
+ * assert that a message is transactional; a registerable template makes that assertion writable
32
+ * again, and the mail it produces ignores an unsubscribe under the adopter's own sending domain.
33
+ * 3. **Escaping is structural here, not conventional.** `testerNudge` and `supportReply` are safe
34
+ * because their bodies are fixed and the words arrive as escaped values. A supplied body with one
35
+ * `{{{triple}}}` is a phishing page sent over the adopter's DKIM signature.
36
+ *
37
+ * What the closure obliges instead: where a shape is missing, the kit adds it, and where the *words*
38
+ * are the adopter's, the template takes them as payload. `supportReply`, `testerNudge` and
39
+ * `operationalNotice` are all that pattern — the kit owns the shell, the caller owns the copy.
40
+ */
41
+
42
+ /** A URL location within a payload that the engine may rewrite to a tracked click callback. A `path`
43
+ * is a top-level key (`"url"`) or an array element field (`"articles[].link"`). */
44
+ export interface LinkSpec {
45
+ /** The payload path to the URL: a key, or `key[].subkey` for each element of an array. */
46
+ path: string;
47
+ /** The link's identity/label, recorded on the click event for attribution. */
48
+ label: string;
49
+ }
50
+
51
+ /** One template: its id, category, kind, payload schema, Handlebars sources, and trackable link locations. */
52
+ export interface EmailTemplate {
53
+ /** The template id used to enqueue and render (e.g. `magicLink`). */
54
+ id: string;
55
+ /** What the message is — drives tracking defaults, and makes an unsubscribe link mandatory for `marketing`. */
56
+ category: TemplateCategory;
57
+ /**
58
+ * Whether a recipient may refuse this message.
59
+ *
60
+ * **Required, and declared here rather than passed at the call site.** If "is this transactional" were
61
+ * an argument, a caller could get it wrong, and the failure mode is an account nobody can reach: a
62
+ * magic link sent as elective is a magic link an unrelated unsubscribe silently swallows. Templates
63
+ * are this capability's own — an adopter cannot register one — so declaring the kind on the template
64
+ * makes the wrong thing impossible to write rather than merely discouraged. There is no default, for
65
+ * the same reason: a forgotten field must be a type error, not a silent lockout.
66
+ */
67
+ kind: EmailKind;
68
+ /** The body width this template renders at — a property of the email type, not the brand theme. */
69
+ width: ContentWidth;
70
+ /** The Zod payload schema — the validated, documented input-variable contract. */
71
+ payload: z.ZodType;
72
+ /** The subject line, as a Handlebars source over the payload + theme. */
73
+ subject: string;
74
+ /** The HTML body, as a Handlebars source including the shared header/footer partials. */
75
+ html: string;
76
+ /** The plain-text body, as a Handlebars source. Always shipped alongside HTML. */
77
+ text: string;
78
+ /** The URL locations the engine rewrites when click tracking is on. */
79
+ links: LinkSpec[];
80
+ }
81
+
82
+ /** The accent CTA button, with a VML fallback so it renders solid in Outlook. Ink text reads on saffron in both modes. */
83
+ function button(urlVar: string, label: string): string {
84
+ return `<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{${urlVar}}}" style="height:44px;v-text-anchor:middle;width:220px;" arcsize="16%" fillcolor="{{theme.accent}}" stroke="f"><w:anchorlock/><center style="color:#111111;font-family:sans-serif;font-size:14px;font-weight:600;">${label}</center></v:roundrect><![endif]--><!--[if !mso]><!--><a href="{{${urlVar}}}" style="display:inline-block; margin:24px 0; background-color:{{theme.accent}}; color:#111111; font-size:14px; font-weight:600; text-decoration:none; padding:13px 26px; border-radius:8px">${label}</a><!--<![endif]-->`;
85
+ }
86
+
87
+ /** A heading in the primary text color (dark-mode aware via the t-ink class). */
88
+ function heading(text: string): string {
89
+ return `<h1 class="t-ink" style="margin:0 0 16px; font-size:22px; font-weight:600; letter-spacing:-0.02em; color:{{theme.light.text}}">${text}</h1>`;
90
+ }
91
+
92
+ /** A hairline separator that flips color in dark mode via the sep class. */
93
+ const sep = `<div role="separator" class="sep" style="background-color:{{theme.light.separator}}; height:1px; line-height:1px; margin:28px 0">&zwj;</div>`;
94
+
95
+ /** Wrap a body fragment in the shared head/footer partials. */
96
+ function layout(body: string): string {
97
+ return `{{> emailHead}}${body}{{> emailFoot}}`;
98
+ }
99
+
100
+ /**
101
+ * The salutation every kit-authored template opens with, named when the payload carries a name.
102
+ *
103
+ * Two catalog keys rather than one sentence with an optional placeholder: a language that greets an
104
+ * unnamed reader differently — not merely with the name deleted — has nowhere to say so otherwise.
105
+ * `{{#if name}}` stays in the template because the *choice* is structural and the *words* are not.
106
+ */
107
+ const greeting = `{{#if name}}{{t "email/shell.greeting_named" name=name}}{{else}}{{t "email/shell.greeting"}}{{/if}}`;
108
+
109
+ // --- Payload schemas (the typed contracts) ---
110
+
111
+ const MagicLinkPayload = z
112
+ .object({
113
+ name: z.string().optional().describe("The recipient's name, for a personal greeting. Optional."),
114
+ url: z.string().describe("The single-use magic sign-in link. Tracked when click tracking is on."),
115
+ expiresMinutes: z.number().int().describe("How many minutes until the link expires, shown to the recipient."),
116
+ })
117
+ .describe("Inputs for the passwordless magic-link sign-in email.");
118
+
119
+ const OtpPayload = z
120
+ .object({
121
+ name: z.string().optional().describe("The recipient's name, for a personal greeting. Optional."),
122
+ code: z.string().describe("The one-time verification code to display prominently."),
123
+ expiresMinutes: z.number().int().describe("How many minutes until the code expires, shown to the recipient."),
124
+ })
125
+ .describe("Inputs for the one-time-passcode (OTP) verification email.");
126
+
127
+ const WelcomePayload = z
128
+ .object({
129
+ name: z.string().describe("The new user's name, for the greeting."),
130
+ ctaUrl: z.string().describe("The link to get started (dashboard, onboarding). Tracked when click tracking is on."),
131
+ ctaLabel: z.string().describe("The call-to-action button label, e.g. `Open your dashboard`."),
132
+ })
133
+ .describe("Inputs for the post-signup welcome email.");
134
+
135
+ const SecurityAlertPayload = z
136
+ .object({
137
+ name: z.string().optional().describe("The recipient's name, for a personal greeting. Optional."),
138
+ event: z.string().describe("A short description of the security event, e.g. `New sign-in from Chrome on macOS`."),
139
+ when: z.string().describe("A human-readable timestamp of the event."),
140
+ ipAddress: z.string().optional().describe("The originating IP address, if known. Optional."),
141
+ actionUrl: z
142
+ .string()
143
+ .describe("A link to review activity or secure the account. Tracked when click tracking is on."),
144
+ })
145
+ .describe("Inputs for a security-alert email (new sign-in, settings change, etc.).");
146
+
147
+ const InvitePayload = z
148
+ .object({
149
+ inviterName: z.string().describe("The name of the person who sent the invitation."),
150
+ organizationName: z.string().describe("The team or organization the recipient is invited to."),
151
+ acceptUrl: z.string().describe("The link to accept the invitation. Tracked when click tracking is on."),
152
+ })
153
+ .describe("Inputs for a team/organization invitation email.");
154
+
155
+ /**
156
+ * The one template `@pithy-sh/testers` sends everything through — invitations, confirmations, and every
157
+ * nudge kind.
158
+ *
159
+ * **`paragraphs` is an array of plain strings, and that is the security boundary.** A control-plane
160
+ * caller may supply the words of a nudge, and those words go out over the adopter's own DKIM signature
161
+ * to the adopter's own users. Rendering them through `{{this}}` means Handlebars escapes every one of
162
+ * them, so supplied markup arrives as visible text rather than as markup — structurally, not because a
163
+ * filter guessed right about what to strip. A single `body` string with `{{{triple}}}` interpolation
164
+ * would turn a leaked dashboard credential into a phishing platform running from a trusted domain.
165
+ *
166
+ * One template rather than one per nudge kind, because the kinds differ only in their words, and the
167
+ * words are the half this template deliberately does not own.
168
+ */
169
+ const TesterNudgePayload = z
170
+ .object({
171
+ subject: z
172
+ .string()
173
+ .describe(
174
+ "The subject line. Bounded and stripped of control characters by the testers capability before it arrives.",
175
+ ),
176
+ heading: z.string().describe("The email's heading. Always supplied by the testers capability, never by a caller."),
177
+ paragraphs: z
178
+ .array(z.string())
179
+ .describe(
180
+ "The body, as one plain string per paragraph. Each renders HTML-escaped, which is what makes caller-supplied copy safe to send over the adopter's own sending domain.",
181
+ ),
182
+ ctaUrl: z
183
+ .string()
184
+ .optional()
185
+ .describe("The confirmation link, when the message carries one. Tracked when click tracking is on."),
186
+ ctaLabel: z.string().optional().describe("The button label. Only rendered when `ctaUrl` is present."),
187
+ footnote: z
188
+ .string()
189
+ .optional()
190
+ .describe(
191
+ "A closing line in muted type — used for the honesty note about who actually decides the test's outcome.",
192
+ ),
193
+ optOutUrl: z
194
+ .string()
195
+ .optional()
196
+ .describe(
197
+ "The tester's own way out, rendered as a footer link. Transactional mail carries no unsubscribe by default, but a testing program asks one person for something repeatedly over a fortnight, so someone being chased must be able to stop it.",
198
+ ),
199
+ optOutLabel: z
200
+ .string()
201
+ .optional()
202
+ .describe(
203
+ "The wording of that link. Supplied by the capability, never by a caller. Only rendered with `optOutUrl`.",
204
+ ),
205
+ })
206
+ .describe(
207
+ "Inputs for a testing-cohort invitation, confirmation, or nudge. The words may be supplied; the shell never is.",
208
+ );
209
+
210
+ const PasswordChangedPayload = z
211
+ .object({
212
+ name: z.string().optional().describe("The recipient's name, for a personal greeting. Optional."),
213
+ when: z.string().describe("A human-readable timestamp of when the credential changed."),
214
+ supportUrl: z
215
+ .string()
216
+ .describe("A link to contact support if the change was not the recipient. Tracked when click tracking is on."),
217
+ })
218
+ .describe("Inputs for the account-credential-changed security notice.");
219
+
220
+ /** One labeled fact in an operational notice — what makes the notice specific rather than a mood. */
221
+ const OperationalNoticeFact = z
222
+ .object({
223
+ label: z.string().describe("What this fact is: `Environment`, `Last rotated`, `Version`, `Owner`."),
224
+ value: z
225
+ .string()
226
+ .describe("The fact itself, as text. Rendered HTML-escaped — it is a value read off a system, never markup."),
227
+ })
228
+ .describe("One label/value row in an operational notice's fact table.");
229
+
230
+ /**
231
+ * The operational notice: *something about your own infrastructure changed or needs attention*.
232
+ *
233
+ * **It is not `securityAlert`, and the difference is the whole reason it exists.** That template is
234
+ * about a session — it describes a sign-in and closes with "if this was you, no action is needed",
235
+ * which is the opposite of what an overdue secret or a security release means. This one assumes the
236
+ * recipient is the operator and that the fact is true; there is nothing to confirm, only something to
237
+ * do or to know.
238
+ *
239
+ * **One template rather than one per notice, and one per capability is what it replaces.** A rotation
240
+ * that failed, a release with a security fix, a connection that stopped answering and a job retrying
241
+ * for a day differ only in their words and their urgency. Both of those are payload. What is fixed —
242
+ * the shell, the escaping, the kind, the severity vocabulary — is what the kit is for.
243
+ *
244
+ * The severity is required and has no default. A default would be `info`, and a capability that forgot
245
+ * the field would then send a critical fault at the volume of a newsletter.
246
+ */
247
+ export const OperationalNoticePayload = z
248
+ .object({
249
+ severity: NoticeSeverity.describe(
250
+ "How urgent this is. Sets the subject-line label (`Notice:` / `Action needed:` / `Critical:`), so the level is visible in the inbox before the message is opened.",
251
+ ),
252
+ summary: z
253
+ .string()
254
+ .describe("What happened, in one line. It is the subject after the severity label, and the heading in the body."),
255
+ thing: z
256
+ .string()
257
+ .describe(
258
+ "What it happened to, named the way an operator would recognize it: `STRIPE_SECRET_KEY`, `@pithy-sh/auth`, `acme-prod-db`. A notice that does not name its subject cannot be acted on.",
259
+ ),
260
+ when: z
261
+ .string()
262
+ .describe(
263
+ "When it happened, human-readable (`2 hours ago`, `18 June, 14:02 UTC`). Formatted by the caller, who knows the recipient's locale and whether an exact time matters.",
264
+ ),
265
+ detail: z
266
+ .string()
267
+ .optional()
268
+ .describe(
269
+ "One paragraph explaining what it means or what to do. Rendered HTML-escaped as a single block. Optional — the summary and the facts already stand alone.",
270
+ ),
271
+ facts: z
272
+ .array(OperationalNoticeFact)
273
+ .default([])
274
+ .describe(
275
+ "Supporting facts as label/value rows — version, environment, last success, owner. Empty renders nothing at all rather than an empty table.",
276
+ ),
277
+ actionUrl: z
278
+ .string()
279
+ .optional()
280
+ .describe(
281
+ "The one place this can be acted on. Optional, because a caller with nowhere to send somebody would otherwise invent a link, and a dead link in a critical notice is worse than none. Tracked when click tracking is on.",
282
+ ),
283
+ actionLabel: z
284
+ .string()
285
+ .default("Open")
286
+ .describe("The button's words. Only rendered alongside `actionUrl`; defaults to a plain `Open`."),
287
+ })
288
+ .describe(
289
+ "Inputs for an operational notice — what happened, to what, when, how serious, and one place to act on it.",
290
+ );
291
+ /**
292
+ * `z.input`, not `z.output`: `facts` and `actionLabel` carry defaults, so the parsed shape is the
293
+ * renderer's and this one is the caller's. It is exported because a capability building a notice should
294
+ * be told at compile time that it forgot the severity — the payload reaches `enqueueEmail` as
295
+ * `unknown`, and a Zod failure there is a runtime error in a code path that only runs when something is
296
+ * already wrong.
297
+ */
298
+ export type OperationalNoticePayload = z.input<typeof OperationalNoticePayload>;
299
+
300
+ const NewsletterArticle = z
301
+ .object({
302
+ title: z.string().describe("The article headline."),
303
+ summary: z.string().describe("A one- or two-sentence summary shown under the headline."),
304
+ link: z.string().describe("The link to the full article. Tracked when click tracking is on."),
305
+ featureImage: z.string().optional().describe("An absolute URL of a header image for the article. Optional."),
306
+ })
307
+ .describe("One article block in a newsletter's iterable list.");
308
+
309
+ const NewsletterPayload = z
310
+ .object({
311
+ subject: z.string().describe("The newsletter subject line."),
312
+ intro: z.string().describe("The opening paragraph above the article list."),
313
+ articles: z.array(NewsletterArticle).describe("The iterable list of article blocks rendered with `{{#each}}`."),
314
+ outro: z.string().optional().describe("An optional closing paragraph below the article list."),
315
+ })
316
+ .describe("Inputs for the newsletter email — opening copy plus an iterable list of articles.");
317
+
318
+ const LeadCapturePayload = z
319
+ .object({
320
+ name: z.string().optional().describe("The lead's name, for a personal greeting. Optional."),
321
+ assetName: z.string().describe("The name of the downloadable asset, e.g. `The 2026 Backend Playbook`."),
322
+ assetUrl: z.string().describe("The link to download the asset. Tracked when click tracking is on."),
323
+ message: z.string().optional().describe("An optional custom message above the download link."),
324
+ })
325
+ .describe("Inputs for the lead-capture delivery email (a notice with a link to a downloadable asset).");
326
+
327
+ const MarketingCampaignPayload = z
328
+ .object({
329
+ subject: z.string().describe("The campaign subject line."),
330
+ heading: z.string().describe("The headline at the top of the body."),
331
+ body: z.string().describe("The main campaign copy. Plain text rendered into a paragraph."),
332
+ ctaUrl: z.string().describe("The campaign call-to-action link. Tracked when click tracking is on."),
333
+ ctaLabel: z.string().describe("The call-to-action button label."),
334
+ })
335
+ .describe("Inputs for a per-user marketing campaign email (tracking-enabled, marketing category).");
336
+
337
+ /**
338
+ * The one template `@pithy-sh/support` sends through.
339
+ *
340
+ * **One template, not one per canned reply.** The wording of a support answer belongs to the adopter
341
+ * and changes on a Tuesday; a Handlebars body here is precompiled at build time and changes on a
342
+ * release. So this is the *shell* — the theme, the HTML and text pair, the shape a reply arrives in —
343
+ * and the words are `body`, chosen and edited by a human in the dashboard from the catalog
344
+ * `@pithy-sh/support` federates. The machine's job is a better blank page, not the letter.
345
+ *
346
+ * `{{body}}` is Handlebars-escaped, which matters more here than anywhere else in this file: it is
347
+ * the only template whose payload is free text somebody typed rather than a value this codebase
348
+ * produced.
349
+ */
350
+ const SupportReplyPayload = z
351
+ .object({
352
+ subject: z.string().describe("The reply's subject line, already `Re:`-prefixed by the support capability."),
353
+ body: z
354
+ .string()
355
+ .describe(
356
+ "The reply text a human wrote. Rendered as paragraphs, HTML-escaped — it is free text from an operator, never markup.",
357
+ ),
358
+ agentName: z
359
+ .string()
360
+ .optional()
361
+ .describe("Who is answering, signed at the bottom. Optional; omitted rather than guessed."),
362
+ })
363
+ .describe("Inputs for a support reply — the shell around text a human wrote and edited.");
364
+
365
+ /** The full template set, keyed by id. */
366
+ export const templates: Record<string, EmailTemplate> = {
367
+ magicLink: {
368
+ id: "magicLink",
369
+ category: "transactional",
370
+ // The kind that matters most in this file. Passwordless is the kit's sign-in and there is no
371
+ // password to fall back to, so an unsubscribe that reached this template would not withhold a
372
+ // preference — it would make the account permanently unreachable, silently, from both ends.
373
+ kind: "transactional",
374
+ width: "narrow",
375
+ payload: MagicLinkPayload,
376
+ subject: `{{t "email/magic_link.subject"}}`,
377
+ html: layout(
378
+ `${heading('{{t "email/magic_link.heading"}}')}<p style="margin:0 0 16px">${greeting} {{t "email/magic_link.instruction"}} {{tn "email/magic_link.expiry" count=expiresMinutes}}</p>${button("url", '{{t "email/magic_link.cta"}}')}<p class="t-subtle" style="margin:16px 0 0; font-size:13px; color:{{theme.light.textSubtle}}">{{t "email/magic_link.ignore"}}</p>`,
379
+ ),
380
+ text: `${greeting}\n\n{{tn "email/magic_link.text_instruction" count=expiresMinutes}}\n{{url}}\n\n{{t "email/magic_link.text_ignore"}}`,
381
+ links: [{ path: "url", label: "magic-link" }],
382
+ },
383
+ otp: {
384
+ id: "otp",
385
+ category: "transactional",
386
+ kind: "transactional",
387
+ width: "narrow",
388
+ payload: OtpPayload,
389
+ subject: `{{t "email/otp.subject"}}`,
390
+ html: layout(
391
+ `${heading('{{t "email/otp.heading"}}')}<p style="margin:0 0 12px">${greeting} {{t "email/otp.lead"}}</p><p class="t-ink" style="font-size:32px; font-weight:700; letter-spacing:6px; color:{{theme.accent}}; margin:16px 0">{{code}}</p><p style="margin:0">{{tn "email/otp.expiry" count=expiresMinutes}}</p>`,
392
+ ),
393
+ text: `${greeting}\n\n{{tn "email/otp.text_body" count=expiresMinutes code=code}}`,
394
+ links: [],
395
+ },
396
+ welcome: {
397
+ id: "welcome",
398
+ category: "transactional",
399
+ kind: "transactional",
400
+ width: "narrow",
401
+ payload: WelcomePayload,
402
+ subject: `{{t "email/welcome.subject" app=theme.appName}}`,
403
+ html: layout(
404
+ `${heading('{{t "email/welcome.heading" app=theme.appName}}')}<p style="margin:0 0 8px">{{t "email/welcome.body" name=name app=theme.appName}}</p>${button("ctaUrl", "{{ctaLabel}}")}`,
405
+ ),
406
+ text: `{{t "email/shell.greeting_named" name=name}}\n\n{{t "email/welcome.text_body" app=theme.appName}}\n\n{{ctaLabel}}: {{ctaUrl}}`,
407
+ links: [{ path: "ctaUrl", label: "welcome-cta" }],
408
+ },
409
+ securityAlert: {
410
+ id: "securityAlert",
411
+ category: "transactional",
412
+ kind: "transactional",
413
+ width: "narrow",
414
+ payload: SecurityAlertPayload,
415
+ subject: `{{t "email/security_alert.subject" event=event}}`,
416
+ html: layout(
417
+ `${heading('{{t "email/security_alert.heading"}}')}<p style="margin:0 0 8px">${greeting} {{t "email/security_alert.body" event=event when=when}}{{#if ipAddress}} {{t "email/security_alert.ip" ip=ipAddress}}{{/if}}</p><p style="margin:0 0 8px">{{t "email/security_alert.reassure"}}</p>${button("actionUrl", '{{t "email/security_alert.cta"}}')}`,
418
+ ),
419
+ text: `${greeting}\n\n{{t "email/security_alert.body" event=event when=when}}{{#if ipAddress}} {{t "email/security_alert.text_ip" ip=ipAddress}}{{/if}}\n\n{{t "email/security_alert.text_action"}} {{actionUrl}}`,
420
+ links: [{ path: "actionUrl", label: "security-action" }],
421
+ },
422
+ invite: {
423
+ id: "invite",
424
+ category: "transactional",
425
+ kind: "transactional",
426
+ width: "narrow",
427
+ payload: InvitePayload,
428
+ subject: `{{t "email/invite.subject" inviter=inviterName organization=organizationName}}`,
429
+ html: layout(
430
+ `${heading('{{t "email/invite.heading"}}')}<p style="margin:0 0 8px">{{t "email/invite.body" inviter=inviterName organization=organizationName app=theme.appName}}</p>${button("acceptUrl", '{{t "email/invite.cta"}}')}`,
431
+ ),
432
+ text: `{{t "email/invite.body" inviter=inviterName organization=organizationName app=theme.appName}}\n\n{{t "email/invite.text_accept"}} {{acceptUrl}}`,
433
+ links: [{ path: "acceptUrl", label: "invite-accept" }],
434
+ },
435
+ testerNudge: {
436
+ id: "testerNudge",
437
+ category: "transactional",
438
+ // Elective, though the category is transactional — the two axes genuinely disagree here and this is
439
+ // the template that proves they are separate. A testing program chases one person repeatedly over
440
+ // a fortnight, so somebody who said "stop emailing me" means this mail, and it is the mail an
441
+ // unsubscribe must stop. Nothing is locked by withholding it: a tester who never confirms simply
442
+ // lapses, which is already a state the cohort handles.
443
+ kind: "elective",
444
+ width: "narrow",
445
+ payload: TesterNudgePayload,
446
+ subject: "{{subject}}",
447
+ html: layout(
448
+ `{{#if heading}}<h1 class="t-ink" style="margin:0 0 16px; font-size:22px; font-weight:600; letter-spacing:-0.02em; color:{{theme.light.text}}">{{heading}}</h1>{{/if}}{{#each paragraphs}}<p style="margin:0 0 16px">{{this}}</p>{{/each}}{{#if ctaUrl}}${button("ctaUrl", "{{ctaLabel}}")}{{/if}}{{#if footnote}}<p class="t-subtle" style="margin:16px 0 0; font-size:13px; color:{{theme.light.textSubtle}}">{{footnote}}</p>{{/if}}{{#if optOutUrl}}<p class="t-subtle" style="margin:20px 0 0; font-size:13px; color:{{theme.light.textSubtle}}"><a href="{{optOutUrl}}" class="hover-underline t-subtle" style="color:{{theme.light.textSubtle}}; text-decoration:underline">{{optOutLabel}}</a></p>{{/if}}`,
449
+ ),
450
+ text: "{{heading}}\n\n{{#each paragraphs}}{{this}}\n\n{{/each}}{{#if ctaUrl}}{{ctaLabel}}: {{ctaUrl}}\n\n{{/if}}{{#if footnote}}{{footnote}}\n\n{{/if}}{{#if optOutUrl}}{{optOutLabel}}: {{optOutUrl}}{{/if}}",
451
+ links: [{ path: "ctaUrl", label: "tester-confirm" }],
452
+ },
453
+ passwordChanged: {
454
+ id: "passwordChanged",
455
+ category: "transactional",
456
+ kind: "transactional",
457
+ width: "narrow",
458
+ payload: PasswordChangedPayload,
459
+ subject: `{{t "email/password_changed.subject"}}`,
460
+ html: layout(
461
+ `${heading('{{t "email/password_changed.heading"}}')}<p style="margin:0 0 8px">${greeting} {{t "email/password_changed.body" when=when}}</p><p style="margin:0 0 8px">{{t "email/password_changed.warn"}}</p>${button("supportUrl", '{{t "email/password_changed.cta"}}')}`,
462
+ ),
463
+ text: `${greeting}\n\n{{t "email/password_changed.text_body" when=when}} {{supportUrl}}`,
464
+ links: [{ path: "supportUrl", label: "password-support" }],
465
+ },
466
+ operationalNotice: {
467
+ id: "operationalNotice",
468
+ category: "transactional",
469
+ // Transactional in both axes. An operator who unsubscribed from a product newsletter has not asked
470
+ // to stop being told their secret expired — and unlike a nudge, nothing here is a request they can
471
+ // let lapse. The notice is about infrastructure they are responsible for.
472
+ kind: "transactional",
473
+ width: "narrow",
474
+ payload: OperationalNoticePayload,
475
+ // The label leads the subject, so the severity is legible in a list of forty unread messages. It is
476
+ // also why the summary is one line: everything after `Critical: ` competes with the sender name for
477
+ // the width of a phone.
478
+ subject: "{{severityLabel severity}}: {{summary}}",
479
+ html: layout(
480
+ // The severity renders as a word in color, not as a color. `sev-{{severity}}` is the dark-mode
481
+ // hook (the class is safe to interpolate: the value is enum-constrained before it reaches here),
482
+ // and `severityColor` supplies the light value inline, the way every other color in this shell
483
+ // is applied.
484
+ `<p class="sev-{{severity}}" style="margin:0 0 10px; font-size:12px; font-weight:700; letter-spacing:0.08em; text-transform:uppercase; color:{{severityColor severity}}">{{severityLabel severity}}</p>${heading("{{summary}}")}<p class="t-subtle" style="margin:0 0 20px; font-size:13px; color:{{theme.light.textSubtle}}">{{thing}} &middot; {{when}}</p>{{#if detail}}<p style="margin:0 0 16px">{{detail}}</p>{{/if}}{{#if facts.length}}<table cellpadding="0" cellspacing="0" role="none" style="width:100%; margin:0 0 8px">{{#each facts}}<tr><td class="t-subtle" style="padding:4px 16px 4px 0; font-size:13px; color:{{../theme.light.textSubtle}}; vertical-align:top">{{label}}</td><td class="t-ink" style="padding:4px 0; font-size:13px; color:{{../theme.light.text}}; vertical-align:top">{{value}}</td></tr>{{/each}}</table>{{/if}}{{#if actionUrl}}${button("actionUrl", "{{actionLabel}}")}{{/if}}`,
485
+ ),
486
+ text: "{{severityLabel severity}} — {{summary}}\n\n{{thing}}\n{{when}}\n\n{{#if detail}}{{detail}}\n\n{{/if}}{{#each facts}}{{label}}: {{value}}\n{{/each}}{{#if actionUrl}}\n{{actionLabel}}: {{actionUrl}}{{/if}}",
487
+ links: [{ path: "actionUrl", label: "operational-action" }],
488
+ },
489
+ supportReply: {
490
+ id: "supportReply",
491
+ category: "transactional",
492
+ kind: "transactional",
493
+ // Wide: a support reply is prose, frequently quoting the customer back at themselves, and the
494
+ // narrow shell is sized for a button and a sentence.
495
+ width: "wide",
496
+ payload: SupportReplyPayload,
497
+ subject: "{{subject}}",
498
+ html: layout(
499
+ // No heading, and no button. A reply that opened with a headline would read as a broadcast —
500
+ // the whole point is that it looks like a person wrote it, because one did. `linebreaks` keeps
501
+ // the operator's paragraphing while Handlebars still escapes the content.
502
+ `<div style="margin:0 0 16px; white-space:pre-wrap">{{body}}</div>{{#if agentName}}<p class="t-subtle" style="margin:24px 0 0; font-size:14px">— {{agentName}}</p>{{/if}}`,
503
+ ),
504
+ text: "{{body}}{{#if agentName}}\n\n— {{agentName}}{{/if}}",
505
+ // Deliberately none. Rewriting a link a human typed into a tracked redirect would put a
506
+ // marketing URL in a one-to-one reply, and a support answer is a letter, not a campaign.
507
+ links: [],
508
+ },
509
+ newsletter: {
510
+ id: "newsletter",
511
+ category: "marketing",
512
+ kind: "elective",
513
+ width: "wide",
514
+ payload: NewsletterPayload,
515
+ subject: "{{subject}}",
516
+ html: layout(
517
+ `<p style="margin:0 0 8px">{{intro}}</p>{{#each articles}}${sep}{{#if featureImage}}<a href="{{link}}"><img src="{{featureImage}}" alt="{{title}}" width="100%" style="height:180px; width:100%; object-fit:cover; border-radius:8px; margin-bottom:12px; border:0" /></a>{{/if}}<h2 class="t-ink" style="margin:0 0 8px; font-size:19px; font-weight:600; letter-spacing:-0.02em; color:{{../theme.light.text}}">{{title}}</h2><p style="margin:0 0 8px">{{summary}}</p><a href="{{link}}" class="t-ink" style="color:{{../theme.light.text}}; font-weight:600; text-decoration:none">Read more &rarr;</a>{{/each}}{{#if outro}}${sep}<p style="margin:0">{{outro}}</p>{{/if}}`,
518
+ ),
519
+ text: "{{intro}}\n\n{{#each articles}}{{title}}\n{{summary}}\n{{link}}\n\n{{/each}}{{#if outro}}{{outro}}{{/if}}",
520
+ links: [{ path: "articles[].link", label: "newsletter-article" }],
521
+ },
522
+ leadCapture: {
523
+ id: "leadCapture",
524
+ category: "transactional",
525
+ // A lead magnet is list-building wearing a receipt's clothes, so it is elective even though the
526
+ // delivery answers something the person just did. Refusing it costs them a download they can get
527
+ // another way; sending it to somebody who opted out is exactly the mail they refused, and the
528
+ // complaint that follows is charged to the adopter's sending domain.
529
+ kind: "elective",
530
+ width: "narrow",
531
+ payload: LeadCapturePayload,
532
+ subject: `{{t "email/lead_capture.subject" asset=assetName}}`,
533
+ html: layout(
534
+ `${heading('{{t "email/lead_capture.heading"}}')}<p style="margin:0 0 8px">${greeting}</p>{{#if message}}<p style="margin:0 0 8px">{{message}}</p>{{/if}}<p style="margin:0 0 8px">{{t "email/lead_capture.ready" asset=assetName}}</p>${button("assetUrl", '{{t "email/lead_capture.cta"}}')}`,
535
+ ),
536
+ text: `${greeting}\n\n{{#if message}}{{message}}\n\n{{/if}}{{t "email/lead_capture.text_ready" asset=assetName}} {{assetUrl}}`,
537
+ links: [{ path: "assetUrl", label: "lead-asset" }],
538
+ },
539
+ marketingCampaign: {
540
+ id: "marketingCampaign",
541
+ category: "marketing",
542
+ kind: "elective",
543
+ width: "narrow",
544
+ payload: MarketingCampaignPayload,
545
+ subject: "{{subject}}",
546
+ html: layout(`${heading("{{heading}}")}<p style="margin:0 0 8px">{{body}}</p>${button("ctaUrl", "{{ctaLabel}}")}`),
547
+ text: "{{heading}}\n\n{{body}}\n\n{{ctaLabel}}: {{ctaUrl}}",
548
+ links: [{ path: "ctaUrl", label: "campaign-cta" }],
549
+ },
550
+ };
@@ -0,0 +1,75 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Placeholder payloads — one valid input per template — for previewing a rendered email without real
6
+ * data. `pithy email test` uses these to send a sample of any template through a project's config so a
7
+ * user can see the result and confirm setup. Each satisfies its template's Zod payload schema.
8
+ */
9
+ export const samplePayloads: Record<string, unknown> = {
10
+ magicLink: { name: "Sam", url: "https://example.com/signin?token=demo", expiresMinutes: 15 },
11
+ supportReply: {
12
+ subject: "Re: I was charged twice this month",
13
+ body: "Hi Sam,\n\nI have refunded the duplicate charge — it should be back on your card within three working days.\n\nSorry for the trouble.",
14
+ agentName: "Alex",
15
+ },
16
+ otp: { name: "Sam", code: "123456", expiresMinutes: 10 },
17
+ welcome: { name: "Sam", ctaUrl: "https://example.com/start", ctaLabel: "Open your dashboard" },
18
+ securityAlert: {
19
+ name: "Sam",
20
+ event: "New sign-in from Chrome on macOS",
21
+ when: "just now",
22
+ ipAddress: "203.0.113.4",
23
+ actionUrl: "https://example.com/activity",
24
+ },
25
+ invite: { inviterName: "Pat", organizationName: "Acme", acceptUrl: "https://example.com/accept" },
26
+ testerNudge: {
27
+ subject: "One step left to join the test",
28
+ heading: "Confirm your place",
29
+ paragraphs: [
30
+ "You were invited to test an early build. Confirming takes one tap, and it is the step that actually enrolls you.",
31
+ "The test runs for a fixed period, and it needs everyone who joined to stay joined for the whole of it.",
32
+ ],
33
+ ctaUrl: "https://example.com/testers/opt-in/sample-token",
34
+ ctaLabel: "Confirm",
35
+ optOutUrl: "https://example.com/testers/opt-out/sample-token",
36
+ optOutLabel: "No thanks, take me off this list",
37
+ },
38
+ passwordChanged: { name: "Sam", when: "just now", supportUrl: "https://example.com/support" },
39
+ // `warning` rather than `info`: a sample is what somebody looks at to decide whether the severity is
40
+ // legible, and the middle level is the one that has to earn its place between the other two.
41
+ operationalNotice: {
42
+ severity: "warning",
43
+ summary: "A secret has not been rotated in 90 days",
44
+ thing: "STRIPE_SECRET_KEY",
45
+ when: "18 June, 14:02 UTC",
46
+ detail: "Rotation is overdue. The old value keeps working until the new one is in place.",
47
+ facts: [
48
+ { label: "Environment", value: "prod" },
49
+ { label: "Last rotated", value: "20 March" },
50
+ ],
51
+ actionUrl: "https://example.com/secrets/stripe-secret-key",
52
+ actionLabel: "Rotate it",
53
+ },
54
+ newsletter: {
55
+ subject: "Sample newsletter",
56
+ intro: "Here's what's new this week.",
57
+ articles: [
58
+ { title: "A first headline", summary: "A one-line summary of the first article.", link: "https://example.com/a" },
59
+ {
60
+ title: "A second headline",
61
+ summary: "A one-line summary of the second article.",
62
+ link: "https://example.com/b",
63
+ },
64
+ ],
65
+ outro: "That's it for this week.",
66
+ },
67
+ leadCapture: { name: "Sam", assetName: "The 2026 Backend Playbook", assetUrl: "https://example.com/download" },
68
+ marketingCampaign: {
69
+ subject: "A sample campaign",
70
+ heading: "Big news",
71
+ body: "The body copy of a marketing campaign.",
72
+ ctaUrl: "https://example.com/go",
73
+ ctaLabel: "See what's new",
74
+ },
75
+ };