MAIL GATEWAY · REST + MCP
Your agent may ask.
Only you may send.
Alcarta gives an AI agent real access to mailboxes that already live somewhere else — IMAP, SMTP, JMAP — without ever handing it the credentials. Every message that leaves, and every deletion, waits for a person.
The approval queue is the spine.
IMAP access is a commodity. The part worth building is that every destructive or outbound action is a two-party transaction. An agent may only declare what it wants. It receives an approval id and nothing happens.
agent → wants_to_send_draft(draft_id: "drf_8fK2Lp")
gateway → { "approval_id": "chk_9Qm4vR7x", "state": "waiting" }
// no message was sent.
A person opens the queue, reads one sentence that the gateway wrote and the agent cannot influence, sees the message as it stands on the mail server right now, and decides. Only then does the gateway perform the real SMTP or IMAP action — exactly once, whatever number of replicas are running — and write an audit row naming both identities.
An approval that nobody answers expires on its own. A message that moved or that somebody deleted between the request and the decision makes the action stop and say so. There is no code path that skips the check: the web pages are a client of the same REST API, with the same tokens and the same scope middleware as an agent.
read_inbox opens IMAP from the gateway, not from the agent. The agent receives headers and bodies. The credential is envelope-encrypted per row, bound to its mailbox and its organisation, and is decrypted only inside the process that dials the mail server.wants_to_send_draft returns chk_9Qm4vR7x. The bytes stop at the barrier until a person presses approve — or until the approval expires, which also ends it.Try the gesture.
This is the real card, with the real five-second undo. The first click is the decision; the window is the take-back. Nothing here reaches a mail server.
triage-bot wants to send a message to 3 recipients from finance@acme.com.
agt_7fK2wQ · mcp · token “nightly-triage” · 14:02:11
Reason supplied by the agent — untrusted text
Quarterly invoice reminder for the three accounts that passed their due date this morning.
- MAILBOX
- finance@acme.com
- RECIPIENTS
- 3
- FOLDER
- Drafts
- SIZE
- 12.4 KB
- APPROVAL
chk_9Qm4vR7x- DRAFT
drf_8fK2Lp
PREVIEW [12 records]
From: finance@acme.com To: ap@northwind.example, ap@contoso.example, billing@fabrikam.example Subject: Invoice reminder — three accounts past due Good morning, Three invoices passed their due date this morning. The details are below.
Twenty-four tools, one authorization path.
The same twenty-four tools are reachable over REST at /api/v1 and over MCP at
/mcp. Each is implemented once, in the crate that owns its domain, and each
carries one required scope. A tool that behaves differently on the two surfaces is a bug,
and a test fails on it.
It reads
-
get_identityREADany active token -
read_inboxREADread_inbox@<mbx> -
monitor_inboxREADmonitor_inbox@<mbx>
It declares, and stops
-
wants_to_moveNEEDS APPROVALwants_to_move@<mbx> -
wants_to_copyNEEDS APPROVALwants_to_copy@<mbx> -
wants_to_expungeNEEDS APPROVALwants_to_expunge@<mbx> -
wants_to_send_draftNEEDS APPROVALwants_to_send_draft@<mbx>
It acts, because a person granted the scope
-
moveDIRECTmove@<mbx> -
copyDIRECTcopy@<mbx> -
trashDIRECTtrash@<mbx> -
spamDIRECTspam@<mbx> -
archiveDIRECTarchive@<mbx> -
set_flagsDIRECTset_flags@<mbx> -
start_draftDIRECTstart_draft@<mbx> -
edit_draftDIRECTedit_draft@<mbx> -
patch_draftDIRECTpatch_draft@<mbx> -
reply_to_threadDIRECTreply_to_thread@<mbx> -
discard_draftDIRECTdiscard_draft@<mbx>
The decision itself
-
approval_statusDECIDESapproval_status@<mbx> -
approval_listDECIDESapproval_list@<mbx> -
monitor_approvalDECIDESmonitor_approval@<mbx> -
cancel_approvalDECIDEScancel_approval@<mbx>, or be the requester -
approval_commitDECIDESapproval_commit@<mbx> -
send_draftDECIDESsend_draft@<mbx> + an approved chk_
A scope is granted per mailbox, never per organisation. A subject with no grant on a mailbox is told the mailbox does not exist, rather than that it may not see it.
Run it yourself.
One binary, PostgreSQL or SQLite, and Redis when you run more than one replica. Migrations run on boot. Long-lived key material is read from mode-0600 files, never from environment values.
services:
gateway:
image: alcarta-core:latest
environment:
GW_BASE_URL: https://mail.example.com
GW_BIND_ADDR: 0.0.0.0:8080
GW_DATABASE_URL: postgres://gw@db/gw
GW_REDIS_URL: redis://redis:6379
volumes:
- ./keys:/keys:ro # the KEK, mode 0600
ports: ["8080:8080"]
- REST
/api/v1 - MCP
/mcp— streamable HTTP - MAILIMAP · SMTP · JMAP
- STOREPostgreSQL · SQLite
- AUTHEd25519 JWT · PAT · OAuth 2.1