@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,102 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * How urgent an operational notice is, and how that urgency is said out loud.
8
+ *
9
+ * **Severity is words first and color second, and the order matters.** A notice is read in an inbox
10
+ * list before it is opened, in a plain-text client that has no colors at all, and by people who cannot
11
+ * tell the red one from the amber one. So each level owns a *label* — which goes in the subject line,
12
+ * in the body, and in the text part — and a color that only ever reinforces it. A design where the
13
+ * only difference between "a release is out" and "your sign-in is broken" is a hex value has flattened
14
+ * them, and a reader who cannot see the difference learns to ignore both.
15
+ *
16
+ * The labels name the response, not the volume. "Action needed" tells somebody what the message wants
17
+ * from them; "Warning" only tells them how loudly it is being said.
18
+ */
19
+
20
+ export const NoticeSeverity = z
21
+ .enum(["info", "warning", "critical"])
22
+ .describe(
23
+ "How urgent an operational notice is. `info` is something that happened and needs nothing (a release is out); `warning` is something that needs attention before it becomes a fault (a secret is overdue for rotation); `critical` is something that is failing now. The level sets the subject-line label, so it is visible in an inbox before the message is opened.",
24
+ );
25
+ export type NoticeSeverity = z.output<typeof NoticeSeverity>;
26
+
27
+ /** One severity's presentation: the key its word is looked up under, and the color that reinforces it. */
28
+ interface SeverityPresentation {
29
+ /**
30
+ * The catalog key holding the word — in the subject line, the body, and the text part.
31
+ *
32
+ * A key rather than the word itself, because the word is the half of this table that changes with the
33
+ * reader. The colors below do not: an accent belongs to a brand and "this is on fire" belongs to
34
+ * everyone, so they stay literals here while the label goes through `email/severity.*` in the
35
+ * catalog. `messages.ts` writes the English; `@pithy-sh/i18n` ships the rest.
36
+ */
37
+ labelKey: string;
38
+ /** The light-mode color, applied inline. Contrast-checked against the card white every preset uses. */
39
+ light: string;
40
+ /** The dark-mode color, swapped in by class under `prefers-color-scheme: dark`. */
41
+ dark: string;
42
+ }
43
+
44
+ /**
45
+ * The one place a severity's presentation is decided.
46
+ *
47
+ * Colors are fixed rather than themed, deliberately: an accent belongs to a brand, but "this is on
48
+ * fire" belongs to the reader. A project whose accent is red would otherwise render a routine notice
49
+ * in the color of an emergency. Both ramps are contrast-checked — the light values against the white
50
+ * card every preset ships, the dark values against the near-black one.
51
+ */
52
+ const PRESENTATION = {
53
+ info: { labelKey: "email/severity.info", light: "#475467", dark: "#98A2B3" },
54
+ warning: { labelKey: "email/severity.warning", light: "#B54708", dark: "#FEC84B" },
55
+ critical: { labelKey: "email/severity.critical", light: "#B42318", dark: "#FDA29B" },
56
+ } as const satisfies Record<NoticeSeverity, SeverityPresentation>;
57
+
58
+ /**
59
+ * The presentation for a value that has already been through {@link NoticeSeverity}.
60
+ *
61
+ * A template renders after its payload is parsed, so the value is always one of the three. The
62
+ * fallback exists because a Handlebars helper must return *something* and `info` is the only safe
63
+ * guess: a notice that renders as calmer than it is can still be read, while one that throws mid-render
64
+ * is the notice nobody receives.
65
+ */
66
+ function presentationOf(severity: unknown): SeverityPresentation {
67
+ // `Object.hasOwn`, not `in`: `in` walks the prototype, so `severity === "constructor"` would answer
68
+ // true and hand back `Object` itself. Unreachable today — the payload is `NoticeSeverity`-parsed
69
+ // before a render — but it is the same read the i18n lookups were fixed for, and one site left
70
+ // spelled the other way is the one somebody copies.
71
+ return typeof severity === "string" && Object.hasOwn(PRESENTATION, severity)
72
+ ? PRESENTATION[severity as NoticeSeverity]
73
+ : PRESENTATION.info;
74
+ }
75
+
76
+ /**
77
+ * The catalog key holding the word this level is called.
78
+ *
79
+ * The engine's `{{severityLabel severity}}` helper resolves this through the render's translator, so
80
+ * the level is said in the recipient's language in all three places it appears. Exported as a key and
81
+ * not as a word, because a function returning a word would have to be handed a translator, and then
82
+ * every caller would carry one to ask a question about an enum.
83
+ */
84
+ export function severityLabelKey(severity: unknown): string {
85
+ return presentationOf(severity).labelKey;
86
+ }
87
+
88
+ /** The `{{severityColor severity}}` helper: the light-mode color, applied inline like every other one. */
89
+ export function severityColor(severity: unknown): string {
90
+ return presentationOf(severity).light;
91
+ }
92
+
93
+ /**
94
+ * The dark-mode overrides for the severity colors, generated from the same table.
95
+ *
96
+ * The shared head partial carries the light values inline and swaps these in under
97
+ * `prefers-color-scheme: dark`, exactly as it does for the theme's own palette. Generated rather than
98
+ * written out so a color changed here cannot leave dark mode showing the old one.
99
+ */
100
+ export const severityDarkModeCss: string = Object.entries(PRESENTATION)
101
+ .map(([name, { dark }]) => ` .sev-${name} { color: ${dark} !important; }`)
102
+ .join("\n");
@@ -0,0 +1,212 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * The theme injected into every template: "plug your colors in, point at your logo." Templates render
8
+ * with full **light/dark** support — each mode has its own background, card, text, and separator
9
+ * colors, applied inline for light and swapped in via a `prefers-color-scheme: dark` style block. A
10
+ * single `accent` (saffron) carries through both modes. Visual polish is deliberately secondary to the
11
+ * typed payload contracts — this is enough to brand the starter set well.
12
+ */
13
+
14
+ export const ContentWidth = z
15
+ .enum(["narrow", "wide"])
16
+ .describe(
17
+ "The email body width: `narrow` (600px — the transactional default) or `wide` (720px — better for newsletters).",
18
+ );
19
+ export type ContentWidth = z.output<typeof ContentWidth>;
20
+
21
+ /** One mode's color set — the surfaces and text shades the templates reference. */
22
+ export const Palette = z
23
+ .object({
24
+ background: z.string().describe("The page background behind the card."),
25
+ cardBackground: z.string().describe("The card surface the email content sits on."),
26
+ text: z.string().describe("The primary text/heading color."),
27
+ textMuted: z.string().describe("Secondary body text — summaries, supporting copy."),
28
+ textSubtle: z.string().describe("Tertiary text — the footer and fine print."),
29
+ separator: z.string().describe("The hairline rule color between sections."),
30
+ })
31
+ .describe("A light- or dark-mode color set for the email templates.");
32
+ export type Palette = z.output<typeof Palette>;
33
+
34
+ /** A footer link (social or otherwise). */
35
+ export const FooterLink = z
36
+ .object({
37
+ label: z.string().describe("The link text shown in the footer."),
38
+ href: z.string().describe("The absolute URL the footer link points to."),
39
+ })
40
+ .describe("One footer link rendered in the email footer.");
41
+ export type FooterLink = z.output<typeof FooterLink>;
42
+
43
+ export const EmailTheme = z
44
+ .object({
45
+ appName: z.string().describe("The product name shown in the header (when no logo is set) and the footer."),
46
+ accent: z
47
+ .string()
48
+ .describe(
49
+ "The single accent color (hex) for the CTA button and the header rule, in both modes. Defaults to saffron.",
50
+ ),
51
+ logoUrl: z.string().describe("Absolute URL of the light-mode header logo PNG. Empty renders the app name as text."),
52
+ logoDarkUrl: z
53
+ .string()
54
+ .describe(
55
+ "Absolute URL of the dark-mode logo PNG, swapped in under `prefers-color-scheme: dark`. Empty falls back to the light logo / app name.",
56
+ ),
57
+ footerAddress: z
58
+ .string()
59
+ .describe("The physical mailing address shown in the footer — required for marketing (CAN-SPAM) compliance."),
60
+ light: Palette.describe("The light-mode color set, applied inline."),
61
+ dark: Palette.describe("The dark-mode color set, applied via a `prefers-color-scheme: dark` style block."),
62
+ links: z.array(FooterLink).describe("Footer links (e.g. social profiles), rendered as a separated row."),
63
+ })
64
+ .describe("Brand theme for the email templates — light/dark palettes, accent, logo, and the compliance footer.");
65
+ export type EmailTheme = z.output<typeof EmailTheme>;
66
+
67
+ /**
68
+ * A partial override of {@link EmailTheme} — the thin config surface a project tweaks. Every field is
69
+ * optional and deep-merges over the chosen preset, so `customTheme: { accent: "#7C3AED", dark: { background: "#0A0A0A" } }`
70
+ * changes just those values and inherits the rest. Body width is **not** here: it is a property of the
71
+ * template (transactional is narrow, newsletters wide), not the brand.
72
+ */
73
+ export const CustomTheme = EmailTheme.partial()
74
+ .extend({
75
+ // Override `light`/`dark` with *partial* palettes so a project can change one color, not the whole set.
76
+ // The scalar fields + `links` are inherited (as optional) from EmailTheme, so this can never drift from it.
77
+ light: Palette.partial().optional().describe("Override individual light-mode palette colors."),
78
+ dark: Palette.partial().optional().describe("Override individual dark-mode palette colors."),
79
+ })
80
+ .describe("A partial, deep-merged override of the chosen theme preset — derived from EmailTheme so it can't drift.");
81
+ export type CustomTheme = z.output<typeof CustomTheme>;
82
+
83
+ /** A named, off-the-shelf palette pair plus its accent — the base a config picks and may override. */
84
+ export interface ThemePalettes {
85
+ accent: string;
86
+ light: Palette;
87
+ dark: Palette;
88
+ }
89
+
90
+ /**
91
+ * Off-the-shelf themes a project can pick by name to bootstrap its config (`email({ theme: "midnight" })`),
92
+ * then override piecemeal (`accent`, `light`, `dark`). Each ships a matched light/dark pair so dark mode
93
+ * looks intentional, not auto-inverted. `saffron` is the Pithy default.
94
+ */
95
+ export const themePresets = {
96
+ /** Pithy brand — warm parchment/ink, saffron accent. */
97
+ saffron: {
98
+ accent: "#D4A017",
99
+ light: {
100
+ background: "#FAFAF6",
101
+ cardBackground: "#FFFFFF",
102
+ text: "#111111",
103
+ textMuted: "#5F5D57",
104
+ textSubtle: "#9A988F",
105
+ separator: "#E7E5DF",
106
+ },
107
+ dark: {
108
+ background: "#14110D",
109
+ cardBackground: "#1C1813",
110
+ text: "#FAFAF6",
111
+ textMuted: "#A09C90",
112
+ textSubtle: "#6B675E",
113
+ separator: "#2A251E",
114
+ },
115
+ },
116
+ /** Cool, modern neutral with a blue accent — a safe SaaS default. */
117
+ midnight: {
118
+ accent: "#3B82F6",
119
+ light: {
120
+ background: "#F4F6FB",
121
+ cardBackground: "#FFFFFF",
122
+ text: "#0F172A",
123
+ textMuted: "#475569",
124
+ textSubtle: "#94A3B8",
125
+ separator: "#E2E8F0",
126
+ },
127
+ dark: {
128
+ background: "#0B1120",
129
+ cardBackground: "#111827",
130
+ text: "#F1F5F9",
131
+ textMuted: "#94A3B8",
132
+ textSubtle: "#64748B",
133
+ separator: "#1E293B",
134
+ },
135
+ },
136
+ /** Calm green — good for product/community mail. */
137
+ forest: {
138
+ accent: "#2F9E6E",
139
+ light: {
140
+ background: "#F3F7F4",
141
+ cardBackground: "#FFFFFF",
142
+ text: "#13231B",
143
+ textMuted: "#4B6358",
144
+ textSubtle: "#8AA597",
145
+ separator: "#DDE8E1",
146
+ },
147
+ dark: {
148
+ background: "#0C140F",
149
+ cardBackground: "#141F18",
150
+ text: "#ECF5EF",
151
+ textMuted: "#9CB6A7",
152
+ textSubtle: "#5F7568",
153
+ separator: "#213028",
154
+ },
155
+ },
156
+ /** Warm rose — softer, friendlier for consumer/lifecycle mail. */
157
+ rose: {
158
+ accent: "#E0567A",
159
+ light: {
160
+ background: "#FBF4F6",
161
+ cardBackground: "#FFFFFF",
162
+ text: "#26101A",
163
+ textMuted: "#6B4451",
164
+ textSubtle: "#B08A95",
165
+ separator: "#F0DDE3",
166
+ },
167
+ dark: {
168
+ background: "#160C10",
169
+ cardBackground: "#20141A",
170
+ text: "#F8EEF1",
171
+ textMuted: "#C29AA6",
172
+ textSubtle: "#7A5662",
173
+ separator: "#33222A",
174
+ },
175
+ },
176
+ } satisfies Record<string, ThemePalettes>;
177
+
178
+ /** The names of the off-the-shelf themes. */
179
+ export type ThemePreset = keyof typeof themePresets;
180
+
181
+ /** The default theme — Pithy brand (`saffron`), parchment/ink light, deep-ink dark, logo-less. */
182
+ export const defaultTheme: EmailTheme = {
183
+ appName: "Pithy",
184
+ accent: themePresets.saffron.accent,
185
+ logoUrl: "",
186
+ logoDarkUrl: "",
187
+ footerAddress: "",
188
+ light: themePresets.saffron.light,
189
+ dark: themePresets.saffron.dark,
190
+ links: [],
191
+ };
192
+
193
+ /** Build a full {@link EmailTheme} from a preset plus an optional partial override (deep-merged). */
194
+ export function resolveTheme(preset: ThemePreset, custom?: CustomTheme): EmailTheme {
195
+ const base = themePresets[preset];
196
+ const merged: EmailTheme = {
197
+ appName: custom?.appName ?? defaultTheme.appName,
198
+ accent: custom?.accent ?? base.accent,
199
+ logoUrl: custom?.logoUrl ?? "",
200
+ logoDarkUrl: custom?.logoDarkUrl ?? "",
201
+ footerAddress: custom?.footerAddress ?? "",
202
+ light: { ...base.light, ...custom?.light },
203
+ dark: { ...base.dark, ...custom?.dark },
204
+ links: custom?.links ?? [],
205
+ };
206
+ return merged;
207
+ }
208
+
209
+ /** The pixel width the layout uses for a given content width. */
210
+ export function widthPx(width: ContentWidth): number {
211
+ return width === "wide" ? 720 : 600;
212
+ }
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
5
+ //
6
+ // A Worker cannot read its own package.json, so this is how @pithy-sh/email knows its own version at
7
+ // runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
8
+ // which is what answers "should this project upgrade" and "is this customer exposed to what we just
9
+ // fixed". Those questions are only answerable per module, because a project composes some capabilities
10
+ // and not others.
11
+
12
+ /** This package's npm name — the join key against a release feed. */
13
+ export const PACKAGE_NAME = "@pithy-sh/email";
14
+
15
+ /** This package's version, stamped from its own package.json at generation time. */
16
+ export const PACKAGE_VERSION = "0.1.0";
@@ -0,0 +1,54 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
5
+ import type { AmbientEnv } from "@pithy-sh/core/src/env/ambient";
6
+ import { pithyErrorHandler } from "@pithy-sh/core/src/error/http";
7
+ import { registerWorkflowDispatchRoute } from "@pithy-sh/core/src/workflow/dispatchRoute";
8
+ import { Hono } from "hono";
9
+ import { EMAIL_CAPABILITY } from "../provision/provisionEmail";
10
+ import { emailWorkflowRegistry } from "../provision/resolveEmailConfig";
11
+
12
+ /**
13
+ * The email host worker's HTTP surface: one route, and only in `dev`.
14
+ *
15
+ * The host had no `fetch` at all — it was reached by Workflow dispatch and its cron, and nothing
16
+ * else. That is still true of a deployed environment: an app worker holds a cross-script
17
+ * `EMAIL_SENDER` binding and never speaks HTTP to this Worker. Locally there is no such binding,
18
+ * because `pithy dev` runs each worker as its own `wrangler dev` and CLAUDE.md rules out wrangler's
19
+ * cross-process service registry. So `pithy dev` composes a loopback dispatcher instead, pointed at
20
+ * `EMAIL_ORIGIN`, and this is the door it knocks on (pithy-sh/pithy#410).
21
+ *
22
+ * Everything about the route — the environment gate, the `public` verification strategy, the request
23
+ * contract, the 202 — belongs to `@pithy-sh/core/src/workflow/dispatchRoute`, so the other eight
24
+ * capability hosts mount the identical thing. What is email's is the registry: `:binding` resolves
25
+ * against email's own two jobs, and the payload validates against the declaring spec's schema.
26
+ *
27
+ * Built here rather than inside `worker.ts` because that module imports `cloudflare:workers` and so
28
+ * cannot be loaded under node. This one can, which is what lets the wiring be tested without a
29
+ * Workers runtime — and the wiring is the part that was missing.
30
+ */
31
+
32
+ /** What the app needs to know beyond its registry: where it is running. */
33
+ export interface EmailHostAppOptions {
34
+ /**
35
+ * The ambient environment the dispatch gate reads. Defaults to the process env, which in a Worker
36
+ * is the script's own vars — where provisioning stamps `ENVIRONMENT`. Injectable for tests.
37
+ */
38
+ env?: AmbientEnv;
39
+ }
40
+
41
+ /** Build the email host worker's app: the Pithy error handler, and the workflow dispatch route. */
42
+ export function createEmailHostApp(options: EmailHostAppOptions = {}): Hono<PithyHonoEnv> {
43
+ const app = new Hono<PithyHonoEnv>();
44
+ // The same handler every composed Pithy app mounts, so a refusal on this door renders as a
45
+ // `PithyError` on the wire rather than as a stack trace the loopback dispatcher would report as an
46
+ // unreadable body.
47
+ app.onError(pithyErrorHandler);
48
+ registerWorkflowDispatchRoute(app, {
49
+ capability: EMAIL_CAPABILITY,
50
+ registry: emailWorkflowRegistry,
51
+ env: options.env,
52
+ });
53
+ return app;
54
+ }
@@ -0,0 +1,219 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { defineHostEnv, type HostEnvProvider } from "@pithy-sh/core/src/workflow/hostEnv";
6
+ import type { SecretBinding } from "@pithy-sh/secrets/src/env/bindings";
7
+ import { z } from "zod";
8
+ import type { EmailSender } from "../send/sender";
9
+ import { defaultTheme, EmailTheme } from "../templates/theme";
10
+ import type { SendWorkflowInstances } from "./instances";
11
+
12
+ /**
13
+ * What the prebuilt email host worker reads out of its env — declared once, validated at boot, and
14
+ * read statically by `pithy doctor` (pithy-sh/pithy#410, #411).
15
+ *
16
+ * Nobody authors this Worker. `pithy email provision` resolves the committed template beside this
17
+ * file and deploys it, so every value here arrives as a binding, a var or a Secrets Store entry that
18
+ * a *machine* wrote — and until this module, none of it was checked. Three failures, all of them
19
+ * discovered as mail that did not arrive:
20
+ *
21
+ * - `BASE_URL` absent → every magic link in the batch pointed at `undefined/auth/…`.
22
+ * - `EMAIL_THEME` unparseable → a `JSON.parse` threw inside a render step, three retries deep,
23
+ * where the message reads as a template fault.
24
+ * - `SCHEDULER_BATCH_SIZE` written `"fifty"` → `Number(...)` gave `NaN`, the scheduler claimed
25
+ * nothing, and it did so on every tick, forever, without a line in the log.
26
+ *
27
+ * So the parse is the boot check: `z.output` is what the host actually runs on, which is why the
28
+ * coercions live in the schema rather than at the call sites. `Number(env.MAX_ATTEMPTS ?? 5)` in
29
+ * `worker.ts` was the shape of the bug — a default and a coercion restated at every reader, each free
30
+ * to disagree with the next.
31
+ *
32
+ * ## Two names, deliberately
33
+ *
34
+ * {@link EmailHostEnv} is the schema (and the type the host runs on). {@link emailHostEnv} is the
35
+ * *declaration*: the schema plus what fills each field, which is the object the CLI imports. The
36
+ * second is the one an operator's report is built from — a missing field is only actionable when
37
+ * something names the binding, var or command that writes it.
38
+ *
39
+ * Node-safe on purpose. No `cloudflare:` import and no filesystem, so `pithy doctor` can import this
40
+ * without executing the Worker — the same constraint `@pithy-sh/core`'s host modules carry.
41
+ */
42
+
43
+ /** The command that writes every provisioned value here. Stated once so nine action lines cannot drift. */
44
+ const PROVISION = "pithy email provision --env <env>";
45
+
46
+ /** A binding `pithy email provision` wires into the resolved `wrangler.jsonc`. */
47
+ const binding = (name: string): HostEnvProvider => ({ kind: "binding", name, command: PROVISION });
48
+
49
+ /** A var the same run stamps into that config's `vars` block. */
50
+ const provisionedVar = (name: string): HostEnvProvider => ({ kind: "var", name, command: PROVISION });
51
+
52
+ /** A var with a shipped default — the template carries it, and an adopter may tune it in place. */
53
+ const tunedVar = (name: string): HostEnvProvider => ({ kind: "var", name });
54
+
55
+ /**
56
+ * A binding the host calls one method on, checked structurally.
57
+ *
58
+ * Duck-typed rather than `instanceof`-checked because these are host objects the runtime hands over:
59
+ * there is no class to compare against, and `wrangler dev`'s stand-ins are different objects again.
60
+ * What the host depends on is the method, so the method is what is asserted.
61
+ */
62
+ function callable<T>(method: string, what: string): z.ZodType<T> {
63
+ return z.custom<T>((value) => typeof (value as Record<string, unknown> | null | undefined)?.[method] === "function", {
64
+ error: `${what} — the binding is missing, or it is not the kind of binding this name expects.`,
65
+ });
66
+ }
67
+
68
+ /** A D1 binding. `prepare` is the whole of what Kysely's D1 dialect needs from one. */
69
+ const d1Binding = (what: string): z.ZodType<D1Database> => callable<D1Database>("prepare", what);
70
+
71
+ /**
72
+ * A count or a duration wrangler binds as a string.
73
+ *
74
+ * `z.coerce` before the integer check, so `"50"` is fifty and `"fifty"` is a refusal naming the field
75
+ * — which is precisely the difference between this and the `Number(...)` it replaces.
76
+ */
77
+ const tunedNumber = (fallback: number) => z.coerce.number().int().positive().default(fallback);
78
+
79
+ /** The email host's env, as the host runs on it. Every field is coerced here, never at its reader. */
80
+ export const EmailHostEnv = z
81
+ .object({
82
+ DB: d1Binding("The app database holding this environment's email jobs and events").describe(
83
+ "The app D1 database. The `pithy_email_jobs` and `pithy_email_events` tables live here, beside the adopter's own data. Bound by `pithy email provision`, which fills the id for the environment being deployed.",
84
+ ),
85
+ EMAIL_SUPPRESSIONS: d1Binding("The shared suppression database every environment of this project binds").describe(
86
+ "The durable suppression D1 database — one per project, bound identically in every environment, so an unsubscribe or a hard bounce applies everywhere the project sends from. Named `<project>-global-email-suppressions`.",
87
+ ),
88
+ SECRETS: d1Binding("The per-environment secrets database the link-signing key is read from").describe(
89
+ "The per-environment secrets D1 database. The link-signing key is stored here as an encrypted row and read through `@pithy-sh/secrets`; without it, tracking and unsubscribe links cannot be signed.",
90
+ ),
91
+ SECRETS_ENCRYPTION_KEYS: z
92
+ .union([z.string().min(1), callable<SecretBinding>("get", "The master key binding")])
93
+ .describe(
94
+ "The master key that decrypts the secrets database — a Cloudflare Secrets Store binding in a deployed environment, and the same name as a plain string in `.dev.vars` locally. The one secret read outside the `secretsStore` accessor, because it is what makes the accessor work.",
95
+ ),
96
+ EMAIL: callable<EmailSender>("send", "The Cloudflare Email Service send binding").describe(
97
+ "The Cloudflare Email Service `send_email` binding — the only thing in the kit that puts a message on the wire. `wrangler dev` simulates it locally; `remote: true` (the default under `pithy dev`) sends for real from the developer's machine.",
98
+ ),
99
+ EMAIL_SENDER: z
100
+ .custom<EmailSenderBinding>((value) => isCallable(value, "create") && isCallable(value, "get"), {
101
+ error: "The send Workflow binding — it must both start an instance and answer about one.",
102
+ })
103
+ .describe(
104
+ "This host's own send Workflow, same-script. Two calls, and both matter: `create` starts a batch, and `get` is how the scheduler asks whether the instance a stranded row names is still alive before re-driving it.",
105
+ ),
106
+ EMAIL_SCHEDULER: callable<{ create(): Promise<unknown> }>("create", "The scheduler Workflow binding").describe(
107
+ "This host's own scheduler Workflow, same-script. Fired by the every-minute cron; it finds due jobs and fans them out into send batches.",
108
+ ),
109
+ BASE_URL: z
110
+ .url()
111
+ .describe(
112
+ "The app worker's public base URL for this environment. Every link that leaves in an email — magic link, tracked click, unsubscribe — is built against it, so a wrong value is a message that arrives and cannot be acted on.",
113
+ ),
114
+ EMAIL_THEME: z
115
+ .string()
116
+ .optional()
117
+ .transform((raw, ctx): unknown => {
118
+ if (raw === undefined || raw.trim().length === 0) return defaultTheme;
119
+ try {
120
+ return JSON.parse(raw) as unknown;
121
+ } catch {
122
+ ctx.addIssue({ code: "custom", message: "Not valid JSON. The whole theme travels as one JSON var." });
123
+ return z.NEVER;
124
+ }
125
+ })
126
+ // Parsed, then validated: an unreadable theme must fail here, at boot, and not inside a render
127
+ // step three retries deep where the message reads as a broken template.
128
+ .pipe(EmailTheme)
129
+ .describe(
130
+ "The resolved brand theme, serialized as one JSON var at provision from the adopter's `email()` config. Absent falls back to the kit's default theme; present-and-unreadable is refused.",
131
+ ),
132
+ ENVIRONMENT: z
133
+ .string()
134
+ .min(1)
135
+ .optional()
136
+ .describe(
137
+ "The environment this host was deployed for. Stamped on every send as `X-Pithy-Env`, which is how the single inbound bounce worker attributes a bounce back to the environment that sent it.",
138
+ ),
139
+ LINK_TTL_DAYS: tunedNumber(90).describe(
140
+ "How long a signed tracking or unsubscribe link stays valid, in days. Long by design: the link is minted into mail nobody can recall, and an unsubscribe that has expired is a complaint.",
141
+ ),
142
+ MAX_ATTEMPTS: tunedNumber(5).describe(
143
+ "How many times one job may be attempted before it is failed terminally. Spent attempts are what separates a provider hiccup from an address that will never accept mail.",
144
+ ),
145
+ SCHEDULER_ENABLED: z
146
+ .enum(["true", "false"])
147
+ .describe("The var as wrangler binds it — a string, and only these two spellings of it.")
148
+ .default("true")
149
+ .transform((value) => value === "true")
150
+ .describe(
151
+ "Whether the every-minute scheduler Workflow runs. `false` stops due jobs being claimed at all — the cron still fires and does nothing — so it is a maintenance switch, not a tuning knob.",
152
+ ),
153
+ SCHEDULER_BATCH_SIZE: tunedNumber(50).describe(
154
+ "How many jobs one send Workflow instance takes. Each job is a durable step, so this is the unit of retry and of the instance's report.",
155
+ ),
156
+ SCHEDULER_MAX_JOBS: tunedNumber(500).describe(
157
+ "The most rows one scheduler tick will claim. A ceiling on the work a single minute can start, so a backlog drains steadily rather than in one instance-creating burst.",
158
+ ),
159
+ SCHEDULER_GRACE_MS: tunedNumber(2 * 60_000).describe(
160
+ "How long an immediate job is left alone before the safety net treats it as stranded, in milliseconds. The window a freshly-enqueued job's own dispatch has to land in; too short re-drives sends that were already on their way.",
161
+ ),
162
+ SCHEDULER_STUCK_MS: tunedNumber(15 * 60_000).describe(
163
+ "How long a job may sit `sending` before the safety net re-drives it, in milliseconds. A send Workflow in retry backoff writes nothing, so this is what tells a slow batch apart from a dead one.",
164
+ ),
165
+ })
166
+ .describe("Everything the prebuilt email host worker reads out of its env, in the shape it runs on.");
167
+ export type EmailHostEnv = z.output<typeof EmailHostEnv>;
168
+
169
+ /** The send Workflow binding as the host holds it: it starts instances and answers about them. */
170
+ type EmailSenderBinding = {
171
+ create(options: { id?: string; params: { jobIds: string[] } }): Promise<unknown>;
172
+ } & SendWorkflowInstances;
173
+
174
+ /** Whether a value carries a callable method of that name. */
175
+ function isCallable(value: unknown, method: string): boolean {
176
+ return typeof (value as Record<string, unknown> | null | undefined)?.[method] === "function";
177
+ }
178
+
179
+ /**
180
+ * The email host's env declaration — the schema plus what fills each field.
181
+ *
182
+ * The host validates against this at boot ({@link import("@pithy-sh/core/src/workflow/hostEnv").requireHostEnv});
183
+ * `pithy doctor` walks the same object to check an adopter's *resolved* settings without running a
184
+ * Worker. One declaration, so the check an operator runs and the check the host runs cannot disagree.
185
+ */
186
+ export const emailHostEnv = defineHostEnv({
187
+ capability: "email",
188
+ env: EmailHostEnv,
189
+ provided: {
190
+ DB: binding("DB"),
191
+ EMAIL_SUPPRESSIONS: binding("EMAIL_SUPPRESSIONS"),
192
+ SECRETS: binding("SECRETS"),
193
+ SECRETS_ENCRYPTION_KEYS: {
194
+ kind: "secret",
195
+ name: "SECRETS_ENCRYPTION_KEYS",
196
+ command: "pithy secrets provision --env <env>",
197
+ },
198
+ EMAIL: binding("EMAIL"),
199
+ EMAIL_SENDER: binding("EMAIL_SENDER"),
200
+ EMAIL_SCHEDULER: binding("EMAIL_SCHEDULER"),
201
+ BASE_URL: provisionedVar("BASE_URL"),
202
+ // Written from the adopter's own `email()` config, so the fix is in `pithy.config.ts` and only
203
+ // then in a provision run. Named as the config key, because that is where a person edits it.
204
+ EMAIL_THEME: { kind: "config", name: "email({ theme, customTheme })", command: PROVISION },
205
+ // The catalogs are deliberately absent from this map and from the schema above. Their variable
206
+ // *names* are the project's locales — `EMAIL_MESSAGES_ES`, `EMAIL_MESSAGES_PT_BR` — and neither a
207
+ // Zod object nor a fixed declaration table can name a key that is a decision made downstream.
208
+ // `catalogsFromEnv` collects them off the raw env and validates each value; a missing one is not a
209
+ // fault, because a project that serves only English has none to carry.
210
+ ENVIRONMENT: provisionedVar("ENVIRONMENT"),
211
+ LINK_TTL_DAYS: tunedVar("LINK_TTL_DAYS"),
212
+ MAX_ATTEMPTS: tunedVar("MAX_ATTEMPTS"),
213
+ SCHEDULER_ENABLED: tunedVar("SCHEDULER_ENABLED"),
214
+ SCHEDULER_BATCH_SIZE: tunedVar("SCHEDULER_BATCH_SIZE"),
215
+ SCHEDULER_MAX_JOBS: tunedVar("SCHEDULER_MAX_JOBS"),
216
+ SCHEDULER_GRACE_MS: tunedVar("SCHEDULER_GRACE_MS"),
217
+ SCHEDULER_STUCK_MS: tunedVar("SCHEDULER_STUCK_MS"),
218
+ },
219
+ });
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * What a Workflow instance's status means to the scheduler (pithy-sh/pithy#342).
6
+ *
7
+ * The scheduler re-drives a `sending` job only when the batch holding it is not alive, and "alive" is a
8
+ * question about a Workflow instance rather than about a row. This is the one statement of which answers
9
+ * count as alive — kept out of `worker.ts` because that module imports `cloudflare:workers` and can
10
+ * therefore only be exercised by deploying it, and this is a rule worth driving.
11
+ */
12
+
13
+ /**
14
+ * The statuses that mean "this batch is still coming".
15
+ *
16
+ * Named as the live set rather than the dead one, deliberately. A status the platform adds tomorrow then
17
+ * reads as dead and its jobs are re-driven — the safety net erring towards recovery, which costs a
18
+ * duplicate render that `runSend` short-circuits for anything already `sent` — instead of this code
19
+ * silently vouching for an instance state it has never heard of.
20
+ *
21
+ * `queued` is live because the instance exists and will start. `paused` is live because a paused
22
+ * instance is resumable and still owns its rows; re-driving one would put a second Workflow behind the
23
+ * same jobs and make pausing a batch a way to send everyone in it twice.
24
+ *
25
+ * Outside it today: `errored`, `terminated`, `complete`, and anything unrecognized. None of those will
26
+ * touch another job, so whatever they left in `sending` is genuinely stranded.
27
+ */
28
+ const LIVE_INSTANCE_STATUSES: ReadonlySet<string> = new Set([
29
+ "queued",
30
+ "running",
31
+ "paused",
32
+ "waiting",
33
+ "waitingForPause",
34
+ ]);
35
+
36
+ /** Does this instance status mean the batch is still coming? */
37
+ export function isLiveInstanceStatus(status: string): boolean {
38
+ return LIVE_INSTANCE_STATUSES.has(status);
39
+ }
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The half of the Workflows binding that answers about an instance already created.
6
+ *
7
+ * Declared here rather than taken from `cloudflare:workers` because the scheduler must not depend on
8
+ * the platform types — it takes a question as a function, and only the host answers it with a real
9
+ * Workflow. It lives in its own module rather than beside that host because `workflows/worker.ts`
10
+ * imports `cloudflare:workers` and so cannot be loaded under node, while
11
+ * {@link ./hostEnv.ts} — which `pithy doctor` imports — has to be.
12
+ */
13
+ export interface SendWorkflowInstances {
14
+ /** Look an instance up by the id it was created with. Rejects when no such instance exists. */
15
+ get(id: string): Promise<{ status(): Promise<{ status: string }> }>;
16
+ }