Email worker — SMTP send and real-time IMAP read with IDLE push (email::*).
- macOS: arm64
- Linux: arm64 · armv7 · x64
- Windows: arm64 · x64
exact versions are immutable; binary and bundle artifacts are digest-pinned.
readme
open as markdownEmail 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 |