Email worker — SMTP send and real-time IMAP read with IDLE push (email::*).
- macOS: arm64 · x64
- Linux: arm64 · armv7 · x64
- Windows: arm64 · x64 · x86
exact versions are immutable; binary and bundle artifacts are digest-pinned.
full markdown
/workers/email.md?version=0.1.4. paste it into an llm prompt or pipe it through curl from a worker.install
configuration
- accounts:
alice:
from: Alice <alice@local.test>
provider: smtp
smtp:
host: localhost
port: 3025
starttls: false
bob:
from: Bob <bob@local.test>
provider: smtp
smtp:
host: localhost
port: 3025
starttls: false
limits:
imap_connect_timeout_ms: 15000
max_attachment_bytes: 26214400
max_recipients: 100
send_timeout_ms: 30000dependencies
readme
Email worker for the iii engine. SMTP send + real-time IMAP read with
IDLE push. The worker refuses to fall back to polling: an IMAP server
without IDLE fails fast at startup with E610. Inbound messages flow
through the email::new-mail trigger type, fanned out the moment the
server pushes EXISTS.
Credentials live in harness/auth-credentials under provider key
email::. Pair both workers in any real deployment.
Install
iii worker add harness/auth-credentials
iii worker add emailSkills
Install the email agent skill for Claude Code, Cursor, and 30+ other agents:
npx skills add iii-hq/workers --skill emailBrowse or install every worker skill at once:
npx skills add iii-hq/workers --list
npx skills add iii-hq/workers --allQuickstart
use iii_sdk::{register_worker, InitOptions, protocol::TriggerRequest};
use serde_json::json;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let worker = register_worker("ws://localhost:49134", InitOptions::default());
let result = worker.trigger(TriggerRequest {
function_id: "email::send".into(),
payload: json!({
"account": "support",
"to": ["recipient@example.com"],
"subject": "Your ticket has been updated",
"text": "Hi — thanks for reaching out."
}),
action: None,
timeout_ms: Some(30_000),
}).await?;
println!("{result:#?}");
Ok(())
}import { registerWorker } from 'iii-sdk'
const worker = registerWorker('ws://localhost:49134')
await worker.trigger({
function_id: 'email::send',
payload: {
account: 'support',
to: ['recipient@example.com'],
subject: 'Your ticket has been updated',
text: 'Hi — thanks for reaching out.',
},
})from iii import register_worker
worker = register_worker("ws://localhost:49134")
worker.trigger({
"function_id": "email::send",
"payload": {
"account": "support",
"to": ["recipient@example.com"],
"subject": "Your ticket has been updated",
"text": "Hi — thanks for reaching out.",
},
})Other entry points: email::accounts::list, email::list, email::get,
email::search, email::flag, email::move, email::attachment::get.
Configuration
accounts:
# Send-only: only smtp:, provider: smtp.
support:
provider: smtp
from: "Support <support@example.com>"
smtp:
host: smtp.example.com
port: 587
starttls: true
# Two-way: smtp: + imap:, provider: imap.
inbox:
provider: imap
from: "Inbox <inbox@example.com>"
smtp:
host: smtp.example.com
port: 587
starttls: true
imap:
host: imap.example.com
port: 993
tls: true
folders: ["INBOX"]
limits:
max_attachment_bytes: 26214400 # 25 MiB
max_recipients: 100 # to + cc + bcc combined
send_timeout_ms: 30000
imap_connect_timeout_ms: 15000Credentials are fetched on every connect from harness/auth-credentials
under provider key email:: with shape
{ "type": "api_key", "username": "...", "password": "..." }. For Gmail,
generate an app password at https://myaccount.google.com/apppasswords —
the worker accepts both spaced (abcd efgh ijkl mnop) and joined
(abcdefghijklmnop) formats.
Triggers
| Name | Fires when |
|---|---|
email::new-mail |
IMAP IDLE push delivers a new message to a configured (account, folder). |
Subscriber config:
triggers:
- type: email::new-mail
function_id: my-worker::on-mail
config:
account: support
folder: INBOX # optional, default "INBOX"
handler_timeout_ms: 30000 # optional, default 30000Payload your function receives per inbound message:
{
"account": "support",
"folder": "INBOX",
"uid": 12345,
"message_id": "<abc@mx.example.com>",
"from": "sender@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 server's EXISTS push — within
milliseconds of a new message landing in the watched folder.
Local development & testing
# In one terminal: start the engine
iii
# In another: build & run the worker
cargo run --release -- --url ws://127.0.0.1:49134 --config ./config.yamlThe worker registers 8 functions + 1 trigger type, then spawns one
persistent IMAP+IDLE supervisor per (account, folder) configured with
provider: imap. Reads (list/get/search/flag/move/attachment::get)
borrow an on-demand session from a separate pool so the half-duplex IMAP
socket is never shared with the supervisor.
Seed credentials before exercising email::send or any IMAP function:
iii trigger auth::set_token \
provider=email::support \
credential='{"type":"api_key","username":"you@example.com","password":"<app-password>"}'--manifest prints the registry-publish JSON without touching the engine:
cargo run -- --manifest | jq .Tests
cargo test # unit tests (config, manifest, triggers)
cargo test --test bdd # cucumber: --manifest subprocess contracttests/bdd.rs self-skips @engine scenarios when no engine is reachable
on III_ENGINE_WS_URL (default ws://127.0.0.1:49134), so contributor
laptops without a running engine still pass @pure scenarios.
Verification before publishing
The full preflight checklist for binary workers
(docs/sops/binary-worker.md):
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
./target/debug/email --manifest | jq .Errors
| 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 |
api reference (json)
{
"functions": [
{
"description": "List configured email accounts. Returns { accounts: [{ name, provider, from, can_send, can_read, folders }] }. Use `name` as the `account` field for email::send, email::list, email::get, email::search, email::flag, email::move, and email::attachment::get.",
"metadata": {},
"name": "email::accounts::list",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AccountsListReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AccountInfo": {
"properties": {
"can_read": {
"type": "boolean"
},
"can_send": {
"type": "boolean"
},
"folders": {
"items": {
"type": "string"
},
"type": "array"
},
"from": {
"type": "string"
},
"name": {
"type": "string"
},
"provider": {
"type": "string"
}
},
"required": [
"can_read",
"can_send",
"folders",
"from",
"name",
"provider"
],
"type": "object"
}
},
"properties": {
"accounts": {
"items": {
"$ref": "#/definitions/AccountInfo"
},
"type": "array"
}
},
"required": [
"accounts"
],
"title": "AccountsListResp",
"type": "object"
}
},
{
"description": "Stream an attachment's raw bytes from a message. Payload: { account, folder, uid, part_id }. `part_id` comes from email::get's attachments[].part_id. Bytes flow over the response channel in chunks as IMAP delivers them; no in-memory buffering. Channel closes on EOF.",
"metadata": {},
"name": "email::attachment::get",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"StreamDirection": {
"enum": [
"read",
"write"
],
"type": "string"
},
"StreamRef": {
"description": "Wire-identical mirror of `iii_sdk::channels::StreamChannelRef`. The SDK type does not derive `JsonSchema`, which would block typed registration of `email::search` and `email::attachment::get`. Converted via `From` at the handler boundary so the rest of the worker keeps using the SDK type.",
"properties": {
"access_key": {
"type": "string"
},
"channel_id": {
"type": "string"
},
"direction": {
"allOf": [
{
"$ref": "#/definitions/StreamDirection"
}
],
"default": "write"
}
},
"required": [
"access_key",
"channel_id"
],
"type": "object"
}
},
"properties": {
"account": {
"type": "string"
},
"folder": {
"type": "string"
},
"part_id": {
"type": "string"
},
"response": {
"$ref": "#/definitions/StreamRef"
},
"uid": {
"format": "uint32",
"minimum": 0,
"type": "integer"
}
},
"required": [
"account",
"folder",
"part_id",
"response",
"uid"
],
"title": "AttachReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"ok": {
"type": "boolean"
}
},
"required": [
"ok"
],
"title": "AttachmentGetResp",
"type": "object"
}
},
{
"description": "Add or remove a system flag on a message. Payload: { account, folder, uid, flag, add?=true }. `flag` is one of 'seen', 'flagged', 'answered', 'deleted', 'draft' (without the leading backslash). Marking a message 'deleted' does NOT expunge — use email::move to trash, or future email::expunge.",
"metadata": {},
"name": "email::flag",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string"
},
"add": {
"default": true,
"type": "boolean"
},
"flag": {
"type": "string"
},
"folder": {
"type": "string"
},
"uid": {
"format": "uint32",
"minimum": 0,
"type": "integer"
}
},
"required": [
"account",
"flag",
"folder",
"uid"
],
"title": "FlagReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"ok": {
"type": "boolean"
}
},
"required": [
"ok"
],
"title": "FlagResp",
"type": "object"
}
},
{
"description": "Fetch a single message by UID. Payload: { account, folder, uid }. Returns { uid, message_id, from, to[], subject, date, html?, text?, attachments: [{ part_id, filename, content_type, size }] }. Use email::attachment::get with the returned part_id to stream attachment bytes.",
"metadata": {},
"name": "email::get",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string"
},
"folder": {
"type": "string"
},
"uid": {
"format": "uint32",
"minimum": 0,
"type": "integer"
}
},
"required": [
"account",
"folder",
"uid"
],
"title": "GetReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AttachmentInfo": {
"properties": {
"content_type": {
"type": "string"
},
"filename": {
"type": "string"
},
"part_id": {
"type": "string"
},
"size": {
"format": "uint",
"minimum": 0,
"type": "integer"
}
},
"required": [
"content_type",
"filename",
"part_id",
"size"
],
"type": "object"
}
},
"properties": {
"attachments": {
"items": {
"$ref": "#/definitions/AttachmentInfo"
},
"type": "array"
},
"date": {
"type": [
"string",
"null"
]
},
"from": {
"type": [
"string",
"null"
]
},
"html": {
"type": [
"string",
"null"
]
},
"message_id": {
"type": "string"
},
"subject": {
"type": "string"
},
"text": {
"type": [
"string",
"null"
]
},
"to": {
"items": {
"type": "string"
},
"type": "array"
},
"uid": {
"format": "uint32",
"minimum": 0,
"type": "integer"
}
},
"required": [
"attachments",
"message_id",
"subject",
"to",
"uid"
],
"title": "GetResp",
"type": "object"
}
},
{
"description": "List recent messages in a folder. Payload: { account, folder?='INBOX', limit?=50, since_uid?=<int> }. Returns { items: [{ uid, message_id, from, subject, snippet, ts, seen, flagged }], next_since_uid }. Pass next_since_uid back as since_uid to page forward without missing or duplicating messages.",
"metadata": {},
"name": "email::list",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string"
},
"folder": {
"default": "INBOX",
"type": "string"
},
"limit": {
"default": 50,
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"since_uid": {
"default": null,
"format": "uint32",
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"required": [
"account"
],
"title": "ListReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"HeaderSummary": {
"properties": {
"flagged": {
"type": "boolean"
},
"from": {
"type": [
"string",
"null"
]
},
"message_id": {
"type": [
"string",
"null"
]
},
"seen": {
"type": "boolean"
},
"snippet": {
"type": [
"string",
"null"
]
},
"subject": {
"type": [
"string",
"null"
]
},
"ts": {
"format": "date-time",
"type": [
"string",
"null"
]
},
"uid": {
"format": "uint32",
"minimum": 0,
"type": "integer"
}
},
"required": [
"flagged",
"seen",
"uid"
],
"type": "object"
}
},
"properties": {
"items": {
"items": {
"$ref": "#/definitions/HeaderSummary"
},
"type": "array"
},
"next_since_uid": {
"format": "uint32",
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"required": [
"items"
],
"title": "ListResp",
"type": "object"
}
},
{
"description": "Move a message to another folder. Payload: { account, folder, uid, dst_folder }. Uses RFC 6851 UID MOVE when the server supports it; falls back to COPY + STORE \\Deleted otherwise. Destination folder must exist on the server.",
"metadata": {},
"name": "email::move",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string"
},
"dst_folder": {
"type": "string"
},
"folder": {
"type": "string"
},
"uid": {
"format": "uint32",
"minimum": 0,
"type": "integer"
}
},
"required": [
"account",
"dst_folder",
"folder",
"uid"
],
"title": "MoveReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"method": {
"type": "string"
},
"ok": {
"type": "boolean"
}
},
"required": [
"method",
"ok"
],
"title": "MoveResp",
"type": "object"
}
},
{
"description": "Stream search results from an account's IMAP. Payload: { account, folder?='INBOX', query }. `query` is IMAP SEARCH syntax (RFC 9051), e.g. 'FROM alice@x SINCE 1-Jan-2026' or 'UNSEEN SUBJECT \"invoice\"'. Returns NDJSON frames on the response channel; each frame is { uid, message_id, from, subject, snippet, ts, seen, flagged }. Channel closes when search completes. No polling — frames stream as IMAP returns matches.",
"metadata": {},
"name": "email::search",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"StreamDirection": {
"enum": [
"read",
"write"
],
"type": "string"
},
"StreamRef": {
"description": "Wire-identical mirror of `iii_sdk::channels::StreamChannelRef`. The SDK type does not derive `JsonSchema`, which would block typed registration of `email::search` and `email::attachment::get`. Converted via `From` at the handler boundary so the rest of the worker keeps using the SDK type.",
"properties": {
"access_key": {
"type": "string"
},
"channel_id": {
"type": "string"
},
"direction": {
"allOf": [
{
"$ref": "#/definitions/StreamDirection"
}
],
"default": "write"
}
},
"required": [
"access_key",
"channel_id"
],
"type": "object"
}
},
"properties": {
"account": {
"type": "string"
},
"folder": {
"default": "INBOX",
"type": "string"
},
"query": {
"type": "string"
},
"response": {
"$ref": "#/definitions/StreamRef"
}
},
"required": [
"account",
"query",
"response"
],
"title": "SearchReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"ok": {
"type": "boolean"
}
},
"required": [
"ok"
],
"title": "SearchResp",
"type": "object"
}
},
{
"description": "Send an email via the account's SMTP transport. Payload: { account: string (key from email worker config), to: string[], cc?: string[], bcc?: string[], subject: string, html?: string, text?: string, reply_to?: string, in_reply_to?: string (Message-ID), references?: string[], attachments?: [{ filename, content_type, source: { kind: 'base64', data } }] }. Returns { message_id }. Credentials are fetched from auth-credentials under provider key `email::<account>` ({ username, password }). Provide html OR text (or both); at least one body is required.",
"metadata": {},
"name": "email::send",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"Attachment": {
"properties": {
"content_type": {
"type": "string"
},
"filename": {
"type": "string"
},
"source": {
"$ref": "#/definitions/AttachmentSource"
}
},
"required": [
"content_type",
"filename",
"source"
],
"type": "object"
},
"AttachmentSource": {
"oneOf": [
{
"description": "Inline base64 bytes. Best for LLM tool-use; bounces the full payload through the engine. For large files prefer a sidecar upload via the `storage` worker followed by a future stream-source variant.",
"properties": {
"kind": {
"enum": [
"base64"
],
"type": "string"
}
},
"required": [
"kind"
],
"type": [
"object",
"string"
]
}
]
}
},
"properties": {
"account": {
"type": "string"
},
"attachments": {
"items": {
"$ref": "#/definitions/Attachment"
},
"type": "array"
},
"bcc": {
"default": [],
"items": {
"type": "string"
},
"type": "array"
},
"cc": {
"default": [],
"items": {
"type": "string"
},
"type": "array"
},
"html": {
"default": null,
"type": [
"string",
"null"
]
},
"in_reply_to": {
"default": null,
"type": [
"string",
"null"
]
},
"references": {
"default": [],
"items": {
"type": "string"
},
"type": "array"
},
"reply_to": {
"default": null,
"type": [
"string",
"null"
]
},
"subject": {
"type": "string"
},
"text": {
"default": null,
"type": [
"string",
"null"
]
},
"to": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"account",
"subject",
"to"
],
"title": "SendReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"message_id": {
"type": "string"
}
},
"required": [
"message_id"
],
"title": "SendResp",
"type": "object"
}
}
],
"triggers": [
{
"description": "Fires when IMAP IDLE pushes a new message to a configured (account, folder). Payload: { account, folder, uid, message_id, from, subject, snippet, ts }. If the IMAP server lacks the IDLE capability, the account fails at startup (E610).",
"invocation_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"account": {
"type": "string"
},
"folder": {
"default": "INBOX",
"type": "string"
},
"handler_timeout_ms": {
"default": 30000,
"format": "uint64",
"minimum": 0,
"type": "integer"
}
},
"required": [
"account"
],
"title": "NewMailBindingConfig",
"type": "object"
},
"metadata": {},
"name": "email::new-mail",
"return_schema": {}
}
]
}