# email

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

| field | value |
|-------|-------|
| version | 0.1.17 |
| type | binary |
| license | Apache-2.0 |
| repo | https://github.com/iii-hq/workers |
| supported_targets | x86_64-apple-darwin, aarch64-apple-darwin, x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-unknown-linux-musl, armv7-unknown-linux-gnueabihf |
| author | iii |

## installation

```sh
iii trigger compose::add worker=email@0.1.17
```

## dependencies

- `configuration` @ `latest`

## readme

# 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`.

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

```yaml
# 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:

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

Browse or install every worker skill at once:

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

## Quickstart

```rust
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(())
}
```

```typescript
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.',
  },
})
```

```python
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`.

```yaml
# 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](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::<account>` 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:

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

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

```bash
# 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.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.

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:

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

```bash
cargo run -- --manifest | jq .
```

### Tests

```bash
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`](https://github.com/iii-hq/workers/blob/main/docs/sops/binary-worker.md)):

```bash
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

This version ships 1 skill file for agents, also browsable on the worker page:

- [`SKILL.md`](https://workers.iii.dev/workers/email?tab=skills&skill=SKILL.md&version=0.1.17)

### SKILL.md

# 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 |

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