
- 15 installs
- 90 repo stars
- Updated August 5, 2026
- iii-hq/workers
Send and read email from the iii engine over SMTP and IMAP, with real-time IDLE push delivered as a subscribable new-mail trigger type.
About
The email worker speaks SMTP and IMAP through persistent per-account connections, exposing send, list, get, search, flag, move, and attachment functions plus a new-mail trigger. A developer uses it to send transactional email and react to inbound mail the moment it lands, with credentials fetched from the harness on each connect.
- IDLE-driven new-mail trigger; refuses polling fallback (E610)
- Credentials pulled from harness/auth-credentials on every connect
Email by the numbers
- 15 all-time installs (skills.sh)
- Ranked #1,404 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/iii-hq/workers --skill emailAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 15 |
|---|---|
| repo stars | ★ 90 |
| Last updated | August 5, 2026 |
| Repository | iii-hq/workers ↗ |
What it does
Send and read email from the iii engine over SMTP and IMAP, with real-time IDLE push delivered as a subscribable new-mail trigger type.
Files
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 account field (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-mail trigger 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-credentials until the OAuth follow-up PR.
- Not a polling fallback — IMAP servers without
IDLEfail fast with
E610 so 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; deploying email alone is unsupported.
email::flagwithflag: "deleted"does NOT expunge — use
email::move to a Trash folder for visible deletion.
email::searchandemail::attachment::getuse a streaming response
channel; they're invoked by sibling workers passing a StreamChannelRef, not directly from iii 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 with
provider, from, can_send, can_read, and folders.
email::list— page recent UIDs in a folder, newest first, with
next_since_uid cursor.
email::get— fetch one message by UID; returns parsedhtml,text,
headers, and an attachments[] list of part refs.
email::search— stream IMAPSEARCHresults as NDJSON frames onto
a StreamChannelRef; one header-summary object per match.
email::flag— add or remove a system flag on a UID via
UID STORE +FLAGS.SILENT / -FLAGS.SILENT.
email::move— move a UID to another folder with RFC 6851
UID MOVE; falls back to COPY + STORE \Deleted when the server lacks MOVE.
email::attachment::get— stream attachment bytes bypart_idonto a
StreamChannelRef chunk-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: imap
accounts open an IDLE listener.
- You only need to act on outbound mail —
email::sendalready gives
you the SMTP response synchronously.
How to bind
1. Register a handler: registerFunction('my-worker::on-mail', handler). 2. 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 current UIDNEXT - 1; UIDs that arrived during downtime are reachable via email::list but 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 use email::list with a persisted since_uid cursor 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 |