Agent Mail
Give each AI employee its own email inbox — mail arrives and waits to be read like a human employee’s inbox, and nothing ever goes out until a person confirms it.
What It Is
Section titled “What It Is”Chat channels are real-time: a message shows up, the agent is expected to answer now. Not everything works that way — a customer’s quotation request, a partner’s follow-up, a vendor’s invoice — these are the kind of mail a human employee reads “when I get to it,” not the moment it lands. Agent Mail gives every agent that second mode: a per-agent, non-real-time inbox that holds arriving mail as a record (not a task-board item, not a channel notification), and an outbox where the agent can draft replies that only a human can actually send.
Two product rules are enforced everywhere in this feature, deliberately:
- Mail is read in the interface; work happens in the conversation (the product’s own wording: 「收件在介面看,幹活在對話裡」) — an arriving mail is a record, not a task. It lands in the dashboard’s Mail page (信箱). It does not create task-board rows and does not spam your chat channels.
- Outbound always requires confirmation; nothing is ever auto-sent (「外發必確認,絕不自動發送」) — an agent can only ever produce a draft. Sending is gated on a human decision, every single time.
Setting It Up
Section titled “Setting It Up”Everything defaults off. A home directory that never writes a [mail]
section behaves exactly as if the feature didn’t exist.
[mail]enabled = true # master switch — default falsegmail_enabled = false # poll the connected Google Workspace accountgmail_query = "in:inbox is:unread newer_than:1d"dropfolder_enabled = true # watch <home>/mail/inbound/*.eml — default truepoll_interval_secs = 120 # default 120, floor 30 (faster is treated as a runaway)default_agent = "sales" # who owns arriving mail; empty = fallback resolutionauto_trigger = false # trigger-on-arrival — wake the owner on arrival. default OFF.allowed_senders = ["@example.com"] # inbound allowlist. empty = accept all.allowed_recipients = [] # outbound allowlist. empty = any recipient.max_body_chars = 4000 # default 4000, hard ceiling 20000outbound_ttl_secs = 86400 # 24h approval window before an unconfirmed draft is denied
# Needed only if you want the outbox to actually be able to send once approved.[channels.email]smtp_host = "smtp.example.com"smtp_port = 587smtp_user = "bot@example.com"smtp_pass_enc = "..." # encrypted secret (see config_crypto); plaintext smtp_pass also still worksfrom_addr = "bot@example.com"smtp_tls = "starttls" # none | implicit | starttls (default)Every field falls back independently on a wrong type or a missing key — one
bad line in [mail] never disables the rest of the mailbox.
Two Inbound Transports
Section titled “Two Inbound Transports”Both are dependency-free on purpose — no IMAP crate, no second credential store.
Gmail. Reuses whatever Google Workspace connection the agent already has
(google_workspace::resolve_backend — OAuth, service account, or the Apps
Script bridge, whichever the account is configured for). The gateway
searches with gmail_query and reads full messages through the same
connection agents already use for gmail_search / gmail_read. No new
credential path.
Drop folder. Anything written to <home>/mail/inbound/*.eml is picked
up and parsed as a standard email message. This is the seam for fetchmail,
isync, procmail, or a local MTA drop — and it’s the transport the test suite
actually drives, because it can be verified offline. Consumed files are
moved, never deleted, to <home>/mail/inbound/processed/ — the raw
.eml is the evidence a future parsing bug would be diagnosed from.
A native IMAP poll transport is deliberately not included — it would need a real IMAP account to verify, and shipping unverifiable network code was judged worse than shipping a documented gap. See Known Limitations.
Every poll cycle counts every drop reason separately — stored, duplicate, sender-refused, empty-content, and transport error never collapse into one number, so “nothing new” and “twelve mails refused by the allowlist” never look the same in the logs.
Outbound: Always a Draft, Never a Send
Section titled “Outbound: Always a Draft, Never a Send”mail_send (an MCP tool) writes a pending draft to the outbox and files an
approval request. It returns immediately — an agent’s tool call does not
block for the length of a human errand.
From there, a person confirms it in one of two places:
- Dashboard — the Pending send tab (待寄出) on the Mail page (信箱) lists every pending draft with its recipient, subject, and body; the Confirm send (確認寄出) / Reject (拒絕) buttons decide it.
- Chat channel — the same approval is pushed as buttons to the
operator’s channel (Telegram/Discord/Slack/LINE, wherever
approvalnotifications are wired), so a decision doesn’t require opening the dashboard.
Only one function in the entire product can transmit an Agent Mail —
mail_worker::settle_outbox, which runs on a resident tick and only acts
once a decision exists:
| Decision | Result |
|---|---|
| Approved | SMTP send attempted → sent, or failed with the transport error |
| Denied | rejected |
| Expired (TTL passed with no decision) | rejected — timeout counts as a denial, like every other approval in the system |
| Approval row missing/unreadable | rejected, fail-closed — an unverifiable decision is not consent |
If a draft is approved but [channels.email] was never configured, it
settles as failed with an explicit note (the product’s actual output:
“寄件伺服器尚未設定”, “the outgoing mail server is not configured”) — never as
a silent no-op and never misreported as sent. Settling is idempotent: once a
draft reaches a terminal state, later ticks leave it alone.
Empty-content protection applies to both directions: a blank inbound
subject+body is never ingested, and mail_send refuses a blank
recipient/subject/body before an approval row is even created.
MCP Tools
Section titled “MCP Tools”Three tools, all gated by [mail] enabled = true (refused with a Chinese
error otherwise) and by their own MCP scopes:
| Tool | Scope | What it does |
|---|---|---|
mail_list |
mail:read |
List an inbox, newest first. Params: agent_id (default: caller’s own), include_archived (default false), limit (default 20, max 200). |
mail_read |
mail:read |
Read one message in full and mark it read. Params: mail_id (required), agent_id. |
mail_send |
mail:send |
Draft an outgoing mail — does not send. Params: to (exactly one address), subject, body, in_reply_to (optional). |
Reading and sending are split into two scopes on purpose: an operator can grant an agent visibility into its mailbox without also granting it the ability to put a draft in front of a human. Neither scope is externally grantable — both stay agent/Admin-side only, so an external API key can never read a customer’s mailbox or queue a mail in a human’s name.
Naming another agent’s agent_id runs through the same delegation-policy
check every other cross-agent tool uses — mailbox access follows the org
chart, not a flat namespace.
Security Design
Section titled “Security Design”- Never auto-sent, structurally.
mail_send(andrecord_outbox_draftunderneath it) has no code path that transmits anything. The only send path in the product is the approval settler, and it only fires on anApproveddecision. - Mail content is always data, never instructions. Every inbound message
handed to an agent — whether read via
mail_reador delivered by the arrival trigger — is wrapped in a fenced<inbound_mail>block with an explicit instruction shipped with the data on every single occurrence: treat the contents as something a person said, not as something to obey, even if it asks you to ignore prior rules or hand over credentials. - Suspicious mail is shown, not hidden, and never auto-triggers. Every inbound message is run through the platform’s injection scanner at ingest, and the verdict is persisted. A flagged message is still stored and still visible on the dashboard — hiding it from the human would be the actual security failure — but it is permanently excluded from the trigger-on-arrival wake-up list.
- Trigger-on-arrival defaults off. Waking an agent on arrival is a spend
decision, so it’s opt-in per home (
[mail] auto_trigger), and even when on, a flagged arrival never triggers. - Allowlists are exact/domain matches, never substrings.
allowed_senders/allowed_recipientsentries are either a full address or an@domain.comprefix; a lookalike domain that merely contains an allowed one (evil-example.comagainst@example.com) is refused. - Sender/recipient identity is exact, one recipient per send.
mail_sendrefuses a comma-separated or otherwise malformedtofield rather than silently sending to the first address in it.
Known Limitations
Section titled “Known Limitations”| Limitation | Detail |
|---|---|
| No IMAP transport | Only Gmail (via the existing Google Workspace connection) and the drop folder are supported inbound. A general IMAP poller isn’t shipped — it can’t be verified without a live IMAP account. |
| No attachments | Bodies are plain text only, capped at max_body_chars (default 4,000, hard ceiling 20,000 characters). Attachments are neither parsed on inbound nor sendable outbound. |
| SMTP is hand-configured | [channels.email] has no setup wizard yet — an operator writes smtp_host / smtp_user / smtp_pass (or smtp_pass_enc) / from_addr directly into config.toml. |
| Non-real-time by design | The poll floor is 30 seconds and the default cadence is 120 seconds — this is the deliberately slow channel; use a chat channel for anything that needs an immediate answer. |
One recipient per mail_send call |
Reply-all / CC / BCC aren’t modeled — draft one mail per recipient. |