Email worker — SMTP send and real-time IMAP read with IDLE push (email::*).
Install
iii trigger compose::add worker=emailRun in your project with the iii engine running. Exact versions are immutable and digest-pinned.
Full markdown
/workers/email.md. Paste it into an LLM prompt or pipe it through curl from a worker.Install
iii trigger compose::add worker=email@0.1.17Dependencies
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.
Accounts, limits, and logins live in the worker's configuration entry
(hot-reloaded; see Configuration below), so a compose file is the whole
deployment.
Install
# worker-compose.yaml
containers:
email:
worker: package://api.workers.iii.dev/email
version: "0.2.0"Accounts go in the container's config_override (see Configuration).
Skills
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
The worker owns one entry in the configuration worker (id email, or
III_CONFIG_NAME when a supervisor sets it). Under iii compose the
daemon writes that entry from the manifest defaults merged with the
container's config_override; on a bare engine the built-in default
(no accounts) is seeded on first boot, and configuration::set or the
worker's form in the Console's global Settings modal edits it afterwards. The worker hot-reloads on every
configuration:updated: limit changes swap the snapshot, account changes
respawn the IMAP supervisors and drop pooled sessions. A value that fails
validation is rejected and the previous accounts stay live;
email::config-status reports last_outcome, last_error, and
rejected_reloads.
# worker-compose.yaml
containers:
email:
worker: package://api.workers.iii.dev/email
version: "0.2.0"
config_name: email
config_override:
accounts:
# Send-only: only smtp:, provider: smtp.
support:
provider: smtp
from: "Support <support@example.com>"
smtp:
host: smtp.example.com
port: 587
starttls: true
username: ${SMTP_USERNAME}
password: ${SMTP_PASSWORD}
# Two-way: smtp: + imap:, provider: imap.
inbox:
provider: imap
from: "Inbox <inbox@example.com>"
smtp:
host: smtp.example.com
port: 587
starttls: true
username: ${SMTP_USERNAME}
password: ${SMTP_PASSWORD}
imap:
host: imap.example.com
port: 993
tls: true
folders: ["INBOX"]
username: ${SMTP_USERNAME}
password: ${SMTP_PASSWORD}
limits:
max_attachment_bytes: 26214400 # 25 MiB
max_recipients: 100 # to + cc + bcc combined
send_timeout_ms: 30000
imap_connect_timeout_ms: 15000${NAME} placeholders are expanded by the configuration worker against the
engine's environment on every read (under iii compose that is the daemon's
environment, which the managed engine inherits), so secrets stay out of the
stored value. The same file shape works as a one-time seed for a bare engine:
email --config ./config.yaml installs it as the entry's initial value
(docs/examples/config.yaml).
Credentials
An account logs in with its own smtp.username / smtp.password
(imap.username / imap.password for the IMAP side). When an account carries
no login, the worker falls back to auth::get_token under provider key
email:: with shape
{ "type": "api_key", "username": "...", "password": "..." }, for deployments
that run a credentials vault. 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 ./docs/examples/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.
Give the accounts a login before exercising email::send or any IMAP
function: smtp.username / smtp.password (and the imap.* pair) in the
configuration, or, when a vault worker serves auth::get_token, seed it with:
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 |
Account has no configured login and the auth::get_token fallback failed |
E607 |
Account has no configured login and no credential is stored for it |
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 |
Skills
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": "Download an email's attachment by part id. 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": "Report the last configuration hot-reload outcome: last_outcome (applied|rejected), last_error, and rejected_reloads (count since boot). A rejected outcome means a stored config was refused and the live accounts diverged from the central store. Takes no arguments.",
"metadata": {
"internal": true,
"trace_hidden": true
},
"name": "email::config-status",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ConfigStatusRequest",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ReloadOutcome": {
"enum": [
"applied",
"rejected"
],
"type": "string"
}
},
"properties": {
"last_error": {
"type": [
"string",
"null"
]
},
"last_outcome": {
"$ref": "#/definitions/ReloadOutcome"
},
"rejected_reloads": {
"format": "uint64",
"minimum": 0,
"type": "integer"
}
},
"required": [
"last_outcome",
"rejected_reloads"
],
"title": "ReloadStatus",
"type": "object"
}
},
{
"description": "Mark an email read, flagged, or answered by setting a flag. Payload: { account, folder, uid, flag, add?=true }. `flag` is one of 'seen', 'flagged', 'answered', 'deleted', 'draft' (without the leading backslash). Marking 'deleted' does NOT expunge — use email::move to trash.",
"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": "Read a single email 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 emails in a folder, newest first. 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 gaps or duplicates.",
"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 an email to another folder, e.g. archive or trash. Payload: { account, folder, uid, dst_folder }. Uses RFC 6851 UID MOVE when supported; else COPY + STORE \\Deleted. 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": "Internal: hot-reload the email worker from the authoritative configuration when it changes. Limit changes swap the snapshot; account changes respawn the IMAP supervisors and drop pooled sessions. Rejected values keep the previous runtime.",
"metadata": {
"internal": true,
"trace_hidden": true
},
"name": "email::on-config-change",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"default": null,
"type": [
"string",
"null"
]
}
},
"title": "OnConfigChangeEvent",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"ok": {
"type": "boolean"
}
},
"required": [
"ok"
],
"title": "OnConfigChangeResponse",
"type": "object"
}
},
{
"description": "Search an account's emails by subject, sender, date, or text. `query` is IMAP SEARCH syntax (RFC 9051), e.g. 'FROM alice@x SINCE 1-Jan-2026'. Frames { uid, message_id, from, subject, snippet, ts, seen, flagged } stream as NDJSON on the response channel, which closes at completion.",
"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; provide `html`, `text` or both. The SMTP login comes from the account's `smtp.username` / `smtp.password` configuration, else from `auth::get_token` under provider key `email::<account>`.",
"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.",
"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": {}
}
]
}