Agent IMAP Read Capability as Native @step Functions
Context
Agents had no email capability. Issue #1507 introduces the first one: letting an agent connect to an IMAP inbox, list unread messages, and fetch a chosen message with its attachments. This is the greenfield foundation two sibling stories build on (moving mail to folders, drafting replies) — both blocked on this decision.
Three questions had to be settled before writing code:
- How should the capability be exposed? The platform already has an external-tool integration path (
McpReactAgentcalling out to MCP servers) and human/bot escalation paths (HITL, BITL). Email reading could plausibly ride any of them. - Where do the connection config and the result events live, given
packages/coreis shared infrastructure and its guidance discourages agent-specific events? - How are attachment bytes carried through the Swiss AI Agent Protocol, whose events are persisted to the audit trail (FerretDB) and streamed to the frontend over WebSocket?
The relevant existing pieces: McpClientConfig (a StepConfig in packages/core/mcp/), the ControlEvent / DisplayEvent / ControlAndDisplayEvent hierarchy, and UserUploadedFile — the platform's only file-into-agent contract, which references files by file_id and derives the S3 location at runtime rather than carrying bytes.
Decision Drivers
- In-process, deterministic, auditable
Reading mail is a pure workflow step with no human judgement and no external tool server. It should be a native, traceable@step— not an opaque MCP tool call and not a HITL/BITL pause. - Reuse by the sibling stories
The connection config and result events must be shared, first-class protocol types that the move-mail and draft-reply stories extend without cross-package internal imports. - Events must stay small
Every protocol event is persisted to the audit trail and serialized over WebSocket. Embedding raw attachment bytes (base64, +33%) would bloat both paths and risk NATS/FerretDB message-size limits. - Follow the one existing file convention
UserUploadedFilealready establishes reference-by-file_idwith runtime-derived S3 location for IDOR-safety. Attachments should follow the same shape, not invent a bytes-in-event exception. - No new secret infrastructure for a first capability
Mailbox credentials should reuse the establishedPasswordform-field-into-agent-config pattern (McpClientConfig'sapi_key), not a bespoke secret store.
Decision
The capability is a set of native @step-usable functions, not an MCP tool and not HITL/BITL.
- Connection config:
ImapClientConfig(aStepConfig) lives inpackages/core/imap/, mirroringMcpClientConfig. Itspasswordfield is aPasswordform element; the value is stored at-rest in the agent config (MongoDBagent_configs), identical to howMcpClientConfig.api_keyis handled today. - Result events:
UnreadMailListedEventandMailFetchedEventlive inpackages/core(events/agent/imap/) asControlAndDisplayEvents — control, because steps consume them; display, so they render by name in the event timeline. They are protocol types deliberately placed in core for reuse by the sibling stories, the same way MCP'sToolEventlives in core. - Attachments as S3 references:
MailFetchedEventcarriesMailAttachmentRef(filename, content-type,file_id, size) — never raw bytes. The fetch step writes attachment bytes to the sharedagent-filesbucket under the agent's own path prefix and emits references, mirroringUserUploadedFile. This required wiring an S3 client into the agent runtime for the first time (MailAttachmentStore, usingS3StorageSettingswith the blocking put wrapped in a thread). - New dependency:
imapclient(BSD-3-Clause) for IMAP. It is synchronous, so its blocking calls are off-loaded withasyncio.to_thread(the same pattern the attachment store uses for S3). It parses server responses into structured dicts and raises onNO/BADresponses, so no hand-rolled protocol-line parsing or status-check shim is needed. MIME/attachment parsing uses the Python standard-libraryemailmodule — no additional dependency.aioimaplibwas the initial choice but is GPL-3.0, incompatible with the Apache-2.0 license ofpackages/agent, and was rejected on the license-check gate. - Read-only scope: no SMTP, no sending — ever. Sending is explicitly out of scope for the whole email capability, not just this story. Read-only extends to the protocol level: listing and fetching use a read-only
SELECT(EXAMINE) plusBODY.PEEK[...]so the\Seenflag is never set, and imapclient raises on a failed login or select rather than surfacing it as an empty inbox. (The move-mail sibling story, #1508, is the first capability to open the folder writable — it relocates a message viaMOVEorCOPY+UID EXPUNGE, but is still non-destructive at the mailbox level: it moves, never permanently deletes, and never sends.) - Bounded payloads: three deployment-fixed caps (not user-configurable) keep a single hostile or oversized message from overloading the agent or exceeding NATS/FerretDB message-size limits.
ImapClientConfig.max_message_bytes(default 50 MB) is the peak-memory bound: the raw RFC822 size is checked with a cheapRFC822.SIZEfetch before the body is downloaded, so an oversized message is refused instead of being pulled into memory.max_body_bytes(default 1 MB) then truncates the decoded body carried in a fetch event, andmax_attachment_bytes(default 10 MB) skips oversized attachments — both only trim what is kept after parsing. - Stable message identity: messages are addressed by IMAP UID (
UID SEARCH/UID FETCH), not sequence numbers — list and fetch run on separate connections (possibly separate servers), and sequence numbers shift when another client expunges mail in between. - Bounded listing:
ImapClientConfig.max_messages(default 50) caps how many unread summaries a singleUnreadMailListedEventcarries, keeping the persisted/streamed event small even for overflowing inboxes.
A demonstrator agent (playground/minimal_workflow/imap_workflow) exercises the capability end to end (list_unread_step → fetch_mail_step → stop) and hosts the BDD tests. It is non-conversational, like RetrievalAgent: it is triggered by a dedicated ReadMailStartEvent (a StartEvent subclass) rather than UserMessageEvent, so it stays out of the chat UI (is_conversational is False) and is configured via its form and started programmatically. It has its own deployable entry point (app/imap_agent/main.py) so it can run as a real agent process; a production email agent can graduate from the final sibling story.
Consequences
Positive
- The move-mail and draft-reply stories inherit a ready connection config, event pair, and attachment-reference model — no rework.
- Persisted and streamed events stay small; attachment size no longer threatens message-size limits, and the mail body is not scattered across the event store in duplicate.
- Attachment handling matches the platform's existing IDOR-safe file contract instead of introducing a bytes-in-event exception.
- Mail reading is fully traceable in the timeline like any other workflow step (OpenTelemetry + Langfuse), with no external tool server or human pause in the loop.
Trade-offs
- The agent runtime now depends on S3. Agents previously touched only NATS, JetStream, Redis, Milvus, and MongoDB; reading mail attachments now also requires SeaweedFS/S3 reachability and credentials in agent deployments.
- This deviates from the issue's literal wording ("attachments (bytes + names)"). The reference-based transport was chosen over inline bytes for the reasons above and is flagged for reviewer sign-off.
- Mailbox credentials are stored at-rest in the agent config, like MCP
api_key. A platform-wide secret-indirection mechanism (Key Vault / references) for agent configs remains a possible follow-up if the security posture needs to tighten. imapclientis a new runtime dependency inpackages/agent, adding to the maintenance and supply-chain surface. It is BSD-3-Clause (permissive) and has no transitive runtime dependencies of its own.- Mail bodies are protocol payloads.
MailFetchedEventcarries only the plain-textbody_textof arbitrary inbound mail into the audit trail (FerretDB) and to the frontend over WebSocket; Presidio only guards the LLM path. The HTML body is parsed but deliberately not surfaced on the event — it is kept in-process onParsedMessage(body_html) as the parse result and never enters the persisted/streamed event or the generated SDK types, so a hostile sender's markup cannot reach a frontend that might render it as raw HTML (XSS). A future consumer that needs the HTML body must add it to the event explicitly and sanitize it server-side first.
Draft-reply extension (#1509)
The third sibling story lets the agent draft a reply to the fetched message and save it for a human, building directly on the read capability:
APPEND-only handoff, never send. The reply is appended to the configureddrafts_folderflagged\DraftviaImapClient.append_draft; there is no SMTP path and no HITL/BITL approval event. The draft sitting in the user's Drafts folder is the human handoff — a person reviews and sends it from their own mail client.APPENDtargets the folder by name and needs no writableSELECT, so the existing connection is reused; it returns theAPPENDUIDwhen the server supports UIDPLUS, elseNone.- LLM-drafted body, deterministic envelope. The reply body is generated by a platform LLM selected from the model list, steered by an optional
draft_prompt(aTextareapre-filled with an example prompt).ReplyComposerwraps that body with a deterministic envelope:To= originalReply-ToelseFrom, an idempotentRe:subject, andIn-Reply-To/Referencesbuilt from the original RFCMessage-IDso the draft threads correctly. This required carrying the RFCMessage-ID/References/Reply-Toheaders onMailFetchedEvent(previously only the IMAP UID was exposed). - New protocol event.
MailDraftCreatedEvent(ControlAndDisplayEvent, inpackages/core) records the drafts folder,in_reply_to, subject, recipient, and the assigneddraft_uid; it is added to the APIDisplayEventsunion so it renders on the timeline. - Demonstrator wiring. The chain is
list → fetch → move → draft → stop. Drafting stays independent of moving:move_mail_stepdoes not short-circuit toStopEventwhen moving is disabled — it emits a no-opMailMovedEvent(moved=False)(themovedflag) purely to forward the run. The draft step consumes bothMailFetchedEvent(identity) andMailMovedEvent(ordering) and returnsStopEventwhen drafting is disabled. - Untrusted mail into the LLM. Inbound mail body is attacker-controlled and enters the LLM prompt; the platform's Presidio guard covers the LLM path. No new dependency and no frontend change —
Textarea/ModelSelectalready render via the form-duality FormKit renderer.
Draft reads from the Move folder + grouped draft settings
A follow-up refined where the draft is read from and how the draft settings are presented, without changing the protocol events:
- Draft reads strictly from the Move step's folder, not the inbox copy. The draft step no longer drafts from the message fetched out of the inbox, and there is no configurable source folder and no inbox fallback. It re-reads the message from
imap.processed_folder— the folder the move step files into — re-locating it by its RFCMessage-ID(IMAP UIDs are folder-specific, but theMessage-IDis stable across the move). Implemented withImapClient.find_message_uid(folder, message_id)(an IMAPSEARCH HEADER Message-ID) plus a folder-awarefetch_message(uid, folder=…). If the message is not in the processed folder — e.g. moving is disabled, so nothing was relocated — the step stops with a thought and drafts nothing. Consequence: drafting is only meaningful when moving is enabled; the draft sequences after the move (consumingMailMovedEventfor ordering) and sources from the move's target. This deliberately removes the earlier move-off duplicate-draft path (with no move to relocate the mail and reading capped byUNSEEN, re-triggering used to re-draft the same inbox message every run). An earlier iteration used a user-configurablesource_folder(defaultINBOX) with an inbox fallback and kept the steps fully independent; that was superseded by this decision. DraftEmailSettingsconfig group. Because a formGroupis also a data group (its name is a key in the submitted config), grouping the draft settings under one "Draft email settings" section required nesting them structurally. A newDraftEmailSettings(StepConfig, inpackages/core/imap/) holdsenable_draft,drafts_folder, the LLMmodel_name(ModelSelect), anddraft_prompt;enable_draft/drafts_foldermoved out of the sharedImapClientConfig, andllm/draft_promptmoved offImapAgentConfig. The drafting LLM is rebuilt from the selected model via aDraftEmailSettings.llmproperty (LLMConfig(model_name=…)), so the chat-LLM default parameters (temperature, timeout, …) are no longer exposed in the form — the form shows only the model picker. This is a breaking config-shape change (no compatibility shim); existing IMAP agent profiles must be recreated.
Independent batch drafting (own start event + flag-based dedup)
A further iteration made drafting a fully independent capability rather than a tail of the read/move run, because drafting is scheduled separately and must be able to work through mail that accumulated in a folder over earlier runs:
- Own start event, same agent. Drafting is triggered by a new
DraftMailStartEvent(aStartEventon the sameImapAgent), so the platform exposes a second trigger endpoint automatically. The read/move chain no longer drafts — it ends at afinish_after_move_step. Both start events are fired by an external scheduler (out of scope). - Batch, read from a configurable source folder.
draft_batch_steplists up toDraftEmailSettings.batch_size(default 5, configurable) messages fromDraftEmailSettings.source_folder(defaultINBOX; point it at the processed folder for the accumulate-then-draft flow) and drafts a reply for each. A single looping step is used rather than event fan-out because the engine's fan-out join (FixedList(T, N)) needs a compile-time constant, unusable for a runtime-variable candidate count. - Flag-based idempotency, source stays unread. Re-drafting is prevented by a dedup flag on the source message, not by
\Seen: a custom IMAP keyword$AiHubDraftedis preferred (detected viaPERMANENTFLAGS \*), falling back to\Answeredwhere custom keywords are unsupported. The drafter listsUNKEYWORD $AiHubDrafted(orUNANSWERED), and after appending each draft marks the source with the flag. All reads useBODY.PEEKand the flagSTOREnever sets\Seen, so the source mail stays unread. This removes the previous "no move ⇒ re-draft every run" footgun without relying on the move step. - At-least-once ordering. The draft is appended to Drafts before the source is flagged, so a crash in between re-drafts that one message next run (a recoverable duplicate) rather than dropping the reply.
- New batch event.
MailBatchDraftedEvent(ControlAndDisplayEvent, inpackages/core) summarises a run:source_folder,count, anddrafted: list[DraftedReplyRef]. It is added to the APIDisplayEventsunion and renders on the timeline via the generic display fallback (no bespoke frontend component — matching the other mail events).ReplyComposergainedcompose_from_parsedso the batch drafter can build the threaded envelope from a freshly-fetchedParsedMessageinstead of aMailFetchedEvent. This supersedes the move-follower single-draft step and its per-draftMailDraftCreatedEventin the workflow.
