Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration

There are three layers:

  1. Deployment configuration: the Worker’s bindings, variables and secrets, written into deploy/wrangler.toml by pmail setup and uploaded by pmail deploy.
  2. Tenant policy: a JSON document per tenant, managed through the API. Defaults come from PM_DEFAULT_POLICY or the built-in defaults below.
  3. CLI configuration: ~/.config/pylota-mail/config.toml on the machine that runs pmail.

Bindings

BindingTypeName created by setupNotes
DBD1pylota-mailCreated with the chosen jurisdiction. It cannot be moved later
BLOBSR2pylota-mail-blobsSame jurisdiction. Lifecycle rule: delete inbound-staging/ after 1 day
MAILBOXDurable Object namespaceclass IdentityMailboxSQLite-backed
DOMAINSDurable Object namespaceclass DomainMonitorSQLite-backed
JOBSDurable Object namespaceclass JobRunnerSQLite-backed
QUOTADurable Object namespaceclass TenantQuotaSQLite-backed
SES_CONTROLDurable Object namespaceclass SesControlSQLite-backed. One object per deployment, used only when SES is configured: the SES control-plane token bucket
NOTIFYDurable Object namespaceclass NotifierSQLite-backed. One object per tenant: coalescing, schedules and caps of notification email (Notifications)
Q_INBOUNDQueue producer and consumerpm-inbound (DLQ pm-inbound-dlq)Batch 10, max retries 10
Q_OUTBOUNDQueue producer and consumerpm-outbound (DLQ pm-outbound-dlq)Batch 10, max retries 100. They count only unexpected errors: provider rate-limit, quota and relay back-offs re-enqueue a new message with a delay, so they never use them up; every back-off ends failed (quota_exhausted) 24 hours after submit
Q_DELIVERYQueue consumer and producerpm-delivery-events (DLQ pm-delivery-events-dlq)Batch 10, max retries 20 (the G8 retry schedule needs at least 11). Fed by Email Sending event subscriptions; the producer is used only to redrive dead-lettered items
Q_WEBHOOKSQueue producer and consumerpm-webhooks (DLQ pm-webhooks-dlq)Batch 20, max retries 13
Q_INDEXQueue producer and consumerpm-index (DLQ pm-index-dlq)Batch 10, max retries 10
VECTORSVectorizepm-mail-chunks1024 dimensions, cosine, 8 metadata indexes
VECTORS_NEXTVectorizethe new index of a re-embedOnly while an embedding-model change is re-embedding; pmail deploy adds and removes it (Search design › Index lifecycle)
AIWorkers AI–Embeddings, rerank, triage, planner, toMarkdown
EMAILsend_email–No address restrictions; the Worker enforces policy
RL_APIRate limiting–600 per 60 s, keyed by API key ID
RL_SEARCHRate limiting–120 per 60 s, keyed by API key ID
RL_AGENTICRate limiting–20 per 60 s, keyed by API key ID
RL_SENDRate limiting–120 per 60 s, keyed by identity ID
RL_SIGNINRate limiting–10 per 60 s, keyed by client IP (CF-Connecting-IP). Applies to POST /console/sign-in, /console/sign-in/link, /console/sign-in/code, /console/sign-up and /console/waitlist (Cloud sign-up)
RL_SIGNRate limiting–600 per 60 s, keyed by identity ID. Agent assertions and signed HTTP requests together (Agent signing keys)
METRICSAnalytics Engine datasetpylota_mail_metricsMetrics and alerts (Observability). Holds IDs and counts only
BACKUPR2the value of PM_BACKUP_BUCKETOnly when PM_BACKUP_BUCKET is set. Same jurisdiction as BLOBS

Every work queue has a dead-letter queue with a consumer (batch 100) that records items in D1 (dlq_items). That makes ten queues in all.

The Durable Object migrations (new_sqlite_classes: IdentityMailbox, DomainMonitor, JobRunner, TenantQuota, SesControl and Notifier, six classes) are part of the generated wrangler.toml and are versioned with the release (Rust workspace › Generated wrangler.toml). The generated file also turns invocation logs off and traces off in production ([observability.logs] invocation_logs = false), because invocation logs record request URLs and email recipients (Observability › Signals).

Cron triggers:

  • * * * * *: address retirement, the platform-event outbox sweep, restarting jobs left queued, the state-alert evaluator, and the SES inbound backstop (draining PM_SES_INBOUND_QUEUE_URL, when set). Retrying stuck sends, transport claims and uncertain-send bookkeeping run in each mailbox’s own alarms, not in the cron.
  • */15 * * * *: domain health scheduling, retention, usage roll-up, the master-key re-seal sweep, and the nightly backup job once per UTC day when PM_BACKUP_BUCKET is set.

Variables

VariableDefaultMeaning
PM_PLATFORM_DOMAIN– (required)The shared mail domain. It must be a zone apex in this account
PM_API_HOST– (required)The host that serves the REST API (/v1/*, including signed links /v1/links/*), MCP (/mcp), /openapi.json, /health, /.well-known/* (the security contact, identity JWKS and the Web Bot Auth key directory), /hooks/* and the Stripe webhook (/billing/stripe/webhook), for example mail.example.com. It is the issuer (iss) of agent assertions. It also serves the console unless PM_CONSOLE_HOST names another host
PM_JURISDICTIONeueu or default. Applied to D1, R2 and Durable Objects at creation, where eu is Cloudflare’s jurisdiction: the European Union only (R2 data location, read 2026-10-09). For the SES region checked by pmail setup ses, eu means “EU or UK”: the UK has an EU adequacy decision under the GDPR (European Commission adequacy decisions, renewed 19 December 2025, read 2026-10-09), so eu-west-2 (London) is accepted
PM_CF_ACCOUNT_ID– (written by setup)The Cloudflare account ID. Needed by the Worker’s own Cloudflare REST calls (zone onboarding, event subscriptions, the Email Sending suppression list)
PM_ENVproductionproduction, staging or local. Shown in /health and logs
PM_EMBED_MODEL@cf/baai/bge-m3Changing it starts a background re-embed (see Search design › Index lifecycle)
PM_EMBED_MODEL_PREVIOUSunsetOnly during a re-embed: the old model, used for semantic reads until the new index is complete. pmail deploy sets and removes it
PM_RERANK_MODEL@cf/baai/bge-reranker-baseSet to none to disable reranking
PM_AGENT_MODEL@cf/qwen/qwen3.8-27bThe function-calling model for agentic search
PM_TRIAGE_MODEL@cf/openai/gpt-oss-20bA JSON-output model for triage
PM_AI_GATEWAYunsetOptional AI Gateway ID. Model calls go through it for logging and caching
PM_TRUSTED_AUTHSERV_IDempty (set by pmail setup)The Authentication-Results authserv-id stamped by Cloudflare’s MX. pmail setup runs the mail test as its last step and writes the authserv-id it observed here; pmail doctor --mail-test prints it too. While it is empty, SPF cannot be read (Email Workers do not see the client IP), so a sender whose DMARC policy is quarantine or reject and who aligns only through SPF gets the verdict unverified and is quarantined as auth_unverified, never fail. Headers from any other authserv-id are always ignored
PM_DOH_RESOLVERShttps://cloudflare-dns.com/dns-query,https://dns.google/resolveTwo independent resolvers for domain checks and DKIM keys
PM_SES_REGIONunsetThe AWS region of Amazon SES, for both directions: with the secrets PM_SES_ACCESS_KEY_ID and PM_SES_SECRET_ACCESS_KEY it enables the SES transport, and with the three PM_SES_INBOUND_* variables also SES receiving. pmail setup ses requires one of the 22 regions that receive mail (SES endpoints, read 2026-10-09), and a region in the EU or the UK when PM_JURISDICTION=eu unless it was run with --allow-non-eu
PM_SES_SNS_TOPIC_ARNunsetThe only SNS topic whose notifications POST /hooks/ses accepts. Required with the SES transport
PM_SES_INBOUND_BUCKETunsetThe S3 bucket of the receipt rule pm-deliver. Set with the two below, it enables inbound = ses (dns_records, and smtp_relay with inbound: ses). Without all three, those domains get 422 transport_unavailable (ses_receiving_not_configured) (Domains on any DNS host › Deployment set-up for SES)
PM_SES_INBOUND_TOPIC_ARNunsetThe only SNS topic whose notifications POST /hooks/ses/inbound accepts
PM_SES_INBOUND_QUEUE_URLunsetThe SQS backstop queue subscribed to the same topic, drained by the every-minute cron
PM_SES_RULE_SETpylota-mailThe active SES receipt rule set. It holds pm-deliver and the pm-retired-{n} rules that the domain monitors edit
PM_CF_SUBDOMAIN_SETUPoffon allows the delegated_subdomain method (Cloudflare Enterprise accounts only, once spike S10 has passed). While it is off, that method gets 422 transport_unavailable (subdomain_setup_disabled)
PM_WEB_BOT_AUTHoffon publishes the Web Bot Auth key directory at /.well-known/http-message-signatures-directory and allows signed HTTP requests (POST …/http-signatures), for tenants whose policy has web_bot_auth.allowed: true. Turn it on only once spike S13 has passed; while it is off, those requests and the web_bot_auth key rotation get 422 web_bot_auth_disabled and the directory 404 (Agent signing keys, Deploy › Signed HTTP requests)
PM_IDENTITY_KEY_OVERLAP_DAYS7Days a rotated identity signing key stays retiring: still published in the identity’s JWKS, no longer signing (Agent signing keys › Keys)
PM_DAILY_SEND_QUOTAunsetThe account’s Email Sending daily quota, copied from the Cloudflare dashboard. Cloudflare does not expose it to the Worker. When set, an alert fires at 80% of it; when unset, the alert fires on the first quota error (G3)
PM_BACKUP_BUCKETunsetName of a second R2 bucket. When set, setup creates it in the same jurisdiction, binds it as BACKUP, and a nightly job copies new t/ objects into it (Privacy design). Off by default
PM_SCANNER_URLunsetOptional malware scanner endpoint (see Inbound)
PM_SECURITY_CONTACTunsetServed in /.well-known/security.txt
PM_LOG_LEVELinfoerror, warn, info or debug. Content is never logged at any level
PM_DEFAULT_POLICY{}JSON merged over the built-in tenant policy defaults
PM_CONSOLEonon serves the console at /console; off removes its routes
PM_QUARANTINE_KEY_RELEASEonon: API keys with quarantine:review may release quarantined mail (POST …/release). off: only a signed-in person can, in the console, and API keys get 403 permission_denied (FR-CON-6). Pylota Mail Cloud sets off. With PM_CONSOLE=off it is always treated as on
PM_CONSOLE_HOSTthe value of PM_API_HOSTThe host that serves the console. When it differs from PM_API_HOST, console paths answer only on this host and API paths only on PM_API_HOST; anything else gets 404, and no cookie is set or read on the API host. Every console POST must carry Origin: https://{PM_CONSOLE_HOST} (CSRF defence in depth), and console links in mail use that origin. It is read even with PM_CONSOLE=off, because invitation links use it
PM_SIGNUPclosedSelf-serve sign-up: closed (people join by invitation or pmail setup --owner-email), waitlist (double opt-in, invited in batches with pmail waitlist invite) or open (Cloud sign-up)
PM_SYSTEM_FROMPylota Mail <no-reply@{PM_PLATFORM_DOMAIN}>The display name and address of the system identity, which pmail setup creates on the default tenant and which sends sign-in, invitation and notification mail through the platform domain. Its local part may be a reserved name; it is never listed to tenants (Identities and domains › The system identity). Read even with PM_CONSOLE=off
PM_NOTIFICATIONSonon sends notification email to people: usage alerts, new-mail notifications and the daily “needs a person” email, each as their preferences allow. off sends only account emails (Notifications). Read even with PM_CONSOLE=off
PM_TERMS_URL, PM_PRIVACY_URL, PM_DPA_URLunsetTerms of Service, Privacy Policy and Data Processing Addendum, linked from the sign-up checkbox. Required when PM_SIGNUP is not closed
PM_TERMS_VERSIONunsetThe terms version stored on the user (users.terms_version) when they accept. Required when PM_SIGNUP is not closed
PM_SIGNUP_BLOCKED_DOMAINSunsetComma-separated domains refused at sign-up, before any mail is sent, in addition to the built-in list of disposable-mail domains that ships with each release
PM_OAUTH_GOOGLE_CLIENT_IDunsetWith the secret PM_OAUTH_GOOGLE_CLIENT_SECRET, enables “Continue with Google”
PM_OAUTH_GITHUB_CLIENT_IDunsetWith the secret PM_OAUTH_GITHUB_CLIENT_SECRET, enables “Continue with GitHub”
PM_BILLINGoffoff (no plan checks; operator quotas only) or stripe (plans, metering, Stripe checkout and portal)
PM_PLAN_CATALOGbuilt-in Cloud catalogJSON plan catalog (see Billing design), including each plan’s Stripe price IDs
PM_BILLING_GRACE_DAYS7Days a past_due workspace keeps its plan before Free limits apply

Secrets

pmail setup generates the random ones (32 bytes from the OS CSPRNG, base64) and uploads them as Worker secrets. Without --print-secrets they go only to the Worker (through Wrangler, on stdin), never to stdout, a file or a log; with it they are printed once to stdout.

SecretRequiredUsed for (one purpose each; no secret is derived from another)
PM_MASTER_KEYyesAES-256-GCM encryption at rest of webhook secrets, identity signing keys, the Worker-generated thread, link and cursor keys and the Web Bot Auth deployment key, SMTP relay credentials, TOTP secrets and recovery codes, and OAuth PKCE verifiers (Data model › Notes)
PM_MASTER_KEY_NEXTonly during pmail secrets rotate-masterThe new master key while stored values are re-sealed. pmail doctor warns while it is set
PM_KEY_PEPPERyesHMAC-SHA256 of API key secrets
PM_HASH_KEYyesPseudonymisation: address tombstones, suppression hashes, log and query hashes
PM_CF_API_TOKENfor some domain methods (for every deployment if a spike S6 REST fallback is taken)Runtime automation of tenant domains (zone onboarding and creation, literal routing rules, event subscriptions), and the REST fallbacks for Vectorize and Workers AI if spike S6 fails (Rust workspace §7). Required on the Worker for the cloudflare_zone, nameservers (a token that can create zones) and delegated_subdomain methods; without it they get 422 cf_token_required. pmail domains add --local-token with your own token can then add an apex cloudflare_zone domain only (catch-all, no literal rules). dns_records, send_only and smtp_relay need no Cloudflare token. Its permissions are in Deploy › Create a Cloudflare API token
PM_SES_ACCESS_KEY_ID, PM_SES_SECRET_ACCESS_KEYnoAmazon SES in both directions: sending (dns_records, send_only, failover), creating domain identities, and receiving (S3 objects, the SQS backstop, receipt-rule updates). pmail setup ses creates the IAM user with exactly one policy
PM_STRIPE_SECRET_KEYonly with PM_BILLING=stripeStripe API calls (Checkout sessions, Customer Portal sessions, subscription reads). Use a restricted key with only those permissions
PM_STRIPE_WEBHOOK_SECRETonly with PM_BILLING=stripeVerifying the Stripe-Signature header on /billing/stripe/webhook
PM_OAUTH_GOOGLE_CLIENT_SECRET, PM_OAUTH_GITHUB_CLIENT_SECRETonly with the matching client IDThe OAuth code exchange for Google and GitHub sign-in

Rotating PM_KEY_PEPPER (pmail setup --rotate-pepper) invalidates every API key, so it is a break-glass action. Rotating PM_MASTER_KEY uses pmail secrets rotate-master, which uploads PM_MASTER_KEY_NEXT, waits until the Worker has re-sealed every stored value under it, then replaces PM_MASTER_KEY. No secret is ever read back from the Worker by the CLI (Security design).

The keys that sign thread tokens, signed links, search cursors and Web Bot Auth requests are not Worker secrets. The Worker generates them (32 random bytes each), stores them in D1 signing_keys sealed under PM_MASTER_KEY, and puts their key ID (kid) in everything it signs: one character for thread, link and cursor, and the RFC 7638 thumbprint of the public key for web_bot_auth. No API, CLI command or log ever returns a private key; the web_bot_auth public key is published in the key directory.

PurposeSignsRotate withOld kid keeps verifying for
threadThread tokens in Reply-To sub-addressesPOST /v1/platform/keys/thread/rotate90 days
linkSigned attachment and export links, console sign-in, invitation and session tokens, OAuth state hashesPOST /v1/platform/keys/link/rotate7 days (the longest link lifetime)
cursorSearch cursors (next_cursor)POST /v1/platform/keys/cursor/rotate24 hours (the cursor lifetime)
web_bot_authSigned HTTP requests (Web Bot Auth) and the key directory; only with PM_WEB_BOT_AUTH=onPOST /v1/platform/keys/web_bot_auth/rotate7 days (still listed in the key directory)

All four need a platform key with platform:ops (REST API); the CLI is pmail keys rotate thread|link|cursor|web_bot_auth. Identity signing keys are not in this table: each identity has its own, rotated through POST /v1/identities/{identity_id}/keys/rotate (Agent signing keys). Add ?revoke_previous=true (--revoke-previous) after a suspected leak: the previous kid is deleted at once instead of verifying for its window, so what it signed stops working (thread tokens fall back to header threading; open links, sign-in tokens, invitations, sessions, OAuth flows and cursors fail). Then rotate PM_MASTER_KEY.

Tenant policy

Stored per tenant. PATCH /v1/tenants/{tenant_id} (a platform key with tenants:manage) with { "policy": { … } } deep-merges it. This is the full document with defaults:

{
  "identity_daily_send_cap": 500,
  "tenant_daily_send_cap": 5000,
  "max_recipients": 10,
  "send_allowlist_only": false,
  "large_attachments": "refuse",
  "link_ttl_hours": 72,
  "ai_disclosure": { "mode": "none", "text": "This message was written with the help of an AI assistant." },
  "auto_reply": {
    "allowed": true,
    "max_automatic_exchanges": 2
  },
  "quarantine": {
    "on_auth_fail": true,
    "spam_threshold": 0.8,
    "unsolicited_otp": true
  },
  "inbound": {
    "per_sender_per_hour": 60,
    "extract_attachment_text": ["pdf", "office", "text", "html"],
    "extract_image_text": false,
    "ses_bounce_retired": true
  },
  "retention": {
    "raw_days": 90,
    "message_days": null,
    "events_days": 30
  },
  "triage": {
    "enabled": true,
    "categories": null,
    "rules": []
  },
  "search": {
    "agentic_enabled": true,
    "agentic_daily_cap": 500,
    "agentic_max_steps": 6,
    "agentic_max_seconds": 8,
    "refs_packs": ["core"],
    "custom_refs": []
  },
  "webhook_text_bytes": 16384,
  "abuse": {
    "complaint_rate_pause": 0.003,
    "bounce_rate_pause": 0.05
  },
  "domains": {
    "allow_create_zone": false
  },
  "web_bot_auth": {
    "allowed": false
  },
  "domain_fallback": true
}
FieldNotes
tenant_daily_send_capOn Pylota Mail Cloud, a new workspace on the Free plan starts with a cap of 50 for its first 7 days. The ramp lifts on day 7 if its bounce and complaint rates are under the abuse thresholds, or at once on a paid plan (Cloud sign-up › Abuse and safety)
max_recipients1–49. Cloudflare allows 50 recipients per message, and one is kept for the hidden journal copy of Message-ID strategy B (Outbound design)
large_attachmentsrefuse, or link (expiring signed links, link_ttl_hours 1–168)
ai_disclosure.modenone, footer (appended to text and HTML) or header (X-AI-Generated: true)
auto_reply.max_automatic_exchangesAutomatic replies allowed per thread before a human must act (D6)
inbound.ses_bounce_retiredtrue bounces mail to retired addresses on SES-receiving domains with 550 5.1.6, through SES receipt rules; false drops it without a bounce (Domains on any DNS host › Retired and unknown recipients)
quarantine.unsolicited_otpQuarantine password-reset and OTP mail that no wait asked for (E5)
retention.message_daysnull keeps parsed messages indefinitely. A number deletes messages, attachments, index rows and vectors after that age, except held threads
retention.events_days1–365, default 30. Webhook delivery rows, the event index and the event payloads kept for replay are deleted after this many days. Webhook replay reaches back 30 days from an event’s occurred_at, or this many days if fewer (Privacy design › Retention)
triage.categoriesnull uses the built-in list. Otherwise an array of up to 20 { "name": "pcn", "description": "Penalty charge notices from councils" }, which replaces it
triage.rulesDeterministic rules. See Triage
search.refs_packscore (amounts, phones, emails, domains, dates, invoice and order numbers) and optional uk_vehicle (plates, PCNs). There is no built-in pack for booking references: add them with custom_refs
search.custom_refsUp to 20 { "name": "booking", "pattern": "BK-\\d{4,6}", "normalise": "upper" }. Patterns use the regex crate syntax: linear time, no back-references, compiled size capped at 64 KB
domains.allow_create_zoneLets the tenant’s own keys use the nameservers method, which creates a Cloudflare zone. false by default; Pylota Mail Cloud sets it to true. Without it, the request gets 422 transport_unavailable (zone_creation_not_allowed). Platform keys may always use it
web_bot_auth.allowedLets the tenant’s identities obtain signed HTTP requests (Web Bot Auth). false by default: a tenant must opt in, and until it does those requests get 403 policy_denied. It has no effect while PM_WEB_BOT_AUTH is off (Agent signing keys)
domain_fallbackfalse fails sends on a failing domain instead of using the platform address

CLI configuration

# ~/.config/pylota-mail/config.toml
[profiles.default]                 # the profile pmail setup and pmail login write unless --profile names another
url = "https://mail.example.com"
key = "pmk_live_…"                 # or key_command = "op read op://vault/pylota-mail/key"
identity = "bookings.acme@agents.example"   # default for mail commands
account_id = "0123456789abcdef0123456789abcdef"   # Cloudflare account ID, stored by pmail setup

[profiles.staging]
url = "https://mail-staging.example.com"
key_env = "PYLOTA_MAIL_STAGING_KEY"

Precedence, highest first: command-line flags (--url, --key, --profile, --account-id), then the environment (PYLOTA_MAIL_URL, PYLOTA_MAIL_KEY, PYLOTA_MAIL_PROFILE, CLOUDFLARE_ACCOUNT_ID), then the profile in the file. Without --profile or PYLOTA_MAIL_PROFILE, the profile is default_profile when the file sets it, else the one named default. pmail setup and pmail login write the profile named by --profile, default default; PYLOTA_MAIL_PROFILE and default_profile do not change it. The file is created with mode 0600, and pmail refuses to read it (exit 3) if its group or other users have any access to it, or if another user owns it.

The commands that use CLOUDFLARE_API_TOKEN are listed in CLI › Commands that use your Cloudflare token. The token is never stored. The account ID comes from --account-id (a global flag), else CLOUDFLARE_ACCOUNT_ID, else the profile’s account_id (written by pmail setup; not a secret), else PM_CF_ACCOUNT_ID in deploy/wrangler.toml.