@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,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">‍</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}} · {{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 →</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
|
+
};
|