name: email
description: >-
Send and read email from the iii engine — SMTP send, IMAP read, and
real-time IDLE push as a subscribable trigger type. Credentials live in
harness/auth-credentials; this worker fetches them on every connect.
The email worker speaks SMTP and IMAP through persistent per-account
connections. Every callable surface lives under the email::* namespace.
The transport is chosen per account in config.yaml — provider: smtp
for send-only accounts, provider: imap for two-way accounts that also
need to read and react to inbound mail.
The worker refuses to fall back to polling. If an IMAP server does not
advertise the IDLE capability, the supervisor fails at startup with
E610. Inbound messages flow through the email::new-mail trigger type,
fanned out by an IDLE-driven dispatcher the moment a server-side
EXISTS notification lands. Credentials never live in this worker —
every IMAP login and every email::send calls auth::get_token against
harness/auth-credentials under provider key email::<account>.
When to Use
- An agent needs to send a transactional email right now
(
email::send). - You need to enumerate configured accounts and their capabilities so an
LLM picks the right
accountfield (email::accounts::list). - You need to page recent messages in an IMAP folder by UID with a stable
cursor (
email::list). - You need the full parsed body of one message plus attachment part refs
(
email::get). - You need to stream IMAP
SEARCHresults as NDJSON as the server returns matches (email::search). - You need to add or remove a system flag —
\Seen,\Flagged,\Answered,\Deleted,\Draft(email::flag). - You need to move a message to another folder atomically with RFC 6851
UID MOVE(email::move). - You need to stream an attachment's raw bytes onto a response channel
with no in-memory buffering (
email::attachment::get). - You need to react to inbound mail the instant it lands in a configured
(account, folder)(email::new-mailtrigger type).
Boundaries
- Not a mail server — this is a client. Bring your own SMTP and IMAP endpoints.
- Not an OAuth flow runner in 0.1.0 — use app-password style credentials
via
harness/auth-credentialsuntil the OAuth follow-up PR. - Not a polling fallback — IMAP servers without
IDLEfail fast withE610so the failure stays visible instead of silently degrading. - Not a credential store — every connect re-fetches the secret from
harness/auth-credentials. Pair the two workers; deployingemailalone is unsupported. email::flagwithflag: "deleted"does NOT expunge — useemail::moveto a Trash folder for visible deletion.email::searchandemail::attachment::getuse a streaming response channel; they're invoked by sibling workers passing aStreamChannelRef, not directly fromiii trigger.
Functions
email::send— deliver a message via the account's SMTP transport (STARTTLS or plain on a trusted network).email::accounts::list— enumerate configured accounts withprovider,from,can_send,can_read, andfolders.email::list— page recent UIDs in a folder, newest first, withnext_since_uidcursor.email::get— fetch one message by UID; returns parsedhtml,text, headers, and anattachments[]list of part refs.email::search— stream IMAPSEARCHresults as NDJSON frames onto aStreamChannelRef; one header-summary object per match.email::flag— add or remove a system flag on a UID viaUID STORE +FLAGS.SILENT/-FLAGS.SILENT.email::move— move a UID to another folder with RFC 6851UID MOVE; falls back toCOPY + STORE \Deletedwhen the server lacksMOVE.email::attachment::get— stream attachment bytes bypart_idonto aStreamChannelRefchunk-by-chunk; no in-memory buffering.
SMTP send timeout is limits.send_timeout_ms (default 30 s). IMAP
connect timeout is limits.imap_connect_timeout_ms (default 15 s).
Total recipients across to + cc + bcc cap at limits.max_recipients
(default 100). Each attachment caps at limits.max_attachment_bytes
(default 25 MiB).
Stream-source attachments (source.kind = "stream") return E699 in
0.1.0; use source.kind = "base64" until the symmetric attachment-send
path lands.
Reactive triggers
Register an email::new-mail trigger when a function should fire
automatically the moment a new message arrives in a configured
(account, folder) — without polling with email::list.
Reach for it when:
- A support workflow should kick off the instant a ticket email lands.
- A harness session should ingest new mail as an additional turn.
- An archival worker should mirror inbound mail into a database.
Do not bind when:
- The account is
provider: smtp(send-only); onlyprovider: imapaccounts open an IDLE listener. - You only need to act on outbound mail —
email::sendalready gives you the SMTP response synchronously.
How to bind
- Register a handler:
registerFunction('my-worker::on-mail', handler). - Register the trigger:
iii.registerTrigger({
type: 'email::new-mail',
function_id: 'my-worker::on-mail',
config: {
account: 'support',
folder: 'INBOX',
handler_timeout_ms: 30000,
},
})
Config: account (required, must be a configured account with an
imap: block), folder (default "INBOX", must appear in
config.accounts.<name>.imap.folders), handler_timeout_ms (default
30 000).
Event payload per inbound message:
{
"account": "support",
"folder": "INBOX",
"uid": 12345,
"message_id": "<abc@mx.example.com>",
"from": "alice@example.com",
"subject": "Ticket #42",
"snippet": "first ~200 chars of body",
"ts": "2026-05-28T10:14:00+00:00"
}
The dispatch is event-driven off the IMAP server's EXISTS push —
within milliseconds of a new message landing in the watched folder.
Delivery semantics
Best-effort, at-most-once. The dispatcher fires iii.trigger per
subscriber and waits up to handler_timeout_ms. There is no upstream
durable queue: IMAP IDLE is a wakeup mechanism, not a delivery
guarantee.
- A subscriber handler that panics, times out, or returns an error is logged and skipped — the event is NOT redelivered.
- If the worker is down when a message lands, no
email::new-mailevent fires for it. On reconnect the supervisor sets the high-water mark to the currentUIDNEXT - 1; UIDs that arrived during downtime are reachable viaemail::listbut do NOT replay as events. - For at-least-once / replay, write each
email::new-mailevent to a durable store from your handler (database::execute,storage::putObject, …) before doing meaningful work, and useemail::listwith a persistedsince_uidcursor to catch up on downtime gaps.
Errors
Stable across the trigger boundary. Match on the code field of the
returned envelope.
| Code | When |
|---|---|
E600 |
Unknown account name |
E601 |
email::send with empty to |
E602 |
Total recipients over limits.max_recipients |
E603 |
Account missing the required transport block |
E604 |
email::send with neither html nor text |
E605 |
Attachment over limits.max_attachment_bytes |
E606 |
auth::get_token upstream call failed |
E607 |
No credential stored for the account |
E608 |
Credential payload missing username / password |
E609 |
Address parse / MIME build failure |
E610 |
IMAP server lacks IDLE — refusing to fall back to polling |
E612 |
IMAP UID SEARCH failed |
E613 |
Folder not in account's imap.folders config |
E614 |
IMAP connect / TLS handshake failed |
E615 |
Plain (non-TLS) IMAP refused |
E616 |
IMAP login failed |
E617 |
IMAP SELECT failed |
E619 |
IMAP body fetch / MIME parse failed |
E620 |
SMTP send failed |
E621 |
Response channel close failed |
E622 |
Unknown flag name |
E623 |
IMAP STORE failed |
E624 |
IMAP COPY / STORE \Deleted fallback failed |
E625 |
IMAP attachment-part fetch failed |
E626 |
Attachment payload malformed (e.g. invalid base64) |
E627 |
email::move partial: copy succeeded but STORE \Deleted failed — message in BOTH folders, reconcile |
E699 |
Not yet implemented in 0.1.0 |