Deployment and verification
This guide walks through a production deployment to Cloudflare. Provider interfaces and commands change over time — check current Cloudflare, Google, Microsoft, and Postmark docs as you go.
Guided repository import
The Deploy to Cloudflare button in the project README starts Cloudflare's repository-import flow. It's a guided starting point, not a one-click production deploy.
cmail has a SvelteKit Pages application (Cloudflare's frontend hosting) and a separate email Worker (Cloudflare's serverless function runtime). Both share workspace code, D1 (Cloudflare's hosted database), and R2 (Cloudflare's object storage). The Worker receives routed mail and handles the private native outbound path. Cloudflare's deployment-button documentation says buttons support Workers only: no Pages, no full monorepo support, and no deploying multiple Workers together. The button also skips OAuth registrations, production secrets, Email Sending domain onboarding, Email Routing rules, a custom domain, and cmail's one-time bootstrap.
Use the button only to import the repository, then complete every step below before calling the environment deployed. If you fork cmail, point the button at your own reviewed source, not the upstream badge — otherwise it imports upstream cmail instead of your fork. A future move to SvelteKit on Workers may automate more of this, but the email Worker and provider setup will still need manual steps.
1. Prepare a clean release
- Work from a reviewed commit.
- Run
pnpm release:check. - Confirm no secrets, real tenant IDs, private mail, or customer data are tracked.
- Review dependency changes and known advisories.
- Record the commit being deployed.
Updating an existing instance
The management console shows the web build's Git revision and compares it with main of the upstream repository when opened, then hourly while open. It shows one of:
- Up to date: upstream
mainis the commit this build came from. - Update available: · :
mainhas moved on. The commit count and "Review changes" link appear when GitHub can say how far ahead it is. - Custom build: the build had local changes, or its commit isn't on upstream
main(a fork or branch). Review compatibility before updating. - Update status unavailable — : the check couldn't run (repository unreachable,
REPO_URLinvalid or private, or a build without Git metadata). Mail and management are unaffected. - Update checks are off:
UPDATE_CHECK = "off".
How it works, so you can predict and troubleshoot it:
- Upstream.
REPO_URLin the web[vars](defaulthttps://github.com/Rob142857/cmail). It must be a plain publichttps://github.com/<owner>/<repo>URL; anything else is never fetched. The "Update procedure" link points at this section of that repository. - Sources. The latest
maincommit comes from Git's public ref advertisement (github.com/<repo>.git/info/refs) and the version frompackage.jsonat that commit onraw.githubusercontent.com. Neither uses GitHub's REST API, whose 60-requests-an-hour unauthenticated limit is shared by everyone behind the same Cloudflare egress address and is usually exhausted. The REST compare API is tried only for the commit count and fork detection, and losing it is harmless. - Caching. Results are cached in the Cloudflare edge cache and in memory for an hour (ten minutes after a failed check), so a busy console asks GitHub about once an hour per location.
- Server-side, manager-only, no secrets. The browser calls
/admin/source-updateson your own instance; the Worker fetches GitHub. No token is used and the Content Security Policy needs no change. - To turn it off, for example on an air-gapped or private-fork deployment, set
UPDATE_CHECK = "off"in the web[vars]and redeploy.
The notice describes newer source commits, not a certified release, and does not measure the separately deployed email Worker.
For an existing tenant checkout, start with a clean working tree (git status --short should be empty), then:
git pull --ff-only origin main
pnpm install --frozen-lockfile
pnpm release:check
Review the changes and pending migrations, record current deployment IDs, and complete the backup procedure below before changing production. Preserve the tenant's ignored Wrangler manifests and secrets. Then, from the repository root:
pnpm db:migrate
pnpm deploy
The root deploy command installs the email Worker before building and deploying Pages. Complete the verification checklist below, including inbound/outbound mail and the management screens. Refresh the console to see the new web revision. An open browser checks hourly; this is not an email or background notification service.
The console's Email support button opens a draft containing the web build, update status, and change link. Set UPDATE_SUPPORT_EMAIL in the primary apps/web/wrangler.toml [vars] section to your own service desk. If blank, it falls back to the site's configured support address (SUPPORT_EMAIL, including any override in Settings). The dedicated value takes precedence over site overrides; no service provider is hardcoded into the template. The manager reviews and sends the draft. There is no automatic updater: a Pages deploy hook alone would not update the email Worker or migrate D1.
2. Provision Cloudflare resources
pnpm exec wrangler login
pnpm d1:create
pnpm r2:create
Copy the D1 database ID into the web and Worker Wrangler (Cloudflare's CLI) configuration. Confirm both applications use the same R2 bucket name.
The copied apps/web/wrangler.toml and apps/email-worker/wrangler.toml files are ignored by Git — they're environment-specific manifests that pnpm deploy reads directly. Store them in access-controlled operator documentation, not Git. Never copy a manifest, resource ID, bucket name, or Worker name between Cloudflare accounts.
Replace all six namespace_id placeholders in apps/email-worker/wrangler.toml: three production IDs, three preview IDs. Use positive integers, unique within this Cloudflare account, and never reuse one between production and preview. Record the chosen IDs with the environment manifest. The config check catches duplicate IDs, but it can't tell if a placeholder is wrong for your account.
Apply pending migrations:
pnpm db:migrate
The versioned files in packages/shared/migrations are the sole schema source. Review every unapplied migration before running it against a live database. Never rewrite a migration that's already been applied — add a new forward migration instead. Back up existing data and verify a restore path before schema changes. Name local SQL exports with the .d1-export.sql suffix so the repository's ignore rules protect them from accidental commits. Store backups outside the source checkout, with access controls.
Back up and rehearse recovery
Mail metadata, bodies, and attachments live in separate D1 and R2 resources. Back up and restore both together, as one recovery point. Cloudflare has no single atomic snapshot across D1 and R2 — use a controlled maintenance window when you need strict point-in-time consistency. Exports contain private mail, addresses, and audit data: write them only to an encrypted, access-controlled location outside this checkout. Never use a production export as preview or development data.
Use Cloudflare's D1 export and an S3-compatible client such as the documented rclone integration. Give the R2 client a restricted token and a separately controlled backup destination. A D1 export blocks other database requests while it runs. For a routine recovery point: pre-copy R2, export D1, then copy R2 again — the second copy catches objects committed around the export without deleting backup objects. For a strict recovery point, pause Email Routing and interactive writes before the first command, and resume only after validation. Replace all names and the date before running:
rclone copy r2-production:cmail-storage r2-backup:cmail-storage-YYYY-MM-DD --checksum
pnpm exec wrangler d1 export cmail-db --remote --output ../cmail-backups/cmail-db-YYYY-MM-DD.d1-export.sql
rclone copy r2-production:cmail-storage r2-backup:cmail-storage-YYYY-MM-DD --checksum
rclone check r2-production:cmail-storage r2-backup:cmail-storage-YYYY-MM-DD --one-way
Hash the D1 export (sha256sum on Unix, or Get-FileHash -Algorithm SHA256 in PowerShell) and keep the checksum with the R2 prefix and maintenance-window record. A two-pass live copy can include harmless extra R2 objects created after the D1 snapshot — it's not a substitute for pausing writes when you need exact consistency.
Before production mail flow starts, rehearse recovery at least once into new, isolated resources — never overwrite production. Create a recovery D1 database and R2 bucket, then import the matching export and object backup:
pnpm exec wrangler d1 create cmail-db-recovery
pnpm exec wrangler d1 execute cmail-db-recovery --remote --file ../cmail-backups/cmail-db-YYYY-MM-DD.d1-export.sql
rclone copy r2-backup:cmail-storage-YYYY-MM-DD r2-recovery:cmail-storage-recovery --checksum
rclone check r2-backup:cmail-storage-YYYY-MM-DD r2-recovery:cmail-storage-recovery --one-way
Record the D1 export checksum, R2 backup prefix, creation time, source resource names, and confirmation the recovery validated. Check table counts and a small approved sample of message bodies/attachments on the isolated resources. Restoring a live environment needs an approved outage plan: stop mail routing and writes, restore the matching D1 and R2 point together, update both application bindings together, then test before re-enabling routing.
Create the Pages project before writing Pages secrets:
pnpm exec wrangler pages project create cmail-web --production-branch main
If you choose another project name, use it consistently in deployment and secret commands.
Automated backups (optional)
apps/backup-worker is a scheduled Worker that keeps a backup inside the same Cloudflare account, with no extra credentials:
- Mail bodies and attachments are copied from the mail bucket to
objects/<key>in a separate, private backup bucket. Keys never change in cmail, so each object is copied once; should a key's content ever change, the previous copy is kept underversions/YYYY-MM-DD/<key>for the retention window. Each hourly run copies at mostBACKUP_MAX_COPIES_PER_RUN(default 200), so a large first backup completes over several runs. - A daily manifest (
manifests/YYYY-MM-DD.json) lists every mail object that existed that day, withcomplete: trueonce all of them are in the backup. - A daily database dump (
d1/YYYY-MM-DD/): every table as NDJSON parts, the schema, andcomplete.jsonwritten last. - Safety stops. A run refuses outright, writing nothing, if the backup bucket holds non-backup keys (it is probably the mail bucket) or the mail bucket holds a backup identity (the bindings point at a backup bucket). It pauses when the backed-up objects missing from the mail bucket add up to more than
BACKUP_MAX_MISSING_SHAREof the backup (default 20%, at least 25 objects), however slowly that built up, or when the mail bucket is empty or shares no object with the backup. A paused run copies, deletes and writes nothing, keeps every manifest (only old D1 dumps are pruned), still dumps D1, and is marked failed, so it shows in Cron history. Its message ends withBACKUP_ACKNOWLEDGE_DELETIONS=<count>:<code>. Check theSOURCEbinding first. If the disappearance is genuine (a large purge or offboarding), set exactly that value: the code fingerprints those objects, so it accepts that set and nothing else, and a value left in place can never cover a later problem. Remove it after one run completes. The share is a deliberate tolerance: a wrong binding that hides fewer objects than it would not pause, so lower it for large deployments. - Deletions are honoured. An object removed from the mail bucket (retention, offboarding, emptied trash) is kept for
BACKUP_RETENTION_DAYS(default 30), then removed, so the backup never holds deleted mail longer than that. Manifests and dumps older than the window are removed too.
It holds the same private mail as production: keep the backup bucket private (no public access, no r2.dev URL), and treat access to it like access to the mail bucket. It protects against accidental deletion and overwrites within the retention window, not against losing the whole account: for that, also keep the off-account export described above. Large mailboxes need the Workers Paid plan (a run can exceed the Free plan's CPU time per invocation), and each run holds both bucket listings in memory, so it suits deployments up to tens of thousands of mail objects; watch the Worker's Cron Trigger history after the first runs.
pnpm exec wrangler r2 bucket create cmail-backup
cp apps/backup-worker/wrangler.toml.example apps/backup-worker/wrangler.toml # fill in the D1 id and bucket names
cd apps/backup-worker && pnpm exec wrangler deploy
To restore one object, copy it back by key (objects/ prefix removed) and keep its content type, then delete the local copy (it is private mail). This is enough while the message's D1 row still exists; if the row is gone too, restore D1 first (Time Travel below, within its window).
pnpm exec wrangler r2 object get cmail-backup/objects/<key> --remote --file restore.bin
pnpm exec wrangler r2 object put cmail-storage/<key> --remote --file restore.bin --content-type "<original content type>"
rm restore.bin
The NDJSON dump is a last-resort, readable copy of every table (for example after a deleted D1 database, or outside the Time Travel window): rebuild the schema from schema.sql into a new database and import the rows, then validate before pointing any application at it.
Rehearse a restore on preview resources first: point a second copy of the Worker at the preview bucket and a preview backup bucket, let it run, delete a test object from the preview bucket, restore it, and compare checksums.
Link checks against a phishing-domain list
The email Worker's hourly 7 * * * * cron downloads the CERT Polska Warning List (its data usage policy allows any use, including commercial, and requires no attribution; attribution must not imply endorsement) into the mail bucket under link-blocklist/v1/ as 256 small shards. Only changed shards are rewritten, and a failed, oversized or suspiciously small download keeps the last good copy (a list less than half the last size is accepted on the third such download). When a reader clicks a web link in a message, /link checks the host and its parent domains locally: listed means a warning page with a small "go anyway"; not listed means a redirect. Nothing about the click leaves the deployment. Links are signed by cmail, so /link never redirects a link it didn't create. Signatures are tied to the reader and expire within two weeks (they are re-made whenever a message is shown). If the list can't be read, or is more than two days old, links open normally and the decision is logged (fail open: the check is a backstop, so it never blocks mail use). Logs hold the decision only, plus the host for a listed site. Parsing the list (about 125,000 domains) needs more CPU than the Workers Free plan allows per run, so this needs Workers Paid; on Free the refresh fails and links simply open.
This is a backstop, not protection: the list mostly covers threats aimed at Polish users, so most phishing sites aren't on it, and the interface never describes an unlisted link as safe. The backup Worker skips link-blocklist/. Add the cron to an existing deployment's email Worker wrangler.toml (see wrangler.toml.example).
Point-in-time recovery with D1 Time Travel
D1 keeps history for 30 days on Workers Paid (7 days on Workers Free) through Time Travel, so a bad data change (a faulty migration, a wrong bulk action, a script error) can be undone without an export. It covers D1 only: R2 objects deleted or overwritten in the same window are not brought back, so keep the R2 copy described above.
- Before any migration or bulk change, record a bookmark with
pnpm exec wrangler d1 time-travel info cmail-db, and keep it with the change record. - To undo, get approval, pause Email Routing and interactive writes, and restore to that bookmark. To go back to a moment instead, look up the bookmark for that time first (
infotakes an RFC 3339 timestamp), then restore by bookmark. A restore rewinds every table in place, cancels in-flight queries and asks for confirmation; Wrangler then prints an undo bookmark, so record it.
pnpm exec wrangler d1 time-travel info cmail-db --timestamp=2026-01-01T09:00:00Z
pnpm exec wrangler d1 time-travel restore cmail-db --bookmark=<bookmark-before-the-change>
A D1-only restore can leave rows pointing at R2 objects that changed since. If the incident also touched message bodies or attachments, restore the matching R2 point too, as for a full recovery above. Rehearse on a preview or recovery database, never production: record a bookmark, make a harmless marker change (for example create an empty table), restore, and confirm the marker is gone. After any restore, compare per-table row counts with what you expect before re-enabling routing.
Isolate preview deployments
Pages preview builds must never inherit production mail data, storage, service bindings, or outbound capability. The committed Wrangler examples repeat every non-inheritable preview binding and variable on purpose — don't collapse those blocks into production defaults.
Create a separate
cmail-db-previewD1 database andcmail-storage-previewR2 bucket. Put the preview database ID only inenv.previewin both ignored Wrangler files.Keep Pages preview bound to
cmail-email-worker-preview. The Worker preview environment hasworkers_dev=false,preview_urls=false, empty routes, and nosend_emailbinding. Its Pages variables keepOUTBOUND_PROVIDER="none".Apply and deploy the isolated preview resources:
pnpm db:migrate:preview pnpm deploy:email-worker:previewIn the Pages dashboard, create separate Preview values/secrets for session, OAuth, bootstrap, and notification configuration. Use preview-only OAuth clients and callback URLs. Never copy production client secrets or bootstrap capabilities into Preview.
Never point production Email Routing at the preview Worker. If you need inbound preview testing, use a separate test domain and separate Worker secrets. Protect preview URLs with Cloudflare Access or equivalent controls, and restrict which branches create previews.
Before each deploy, inspect the resolved environment and confirm Preview names,
IDs, and bindings can't reach cmail-db, cmail-storage, the production email
Worker, or an outbound provider.
3. Establish the final application origin
Set APP_NAME, MAIL_DOMAIN, and an initial APP_URL in
apps/web/wrangler.toml. The project origin is normally
https://<pages-project>.pages.dev. The Pages configuration references the
private EMAIL_SERVICE binding, so deploy the email Worker first, then make a
provider-free baseline web deployment:
pnpm deploy:email-worker
pnpm deploy:web
Until you configure credentials, this deployment deliberately shows no sign-in providers. If you're using a custom hostname, attach it under the Pages project's Custom domains, wait for HTTPS to activate, then change APP_URL to that exact final origin. Don't continue to OAuth registration until the final origin is fixed and reachable.
4. Configure identity providers and secrets
Register callbacks from the production APP_URL:
<APP_URL>/auth/callback/google
<APP_URL>/auth/callback/microsoft
Restrict consent, account types, tenant selection, and admin access per your organisation's identity policy. Both providers need only the OpenID Connect (OIDC, a standard sign-in protocol) openid email profile scopes. Test with accounts that should and shouldn't be admitted.
For Microsoft 365 plus Outlook, Hotmail, and Live accounts, register a Web
application that supports accounts in any organisational directory and personal
Microsoft accounts, then set MICROSOFT_TENANT_ID=common. For an internal-only
Entra deployment, select the single-tenant account type and set the variable to
that tenant's GUID. Either way, register the exact Microsoft callback URL
shown above. cmail calls
https://graph.microsoft.com/oidc/userinfo with the access token — don't replace
this with /v1.0/me or broaden delegated permissions for cmail.
Now add a unique session key and the credentials for one or both registered providers. The Pages project must already exist for these commands to work:
pnpm exec wrangler pages secret put SESSION_SECRET --project-name cmail-web
pnpm exec wrangler pages secret put GOOGLE_CLIENT_ID --project-name cmail-web
pnpm exec wrangler pages secret put GOOGLE_CLIENT_SECRET --project-name cmail-web
# Optional Microsoft provider:
pnpm exec wrangler pages secret put MICROSOFT_CLIENT_ID --project-name cmail-web
pnpm exec wrangler pages secret put MICROSOFT_CLIENT_SECRET --project-name cmail-web
A provider's sign-in button appears only once its own credentials, the shared APP_URL, and SESSION_SECRET are all set. The default Cloudflare outbound path needs no Pages API token — add an outbound secret only for Postmark or the optional Cloudflare REST fallback.
cmail binds an account to the provider plus the immutable sub (subject ID) returned by UserInfo. Returning sign-in never selects a user by email, UPN (Microsoft's internal username-like ID), or ID-token claim. Email is checked only when consuming a manager-issued first-sign-in invitation, or during the tightly scoped first-manager bootstrap flow: Google requires a matching UserInfo email with email_verified=true; Microsoft requires its matching non-empty access-token-backed OIDC UserInfo email claim plus the independent capability, since Microsoft UserInfo omits email_verified.
A third sign-in method needs no provider registration at all: an
invitation-scoped email one-time code, for invitees whose mail isn't hosted
by Google or Microsoft. It's on by default; set EMAIL_OTP_ENABLED=false to
remove it entirely. To add a bot check on the code-request form, create a
Cloudflare Turnstile widget for APP_URL, set TURNSTILE_SITE_KEY as a
Pages variable, and store TURNSTILE_SECRET_KEY as a Pages secret — both
must be present for the widget to appear. See Email one-time-code
sign-in. The Manager role
always requires Google or Microsoft, regardless of this setting.
Required inbound Worker secret
The default per-sender inbound limit fails closed unless the Worker has an independent 32-byte HMAC (cryptographic signing) key. Generate an unpadded base64url value locally, then paste it into Wrangler's prompt. Never put it in a command argument, Wrangler file, Pages variables, or source control:
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
pnpm exec wrangler secret put INBOUND_SENDER_HASH_KEY --config apps/email-worker/wrangler.toml
Use a production-only value, distinct from the development key pnpm run setup generates. Confirm both runtimes use the same reviewed MAILBOX_STORAGE_QUOTA_BYTES, and review the Worker's MAX_INBOUND_DECODED_BODY_BYTES plus hourly inbound limits before routing mail. Without a valid sender-HMAC key, inbound attempts get a generic temporary SMTP rejection and no message is stored.
5. Configure optional browser notifications
Skip this step if you don't need browser notifications. Generate one VAPID (keys that authenticate your server to push services) pair with pnpm push:keys. Put the same public key and subject in the web and Worker Wrangler [vars], then store the same private key independently in both runtime secret stores:
pnpm exec wrangler pages secret put VAPID_PRIVATE_KEY --project-name cmail-web
pnpm exec wrangler secret put VAPID_PRIVATE_KEY --config apps/email-worker/wrangler.toml
Never put the private key in either Wrangler file. If supported clients use a push service outside the built-in Google, Mozilla, Apple, or Microsoft hosts, review that service before adding its hostname to PUSH_ENDPOINT_HOSTS in both runtimes. See Optional Web Push.
6. Configure outbound delivery
Cloudflare Email Service is the recommended default; Postmark is the alternative. Verify the sending domain and configure a system sender accepted by that provider. OUTBOUND_PROVIDER accepts cloudflare, postmark, auto, or none. Auto mode uses the first complete configuration, checking Cloudflare then Postmark. An explicit selection fails closed if its configuration is incomplete.
For Cloudflare Email Service:
- Confirm the account uses the Workers Paid plan and accepts the operational risk of Email Sending's current public beta status.
- In Compute → Email Service → Email Sending, onboard the sending domain.
Cloudflare DNS is required. Review the proposed
cf-bounceMX, SPF, DKIM, and DMARC records (DNS entries that route and authenticate mail) before accepting them. - Keep
OUTBOUND_PROVIDER = "cloudflare". In the email Worker configuration, keep[[send_email]]namedEMAIL,workers_dev = false,preview_urls = false, androutes = []. In Pages, keep the privateEMAIL_SERVICEservice binding pointed at the same environment's email Worker. - Check the sending domain's Email preview setting. New sending domains enable it automatically; while on, rendered HTML, text, headers, attachments, and raw source are retained for about seven days. Disable it unless that retention is required and approved.
- Verify the account's daily quota and stage messages within Cloudflare's general limit of 50 combined recipients and 5 MiB including attachments.
Deploy the email Worker before Pages. Pages sends only a bounded request over the private service binding; the Worker calls its native Email Sending binding and returns an opaque Cloudflare tracking ID — not the wire RFC Message-ID. Never add [[send_email]] directly to the Pages configuration.
For local or non-service-binding environments only, set the non-secret CLOUDFLARE_ACCOUNT_ID and store a narrowly scoped CLOUDFLARE_EMAIL_API_TOKEN with Email Sending: Edit as a Pages secret. This enables the REST fallback. Cloudflare's REST success response doesn't consistently show every optional field in its narrative examples, but the generated API reference includes RFC-style result.message_id, and cmail validates it when present. REST credentials also enable a single send_raw call for mixed local/external messages, keeping external envelope recipients separate from the full visible To/Cc headers. Without those credentials, native Cloudflare or Postmark submits the full recipient set once, and local recipients return through Email Routing. Local Worker email bindings are simulated unless you explicitly enable remote sending — remote calls send real mail.
For Postmark, set OUTBOUND_PROVIDER=postmark, store POSTMARK_API_KEY as a Pages secret, and publish exactly the DNS records issued for your account.
Bounce and complaint feedback (optional)
Cloudflare Email Service accepts a message and then delivers it later, so a bounce can happen after cmail has already shown the message as sent. With Email Sending event subscriptions, Cloudflare puts each delivery event on a queue and the email Worker reads it:
- A bounced, failed or rejected recipient is added to Failed recipients on the sender's Sent copy and on the mail trace.
- The sender's Sent copy shows a delivery status (Delivered, Delayed and retrying, Not delivered, or marked as spam), with each recipient's latest state under Details.
- Admin → Mail trace shows a Delivery timeline for each sent message: the hand-off to the sending service, then every event (delivered, deferred, bounced, failed, rejected, complained) with its time, the time since the hand-off, the remote server's reply and the reason where the provider gives them.
- Cloudflare reports bounces as events, not as a bounce email. So for each bounced, failed or rejected recipient, cmail puts an Undeliverable notice from "Mail Delivery System" in the Inbox of each mailbox that holds the Sent copy, with a new-mail alert where push is set up. The notice gives the cause in plain words, who has to act, how to fix it, and details for email admins: the status code, the remote server's reply, the original message details and the message hops. Cloudflare doesn't report the remote server's name or IP address, so the notice doesn't show them.
- Events for mail that cmail did not send, such as another app on the same sending domain, are dropped without being stored.
- After a hard bounce or a spam complaint, Cloudflare puts the address on its
suppression list and refuses later sends to it. cmail then tells the sender
which address is blocked and offers Send again. With
CLOUDFLARE_ACCOUNT_IDand a Pages secretCLOUDFLARE_SUPPRESSIONS_API_TOKEN(an account-owned token with Email Sending: Edit only), it also offers Unblock and send again, which lifts a bounce block for an address this mailbox was just refused for. Complaint and read-only blocks are never lifted from cmail. Without the credentials, an administrator lifts a block in the dashboard: Email Service > Email Sending > Suppressions.
To turn it on:
- Apply migrations
0019and0020(pnpm db:migrate) before deploying this release. - Create a queue, for example
cmail-email-events(npx wrangler queues create cmail-email-events). - In the queue's Subscriptions, subscribe it to Email Sending for each
sending domain. Choose all six events, or at least
message.bounced,message.failed,message.rejectedandmessage.complained. From the command line, use the short event names:npx wrangler queues subscription create cmail-email-events --source email.sending --zone-id <zone ID> --domain <sending domain> --events "message.delivered,message.deferred,message.bounced,message.failed,message.rejected,message.complained" - Uncomment the
[[queues.consumers]]block in the email Worker's Wrangler file, setqueueto your queue's name, and deploy the email Worker. - Send a test message to an address that does not exist on a domain you control, and check that the recipient appears under Failed recipients.
Events are stored with their trace row and deleted with it when trace retention runs. An event that arrives before its trace row is retried three times, a minute apart, then dropped. Events only match messages sent through Cloudflare Email Service in the last 30 days. Postmark sends are not covered.
Begin DMARC at p=none, collect authorised aggregate reports, and verify alignment for every legitimate sender before advancing deliberately to p=quarantine and then p=reject. Don't use the historic pct tag for staged enforcement. Changing MX, SPF, DKIM, or DMARC can affect other senders on the domain, so inventory them first and follow Email authentication and sender requirements.
7. Deploy applications
pnpm deploy:email-worker
pnpm deploy:web
The combined command is:
pnpm deploy
Verify the resulting Pages origin and Worker name before changing mail routing.
Deploy the optional product landing page
The public product page is a separate static Pages bundle in landing/. Always
deploy the directory itself so its screenshots and assurance documents are
uploaded with index.html:
pnpm landing:check
pnpm deploy:landing --project-name <PAGES_PROJECT> --branch <PRODUCTION_BRANCH>
pnpm landing:check -- --url https://product.example.com
The final command checks every published product-tour image for a successful
response, image/png content type, PNG signature, and 1280 × 720 dimensions.
This catches a Pages fallback serving index.html at a missing image URL.
Roll back a release
Before each release, record the deployed Git commit and the exact prior Worker version and Pages deployment IDs. These read-only commands list the current rollback candidates:
pnpm exec wrangler deployments list --config apps/email-worker/wrangler.toml
pnpm exec wrangler pages deployment list --project-name cmail-web
Keep a known-good release available until the new release passes verification. Normal releases deploy a backward-compatible Worker first, then Pages. A full rollback reverses that order: roll Pages back first, while the newer Worker still supports the previous private API, then roll the Worker back. This avoids leaving the newer Pages application calling an older Worker contract. Use the Pages project's Deployments → Rollback to this deployment action, then roll back the Worker to the recorded version:
pnpm exec wrangler rollback <WORKER_VERSION_ID> --config apps/email-worker/wrangler.toml --message "Rollback to reviewed release"
Cloudflare documents Pages rollbacks and Worker rollbacks. If the incident is isolated to one runtime, roll back only that runtime, after confirming its counterpart is still compatible. Pause routing changes and outbound tests while deciding, and repeat controlled smoke tests after each rollback.
Do not blindly roll application code back after a D1 migration. Migrations are forward-only: an older application version may not understand the current schema. Roll back only after confirming the prior release is schema-compatible — otherwise deploy a forward corrective release. If recovery requires restoring D1, restore the matching R2 point too. Rehearse with the isolated recovery steps above first, and don't re-enable Email Routing until both bindings and mail-flow verification succeed.
Rotate credentials
Rotate on a schedule you choose, and at once after a suspected leak or when someone with access leaves. Put the new value in with the same command you used the first time. A new email Worker secret takes effect at once; redeploy Pages so the new value is used everywhere. Rotate one secret at a time and check sign-in and a test message after each.
| Secret | Where | What changes when you rotate it |
|---|---|---|
SESSION_SECRET |
Pages | Everyone is signed out. Email sign-in codes and bootstrap proofs still in progress stop working, so ask for a new code. Link-check links in messages already open show the "unverified" warning page when clicked, until the message is reloaded. Pick a quiet hour. |
GOOGLE_CLIENT_SECRET, MICROSOFT_CLIENT_SECRET |
Pages | Create the new secret in the provider's console first, set it in Pages, redeploy, test sign-in, then delete the old secret in the console. |
POSTMARK_API_KEY |
Pages | Create a new server token in Postmark, set it in Pages, redeploy, send a test message, then revoke the old token. Revoking first stops outbound mail. |
CLOUDFLARE_SUPPRESSIONS_API_TOKEN (Unblock button only) |
Pages | Create a new account-owned token with Email Sending: Edit only, set it in Pages, redeploy, check the compose page offers Unblock, then delete the old token. It is separate from the send token, so it never changes how mail is sent. |
CLOUDFLARE_EMAIL_API_TOKEN (REST fallback only) |
Pages | Create a new token with Email Sending: Edit only, set it in Pages, redeploy, send a test message, then delete the old token in the Cloudflare dashboard. The service binding path doesn't use it. |
INBOUND_SENDER_HASH_KEY |
email Worker | Sender limiting starts counting afresh. Nothing else is affected. |
VAPID_PRIVATE_KEY (and the public key) |
Pages and email Worker | Existing browser notification subscriptions stop working; people must turn notifications on again. Generate a new pair (step 5), put the new VAPID_PUBLIC_KEY in both Wrangler [vars], store the new private key in both runtimes, and redeploy both. |
BOOTSTRAP_ADMIN_TOKEN (with BOOTSTRAP_ADMIN_EMAIL) |
Pages | Only needed to create the first administrator. Delete both once bootstrap is done (step 9). If you need bootstrap again, or the token may have leaked, rotate the token and restart bootstrap as step 9 describes. |
Keep the old value until the new one is verified, so you can put it back. Never put a secret on a command line that is saved in shell history or paste it into a ticket or chat.
8. Configure inbound routing
Enable Cloudflare Email Routing for the chosen mail domain. Route only intended addresses, or an explicitly approved catch-all, to the deployed email Worker. Email Routing is cmail's inbound path and is configured independently of the outbound provider — even when Cloudflare Email Service also handles outbound delivery.
The Worker accepts mail only for active mailbox rows in D1. Unknown, disabled, and offboarded addresses all get one generic SMTP-time rejection; the response doesn't reveal whether an address ever existed or why it's unavailable. The sending MTA (mail transfer agent — the server relaying the message) normally generates any non-delivery report (NDR) to its own sender. cmail deliberately sends no branded auto-reply or bounce email for rejected inbound attempts: this uses no cmail outbound quota, prevents backscatter (bounce mail sent to a forged sender), and can't be amplified by a spammer. An SMTP rejection doesn't guarantee a human receives an NDR — sender-side policy and delivery software decide that.
Test a known address and an unknown address before broadening rules. Also test an offboarded address, and confirm it gets the same generic result. Use Cloudflare Email Routing/Worker metrics and logs to monitor rejection patterns — cmail deliberately keeps no durable per-attempt log for rejected addresses.
9. Bootstrap and lock down administration
Generate at least 32 random characters for a one-time bootstrap token, then set both temporary Pages secrets and redeploy:
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
pnpm exec wrangler pages secret put BOOTSTRAP_ADMIN_EMAIL --project-name cmail-web
pnpm exec wrangler pages secret put BOOTSTRAP_ADMIN_TOKEN --project-name cmail-web
pnpm deploy:web
Open <APP_URL>/bootstrap directly — never append the token to the URL. Submit it through the same-origin form. A successful POST creates a signed cmail_bootstrap_proof cookie, valid for 10 minutes, marked HttpOnly, SameSite=Lax, and Secure on production HTTPS. Continue immediately with the provider account whose accepted UserInfo email exactly matches BOOTSTRAP_ADMIN_EMAIL — Google with email_verified=true, or Microsoft with a non-empty access-token-backed OIDC UserInfo email claim — and verify:
- the user has manager status;
- the personal mailbox address is correct;
- Admin routes reject a standard user;
- the OAuth callback uses the intended production origin.
Delete both values immediately after successful bootstrap and redeploy:
pnpm exec wrangler pages secret delete BOOTSTRAP_ADMIN_TOKEN --project-name cmail-web
pnpm exec wrangler pages secret delete BOOTSTRAP_ADMIN_EMAIL --project-name cmail-web
pnpm deploy:web
If the 10-minute proof expires, a binding conflict occurs, or the token may have leaked into a log or screenshot, rotate BOOTSTRAP_ADMIN_TOKEN, clear the browser state, and start again. Don't use bootstrap to recover or add later managers. Configure organisation and system-mail values in Admin → Settings, then invite at least one additional trusted manager through People.
Verification
Complete at least these checks:
Authentication and authorization
- Google and/or Microsoft sign-in succeeds.
- Provider consent requests only
openid email profile, and Microsoft uses the OIDC UserInfo endpoint. - A pending user can't sign in without a valid manager-issued enrolment link.
- A 72-hour invitation is single-use; resending revokes the previous link.
- First-sign-in email must match the invitation: Google requires
email_verified=true; Microsoft requires its non-empty OIDC UserInfoemailclaim plus the invitation capability. - A returning account is resolved by provider plus immutable subject even if its provider email changes.
- Subject collisions, email mismatches, expired links, and provider-binding conflicts fail closed.
- A disabled, paused, or offboarded account can't enter the mailbox.
- A standard user receives a forbidden response for Admin routes.
- A user sees only assigned mailboxes.
- Logout invalidates the browser session.
- If email one-time-code sign-in is enabled, a test invitee to an address hosted by neither Google nor Microsoft receives and can use a code, and an attempt to make that account a Manager is refused.
- If sign-in is restricted to approved countries, a sign-in from outside the list creates a pending travel request and notifies managers; approving, denying, and revoking it all work from Management > Travel approvals.
Mail flow
- An inbound plain-text message arrives in the correct mailbox.
- An inbound HTML message renders without escaping its sandbox.
- Allowed attachments can be downloaded by an assigned user.
- Blocked attachment extensions are rejected.
- Internal delivery reaches each intended mailbox.
- External outbound succeeds through the configured provider.
- Native Cloudflare sends persist opaque provider tracking IDs separately from RFC
Message-IDheaders. - A REST test validates and stores
result.message_idwhen present, and records it as unavailable rather than fabricating one when it's absent. - A mixed-recipient test keeps identical visible To/Cc roles externally and internally, sends each recipient exactly once, and exercises both the raw-REST and provider-loopback plans the deployment will use.
- Explicit outbound selection fails closed when that provider's required values are incomplete; auto selection follows the documented priority.
- cmail rejects a staged Cloudflare message above either the 50-recipient or 5-MiB general-send limit before provider submission.
- Provider failures are visible and never appear as successful sends.
- Unknown inbound recipients are rejected.
- If
SPAM_QUARANTINE_SCOREor a sender allow/block rule is configured, a test message confirms filing to Spam, and release back to Inbox, both work — see Spam and quarantine.
Administration and policy
- Managers can create users and mailboxes and assign permissions.
- Creating a user without sending mail leaves the person pending and unbound; Resend invitation makes enrolment possible.
- Policy publication and acknowledgement work for a new user.
- Relevant actions appear in audit and mail-trace views.
- Organisation sender and support settings appear in generated invitations.
- The public organisation directory is off by default.
- When enabled,
/api/organizationexposes only explicitly public positions' occupant display name, work email, and title — internal positions and hierarchy stay hidden.
Browser notifications, when enabled
- Notification controls remain unavailable when any required VAPID value is absent or invalid.
- Enabling notifications requires a deliberate signed-in user action and browser permission.
- A new-mail notification contains only generic copy and no message or mailbox metadata.
- Disabling notifications removes the user's subscription.
- A VAPID rotation and re-subscription procedure has been tested.
DNS and operations
- MX, SPF, DKIM, and DMARC results match the intended providers.
- Controlled messages at every material receiver show
spf=pass,dkim=pass, and aligneddmarc=passin received authentication results. - The deployment's current Google, Yahoo, Microsoft, and other material receiver classifications and sender policies have been reviewed.
- The accountable owner has confirmed cmail won't be used as a bulk-marketing system without the missing consent, unsubscribe, suppression, reputation, and compliance controls described in the authentication guide.
- When Cloudflare handles outbound delivery, Email preview retention and dashboard access are approved, or preview is disabled.
- D1 and R2 backup/restore procedures have been exercised.
- Monitoring covers provider errors, authentication anomalies, and mail-routing failures.
- A rollback target and credential-rotation procedure are recorded.
Don't use delivery to real recipients as the first production test. Use controlled accounts, and get approval before changing a domain with existing mail traffic.
Official platform references
- Use Deploy to Cloudflare buttons
- Deploy SvelteKit to Cloudflare Pages
- Configure Pages bindings and secrets
- Apply Cloudflare D1 migrations
- Route inbound mail to a Cloudflare Worker
- Onboard a Cloudflare Email Sending domain
- Configure a Cloudflare Email Sending binding
- Use a Pages service binding
- Use the Cloudflare Email Sending Workers API
- Use the optional Cloudflare Email Sending REST fallback
- Review Cloudflare Email Service limits
- Review Cloudflare Email authentication
- Configure and test Cloudflare MTA-STS
- Review Cloudflare Email preview
- Configure Google OpenID Connect
- Register a Microsoft Entra application
- Choose Microsoft supported account types
- Microsoft OpenID Connect UserInfo
- Generate and use VAPID keys with
web-push - Follow browser notification permission best practices