Stand up the email infrastructure — the project’s shared suppression database, a per-environment email worker, and optionally the inbound rule that routes bounces back at your app — and send one rendered template so you can see it arrive.
Synopsis
pithy email provision [--worker <name>] [--routing-zone <zone-id>] [--inbound-address <address>] [--app-worker <name>] [--json]
pithy email deprovision [--suppression] [--json]
pithy email test --to <address> [--template <id>] [--from <address>] [--json]This is the provisioning half of the email capability: pithy add email writes the wiring, and this command creates what the wiring points at. Once it is standing, sending a transactional email is application code.
All three subcommands reach a Cloudflare account. There is no local mode and no --env flag: provisioning spans every managed environment in one run, and test sends a real message.
Flags
provision
| Flag | Default | Purpose |
|---|---|---|
--worker <name> | the project’s only Worker | The app Worker whose wrangler.jsonc carries the per-environment DB binding and public address |
--routing-zone <zone-id> | — | Cloudflare Zone ID of the subdomain receiving the mail. Email Routing must already be enabled on it |
--inbound-address <address> | — | The exact recipient address the rule matches, e.g. bounce@bounce.example.com |
--app-worker <name> | — | Deployed name of the production app worker running the email() bounce handler |
--json | false | Machine-readable output |
deprovision
| Flag | Default | Purpose |
|---|---|---|
--suppression | false | Irreversible. Also delete this project’s suppression database — every environment forgets who unsubscribed or hard-bounced |
--json | false | Machine-readable output |
test
| Flag | Default | Purpose |
|---|---|---|
--to <address> | — | Required. Who receives the sample |
--template <id> | welcome | Which template to render |
--from <address> | the configured fromAddress | Override the sending address for this one message |
--json | false | Machine-readable output |
The three routing flags are wired only when all three are given. A partial set is treated as no routing at all: everything else provisions and routing.skipped is true.
Enabling Email Routing on a zone points its MX at Cloudflare, so the zone, the address and the target worker are an operator’s decision rather than something derived. Bounces routed back at your app are what keep the suppression list honest, which is most of deliverability. Use a subdomain zone, never your apex, or you will move your real mail off your existing provider.
What it does
provision, in order — and the order is the contract:
- Preflight. Verify the account can host a Workflow at all — most importantly a registered
workers.devsubdomain. - Suppression database. Create or reuse
<project>-global-email-suppressions, one per project rather than one per environment: an unsubscribe in production has to stop staging too. - Migrate it. Applied migrations skip.
- Workers. Deploy the prebuilt email worker for every managed environment, each bound to the shared suppression database and to its own environment’s resources.
- Routing rule. Last, and only with all three routing flags.
Each environment’s deploy needs three things resolved first, and each missing one is refused rather than deployed around: the app DB id from that environment’s stanza; that Worker’s public address for the environment; and the environment’s secrets database, which pithy secrets provision creates.
The address is resolved through one resolver that prefers the Worker’s domains declaration, falls back to its route, then to vars.BASE_URL. Tracking and unsubscribe links are built against whatever it returns, so a Worker with none of the three is refused rather than deployed against a guess.
deprovision deletes every environment’s email worker first, because they bind the suppression database, and then deletes the database itself only when --suppression is passed. The global opt-out list is preserved by default; losing it is harmful.
test renders one template through your project’s own configuration — identity, theme and all — and sends it over the Cloudflare Email Sending API. It deploys nothing.
A throwaway tracking context is built so that any template renders, including marketing templates that force an unsubscribe link, but open and click tracking are both off and no link is actually tracked: this is a visual and delivery check of your configuration. A transactional template still renders without an unsubscribe link and without a List-Unsubscribe header, tracking context or not — the kind is declared by the template, so there is no context that could add one to a sign-in message.
--json
email provision
| Key | Type | Meaning |
|---|---|---|
command | "email provision" | The subcommand that produced this line |
suppressionDatabaseId | string | The D1 id of the project’s shared suppression database, created or reused |
environments | string[] | The environments an email worker was deployed for |
routing | object | What happened to the inbound Email Routing rule |
routing.created | boolean | True only when this run created the rule. False when it already existed |
routing.skipped | boolean | True when the routing flags were absent or incomplete, so no rule was made |
email deprovision
| Key | Type | Meaning |
|---|---|---|
command | "email deprovision" | The subcommand that produced this line |
suppressionDeleted | boolean | Whether --suppression was passed |
email test
| Key | Type | Meaning |
|---|---|---|
command | "email test" | The subcommand that produced this line |
template | string | The template id that was rendered |
to | string | The recipient the sample was sent to |
from | string | The address it was sent from |
messageId | string | The id Cloudflare assigned the message. Absent from the line when the API returned none — the key is omitted rather than emitted as null |
Errors
The capability is not configured. No Worker’s pithy.config.ts composes email.
Credentials are missing.
No Secrets Store id. The email worker decrypts its signing key from it. All three subcommands resolve credentials through the same reader, so deprovision and test are refused without it too, though neither writes a secret.
The project has no name. The suppression database is found by name and reused, so a guessed name would adopt another project’s opt-out list.
The app Worker’s environment is not wired, or has no address for it:
api has no prod address.
Declare it in the Worker's pithy.config.ts — `domains: { prod: { pattern: "…", zone: "…" } }`. Tracking and unsubscribe links are built against it.The secrets database does not exist.
The prod secrets database (acme-prod-secrets) does not exist.
Run `pithy secrets provision` first — the email worker reads its signing key from it.An unknown template. The refusal lists every known template id.
Several Workers and no --worker. The resolution error pithy add raises.
Examples
Provision without routing, then add the rule once the bounce subdomain exists:
$ pithy email provision
Suppression database and 2 email workers ready.
Done.pithy email provision --routing-zone 0a1b2c3d --inbound-address bounce@bounce.example.com --app-worker acme-prod --jsonSend yourself the magic-link template as your project renders it:
$ pithy email test --to sam@example.com --template magicLink
Sent "magicLink" from hello@acme.com to sam@example.com (msg-4821).
Done.Take the workers down and keep the opt-out list:
pithy email deprovision --json