pithy add email --with-prerequisites--with-prerequisites composes secrets, which email requires — the link-signing key is read through the registry.
You may never type this command. auth declares email as a peer capability, so pithy add auth --with-prerequisites composes it for you, deepest first.
What lands in your repo
apps/<worker>/pithy.config.ts gains the registration:
email({
fromAddress: "noreply@example.com",
fromName: "Pithy",
baseUrl: PUBLIC_ORIGIN,
theme: "saffron",
}),fromAddress and fromName are the two to change first. The address must use a domain onboarded onto Cloudflare Email Service, and the name is what recipients see — a recognizable one improves deliverability.
baseUrl is written as the derived PUBLIC_ORIGIN constant rather than a literal, because a URL typed there is one environment’s origin written into every link this capability mints.
apps/<worker>/wrangler.jsonc gains three bindings, in every environment stanza:
| Binding | Type | What it is |
|---|---|---|
DB | d1 | Where the job rows live — the same app database |
EMAIL_SUPPRESSIONS | d1 | The project-wide opt-out list, shared across environments |
EMAIL_SENDER | workflow | The Workflow that delivers a queued message |
The Workflow entry names the capability’s host Worker across scripts, so it is complete before that host exists and wrangler dev binds it either way. The host itself is deployed by provision.
What runs, and what is minted
add runs that Worker’s dev migrations, and mints email-link-signing-key into <config>/<project>/secrets.jsonc — outside every checkout. Deployed environments get their own from pithy secrets provision, because a link-signing key is arbitrary: any random string works, since nothing outside the project validates one.
The seven config options
| Option | Default | What it decides |
|---|---|---|
fromAddress | noreply@example.com | The address every message is sent from. Must use an onboarded domain |
fromName | Pithy | The sender display name recipients see |
baseUrl | PUBLIC_ORIGIN | The public origin tracking and unsubscribe links are built against, so it must be reachable from a recipient’s inbox |
basePath | /email | Where the management routes mount — the send log and the suppression list. Every one is control-plane and default-denied |
theme | saffron | One of four off-the-shelf themes to bootstrap from. Fine-tune with a customTheme object |
schedulerEnabled | true | Whether the every-minute scheduler Workflow runs, finding due scheduled and per-timezone sends |
devDelivery | remote | What the prebuilt worker does under pithy dev. remote sends real mail; simulator logs and writes to disk instead |
The recipient callbacks — click, open, unsubscribe — keep a fixed prefix and are unaffected by basePath, because those URLs are already minted into mail nobody can recall.
Then provision
pithy email provisionThat creates the project’s shared suppression database, migrates it, and deploys the per-environment email worker.
Add the inbound bounce route when the subdomain exists:
pithy email provision \
--routing-zone <zone-id> \
--inbound-address bounce@bounce.example.com \
--app-worker acme-prodThe three routing flags are all or nothing, and the reason is worth reading before you type them: enabling Email Routing on a zone points its MX at Cloudflare. Use a subdomain, never your apex, or you will move your real inbound mail off your existing provider.
See a template before you ship it
pithy email test --to you@example.com --template magicLinkThat renders one template through your project’s own configuration — identity, theme and all — and sends it. It deploys nothing, and tracking is off for that message.
Check it worked
pithy doctor reports email under the Worker’s health, and its settings check is one of the two tiers that actually looks at a value: a fromAddress on a domain nobody onboarded is a finding rather than a silent failure on the first send.