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

Concepts

This page defines the things Pylota Mail is made of and how they relate. Each section ends with links to the reference and design documents that hold the detail.

Deployment (platform) ── platform keys, the platform mail domain, platform webhooks
  └─ Tenant (ten_)           live | test · policy · address suffix · quotas
       ├─ Domain (dom_)      zone | delegated | external · connection method
       ├─ Webhook (whk_)     tenant endpoints
       ├─ API key (key_)     tenant or identity level
       └─ Identity (idn_)    one mailbox (Durable Object)
            ├─ Address (adr_)      primary | alias · pending | active | retiring | retired
            ├─ Signing key (kid)   active | retiring | retired
            └─ Thread (thr_)
                 └─ Message (msg_) inbound | outbound
                      └─ Attachment (att_)

Tenants

A tenant is one customer of the deployment, for example one car-rental operator. Every record belongs to exactly one tenant, apart from platform-level settings and the platform domain (FR-TEN-1).

PropertyMeaning
slugShort name, for example acme
modelive or test. A test tenant’s mail never leaves the deployment: it goes to the simulator, or to identities on the same deployment (FR-OUT-12). Its keys start with pmk_test_
address_suffixAppended to usernames on the platform domain, "." + slug by default. bookings in tenant acme becomes bookings.acme@agents.example. Only the default tenant made by pmail setup has an empty suffix
policyCaps, quarantine thresholds, retention, triage rules, search settings and more. See Configuration › Tenant policy
statusactive, suspended, erasing or erased

While a tenant is suspended, every send is refused (403 tenant_suspended) and inbound mail is answered with a temporary failure (a 4xx reply, so senders retry) for up to five days, then refused permanently (550 5.2.1) (FR-TEN-3).

Reference: REST API › Tenants.

Identities

An identity is one agent’s mailbox: its addresses, threads, messages, attachments, search index, contacts and send history. Each identity lives in its own Durable Object, so one mailbox never contends with another and can be deleted in one step.

PropertyMeaning
username, display_namebookings, “Acme Car Hire”. The display name appears in From
ownerThe accountable human (name and email). Required before the identity can send (409 identity_owner_required)
purpose, metadata, signatureFree tags, your own key-value data, and the signature appended to sends
client_idYour own unique name for the identity, such as acme:bookings. A repeated create with the same client_id returns the existing identity
send_policyPer-identity daily cap, auto-reply setting and require_known_recipient
statusactive or paused. A paused identity still receives and stores mail, and refuses every send with 409 identity_paused. pause_reason is manual, abuse_threshold or tenant_suspended; while the tenant is suspended, sends get 403 tenant_suspended instead

Deleting an identity runs an identity-scope erasure and tombstones its addresses permanently: they can never be given to another identity (FR-IDN-4).

An identity can also have a signing key (Ed25519), created on first use and sealed inside the Worker, which never exports it. With it the identity signs agent assertions: short-lived tokens that tell another service which agent it is dealing with, checked against the identity’s published key set. A paused identity cannot sign, and its key set is withdrawn. Keys rotate with an overlap (7 days by default), and a deleted identity’s key IDs are tombstoned like its addresses. Guide: Using it from an agent › Agent assertions.

Reference: REST API › Identities · Design: Identities, addresses and domains, Agent signing keys.

Addresses

An identity has one or more addresses over time. Exactly one is the primary; the others are aliases.

  • On the platform domain: {username}{tenant suffix}@{platform domain}, for example bookings.acme@agents.example.
  • On a tenant’s own domain: {local part}@{domain}, for example bookings@acme.example.com.
  • The username and suffix together can be at most 40 characters, which leaves room in the 64-character local part for a thread token (A12).

Each address has a status:

    domain healthy or degraded          another address promoted,        retire_at reached
 pending ─────────────────────────▶ active ──── or retire called ────▶ retiring ─────────────▶ retired
                                      ▲                                    │
                                      └──────── promote it again ──────────┘
                                                  (rollback)
StatusReceives mailSends
pendingNo. Waiting for its domain to be healthy or degradedNo (domain_not_ready)
activeYesYes
retiringYes, into the same identityOnly on threads that already use it (G7)
retiredNo: 550 5.1.6No

Changing domain is a promotion: add an address on the new domain, wait for it to become active, then promote it. New threads send from the new address. Existing threads keep replying from the address the other party wrote to, until it retires (C3). The old primary becomes a retiring alias for a grace period (90 days by default), except the identity’s platform address, which stays an active alias for good: sends fall back to it when a domain fails, so it can never be retired (409 address_in_use). Promoting the old address again rolls the change back. See Custom domains.

Unknown, deleted and erased addresses are refused with 550 5.1.1, so nobody can tell an erased address from one that never existed (A6). On a domain that receives through Amazon SES, such mail is accepted by SES and then dropped without a bounce (Custom domains › How SES domains differ).

Reference: REST API › Addresses.

Domains

A domain is where addresses live and what mail is sent as. The platform domain must be on Cloudflare; a tenant’s own domains can be on any DNS host.

A tenant adds a domain with one of six connection methods, which says what the tenant changes at their DNS host: cloudflare_zone (a zone already in the deployment’s Cloudflare account), nameservers (a new domain used only for mail), dns_records (records at any DNS host), send_only (their existing mailbox forwards to the agent), smtp_relay (the agent sends through their own provider) and delegated_subdomain (Cloudflare Enterprise). The method fixes the domain’s kind, how its mail arrives and how it is sent. Custom domains explains which to choose.

KindWhat it isInboundOutbound
platformThe deployment’s shared mail domain, a zone apex chosen at setup. Visible to every key with tenant_id: nullCatch-all to the WorkerCloudflare Email Sending
zoneA tenant’s domain whose DNS is a zone in the same Cloudflare account (cloudflare_zone, nameservers)Apex: catch-all. Subdomain: one routing rule per address, at most 200Cloudflare Email Sending
delegatedA subdomain delegated to its own zone in the deployment’s account (delegated_subdomain)Catch-allCloudflare Email Sending
externalA tenant’s domain whose DNS is elsewhere (dns_records, send_only, smtp_relay)Amazon SES, or the domain’s own mail system forwarding to the identity’s platform addressAmazon SES with Easy DKIM, or the tenant’s own SMTP relay

DNS records are always read from the provider APIs when you ask for them, never copied from templates (FR-DOM-3). Each domain has a health state, checked every 15 minutes and after every change, with two independent DNS-over-HTTPS resolvers. A state changes only after two consecutive agreeing results.

pending ─▶ verifying ─▶ healthy ⇄ degraded ─▶ failing ─▶ suspended
                           ▲                     │
                           └──── recovered ──────┘
  • healthy or degraded: the domain sends normally, and addresses on it can be promoted.
  • failing: an authentication record is broken. Pylota Mail never sends as a broken domain. Sends fall back to the identity’s platform address, keeping the display name and the thread, and each such message is flagged sent_via_fallback (FR-DOM-6).
  • suspended: failing for 14 days, or an ownership signal changed (nameservers moved, the ownership TXT record disappeared, the registration changed). Ownership must be proved again.
  • “Recovered” is not a state: it is the domain.recovered event sent when a domain returns to healthy.

Reference: REST API › Domains · Guide: Custom domains.

Threads and messages

A thread is a conversation in one identity’s mailbox. An inbound message joins a thread by, in order (FR-THR-1):

  1. a valid thread token in the recipient address. Outbound messages carry one in their Reply-To sub-address, for example bookings.acme+t03k.9f2mq7xa@agents.example, except from domains whose inbound mail arrives by forwarding (send_only, and smtp_relay with inbound: forward): their own mail system may drop sub-addresses, so those messages have no Reply-To and replies thread by headers. The token is an HMAC, so a forged one is ignored (A2);
  2. In-Reply-To or References matching a stored message;
  3. otherwise it starts a new thread. The subject alone never joins a thread.

A message has a direction and a status:

DirectionStatuses
inboundreceived (visible), quarantined (held for review), throttled (over the per-sender limit, hidden), hidden (from a blocked or suppressed sender, kept for audit)
outboundqueued, submitted, delivered, deferred, bounced, complained, rejected, failed, uncertain, suppressed, canceled. See Outbound status

What an inbound message carries:

FieldMeaning
extracted_textThe new content, with quoted history and signatures removed. Returned by default, and what an agent should read first
text, htmlThe full plain text (derived from HTML when the mail is HTML-only) and sanitised HTML. Returned on request (include=quoted, include=html). The service never renders HTML
trustThe authentication verdict (pass, fail, softfail, none, unaligned or unverified) with SPF, DKIM, DMARC and ARC results, known_sender, spam_score, automated, quarantined and flags such as display_name_spoof, lookalike_domain, reply_to_mismatch and hidden_text
kindnormal, automated, dsn, list, calendar or mdn. Automated mail is marked so agents never auto-reply to it
attachmentsMetadata, text_status for extracted text, and risk for unsafe files
refsExact references found in the mail: plates, PCNs, invoice and order numbers, amounts, phone numbers and your own patterns
triageSee Triage
delivered_to, is_primary_recipientWhich of the tenant’s identities the copy was for, when one message reached several identities (A9)

Everything in a message is untrusted content: show it to a model as data, never as instructions.

Reference: Message object · Design: Threading, Inbound pipeline.

Triage

Triage runs on every inbound, non-quarantined message after it is stored. It produces a category, a needs_reply score from 0 to 1, an urgency from 0 to 3, a summary of at most 280 characters, the language, and risk_flags such as payment_change_request or prompt_injection_suspected. Deterministic rules run first and can skip the model. Triage is advisory: it never sends, deletes or releases anything (FR-TRI-3).

Guide: Triage.

Search modes

ModeHow it worksUse it for
keywordFull-text search (SQLite FTS5, BM25) plus exact reference matching, inside the mailboxExact phrases, operators, references such as ref:AB12CDE
semanticThe query is embedded and matched against message chunks in VectorizeFinding mail by meaning when the words differ
hybrid (default)Both, fused by reciprocal rank and rerankedMost searches
agenticA bounded loop that plans searches, reads the results, refines and answers. Every citation is checked by codeQuestions (“did the insurer accept the claim?”)

All four return the same result shape, with a why list per hit and trust metadata. Keyword search is consistent with the mailbox: a message is searchable in the same transaction that stores it.

Guide: Search.

Events and webhooks

Every state change appends an event in the same transaction as the change, so an event is emitted exactly when something happens. Events are delivered to your webhook endpoints with Standard Webhooks signatures, at least once, retried for about 72 hours.

state change ──▶ outbox (same transaction) ──▶ pm-webhooks queue ──▶ signed POST ──▶ your endpoint
                                                     │                                 │
                                                     └─── retry 30 s … 19 h ◀── non-2xx ┘

Payloads are thin: IDs, a summary, verdicts and a little extracted text. Fetch the rest from the API. Mailbox events carry a per-identity sequence for ordering.

Reference: Webhook events · Guide: Receiving, webhooks and quarantine.

API keys and permissions

An API key has a level, a mode and a list of permissions:

LevelReaches
platformEvery tenant
tenantIts own tenant: all its identities, domains and webhooks
identityIts own identity. It can also read the tenant’s domains and webhooks if it holds the matching :read permission
  • A key can never create a key wider than itself (403 key_scope_exceeded).
  • Scope always comes from the key, never from the request body. A resource outside the key’s scope returns 404, exactly as if it did not exist.
  • Keys look like pmk_live_<lookup>_<secret> or pmk_test_…. A key’s mode follows its tenant.
  • The secret is shown once, stored as a keyed hash, and can expire or be rotated with an overlap.

The permissions are listed in REST API › Permissions. Guide: Security › Keys and permissions.

Idempotency and uncertain sends

Send, reply, reply-all and forward require an Idempotency-Key header. The same key with the same body returns the original result (deduplicated: true), so a retry never sends a second email. The same key with a different body is refused (409 idempotency_conflict). A key is 1–255 printable ASCII characters, and keys are kept for 30 days.

When the transport’s answer is lost (a timeout, or a connection that drops after the request was written), nobody can know whether the email left. The message becomes uncertain, and Pylota Mail never resends it automatically. It tries to reconcile the send from provider events for 30 minutes. Otherwise a person decides with resolve.

POST …/messages ──▶ queued ──▶ transport ─┬─ accepted ─────────▶ submitted ─▶ delivered / bounced / …
 (Idempotency-Key)                        ├─ refused ──────────▶ rejected or failed
                                          └─ no answer ────────▶ uncertain ─┬─ provider event ▶ reconciled
                                                                            └─ resolve sent | not_sent

Guide: Sending › Safe retries.

Quarantine

Quarantine holds inbound mail that should not reach an agent: mail that failed authentication, scored above the spam threshold, carries a risky attachment, or is an unsolicited one-time code. Quarantined mail is stored but hidden from every key without quarantine:review, and is released only by a person with that permission. Lists and search leave quarantined, hidden and throttled mail out by default; it appears only when a request asks for it explicitly and the key holds quarantine:review. Released mail is then triaged like any other.

Guide: Receiving › Quarantine.

Suppressions

A suppression stops mail to one address for one tenant. Hard bounces, spam complaints, unsubscribes, the provider’s own list and manual entries create them. A send skips suppressed recipients and delivers to the rest. A send where every recipient is suppressed ends suppressed. Suppressions are stored as a keyed hash and a masked hint, and outlive erasure, because they record a person’s objection to being contacted (I7).

Guide: Sending › Bounces, complaints and suppressions.

An erasure request deletes data at one of five scopes (message, thread, counterparty, identity or tenant) from every store together: mailbox rows, the keyword index, references, vectors, raw mail and attachments. It returns a receipt that counts what was deleted in each store and records probe searches that came back empty.

A legal hold on a thread stops retention and erasure from deleting it. Erasure skips held threads and lists them in the receipt.

Guide: Privacy, retention and erasure.