skip to content
$worker

email

v0.1.5

Email worker — SMTP send and real-time IMAP read with IDLE push (email::*).

iiiverified
60 installs0 in 7d0 today
install
$iii worker add email
  • macOS: arm64 · x64
  • Linux: arm64 · armv7 · x64
  • Windows: arm64 · x64 · x86

exact versions are immutable; binary and bundle artifacts are digest-pinned.

agent-ready brief for v0.1.5
install + config + dependencies + readme + api reference, all in one place. fetch as agent-context.md for an llm to consume.
the same content rendered as discrete blocks below is exposed as a single markdown document at /workers/email.md. paste it into an llm prompt or pipe it through curl from a worker.

install

install
$iii worker add email@0.1.5

configuration

iii-config.yaml
- 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: 30000

dependencies

no dependencies for v0.1.5

readme

README.md

email

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 email

Skills

Install the email agent skill for Claude Code, Cursor, and 30+ other agents:

npx skills add iii-hq/workers --skill email

Browse or install every worker skill at once:

npx skills add iii-hq/workers --list
npx skills add iii-hq/workers --all

Quickstart

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: 15000

Credentials 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 30000

Payload 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.yaml

The 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 contract

tests/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)

agent-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": {}
    }
  ]
}