email

The email worker speaks SMTP and IMAP through persistent per-account connections.

iii-hq/workers15 installsApache-2.0Synced Aug 26

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI

Agent Skills format with YAML frontmatter. Claude Code reads it as-is.

---
name: "email"
description: "The email worker speaks SMTP and IMAP through persistent per-account connections."
license: "Apache-2.0"
---

# email

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 `SEARCH` results 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 `IDLE` fail 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::flag` with `flag: "deleted"` does NOT expunge — use
  `email::move` to a Trash folder for visible deletion.
- `email::search` and `email::attachment::get` use 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 parsed `html`, `text`,
  headers, and an `attachments[]` list of part refs.
- `email::search` — stream IMAP `SEARCH` results 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 by `part_id` onto 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); only `provider: imap`
  accounts open an IDLE listener.
- You only need to act on outbound mail — `email::send` already gives
  you the SMTP response synchronously.

### How to bind

1. Register a handler: `registerFunction('my-worker::on-mail', handler)`.
2. Register the trigger:

```typescript
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:

```json
{
  "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-mail` event
  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-mail` event 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 |

More General & Other skills

← All General & Other skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY