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)
RL_PARTNERRate limiting–10 per 60 s, keyed by partner ID. POST /v1/tenants and POST /v1/tenants/{tenant_id}/invitations called with a partner key, across all of the partner’s keys (Security § 10)
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, the SES inbound backstop (draining PM_SES_INBOUND_QUEUE_URL, when set), and minting the Durable Object IDs that only the Worker can mint for rows written outside it: the system identity’s mailbox, the DomainMonitor of a domain row with monitor_do_id = '' (the platform domain, and domains added with pmail domains add --local-token), the Notifier of a tenant row with notify_do_id = '' (then NotifierRequest::Init), and the SesControl object when SES is configured. 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, the nightly backup job once per UTC day when PM_BACKUP_BUCKET is set, and the new-workspace send-ramp evaluation once per UTC day (crons/signup_ramp.rs; it finds ramped Free workspaces with PM_BILLING=stripe, and ramped tenants of partners on any deployment).

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), except on a tenant whose policy has quarantine.key_release: true (Tenant policy). 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 and no usage alerts; only the daily caps in tenant policy apply) 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: create and retrieve Checkout Sessions, create Customer Portal sessions, read subscriptions, and cancel subscriptions when a workspace is deleted. Use a restricted key with exactly those permissions (Billing › Stripe integration)
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} with { "policy": { … } } deep-merges it; it needs tenants:manage, so a platform key changes any tenant’s policy and a partner key the policy of the tenants its partner’s keys created (REST API › Partners). Tenant keys and the console cannot change it. A partner key may change only some fields, and some only downwards (Who may change a field), so one partner cannot spend the shared sending reputation or the AI budget of a Cloud deployment. 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,
    "key_release": false
  },
  "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,
    "cloudflare_zones": []
  },
  "web_bot_auth": {
    "allowed": false
  },
  "domain_fallback": true
}
FieldNotes
tenant_daily_send_capWith PM_BILLING=stripe, a new workspace on the Free plan has an effective cap of min(this value, 50) until tenants.ramp_lifted_at is set: for its first 7 days, then until the daily evaluation (the */15 cron’s crons/signup_ramp.rs) finds its bounce and complaint rates under the abuse thresholds. A paid plan lifts it at once. A tenant a partner’s key created follows the same ramp whatever PM_BILLING and its billing mode (exempt included), unless a platform key set the partner’s ramp_exempt; an exempt tenant has no plan, so only the daily evaluation lifts it. The system identity is never counted against this cap (Cloud sign-up › New-workspace send ramp)
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)
quarantine.key_releasetrue lets keys with quarantine:review that reach this tenant, its partner key included, release its quarantined mail even when PM_QUARANTINE_KEY_RELEASE is off. false by default. Only a platform key, or the partner key of the tenant’s own partner, can set it (a tenant key cannot call PATCH /v1/tenants/{tenant_id}: 403 permission_denied). With PM_QUARANTINE_KEY_RELEASE=on it changes nothing. On Pylota Mail Cloud, Pylota’s partner key sets it to true on each operator’s tenant (J14, J16)
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, and its partner key, use the nameservers method, which creates a Cloudflare zone. false by default; Pylota Mail Cloud sets it to true in PM_DEFAULT_POLICY. Without it, the request gets 422 transport_unavailable (zone_creation_not_allowed). Platform keys may always use it. Only a platform key can set it
domains.cloudflare_zonesZones of the deployment’s Cloudflare account, by name (A-label apex, lower case, up to 50), that the tenant’s own keys and its partner key may use with the cloudflare_zone method and with replace_mx, besides the zones this deployment created for the tenant (nameservers, delegated_subdomain). A listed zone grants names strictly under it; its apex and replace_mx there stay platform-only. [] by default. A zone created for another tenant, or one under the zones of PM_PLATFORM_DOMAIN, PM_API_HOST or PM_CONSOLE_HOST, is refused even when listed (403 scope_denied, details.reason = "zone_not_allowed"). Platform keys may use any zone. Only a platform key can set it (Identities and domains › Zone permission)
web_bot_auth.allowedLets the tenant’s identities obtain signed HTTP requests (Web Bot Auth). false by default, and until it is true those requests get 403 policy_denied. Only a platform key can set it: a tenant or partner key cannot turn it on. 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

Who may change a field

Every policy write is checked against this table: the policy of POST /v1/tenants and of PATCH /v1/tenants/{tenant_id}. Only the fields present in the write are compared; one refused field refuses the whole write and nothing is stored. Platform keys may set every field. Tenant keys, identity keys and the console cannot write the policy at all (PATCH /v1/tenants/{tenant_id} needs tenants:manage).

ClassFieldsA partner key
Platform-onlyweb_bot_auth.allowed, domains.allow_create_zone, domains.cloudflare_zones403 scope_denied with details.field
Platform or own partnerquarantine.key_releaseMay set it on the tenants of its own partner, at creation and later
Lower-onlyidentity_daily_send_cap, tenant_daily_send_cap, max_recipients, auto_reply.allowed, auto_reply.max_automatic_exchanges, inbound.per_sender_per_hour, inbound.extract_image_text, retention.raw_days, retention.events_days, triage.enabled, search.agentic_enabled, search.agentic_daily_cap, search.agentic_max_steps, search.agentic_max_seconds, abuse.complaint_rate_pause, abuse.bounce_rate_pauseMay set a value at or below the field’s ceiling; above it, 403 scope_denied with details.field
FreeEvery other field: send_allowlist_only, large_attachments, link_ttl_hours, ai_disclosure, quarantine.on_auth_fail, quarantine.spam_threshold, quarantine.unsolicited_otp, inbound.extract_attachment_text, inbound.ses_bounce_retired, retention.message_days, triage.categories, triage.rules, search.refs_packs, search.custom_refs, webhook_text_bytes, domain_fallbackMay set any valid value
  • Ceiling. A lower-only field’s ceiling is the more restrictive of the deployment default (the built-in defaults above merged with PM_DEFAULT_POLICY) and the tenant’s platform ceiling, the value a platform key last set on that field, at creation or by PATCH (kept in tenants.policy_ceilings_json): min(deployment default, platform ceiling). A platform key’s null for the field removes its platform ceiling. A higher number is always the looser value, the abuse thresholds included (a higher rate makes auto-pause more lenient), and so are longer retention, more steps or seconds, and more automatic exchanges.
  • Switches. For auto_reply.allowed, inbound.extract_image_text, triage.enabled and search.agentic_enabled, true is the looser value (it sends more mail or spends Workers AI). A partner key may always set false, and true only when the deployment default and the platform ceiling (if any) are both true.
  • null from a partner key on a lower-only field resets it to the deployment default, so it is compared as that value: refused when a platform ceiling is lower.
  • Ceilings are checked when a value is written. Changing PM_DEFAULT_POLICY later does not rewrite stored values; a platform key that wants a tenant lower sets the field.
  • An identity’s send_policy.daily_cap (POST …/identities, PATCH /v1/identities/{identity_id}) may not exceed the tenant’s effective identity_daily_send_cap for any key but a platform key (403 scope_denied, details.field = "send_policy.daily_cap"), so a lower-only cap cannot be raised one identity at a time.
  • quarantine.on_auth_fail: false is free, but it removes a guarantee: mail whose authentication verdict is fail or unverified is then stored as received and evented to agents with that verdict, instead of being quarantined (rules 3 and 3a of Inbound › Quarantine decision). A partner that turns it off accepts that its agents must check verdict themselves.

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.