Inboxes · September 21, 2026

Invoice Intake Through an Agent Email Inbox

Receive invoices at a dedicated address, scan attachments before the agent acts, draft the confirmation, and require approval before anything is sent back.

Supplier invoices arrive as email with a PDF stapled to them, into a shared mailbox three people have the password to. It is the workflow an AI agent gets pointed at first, because it looks like pure drudgery: open the mail, pull out the amount and the PO number, write back “received, we’ll pay on the 30th”, file the PDF.

Most of that is buildable today. Some of it is not, and the parts that are not tend to surface three weeks in. This walks the whole path — address, attachment, drafting, approval, threading, evidence — and is explicit about which steps the platform performs and which are your code. Every limit, error code and event name below is the one the deployed system uses. It builds on email for AI agents; for a queue strangers write into, see support triage.

Why accounts-payable mail deserves its own address

A shared ap@yourcompany.com mailbox is the worst place to attach an agent. It holds contract negotiations, disputes, bank-detail-change requests and whatever HR forwarded there by mistake. An OAuth grant against it is a grant against all of that, and the From line on the agent’s replies will name whichever employee authorised it. The general argument is in agent inbox vs connecting Gmail; AP mail sharpens it, because AP mail attracts fraud.

A dedicated inbox — invoices@agents.example.com — narrows the blast radius to one address whose entire contents are supplier invoices. Two details make it behave better than a forwarding rule:

Routing uses the envelope recipient, not the To header. Inbound mail is resolved against the address that actually owned the delivery. The To header is text the sender wrote and can say anything; using it as a routing key is how one tenant’s mail lands in another’s inbox.

Inbound mail is committed even when you are out of allowance. Receiving is not gated on quota — the ingest path records the message first and accounts for it afterwards, so an invoice never disappears because a counter rolled over. Sending the confirmation is gated: that is where payment_required or quota_exceeded (both HTTP 402) appear. An unanswered invoice is a far better failure than an unseen one.

One inbox for all suppliers is usually right: search and pagination are scoped to a single inbox, so one address per supplier turns every “find the March invoice” query into a fan-out.

Attachment safety before any extraction happens

The attachment is the payload, in both senses. When a message lands, each MIME part is stored, hashed with SHA-256, given a sanitised filename and queued for an isolated signature scan. One rule matters most: the scan gates the bytes, not the message. The email text is readable the moment message.received fires; the PDF is readable by nothing until the scan settles.

scan_statusMeaningBytes retrievable
pendingQueued or in progressNo
cleanScanner returned cleanYes
quarantinedIdentified as maliciousNo, permanently
blockedRefused without a verdict — e.g. larger than 10 MiBNo
scan_failedBytes missing or changed, or unscanned past its windowNo

An inbound file over the 10 MiB per-file limit goes straight to blocked and rejected with no scan job queued at all — the bytes are preserved for the record, but nothing will hand them to you. The whole inbound message is capped at 25 MiB of raw MIME before that.

Downloading is GET /v1/projects/{project_id}/attachments/{attachment_id}/download with attachments:read. It checks your grant covers that inbox, refuses anything not clean with attachment_not_ready (HTTP 422, non-retryable), re-hashes the stored bytes against the digest recorded at ingest, and serves with content-disposition: attachment and x-content-type-options: nosniff. There is no unauthenticated variant and no public object URL.

scan_failed is not a soft pass: if scanning is unavailable the file stays unavailable, and your intake job should treat that as “escalate to a human”. Rehearse both branches without touching real malware — a file containing the standard EICAR test string comes back quarantined. Full flow: attachments.

What a clean scan means is narrow: a signature-based engine recognised nothing known-malicious in those bytes at that moment. It says nothing about whether the document is a real invoice, whether its bank details were changed last week, or whether text inside it is trying to instruct your agent.

What an agent can and cannot conclude from a PDF

State this plainly before designing anything: there is no OCR, no document parsing and no attachment-content search here. Nothing reads the invoice for you.

The search index covers four fields per message — subject, a 240-character preview of the text body, the normalised sender address and the sender display name. Attachment contents are not searched, and neither are filenames. Matching is Postgres full-text search with the simple configuration: terms are ANDed, there is no stemming, so q=invoice does not find invoices, and there is no prefix or fuzzy matching. Read search and pagination before building retrieval on it.

Per attachment the agent receives metadata: the att_ ID, safe filename, byte count, content type, disposition, content ID for inline images, scan status and a downloadable flag true only on a clean scan. Enough to decide whether to fetch; not enough to know what the invoice says.

QuestionAnswered by the platformYour code
Did a file arrive, and is it safe to open?Yes — scan status and digest
Who sent it, and when?Yes — normalised sender, timestamps
What is the invoice total?NoDownload bytes, parse or OCR
Is this a duplicate of last month’s?NoYour own dedupe on your fields
Does the PO number exist in the ERP?NoYour integration

A second constraint surprises people who start over MCP. The server exposes six tools — list_inboxes, list_threads, list_messages, get_message, create_draft and send_message — and none of them move file bytes in either direction; the attachment parameter says so outright: “MCP does not upload files.” A client connected over MCP can see that a 214 KB PDF named INV-40118.pdf arrived clean, and can reply about it, but cannot open it. Reading the document needs a REST call with attachments:read from code you control, which makes the MCP or API key decision unusually easy here: invoice extraction is an API-key workload.

Message bodies are served as normalised text plus sanitised HTML. Original raw MIME is never handed to an agent credential — messages:raw cannot be delegated to a connection grant at all, alongside keys:manage, policies:write and exports:write.

Draft-only as the default posture

An invoice-intake agent should not be able to transmit mail on day one, and the way to guarantee that is a credential that structurally cannot. The read_draft preset grants inboxes:read, threads:read, messages:read, attachments:read and events:read plus drafts:read, drafts:write and drafts:submit — and deliberately not messages:send. This is enforced, not advisory. A principal holding drafts:submit without messages:send gets approval_required back with the reason code draft_only_principal, even on an inbox configured for direct sending. Over MCP the create_draft tool strips messages:send from the calling principal before it runs, so that tool cannot transmit under any configuration.

Which means your send call has two success shapes, both HTTP 202, and you must branch on result:

{
  "result": "approval_required",
  "draft_id": "drf_...",
  "draft_version": 1,
  "approval_id": "apr_...",
  "reason_codes": ["draft_only_principal"],
  "request_id": "req_..."
}
{
  "result": "queued",
  "message_id": "msg_...",
  "thread_id": "thd_...",
  "state": "queued",
  "request_id": "req_..."
}

Code that treats 202 as “sent” reports every reviewed confirmation as gone when it is sitting in a queue.

Pair draft-only with a recipient allow list, the highest-value control on the inbox: AP replies go to a known set of supplier domains, so list them. Three behaviours to design around: blocks always beat allows; a non-empty allow list requires every recipient to match, so one unmatched Cc denies the whole send with recipient_not_allowed; and a domain rule is an exact suffix match on @domain, not a subdomain wildcard — example.com does not cover mail.example.com. Add daily_recipient_limit as a cap; it counts recipients, not messages, in a UTC-day bucket.

Anything that acknowledges an amount, confirms a payment date or touches bank details should be read by a person first. Set approval_mode to always and every send from the inbox becomes an approval request.

Two operator-configured rules exist beyond that switch, returned in unmanaged_approval_rules so they stay visible: new_recipient_domain and attachments. Read the second carefully, because its name invites a wrong assumption — it fires when your outbound payload carries attachment IDs, not when the inbound invoice had a PDF on it. A plain text confirmation does not trip it.

What makes the approval meaningful rather than ceremonial:

The review screen is built for this decision. It shows To, Cc and Bcc explicitly, a server-sanitised preview of the HTML the agent wrote, the parent message, and every attached file with its status, polled every five seconds. The approve control stays unavailable until that preview has loaded and every attachment is both ready and clean — a reviewer cannot approve a body they could not see. Reference: sending controls.

Threading the confirmation back correctly

A confirmation that starts a new thread makes the supplier’s clerk hunt for context and makes your own thread view useless as evidence. Threading is handled for you, but only if you pass the field that triggers it: set reply_to_message_id to the msg_ ID of the invoice email on the send or draft call. That requires messages:read, the parent must be in the same inbox and project, and the reply inherits the parent’s thd_ thread.

The RFC headers are resolved at submission time, not when you queue: the sender job reads the parent’s stored rfc_message_id, sets In-Reply-To to it, and builds References from the parent’s own references plus that ID, deduplicated and capped at the most recent 50. Do not synthesise Message-ID headers yourself, and do not rely on prefixing the subject with Re: — inbound threading matches on In-Reply-To and References against stored messages in that inbox, never on subject text.

Then keep two ideas apart, because AP threads are the ones people re-read later:

A third state deserves explicit handling in a finance workflow: submission_unknown. If a submission is interrupted after the request left but before an acknowledgement returned, the system records that rather than guessing, and does not retry — a retry on an unknown outcome is how one confirmation becomes two. The daily-limit reservation is held rather than released, for the same reason. Idempotency keys has the rest.

Audit trail and export

“Who told this supplier we’d pay on the 30th” needs an answer that is not a Slack scrollback. The event stream is the primary record. GET /v1/projects/{project_id}/events returns events in ascending project_sequence, gapless within a project, with a next_cursor to resume from. The chain for one invoice is message.receivedapproval.requestedapproval.approved or approval.rejectedmessage.queuedmessage.acceptedmessage.delivery_updated. Webhooks carry the same events with webhook-id, webhook-timestamp and a signature over id + "." + timestamp + "." + rawBody; reject a timestamp more than 300 seconds from your clock, and expect up to eight attempts.

One detail catches every first integration: the message.received payload contains only message_id, thread_id, inbox_id, environment and simulated — no sender, no subject, no attachment list. The event says that something arrived; you fetch the message to learn what.

Exports have real edges. An export is NDJSON whose first line is a manifest describing the selection, limits, exclusions and integrity information. Caps: 5,000 messages, 500 inboxes, 64 MiB of message records, 2 MiB per record, two exports per member per hour; exceeding a bound fails the export rather than silently truncating it, and a prepared download expires after 24 hours. Critically for AP, the export excludes attachment file contents, along with original MIME, Bcc, envelope recipients, drafts, credentials, webhook secrets and audit logs. If your retention policy requires the PDFs, download them individually and store them yourself — see data controls.

Policy changes are recorded separately: every successful write to an inbox policy stores an audit entry with a before-and-after diff naming the human who made it. Deactivating a project is not erasure — it stops routing and revokes credentials, but stored mail, attachments, provider copies and backups may remain.

Known limits

Each has surprised someone:

Setup checklist

A workable order, safe version before clever version:

  1. Create a test project and a test inbox. Steps 2-6 happen here, with nothing reaching the internet.
  2. Simulate the invoice. POST /v1/projects/{project_id}/test/inbound with a from, to, subject, body and up to 10 attachment_ids you uploaded first. Use a real PDF and, separately, an EICAR file, so you exercise both scan branches.
  3. Consume the event. Poll the events endpoint or register a webhook, catch message.received, then fetch the message and its attachment metadata. Confirm your code waits for scan_status: "clean" and handles attachment_not_ready.
  4. Download and parse. This is your extraction step, over REST with attachments:read. Verify the digest re-check path by handling the 422.
  5. Draft the reply with reply_to_message_id set to the invoice message, a stable Idempotency-Key, and assert your handler branches on result rather than on the status code.
  6. Approve it in the console, reading the review screen as a reviewer would: the recipients, sanitised body, parent message and file statuses.
  7. Go live with read_draft only. Set approval_mode: "always", fill the recipient allow list with your supplier domains, set a daily_recipient_limit.
  8. Run it in shadow. Every reply goes through approval; count how many you edited before approving. That number, not a benchmark, tells you whether to loosen anything — and if you do, loosen to read_draft_send only for message classes your review history shows never needed a change. Keep approval on anything mentioning payment details.

If you have not created the inbox yet, start with how to give an AI agent its own email address — it covers provisioning, credential choice and the first round trip, which every step above depends on.


Keep reading

All guides · Documentation · Inboxes