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_status | Meaning | Bytes retrievable |
|---|---|---|
pending | Queued or in progress | No |
clean | Scanner returned clean | Yes |
quarantined | Identified as malicious | No, permanently |
blocked | Refused without a verdict — e.g. larger than 10 MiB | No |
scan_failed | Bytes missing or changed, or unscanned past its window | No |
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.
| Question | Answered by the platform | Your 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? | No | Download bytes, parse or OCR |
| Is this a duplicate of last month’s? | No | Your own dedupe on your fields |
| Does the PO number exist in the ERP? | No | Your 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.
Approval on payment-related replies
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:
- It is bound to the exact content. Approval and draft version both carry a SHA-256
of the canonical payload. Edit the draft afterwards and approving returns
version_conflict(HTTP 412). You cannot approve one message and have another go out. - It is consumed on use, and approving carries its own
Idempotency-Key, so a double-clicked button cannot send twice. - It expires after 7 days, so a forgotten queue is not a delayed-action mailbomb.
- Authority is re-checked at approval time and again in the send job; a credential revoked in between cannot deliver the message.
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:
message.acceptedmeans the provider acknowledged the submission. That is all it means. It is not proof the supplier’s mail server took it, and certainly not proof anyone read it.- Per-recipient outcomes arrive afterwards as
message.delivery_updated. Recipients startpending, and a message to three addresses can have three different outcomes.
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.received → approval.requested →
approval.approved or approval.rejected → message.queued → message.accepted →
message.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:
- No OCR, no invoice parsing, no duplicate detection, no ERP connector. The inbox delivers provenance and safe bytes; semantics are yours.
- Attachment contents and filenames are not searchable, and body search covers only the first 240 characters.
- MCP cannot move files in either direction. Extraction needs a REST key.
- Test mode is capped at 3 active test inboxes, 100 retained messages, 10 MiB and a
7-day content expiry, on non-routable
.mail.invalidaddresses — a rehearsal environment, not an archive. - Free live storage is 100 MiB per workspace. Invoice PDFs consume it quickly.
- The free allowance is one complimentary live inbox and 100 email units a month per verified owner identity, claimed by that identity’s first eligible workspace. Creating another workspace does not renew it. An email unit is one message in or one recipient out.
invoices@yourcompany.comis not available today. A custom domain requires a live project on an eligible paid subscription, and paid checkout is currently disabled — Builder and Team are proposed plans that cannot be bought yet. Plan on a platform address onagents.emailforagents.ai.- No MCP client is certified or supported here. Where a client has not been exercised against this server the honest status is “Not yet tested”.
- We do not claim to be prompt-injection proof. An invoice PDF or body instructing your agent to mail out new bank details is untrusted input, and the tool descriptions saying so are hints to a model, not controls. What holds regardless: a credential that cannot transmit, an allow list, and a human reading the approval.
Setup checklist
A workable order, safe version before clever version:
- Create a test project and a test inbox. Steps 2-6 happen here, with nothing reaching the internet.
- Simulate the invoice.
POST /v1/projects/{project_id}/test/inboundwith afrom,to,subject, body and up to 10attachment_idsyou uploaded first. Use a real PDF and, separately, an EICAR file, so you exercise both scan branches. - 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 forscan_status: "clean"and handlesattachment_not_ready. - Download and parse. This is your extraction step, over REST with
attachments:read. Verify the digest re-check path by handling the 422. - Draft the reply with
reply_to_message_idset to the invoice message, a stableIdempotency-Key, and assert your handler branches onresultrather than on the status code. - Approve it in the console, reading the review screen as a reviewer would: the recipients, sanitised body, parent message and file statuses.
- Go live with
read_draftonly. Setapproval_mode: "always", fill the recipient allow list with your supplier domains, set adaily_recipient_limit. - 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_sendonly 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
Run Support Triage From an Agent's Own Inbox
A working pattern for support triage: one dedicated address, read and draft permission, approval on anything money-related, and events that prove it worked.
Human Approval Before an AI Agent Sends Email
When to require review, why approval binds to one exact draft version, how recipient rules and daily caps work, and what editing a draft does to its approval.
How to Give an AI Agent Its Own Email Address
Create a dedicated inbox, choose read, draft or send permission, connect over MCP or a scoped key, and prove a real round trip before granting send access.