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

Design

The design documents are binding. A coding agent implements what they say. When the code and a design disagree, the design wins until an ADR changes it (see AGENTS.md). When two pages disagree with each other, Precedence below decides which one wins. Each document cites requirement IDs from the PRD and rows from the edge-case register, and ends with a Tests section that maps them to named tests.

The public contracts live in the reference section and are not repeated here: REST API, Errors, Webhook events, Configuration and Limits. The storage schema is in Data model.

Precedence

When two pages disagree, one rule decides which wins:

Kind of behaviourExamplesWinsMust match it
Wire behaviour: what a client sends and receivesPaths and methods, status codes, error codes, request and response fields, enums, defaults and bounds, required permissions and key levelsopenapi.yamlThe design pages and the other reference pages (REST API, Errors, MCP, the guides)
Internal behaviour: what happens inside the serviceStorage, state machines, algorithms, retry schedules, Durable Object requests, queue messages, logs and metricsThe design page that owns the areaThe other design pages, the guides and the reference prose

Agents follow it as a rule: for wire behaviour, openapi.yaml wins and the design must match; for internal behaviour, the design page wins. A disagreement is a documentation bug, never a choice for the implementer: fix the losing page in the same pull request, and write an ADR only when the winning page is itself wrong (Decision records).

Index

DocumentWhat it decides
Rust workspace and platformThe Cargo workspace, crate boundaries and allowed dependencies, exact dependency pins, the release profile, wasm32 constraints, the platform trait set (clock, randomness, D1, Durable Objects, R2, Queues, Workers AI, Vectorize, email, rate limits, DNS-over-HTTPS, HTTP), the wasm-bindgen externs that fill workers-rs gaps, the generated wrangler.toml, the xtask commands, the CI pipeline and the Rust SDK (FR-SDK-1).
Data modelEvery D1 table, every Durable Object SQLite table, R2 keys and the Vectorize index. It is the source of truth for migrations. The other designs refer to its tables and columns by name.
Inbound pipelineThe email() handler (normalisation, directory lookup, reject codes, the R2 write before acknowledgement), the pm-inbound consumer (MIME parsing under caps, sanitising, text derivation, quote and signature stripping, hidden-text removal, reference extraction, automation classification, the authentication verdict, spam score, quarantine), the IdentityMailbox.ingest transaction, attachment safety and text extraction, DSN routing, re-parsing and test-mode loopback.
Outbound and safe retriesThe send path from request to transport: the idempotency fingerprint and reservation, the ordered policy pipeline with its error codes, message composition (From, Reply-To with thread token, threading headers, signature and disclosure, marketing headers, attachments), the thread lock, the MailTransport trait and its implementations, the classification of every transport outcome, delivery events and status roll-up, suppressions, abuse auto-pause, uncertain-send reconciliation, cancel and resolve, SES specifics and the outbound Message-ID strategy.
ThreadingThe thread token’s exact byte layout and verification, key rotation, the thread resolution order, Message-ID normalisation, subject normalisation, forwarded messages, participants, and which address a reply is sent from (including fallback-pinned threads).
Identities, addresses and domainsThe identity and address state machines (promote, retire, rollback, retirement, tombstones), username validation with reserved and confusable detection, domain kinds and their onboarding against the Cloudflare and SES APIs, the DomainMonitor health state machine, fallback, domain removal, and the Cloudflare API token permissions.
Domains on any DNS hostThe six connection methods and the kind, inbound and transport each one fixes; SES in both directions for domains at any DNS host (deployment set-up, the SNS push plus SQS backstop with the ses_ingest ledger, retired and unknown recipients); send_only forwarding; smtp_relay with its alignment probe; zone creation for dedicated domains; delegated subdomains; health checks per method, cost, and spikes S10–S12.
Agent signing keys and signed requestsPer-identity Ed25519 keys (generation, sealing, rotation with overlap, revocation, tombstones), agent assertions (JWT) and the per-identity JWKS, signed HTTP requests (Web Bot Auth, RFC 9421) with the deployment key and its signed key directory, the kill switch on pause and suspension, and spike S13.
SearchThe query language and its typed parse tree, keyword search (FTS5, references, trigram fallback), semantic search (chunking, embeddings, Vectorize), hybrid fusion and reranking, agentic search with deterministic citation verification, facets, cursors, tenant fan-out and the index lifecycle.
TriageDeterministic rules, the model call with fenced untrusted content, output schema validation, categories and risk flags, the thread roll-up and re-runs.
Webhooks and eventsThe transactional outbox and its dispatch alarm, the event envelope and payload builders, endpoint resolution, Standard Webhooks signing and secret storage, the SSRF-guarded HTTP client, the retry schedule, delivery logs, dead letters, auto-disable and replay.
MCP serverThe Streamable HTTP endpoint at /mcp, authentication, tool definitions and their mapping to REST, permission filtering and the mail_search_strategy prompt.
CLI and setupThe pmail command tree, setup and deploy (resource creation, bundle verification, wrangler.toml rendering), doctor, profiles and output formats.
SecurityThe threat model, key handling (the four key levels, partner keys included), tenant and partner isolation, secrets and their single purposes, SSRF and content-safety rules, and the attack test suite.
Privacy and erasureJurisdiction, retention sweeps, erasure jobs per scope with receipts and probes, legal holds and subject-access export.
Console and workspacesThe server-rendered console at /console: passwordless sign-in, sessions and CSRF, workspaces, members, roles and invitations, and the console’s pages.
Cloud sign-up, sign-in and first runHostnames for Pylota Mail Cloud, PM_SIGNUP and the waitlist, Google and GitHub sign-in, TOTP two-step verification and recovery codes, where people land after sign-in, the Overview and its first-run checklist, the Checkout return, and Cloud abuse controls.
Plans, metering and billingThe plan catalog, allowances and atomic holds in TenantQuota, 402 billing_limit, the usage API, and Stripe checkout, portal and webhooks.
Notifications and usage alertsEmail to the people behind the agents: usage alerts, new-mail notifications, the daily “needs a person” email and account emails; per-person preferences, the per-tenant Notifier Durable Object (coalescing, schedules, caps), one-click unsubscribe, and bounces.
Observability and SLOsStructured logs without content, metrics, alerts, SLOs, dead-letter handling through the platform API, and the runbooks.
TestingThe test layers (core::, conf::, it::, live::), the conformance corpus, the workerd harness, fakes, fuzzing, quality gates and the cross-tenant attack suite.

Shared conventions

These rules apply to every design. A design may add to them but never contradict them.

1. Layering

crates/api-types   types only (serde, utoipa). No I/O.                 depends on: serde, utoipa
crates/core        pure logic. No I/O, no clock, no randomness.         depends on: api-types + pure crates
crates/platform    traits + Cloudflare implementations + fakes.        the ONLY crate that imports `worker`
crates/worker      handlers, Durable Objects, consumers, transports.   depends on: core, api-types, platform
  • core functions take everything they need as arguments: the current time as now_ms: i64, random bytes as [u8; N], lookups as traits implemented by the caller (for example ThreadLookup in Threading). They return decisions and data, never perform effects. core builds for the host and for wasm32-unknown-unknown.
  • platform wraps every Cloudflare API behind a trait. Native tests use the in-memory fakes in platform::fakes. No other crate names a worker:: type, enforced by cargo xtask check-layering.
  • worker orchestrates: it reads, calls core to decide, writes, and schedules follow-up work. Business rules that can be expressed without I/O live in core, so they are unit-tested natively.

2. Errors and the error envelope

Every failure that reaches a client is an ApiError, serialised as the error envelope:

// crates/api-types/src/errors.rs
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode { Unauthenticated, KeyExpired, KeyRevoked, PermissionDenied, ScopeDenied, /* …one
    variant per code in errors.md… */ InternalError, UpstreamError, Unavailable, SearchDegraded, Timeout }

impl ErrorCode {
    pub const fn http_status(self) -> u16;     // from the errors.md tables
    pub const fn retryable(self) -> bool;      // from the errors.md tables, never decided at call sites
    pub const fn default_fix(self) -> &'static str;
}

pub struct ApiError {
    pub code: ErrorCode,
    pub message: String,                       // human text; never contains message content or addresses
    pub fix: Option<String>,                   // overrides default_fix when more specific
    pub details: Option<serde_json::Value>,
}
  • core returns domain errors (AddressError, PolicyError, QueryError, …). Each has one impl From<…> for ApiError in api-types, so a rule and its error code are defined together.

  • platform returns PlatformError { kind, binding, detail }. detail is a short machine string and never contains content or clear-text addresses. The worker maps it:

    PlatformErrorKindErrorCode
    Unavailable (D1 or a Durable Object overloaded, a binding refusing)unavailable (503, retryable)
    Timeout (an internal deadline)timeout (504, retryable)
    Upstream (a Cloudflare or SES REST API returned an unexpected status during a synchronous call)upstream_error (502, retryable)
    NotFound for an object the caller namedthe resource’s own *_not_found code
    NotFound for an internal object, Corrupt, Internalinternal_error (500, retryable), logged with request_id
  • Errors after a send was accepted are never HTTP errors. They become message statuses and reason codes (Errors › Send failures after 202).

  • A 404 returned by the service always carries one of its own codes. Resources outside the key’s scope return the same *_not_found as missing ones (NFR-SEC-1).

3. Time, randomness and IDs

  • Clock. All time comes from platform::Clock::now_ms() (Unix milliseconds, i64), implemented with Date.now(). In Workers, Date.now() advances only across I/O, so two reads in one synchronous block return the same value; designs rely on that only for “same transaction, same timestamp”. std::time::SystemTime::now() is never called in wasm code.
  • Randomness. All randomness comes from platform::Rng (crypto.getRandomValues). core takes random bytes as arguments.
  • IDs are {prefix}_{ULID} (Data model › Conventions), generated only by platform::Ids::new_id(prefix). The generator is monotonic within an isolate: if the clock has not advanced past the last ID’s millisecond, it reuses that millisecond and increments the 80-bit random part by one (moving to the next millisecond on overflow). The req_ request ID is generated at the start of every fetch, email, queue, scheduled and alarm invocation and carried through internal calls and logs.

4. Durable Object transactions

workers-rs 0.8.7 exposes SqlStorage::exec but not transactionSync (read 2026-10-09 from the v0.8.7 source). Cloudflare documents ctx.storage.transactionSync(callback), which rolls back if the callback throws, and forbids BEGIN/SAVEPOINT inside sql.exec() (SQLite storage API, read 2026-10-09). platform therefore provides Sql::transaction_sync, a wasm-bindgen call to ctx.storage.transactionSync (see Rust workspace). The rules:

  1. One write transaction per state change. Every write path in a Durable Object runs inside one transaction_sync closure. The closure is synchronous: no .await, no subrequests.
  2. Decide before, re-check inside. Reads used to decide may happen before the transaction (for example, while waiting on TenantQuota), but every precondition is re-checked inside it, because other requests can interleave at any .await.
  3. Effects after commit. R2 writes, queue sends, D1 writes and calls to other Durable Objects never run inside a transaction. Those that must happen before the state change (an R2 object the row will point to) run before it; the rest run after commit and are either idempotent or repaired by an alarm.
  4. Outbox in the same transaction. Every transaction that changes externally visible state appends its events to the object’s outbox table inside the transaction (section 6).
  5. One alarm, many purposes. An object has a single alarm. Each object keeps its pending wake-ups in meta under alarm:{purpose} (for example alarm:outbox, alarm:check, alarm:claim, alarm:dispatch, alarm:maintenance; Data model lists each object’s keys). After each transaction it sets the alarm to the earliest pending wake-up if that is earlier than the current alarm. Every mailbox has a daily alarm:maintenance that deletes expired idempotency rows, rate_windows older than 48 hours, verifications past expires_at or 1 hour past consumed_at, outbox rows past the tenant’s events_days, unpins quiet fallback threads (Threading), and refreshes the database size in meta.size_bytes (Data model › Mailbox notes). The alarm handler runs every due purpose, then re-arms. Handlers are idempotent, because Cloudflare delivers alarms at least once and retries a failed handler with exponential backoff from 2 seconds, up to six times (Alarms, read 2026-10-09).
  6. Schema on wake. Each object applies its migrations on first access in a transaction, guarded by meta.schema_version (J9).
  7. Limits. Durable Object SQLite allows 100 bound parameters per statement, 100 KB statements and 2 MB per row or value (limits, read 2026-10-09). Designs that store text cap it below those limits (see Inbound › Storage caps).

5. Internal Durable Object RPC

Durable Objects are called with a typed request enum over fetch to the stub. There is no other entry point into an object.

// crates/worker/src/rpc.rs
#[derive(Serialize, Deserialize)]
pub struct RpcEnvelope<T> {
    pub v: u8,                         // 1
    pub tenant_id: Option<String>,     // None only for platform-level DomainMonitor and SesControl calls
    pub identity_id: Option<String>,   // required for IdentityMailbox
    pub request_id: String,            // req_…
    pub actor_key_id: Option<String>,  // for audit
    pub deadline_ms: i64,              // absolute; the object refuses work past it
    pub req: T,
}

#[derive(Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum MailboxRequest {
    Init(InitMailbox),                       // identity created: stores owner in meta, emits identity.created
    Ingest(IngestInput),                     // inbound.md
    Reparse(ReparseInput),                   // inbound.md, J3
    AttachmentTextReady(AttachmentText),     // inbound.md
    Submit(SubmitInput),                     // outbound.md
    BeginTransport(BeginTransport),          // outbound.md: claim before calling a transport
    RecordTransportOutcome(TransportOutcome),// outbound.md
    ApplyDeliveryEvent(DeliveryEvent),       // outbound.md
    Cancel(CancelInput), Resolve(ResolveInput),
    LearnMessageId(LearnMessageId),          // outbound.md: Message-ID strategy B (journal copy)
    RegisterWait(RegisterWait),              // inbound.md › The wait handler: E5 registration, every 10 s
    WaitPoll(WaitQuery),                     // inbound.md › The wait handler: one poll, every 1 s
    EmitEvent(EmitEvent),                    // identity.* events written after a D1 change
    GetEvents(GetEvents),                    // webhooks.md delivery and replay
    // Read, search, triage, label and erasure operations are added by their designs.
}
// DomainRequest, JobRequest, QuotaRequest, SesControlRequest (domain-connections.md § 4.8) and
// NotifierRequest (notifications.md § 3) follow the same pattern, and each starts with an
// `Init` variant that stores the owner in the object's `meta` (QuotaRequest: outbound.md › TenantQuota).

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RpcResult<T> { Ok(T), Err(ApiErrorBody) }
  • The caller addresses the object with id_from_string(<stored DO id>) (IDs are created with unique_id_with_jurisdiction and stored in D1, ADR 0002) and sends POST https://do.internal/rpc with the JSON envelope. A handled result, success or error, is HTTP 200 with RpcResult. Any other status, or a thrown exception, is a platform failure (unavailable).
  • Tenant check. The handler compares tenant_id and identity_id with the owner stored in meta (written by Init). A mismatch returns internal_error, logs the security event rpc_owner_mismatch with both IDs, and increments rpc_owner_mismatch_total, which alerts at 1. The public handler has already checked scope against D1 before calling, so a mismatch is always a bug.
  • An object that has no owner yet accepts only Init. An object marked erased = '1' refuses everything with identity_not_found.
  • The caller’s default deadline is 10 seconds (Ingest and Submit: 30 seconds). The object checks deadline_ms before starting a transaction and returns timeout if it has passed.

6. Transactional outbox

Every object with an outbox table (IdentityMailbox, DomainMonitor, JobRunner) emits events the same way, detailed in Webhooks and events:

  1. Inside the state-change transaction: increment meta.event_seq, build the full envelope (events) with sequence = event_seq, insert it into outbox with dispatched_at = NULL, and record alarm:outbox = now.
  2. The alarm writes event_index rows to D1 (INSERT OR IGNORE), sends one pm-webhooks message per event, then sets dispatched_at.
  3. A crash between steps repeats step 2. Consumers deduplicate on the event ID, so delivery is at least once and never lost.

7. Idempotent queue consumers

Every queue is at least once. Every consumer is written so that processing the same message twice has the same effect as once:

QueueMessage identityHow a repeat is absorbed
pm-inboundmessage_id (allocated in email())ingest deduplicates on raw_sha256 and returns the stored message; post-commit steps (R2 attachment writes, index jobs) are idempotent by key
pm-outboundmessage_idThe transport claim (BeginTransport) admits one transport call per message; a repeat finds the message no longer queued and acks
pm-delivery-eventsprovider eventIdStored in deliveries.provider_event_ids_json; a repeat is a no-op
pm-webhooks(endpoint_id, event_id, attempt)Unique index on webhook_deliveries; a repeat attempt number is skipped
pm-indexjob kind + target + versionEach job checks the target’s status (chunks.status, attachments.text_status, triage version) before working

Rules for every consumer:

  • Process messages one at a time within a batch, and ack() each one after its effect has committed.
  • Retry counting never uses Message.attempts. workers-rs 0.8.7 does not expose it (its Message binding has only id, timestamp, body, retry and ack; read 2026-10-09 from the v0.8.7 source). A bounded retry schedule is implemented in one of two ways, named in each design:
    • Re-enqueue: the body carries attempt: u32. On a known transient failure the consumer sends a new message with attempt + 1 and delay_seconds from the schedule (MessageBuilder::delay_seconds), then acks the current one. Used where the Worker has a producer binding for the queue.
    • Age: the consumer computes the age of the work from a timestamp it owns (a field in the body, a provider event timestamp, or a stored row) and calls retry_with_options with the schedule’s delay until the age limit is reached.
  • An unexpected error (a bug, a panic) is logged and the message is left to the queue’s own retry (retry()), up to the queue’s max_retries, then the dead-letter queue, whose consumer records and alerts (J8).
  • A queue handler that returns an error fails the whole batch (and workers-rs 0.8.7’s own macros turn a returned Err into a panic). Handlers therefore ack or retry each message themselves and return Ok(()); the only deliberate error is the inbound temporary failure in Inbound, which the entry glue raises as a thrown exception (Rust workspace).
  • Queue bodies are JSON with "v": 1 and a "kind" tag. They carry IDs and pointers only, never message bodies, attachment content or subjects (Queues allow 128 KB per message; designs stay under 4 KB).
  • Consumers never trust a pointer’s scope blindly: they re-read the tenant and identity rows from D1 and check status before writing.

8. Logging

Logs never contain message bodies, subjects, attachment content or clear-text addresses (FR-PRV-6). Addresses that must be correlated are logged as HMAC-SHA256(PM_HASH_KEY, address) truncated to 16 hex characters. See Observability.

Spikes

The spikes run in milestone M1 of the build plan against a scratch Cloudflare account. Each result is recorded in the design it affects, under a “Spike result” note with the date. A failed spike takes the listed fallback, and the design is updated before the build continues.

SpikeMust provePass criteriaFallbackAffects
S1 Bindings smokeFrom Rust with worker 0.8.7, through the platform::export_worker! entry glue (which replaces #[event] and #[durable_object], see Rust workspace): receive an email event and read from, to, headers and the raw stream; send with the send_email binding’s structured send() including replyTo, headers (In-Reply-To, References, Auto-Submitted, X-*) and attachments (attachment and inline with contentId); produce and consume Queues with delay_seconds and retry_with_options, and confirm that Message::timestamp() is unchanged across retries; DO SQLite with the transactionSync extern (a thrown error rolls back) and alarms; D1 batch; a multi-statement request to the D1 query API (POST /accounts/{a}/d1/database/{id}/query); and the response of the local wrangler dev email endpoint to a setReject (Testing §6.4)Every call works from Rust. The returned messageId is captured. A rolled-back transaction leaves no rows. A multi-statement D1 query-API request is atomic: when its last statement fails, none of the earlier statements’ rows remainRaw MIME send (EmailMessage) built with mail-builder for any missing structured field. If the entry glue cannot replace the macros: an ADR allowing exactly one file, crates/worker/src/entry.rs, to use them (Rust workspace §2). If the D1 query API is not atomic: every migration file is made re-runnable and a CI lint enforces it (CLI and setup §8.5). transactionSync has no fallback; this is an accepted risk. The planned path is a wasm-bindgen call to ctx.storage.transactionSync, which Cloudflare documents with no restriction on the calling method beyond a SQLite-backed object (SQLite storage API, read 2026-10-09), so any JS method of the class, including the glue’s, may call it. If S1 shows otherwise, the build stops and an ADR is written before M4 continues. The owner accepted this risk on 2026-10-09Rust workspace, Outbound, CLI and setup
S2 Inbound failure semanticsWhat the sending MTA sees when email() throws, versus setReject (documented as a permanent error); which Authentication-Results headers reach the handlerThrowing yields a 4xx temporary failure and the sender retries. The exact SMTP reply text for both cases is recorded. Record which Authentication-Results authserv-id Cloudflare stamps on delivered mail (setup later writes it to PM_TRUSTED_AUTHSERV_ID, Inbound › Authentication verdict)Throw only. The handler keeps its in-handler R2 retries (three attempts) and then throws, as designed, whatever the sender is shown; the spike result records the observed reply in Inbound. There is no forward() to a backup address: setup registers no Email Routing destination address (Identities and domains › Cloudflare API token), so none exists to forward toInbound
S3 FTS5 in DO SQLitecontent='', contentless_delete=1, the trigram tokenizer, bm25() with six column weights, DELETE FROM fts WHERE rowid = ?, and renaming an FTS5 table (for the index swap in Search)All work on the deployed runtime, not only on local workerdExternal-content table fts_docs (Data model); disable trigram and rely on reference normalisation plus semantic fallback; without rename, the swap rebuilds fts in placeData model, Search
S4 Wasm budgetBundle size, cold start, CPU time and peak memory when parsing and verifying a 25 MiB message and a 40 MB message from the SES source (N5), and mail-auth verdict parity on the corpusCompressed bundle ≤ 10 MiB (NFR-SEC-2), cold start under 1 s, both messages parsed under 128 MB peak and inside the CPU limit, DKIM and DMARC verdicts equal the reference implementation on every corpus messageWrite attachments to R2 before parsing bodies; move heavy features behind cargo features; switch the release profile to opt-level = "z"Rust workspace, Inbound
S5 MCP over rmcpStreamable HTTP served from fetch using rmcp 3.4.1 protocol types, without a tokio runtime. Note: rmcp 3.4.1 (like 3.5.1) declares tokio (features sync, macros, rt, time) as a non-optional dependency (crates.io metadata, read 2026-10-10)MCP Inspector and Claude Code connect, list tools and call one; the wasm build never starts a tokio runtime or timer, and the bundle stays inside the S4 budgetImplement the JSON-RPC types locally in worker; keep rmcp as a native dev-dependency for client tests. Taken in advance (ADR 0009): M1 still runs S5 against the local types to record the Inspector and Claude Code resultMCP server
S6 Externs and jurisdictionVectorize upsert, query (namespace and metadata filter), deleteByIds, getByIds and describe() (the vector count); AI.run with the gateway option, including the bge-m3 output shape, the reranker’s score form and the agent model’s chat-completions schema (Search); AI.toMarkdown (including whether PDF output marks page boundaries) — all through wasm-bindgen externs; DO IDs from unique_id_with_jurisdiction("eu") stored as strings and re-addressed with id_from_stringEvery call works and each recorded shape matches the design, or the design is updated with the observed one. An EU object reports the EU jurisdiction (ctx.id.jurisdiction)REST fallbacks (/vectorize/v2/…, /ai/run, /ai/tomarkdown) using PM_CF_API_TOKEN, which then becomes required (Rust workspace §7); one page per document when toMarkdown does not mark pages. If an EU object does not report the EU jurisdiction, the build stops for an owner decision, because FR-PRV-1 depends on itRust workspace, Inbound, Search
S7 Outbound Message-IDThe relationship between the messageId that send() returns and the Message-ID header recipients seeEither a deterministic mapping (strategy A), or the header learned from a journal copy (strategy B)Strategy B: a hidden journal BCC to journal+{message ulid}.{identity ulid}@{PM_PLATFORM_DOMAIN}; the email handler records the header and drops the copy (Outbound). If a journal copy never arrives, that message matches replies by thread token and provider ID onlyOutbound, Threading
S8 SES in wasmSigV4 signing for SES v2 SendEmail with raw content, and SNS message signature verification (SignatureVersion 2; version 1 is refused), from Rust in wasmA real send through SES in eu-west-2; a real SNS notification verified, and a tampered one rejectedSES leaves v1.0: an ADR moves send_only, dns_records, smtp_relay with inbound: ses and the SES failover to v1.1Outbound, Identities and domains
S9 Event subscriptions and onboarding APIsCreate an Email Sending event subscription to pm-delivery-events through the API for one domain (source type email.sending with zone_id and domain; this source shape appears in Wrangler’s source, not yet in the API reference), receive all six event types, delete it. Onboard a zone apex and a subdomain through POST /zones/{zone_id}/email/sending/subdomains, and enable routing on a subdomain through POST /zones/{zone_id}/email/routing/dns with name. A literal routing rule whose worker action value is the script name pylota-mail delivers to the WorkerPayload fields match Outbound › Delivery events; subscriptions can be created per domain at runtime with PM_CF_API_TOKEN; apex and subdomain onboarding both work through the APIFor cloudflare_zone and nameservers, the API creates the domain without a subscription, marks it delivery_events: "manual", and returns its records with details.action = "run pmail domains subscribe <domain>"; delivery events start once that command has run (Identities and domains › Kind zone, tests it::domains::s9_manual_delivery_events and, for nameservers, it::domains::s9_manual_delivery_events_nameservers). Any onboarding step the API cannot do is listed by pmail domains add as a dashboard step and checked by pmail doctorIdentities and domains, Outbound, CLI and setup
S10 Child zonesOn an Enterprise account, a subdomain-setup child zone accepts Email Routing catch-all to the Worker and Email Sending onboarding, and both work end to end. Optional: skipped when no Enterprise account is available (Build plan › Human prerequisites)Mail to any address at the child apex reaches email(); a send is DKIM-aligneddelegated_subdomain stays off; dns_records covers the caseDomains on any DNS host
S11 SES receivingRule set, S3 action and topic as specified; the notification shape, including the objectKey form; S3 GetObject with SigV4 from a Worker; a 39 MB message (N5); user+tag@ routing; the retired-address bounce; the backstop picks up a message whose push failedAll pass in eu-west-2dns_records and smtp_relay with inbound: ses do not ship in v1.0; send_only still doesDomains on any DNS host
S12 SMTP from a WorkerPorts 465 and 587 with StartTls against two real providers; the certificate host name is checked (a wrong-name certificate is refused); timeouts and the uncertain window behave as designedAll passsmtp_relay does not ship in v1.0Domains on any DNS host
S13 Web Bot Auth formatA request signed by core::httpsig with the deployment key (Signature-Agent as a quoted structured-field string; Signature-Input covering @authority, signature-agent and from, with tag="web-bot-auth", keyid = the JWK thumbprint, created, expires and a 64-byte nonce), sent to https://crawltest.com/cdn-cgi/web-bot-auth, which answers 401 for a well-formed message with an unknown key, 200 for a known key that verifies and 400 otherwise (Web Bot Auth, read 2026-10-09). Needs no Cloudflare account401 before the key directory is registered (well-formed, unknown key), never 400Signed HTTP requests stay off in v1.0: PM_WEB_BOT_AUTH cannot be turned on. Agent assertions are unaffectedAgent signing keys