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

Quickstart

In this quickstart you will:

  1. get an API key and log in with the pmail CLI;
  2. create an identity for a bookings agent;
  3. send an email with an idempotency key (with curl, the Rust SDK and the CLI) and see a retry return the original result;
  4. read a reply and answer it;
  5. search the mailbox and ask it a question;
  6. add a webhook and connect an MCP client.

It takes about ten minutes. To try everything without sending real mail, use a test tenant (see Try it without sending real mail).

Before you start

You need:

  • a running Pylota Mail deployment. This page uses https://mail.example.com as its API host and agents.example as its platform mail domain. To run your own, follow Deploy to Cloudflare first;
  • the pmail CLI, from the GitHub Releases page or with cargo install pylota-mail-cli --locked;
  • curl, for the REST examples.

Conventions on this page:

  • The tenant is acme (Acme Car Hire). Its address suffix is .acme, so its identities’ platform addresses look like bookings.acme@agents.example.
  • IDs are shortened for readability. Real IDs are a prefix plus a 26-character ULID, for example msg_01J9Z3K8V4QW7X2M5N6P8R0T1Y.
  • From step 4 on, examples use bookings@acme.example.com. That is the address the identity has once Acme’s own domain is added and promoted (Custom domains). If you are only on the platform domain, use bookings.acme@agents.example instead. Every --identity option accepts any active or retiring address of the identity, or its idn_ ID.

1. Get a key

Every request carries an API key: Authorization: Bearer pmk_live_… (or pmk_test_… for test tenants). Keys have a level (platform, partner, tenant or identity) and a list of permissions. See API keys and permissions.

If someone else runs the deployment, ask them for a tenant key for your tenant. This quickstart needs these permissions: identities:read, identities:write, messages:read, messages:send, messages:write, attachments:read, search:read, search:agentic, webhooks:manage and keys:manage.

If you deployed it yourself, you have a platform key from pmail keys create --level platform, saved in your CLI’s default profile. Use it to create the tenant and a tenant key, so day-to-day work does not use the platform key:

pmail tenants create --slug acme --name "Acme Car Hire"

pmail keys create --level tenant --tenant acme --name acme-quickstart \
  --permissions identities:read,identities:write,messages:read,messages:send,messages:write,attachments:read,search:read,search:agentic,webhooks:manage,keys:manage

If you keep the platform key somewhere else, set PYLOTA_MAIL_URL and PYLOTA_MAIL_KEY for these two commands, then unset PYLOTA_MAIL_KEY: a key in the environment outranks every profile.

The key’s secret (pmk_live_…) is printed once. It is stored only as a keyed hash, so it cannot be shown again. If you lose it, create a new key and revoke the old one.

2. Log in

pmail login --profile acme
pmail config set default_profile acme

pmail login asks for the API URL (https://mail.example.com) and the tenant key, checks the key, and saves both as the profile acme in ~/.config/pylota-mail/config.toml; config set default_profile makes pmail read that profile when you name none. Name the profile: without --profile, login writes the profile default, which holds your platform key if you deployed Pylota Mail yourself. The file is created with mode 0600, and pmail refuses to read it if its group or other users have any access to it.

You can also skip the file and set PYLOTA_MAIL_URL and PYLOTA_MAIL_KEY in your environment. Flags come first, then the environment, then the profile, so a PYLOTA_MAIL_KEY in the environment is the key pmail uses. See CLI › Configuration.

Check the key with the API. The curl examples on this page read the tenant key from PYLOTA_MAIL_KEY; with it exported, the CLI uses the same key:

export PYLOTA_MAIL_KEY=pmk_live_…            # the tenant key
curl -s https://mail.example.com/v1/me -H "Authorization: Bearer $PYLOTA_MAIL_KEY"
{
  "key_id": "key_01J9…", "name": "acme-quickstart", "level": "tenant", "mode": "live",
  "tenant_id": "ten_01J9…", "identity_id": null,
  "permissions": ["identities:read", "identities:write", "messages:read", "..."],
  "expires_at": null
}

3. Create an identity

Create the bookings agent’s identity:

pmail identities create --username bookings --display-name "Acme Car Hire"
{
  "id": "idn_01J9Z3K8V4", "tenant_id": "ten_01J9…",
  "username": "bookings", "display_name": "Acme Car Hire",
  "status": "active", "primary_address": "bookings.acme@agents.example",
  "owner": null,
  "...": "…"
}

An identity cannot send until it records an accountable human: the person responsible for what the agent sends (FR-IDN-2). Without one, sends fail with 409 identity_owner_required. Set the owner:

pmail identities update bookings.acme@agents.example \
  --owner-name "Sam Patel" --owner-email sam@acmecarhire.example

You can also pass --owner-name and --owner-email to pmail identities create directly.

Create a second identity for the compliance agent, which you will use in step 7:

pmail identities create --username compliance --display-name "Acme Car Hire Compliance" \
  --owner-name "Sam Patel" --owner-email sam@acmecarhire.example

Usernames match ^[a-z0-9][a-z0-9._-]{0,23}$, and the username plus the tenant suffix can be at most 40 characters. Reserved names such as postmaster, abuse and noreply, and look-alikes of them, are refused with address_reserved (A4).

If your application provisions identities automatically, pass a client_id (for example acme:bookings) in the API request. Repeating the same create then returns the existing identity instead of making a second one (Identities).

4. Send an email

Every send needs an Idempotency-Key: a string you choose that names this message, such as bk-2291-confirm for the confirmation of booking BK-2291. If you retry with the same key and the same body, you get the original result back, never a second email.

With curl

curl -X POST https://mail.example.com/v1/identities/idn_01J9Z3K8V4/messages \
  -H "Authorization: Bearer $PYLOTA_MAIL_KEY" \
  -H "Idempotency-Key: bk-2291-confirm" \
  -H "Content-Type: application/json" \
  -d '{"to":["renter@example.org"],"subject":"Your booking BK-2291","text":"Your car is ready at 9:00."}'

The response is 202 Accepted with the new message:

{
  "id": "msg_01JA5C2H8R", "thread_id": "thr_01JA5C2H8Q", "identity_id": "idn_01J9Z3K8V4",
  "direction": "outbound", "status": "queued", "kind": "transactional",
  "from": { "address": "bookings@acme.example.com", "name": "Acme Car Hire" },
  "to": [ { "address": "renter@example.org", "name": "" } ],
  "subject": "Your booking BK-2291",
  "deduplicated": false,
  "...": "…"
}

Run exactly the same command again. You get the same id with "deduplicated": true, and the response carries the header Idempotent-Replayed: true. One email was sent.

If you reuse the key with a different body, the request fails with 409 idempotency_conflict. That protects you from a bug where two different messages share a key. Use a new key for a new message.

With the Rust SDK

Add the SDK, pinned to the same version as your deployment (GET /health returns it):

[dependencies]
pylota-mail = "=X.Y.Z"   # replace with your deployment's version
use pylota_mail::Client;

async fn confirm_booking(key: String) -> Result<(), pylota_mail::Error> {
    let client = pylota_mail::Client::new("https://mail.example.com", key);
    let sent = client
        .identity("idn_01J9Z3K8V4")
        .send()
        .to("renter@example.org")
        .subject("Your booking BK-2291")
        .text("Your car is ready at 9:00.")
        .idempotency_key("bk-2291-confirm")
        .await?;
    // a retry with the same key returns this same message
    println!("{} deduplicated={}", sent.id, sent.deduplicated);
    Ok(())
}

With the CLI

pmail send --identity bookings@acme.example.com \
  --to renter@example.org --subject "Your booking BK-2291" \
  --text "Your car is ready at 9:00." \
  --idempotency-key bk-2291-confirm
{ "id": "msg_01JA5C2H8R", "status": "queued", "deduplicated": true }

deduplicated is true here because you already sent this message with curl.

Follow the message

queued means accepted. The message then moves to submitted (the transport took it) and delivered, or to another outbound status. Check it with:

pmail messages get msg_01JA5C2H8R --identity bookings@acme.example.com

The per-recipient outcome is in deliveries. Webhooks report the same changes as message.sent, message.delivered, message.bounced and so on (see step 8).

5. Read a reply and answer it

When the renter replies, the reply joins the same thread. Pylota Mail matches it by the thread token in the Reply-To address it set on your message, or by the In-Reply-To and References headers. The subject alone never joins a thread.

List the threads that need attention:

pmail threads list --identity bookings@acme.example.com
{
  "data": [{
    "id": "thr_01JA5C2H8Q", "subject": "Your booking BK-2291",
    "participants": [ { "address": "renter@example.org", "name": "Jo Rivera" } ],
    "message_count": 2, "unread_count": 1, "last_direction": "inbound",
    "snippet": "Could we move the pick-up to Friday…",
    "category": "customer_request", "needs_reply": 0.92, "urgency": 2
  }],
  "next_cursor": null
}

Read the thread:

pmail threads get thr_01JA5C2H8Q --identity bookings@acme.example.com

Each message carries:

  • extracted_text: the new content, with quoted history and signatures removed. Read this first; it is what fits in a model’s context;
  • trust: the authentication verdict (pass, fail, softfail, none, unaligned or unverified), known_sender, spam_score and flags such as display_name_spoof;
  • triage: category, needs-reply score, urgency, summary and risk flags.

Everything in a message (subject, names, body, filenames) is untrusted content. Show it to a model as data, never as instructions. See Security.

Reply. A reply needs its own idempotency key:

pmail reply msg_01JA6D3J9S --identity bookings@acme.example.com \
  --text "Friday works. See you at 10." \
  --idempotency-key bk-2291-reply-1

The same with curl:

curl -X POST https://mail.example.com/v1/identities/idn_01J9Z3K8V4/messages/msg_01JA6D3J9S/reply \
  -H "Authorization: Bearer $PYLOTA_MAIL_KEY" \
  -H "Idempotency-Key: bk-2291-reply-1" \
  -H "Content-Type: application/json" \
  -d '{"text":"Friday works. See you at 10."}'

The reply goes to the sender, from the address they wrote to, with Re: added to the subject once and In-Reply-To and References set. See Who a reply goes to.

Search one identity’s mailbox:

pmail search "from:@brightwell.example ref:AB12CDE has:attachment" --identity bookings@acme.example.com

This finds mail from any address at that domain that mentions the plate AB12 CDE (with or without the space) and has an attachment. Each hit has a snippet, a why list explaining the match (for example ref:AB12CDE (attachment p.1)) and the sender’s trust.

Vehicle plates and PCNs come from the optional uk_vehicle reference pack. The tenant policy is changed with a platform key that holds tenants:manage, so ask your deployment’s operator, or run this yourself if you deployed it:

curl -X PATCH https://mail.example.com/v1/tenants/ten_01J9… \
  -H "Authorization: Bearer $PLATFORM_KEY" -H "Content-Type: application/json" \
  -d '{"policy":{"search":{"refs_packs":["core","uk_vehicle"]}}}'

References are extracted when mail arrives, so turn the pack on before the mail you want to find comes in.

The default mode is hybrid (keyword and semantic together). Use --mode keyword for exact lookups and --mode semantic for meaning. The operators and modes are in Search.

7. Ask a question

Agentic search plans the searches for you and answers with citations:

pmail ask "Did the insurer accept the Golf claim?" --identity compliance@acme.example.com

The CLI streams progress (each search step and the evidence found), then prints the answer with numbered citations, the cited messages, and the status:

⋯ step 1  search "claim Golf photos" (hybrid) · 7 hits · 412 ms
⋯ step 2  read thread thr_01JA… · 38 ms

Yes. Admiral accepted claim 7781 on 2 October, after the photos sent on 28 September [1][2].

[1] msg_01JA…  2026-10-02  Admiral Claims <claims@admiral.example>  "Claim 7781 – decision"
[2] msg_01JB…  2026-09-28  Acme Car Hire <compliance@acme.example.com>  "Photos for claim 7781"

answered · confidence 0.86 · 3 steps · 2.8 s

Every sentence cites message IDs (the API returns them as [msg_…] markers; the CLI numbers them), and code checks each citation against the evidence before the answer is returned. If the mail does not answer the question, the status is insufficient_evidence, never a guess. Agentic search needs the search:agentic permission. See Search › Agentic search.

8. Add a webhook

Webhooks tell your application what happened. Create an endpoint:

pmail webhooks create --url https://api.example.com/webhooks/mail \
  --events message.received,message.bounced

The response includes the signing secret, whsec_…. It is shown only once. Store it with your application’s other secrets. Then send a test event:

pmail webhooks test whk_01JA…

Your endpoint receives a webhook.test event. Verify the signature on every request and deduplicate on the webhook-id header: the receiving guide has the steps and a Rust example. The event types are in Webhook events.

9. Connect an MCP client

Give the agent its own identity key with only what it needs:

pmail keys create --level identity --identity bookings@acme.example.com --name bookings-agent \
  --permissions messages:read,messages:send,search:read,attachments:read

Add the server to your MCP client’s configuration. pmail mcp config prints this for the current profile:

{
  "mcpServers": {
    "pylota-mail": {
      "url": "https://mail.example.com/mcp",
      "headers": { "Authorization": "Bearer ${PYLOTA_MAIL_KEY}" }
    }
  }
}

Set PYLOTA_MAIL_KEY to the identity key in the environment the client runs in. The client sees only the tools the key allows (for example mail_search, mail_get_thread and mail_reply). Send tools require an idempotency_key argument. The server also offers a mail_search_strategy prompt that teaches the model how to search. See MCP server and Using it from an agent.

Try it without sending real mail

A test tenant never sends mail outside the deployment (FR-TEN-2). Its keys start with pmk_test_, and its sends go to a simulator instead of the internet. Create one with a platform key. If PYLOTA_MAIL_KEY still holds the tenant key, unset it first, because it would outrank the platform key in your default profile:

unset PYLOTA_MAIL_KEY
pmail --profile default tenants create --slug acme-test --name "Acme Car Hire (test)" --mode test
pmail --profile default keys create --level tenant --tenant acme-test --name acme-test-key \
  --permissions identities:read,identities:write,messages:read,messages:send,messages:write,search:read

Create an identity in it (as in step 3), then send to the simulator’s addresses:

pmail send --identity bookings.acme-test@agents.example \
  --to bounce@simulator.invalid --subject "Simulator test" --text "Hello" \
  --idempotency-key sim-bounce-1
RecipientScripted outcome
delivered@simulator.invalidDelivered (message.delivered)
bounce@simulator.invalidA hard bounce (message.bounced, bounce_type: hard). Hard bounces create a suppression
softbounce@simulator.invalidA soft bounce (bounce_type: soft)
complaint@simulator.invalidA spam complaint (message.complained). Complaints create a permanent suppression
deferred@simulator.invalidA temporary failure (message.deferred)
reject@simulator.invalidRefused by the transport before sending (message.rejected)
timeout@simulator.invalidNo answer from the transport: uncertain, never resent (G2)

Mail from a test tenant to an identity on the same deployment is delivered internally, with verdict: pass and the flag loopback (L3), so you can test a full send-and-reply loop between two identities. Any other recipient is refused with 403 test_mode_recipient. A live key cannot act on a test tenant, or the reverse (L4).

The timeout@ address is the best way to practise handling an uncertain send. See Safe retries.

If something goes wrong

You seeWhyWhat to do
401 unauthenticatedNo key, a malformed key, or the wrong deploymentCheck PYLOTA_MAIL_URL and PYLOTA_MAIL_KEY, or the profile in ~/.config/pylota-mail/config.toml
403 permission_deniedThe key lacks a permissionerror.details.required names it. Create a key that has it
403 scope_denied or a 404 with this service’s error codeThe resource is outside the key’s tenant or identityUse a key at the right level. A 404 deliberately does not say whether the resource exists
400 idempotency_key_requiredA send without Idempotency-KeyAdd the header (or --idempotency-key)
409 identity_owner_requiredThe identity has no accountable humanSet --owner-name and --owner-email
409 idempotency_conflictThe key was used for a different messageUse a new key for a new message
403 test_mode_recipientA test tenant tried to send to a real addressSend to *@simulator.invalid or to an identity on this deployment
ref: finds nothingThe reference pack for that kind is off, or the reference is in an attachment whose text is not extracted yetEnable uk_vehicle for plates and PCNs. Check the attachment’s text_status
ask returns insufficient_evidenceThe mailbox does not hold an answerCheck the trace to see what was searched
No webhook arrivesThe endpoint failed or is not HTTPSpmail webhooks deliveries whk_… shows each attempt and its error

Every error has a fix field with one sentence on what to do, and a request_id to quote in bug reports. The full list is in Errors.