# Email Reference

Full reference for the email plugin. Load with `plugin-read` when detailed configuration or behaviour reference is needed.

## Setup

Setup is two steps in plain chat, in this order. **Do the provider lookup before asking for any password** — most providers reject the normal login password and require a credential the operator has to mint in the provider's own settings, so asking for "the password" first sends them down a path that cannot work.

**Step 1 — look up the provider.** As soon as you have the email address, call `email-provider-info` with it. The tool returns one of four outcomes:

- **Known provider** — it gives the IMAP/SMTP hosts (already inferred, no need to ask) and the provider's specific requirement: an app-specific password, a trusted-device/IMAP toggle, enabling IMAP on a paid plan, etc. Relay that requirement to the operator and wait for them to create the credential in their provider's settings. Name the provider and the step it needs — that is the whole point of this tool.
- **Microsoft (redirect)** — an Outlook.com / Hotmail / Live / Microsoft 365 address (consumer domains, or a custom domain whose MX is Microsoft). The email plugin's IMAP path for Microsoft is dead — Microsoft retired Basic auth for Exchange Online and M365 app passwords stopped working on 1 March 2026 — so the tool does **not** return hosts. Do not call `email-setup`. Connect the account through the `outlook` Graph plugin instead: run `outlook-account-register`. See § Microsoft / Microsoft 365 below.
- **Unsupported provider** — some providers (e.g. Tuta/Tutanota) deliberately offer no IMAP/SMTP at all. The tool says so; tell the operator this account cannot be connected here, and stop.
- **Unknown provider** — no host mapping. Ask the operator for the IMAP host/port and SMTP host/port (their provider's "mail client" / "IMAP/SMTP" help page lists these) along with the password.

**Step 2 — collect the credential and connect.** Once the operator has the right password (the app-specific one for known providers, or the account password where no app step is required), call `email-setup`. For a known provider, pass only `password` (and `agentAddress` if it differs) — the hosts are inferred. For an unknown provider, pass the hosts the operator gave you as well.

`email-setup` accepts these fields:

| name | required | notes |
|------|----------|-------|
| `email` | yes | the address being set up |
| `password` | yes | the app-specific password where the provider requires one, otherwise the account password |
| `imapHost` / `imapPort` / `imapSecurity` | only for unknown providers | inferred for known providers |
| `smtpHost` / `smtpPort` / `smtpSecurity` | only for unknown providers | inferred for known providers |
| `agentAddress` | no | for catchall/alias setups where the auth email differs from the address the agent operates as; leave empty when they're the same |

On success, confirm "credentials stored" and the inbox count. On failure, relay the diagnostic — the tool returns specific guidance (wrong password, connection refused, TLS mismatch); an authentication failure on a known provider usually means the operator used the login password instead of the app-specific one.

The agent never displays stored passwords.

### Google / Google Workspace

`email-provider-info` returns the gmail hosts for a Google address by either path: the `gmail.com` / `googlemail.com` static entry, or — for a Google Workspace custom domain (e.g. `name@company.co.uk` whose mail is hosted by Google) — MX detection. A Workspace custom domain is no longer `unknown`: the tool resolves its MX records, sees Google as the mail host, and returns `imap.gmail.com` / `smtp.gmail.com`. There is no host guessing.

For any Google address, **require an app password — never the regular account password.** Google retired regular-password (basic auth) sign-in in May 2025, so the login password cannot work for IMAP/SMTP. Walk the operator through generating one: Google Account → Security → turn on 2-Step Verification (required first) → App passwords → Mail → generate. Paste that 16-character value as `password` to `email-setup`. Do not ask for the normal password at any point.

For a Workspace custom domain there is one extra dependency: the Workspace admin must allow app passwords (Admin console → Security). If the app password is rejected at `email-setup` — an authentication failure with an app password supplied — the tenant has app passwords disabled, and the account needs OAuth instead (not yet shipped). Relay that to the operator rather than retrying the password.

### Microsoft / Microsoft 365

Microsoft mail does not go through the email plugin. Microsoft retired Basic authentication for Exchange Online IMAP/POP, and Microsoft 365 app passwords stopped working on 1 March 2026, so there is no IMAP path left — for consumer Outlook.com / Hotmail / Live / MSN addresses or for a custom domain whose mail is hosted by Microsoft 365 (MX records ending in `mail.protection.outlook.com`).

`email-provider-info` recognises both — the static consumer domains, and a custom M365 domain via MX detection — and returns the **redirect** outcome rather than IMAP hosts. `email-setup` likewise refuses a Microsoft address (even if you hand it `outlook.office365.com` hosts) and points back here; it never attempts a connection.

Connect the account through the **`outlook` Graph plugin** instead: run `outlook-account-register`. That plugin authorises over OAuth — it opens a Microsoft sign-in for the operator (in the headless browser) and stores the token; there is no password or app password to ask for, and there is no Azure app to register — the vendor client ID ships with the brand. After registration, Microsoft mail is read with the `outlook-mail-list` / `outlook-mail-search` tools, not `email-read` / `email-search`. Sending and drafting are available too, over Microsoft Graph rather than SMTP: `outlook-mail-send`, `outlook-draft`, and `outlook-draft-send` (not `email-send` / `email-draft`). A mailbox registered before send existed re-registers once to grant the send scopes. (If a brand ships no vendor Outlook app yet, `outlook-account-register` reports the client ID is not configured and Microsoft mail cannot be connected on that brand.)

## Multiple Mailboxes

An account holds any number of mailboxes at once. Each mailbox is its own `:EmailAccount` node keyed by `(accountId, email)`; `email-setup` adds or updates one mailbox without touching the others, so connecting a second inbox never repoints or deletes the first. The password store is keyed by address, so each mailbox keeps its own credential.

Every operational tool (`email-read`, `email-search`, `email-fetch-body`, `email-fetch-attachment`, `email-send`, `email-reply`, `email-draft`, `email-draft-edit`, `email-draft-send`, `email-delete`, `email-otp-extract`, `email-fetch`, `email-ingest`, `email-status`) takes an optional `mailbox` argument naming the configured mailbox to act on. Resolution is identical everywhere:

- `mailbox` given and configured: act on that mailbox.
- `mailbox` given but not configured: refused with `No mailbox <addr> is configured. Stored mailbox identities: [...]`.
- `mailbox` omitted, exactly one mailbox configured: act on it (single-mailbox installs pass nothing).
- `mailbox` omitted, two or more configured: refused with `Multiple mailboxes are configured. Stored mailbox identities: [...]. Pass mailbox to choose one.`

Read the attached mailboxes with `email-status`, which lists every one, before naming a selector. To act on a message in a second inbox, pass that inbox's address as `mailbox` on the operating tool; there is no setup swap. The poll high-water mark (`lastFetchedUid`) and the pending `email-fetch` / `email-ingest` batch are per-mailbox, so triaging two inboxes never crosses their state.

`email-graph-query` is account-scoped (it reads stored archives, not a live mailbox) and takes no `mailbox` argument. Every credential load logs `[email] cred-resolve ... email=<addr> source=store`, naming the resolved mailbox, so a per-call selection is visible in the logs.

## Reading and Searching

`email-read` and `email-search` return message metadata plus a short body preview (capped at 500 characters): UID, sender, subject, date, and the opening of the body. They are screening tools — use them to find a message, not to read it in full.

### Full message body

`email-fetch-body` returns the **complete** decoded body of a single message — no 500-character cap. Address the message by `uid` (with an optional `folder` of `inbox` or `sent`, default `inbox`) or by `message_id` (resolved across inbox and sent). It downloads the full RFC822 source and decodes it with `mailparser`, so every MIME shape yields a body: flat `text/plain`, single-part `text/html`, `multipart/alternative`, and the nested `multipart/related` / `multipart/mixed` that Outlook and Exchange use for HTML mail with inline images. The preview tools above stay preview-only by design; reach for `email-fetch-body` whenever the operator wants to read or act on a message's actual content (revising a proposal from an emailed reply, quoting a long message, and so on).

### Reading an attachment

`email-fetch-attachment` returns the extracted **text** of one inbox attachment. It is the read-side twin of `email-fetch-body`: the body tool returns the message text, this tool returns what is inside a file. Address the attachment by `uid` plus an optional `index` (1-based, matching the `Attachments (N): …` footer printed by `email-read`/`email-search`/`email-fetch`) or `filename`. With a single attachment and no selector, that attachment is read; with more than one and no selector, or a filename that matches zero or several parts, the call is refused with a numbered listing so you pick one by index.

`application/pdf` is extracted with `pdftotext`; `text/*` is decoded as UTF-8. Any other type returns a line saying its text cannot be extracted (open it directly), and a PDF with no text layer returns a line saying it is a scanned image with no OCR available, so "there is no date in this PDF" never reads the same as "this PDF could not be read". Bytes are downloaded once through the same 25 MB-capped fetch path the ingestion pipeline uses; an oversize or failed download returns a typed line rather than an empty result. Reading is inbox-only. Use this whenever a meeting time, an amount, or any actionable detail may sit inside a PDF rather than the email body.

### Pagination

Both tools return up to 50 messages per request (default 20). When more messages exist beyond the returned set, the response includes a `next_before_uid` value. Pass this value as the `before_uid` parameter on the next call to retrieve the next page of older messages. When `next_before_uid` is absent, all matching messages have been returned.

### Folders

Both tools accept a `folder` parameter: `"inbox"` (default) or `"sent"`. The Sent folder is discovered automatically via the IMAP server's special-use flags — no folder name guessing required.

### Filtering

Both tools support filtering by:
- `sender` — sender address or domain
- `to` — recipient address
- `since` / `before` — ISO date range
- `email-read` additionally supports `subjectPattern` (regex)
- `email-search` additionally supports `subject` (text) and `body` (keyword)

## Replying

`email-reply` sends a reply to an existing email, threaded correctly in the recipient's mail client.

The tool accepts the `messageId` of the email being replied to (visible in `email-read` output as the `Message-ID` field). It looks up the original email in the graph, sets `In-Reply-To` and `References` headers, derives the recipient from the original sender, and prepends `Re:` to the subject (stripping duplicate prefixes).

- **Reply to sender:** Default behaviour — replies to the original sender only.
- **Reply all:** Set `replyAll: true` to include all original recipients (TO + CC). The agent's own address is automatically excluded from the recipient list.
- **Add CC/BCC:** Pass `cc` and/or `bcc` (each an array of email addresses) to copy or blind-copy recipients on the reply. These are added in addition to whatever `replyAll` places in the TO line — they are independent of the reply-list derivation and are passed through exactly as given (an address that is also in TO is not de-duplicated). CC is visible in the header; BCC is hidden from all headers.
- **Threading:** The reply appears in the same conversation thread in Gmail, Apple Mail, Outlook, and other RFC 2822-compliant clients.
- **Prerequisite:** The original email must exist in the graph (ingested via the operator-initiated `email-ingest` flow). If the message hasn't been ingested yet, the tool returns a clear error.

Use `email-reply` when responding to a received email. Use `email-send` for new outbound messages.

## CC and BCC

Both `email-send` and `email-reply` accept optional `cc` and `bcc` fields. Each is an array of email addresses (one element or many); every address is validated individually. CC recipients appear in the message header and are visible to all recipients. BCC recipients receive the message but do not appear in any header — the other recipients cannot see them. Omitting both sends exactly as before, with no CC/BCC headers. The success message names the CC recipients so the operator can confirm the copy went out, and reports the BCC count without naming them, preserving the blind semantics.

## The Sent copy is verified, not assumed

SMTP accepting a message is not evidence that the provider filed a copy anywhere. `email-send`, `email-reply` and `email-draft-send` therefore each read the mailbox's `\Sent` folder back after the send and report what is actually there, as a fact separate from the send itself. The search is by Message-ID, retried briefly because providers do not file the copy at the instant SMTP returns.

Four outcomes are reported, and they are deliberately distinct:

| Result clause | Means |
|---|---|
| `copy confirmed in <folder> (UID n)` | The copy was found by search. A folder and UID are only ever named when they were read back. |
| `no copy was found in <folder>` | The folder was searched and the message is not in it. |
| `no copy was filed — this server advertises no \Sent folder` | There is nowhere to file to. Not the same as a missing copy. |
| `the Sent folder could not be checked (<error>), so the copy is unconfirmed` | The check itself failed. Not evidence either way. |

A send whose copy cannot be located is still a successful send, and the result says both things plainly. Collapsing them into one word is what allowed a sent-copy claim to go unchallenged for four days when no copy was being filed at all.

Every send also appends one row to `config/email-sent-ledger.jsonl` recording the observed outcome. That ledger is the left-hand side of the hourly sweep below; it is written after the read-back, so it records what happened, not what was intended.

## The hourly sent-copy sweep

Per-tool reporting only covers sends the agent was present for. A mailbox that silently stops filing copies emits nothing at all, so nothing surfaces until someone checks by hand.

The install heartbeat therefore spawns `email/mcp/dist/scripts/sent-copy-sweep.js` once an hour. Per account, per mailbox, it takes the Message-IDs this plugin sent in the trailing 24 hours and asks `\Sent` whether each one is actually there, then reports:

```
[email-sent-sweep] op=reconcile account=<id> mailbox=<email> sends=N copies=M shortfall=K status=clean|shortfall|check-failed
```

It reports on mailboxes with zero sends too, so a silent check is distinguishable from an absent one. A mailbox that could not be read is `check-failed`, never a shortfall — an unreadable mailbox and a mailbox losing copies are different findings.

Two properties of that design are load-bearing:

- **Presence is asked per Message-ID, never by comparing totals.** `\Sent` holds everything the mailbox owner sends from any client. A bulk count is dominated by messages this plugin never sent, so a mailbox filing none of our sends would still look healthy the moment its owner sends a few from their phone — which is exactly the incident this check exists to catch.
- **The answer comes from the folder, not from the ledger's own recorded outcome.** Re-reading is what makes the sweep independent evidence rather than a replay of what the tool already said, and it is the only way a copy that was filed and later removed shows up.

The sweep also trims the ledger past its 48-hour retention. That rewrite happens here, once an hour in a single process, rather than on the send path where it could lose a concurrent row.

## Attachments

Both `email-send` and `email-reply` accept an optional `attachments` array of absolute file paths. Each path must point to a regular file inside the account directory and be 25 MB or smaller; the path is validated before the message is sent, so an invalid, missing, oversized, or out-of-account path fails the call with a clear error and no email dispatched. Validated files ride the message as real SMTP attachments, and the success message names each attached file.

## Drafts

`email-draft` composes a message and stores it unsent in the mailbox's Drafts folder; `email-draft-edit` revises a stored draft; `email-draft-send` dispatches one. Nothing is sent until the operator (or `email-draft-send`) says so.

### Which Drafts folder

By default the Drafts folder is the one the server flags `\Drafts` (RFC 6154 special-use). On some non-Gmail hosts the folder the mail client shows as Drafts is a different mailbox — for example `INBOX.Drafts` under an `INBOX.` namespace — so a draft written to the `\Drafts`-flagged folder never appears in the client, even though the IMAP APPEND succeeded.

For those hosts, set an explicit Drafts folder: pass `draftsFolder` to `email-setup` (for example `draftsFolder: "INBOX.Drafts"`). When set, every draft read, write, edit, and delete uses that folder. It is validated against the server's folder list, so a wrong name is refused with the advertised folders listed rather than written to a nonexistent mailbox. When unset, `\Drafts` special-use discovery is unchanged, so Gmail and every host where the flag is correct keep working with no configuration.

The first draft operation per mailbox per session logs the full mailbox list with special-use flags (`[email] drafts-resolve: census — mailbox=<address> <path>:<flag> …`) and each resolution logs its path (`[email] drafts-resolve: resolved — mailbox=<address> via=override|special-use folder=<folder>`). The census is keyed per mailbox, so on an account with more than one mailbox each mailbox's layout is logged, not just whichever resolved first. If drafts are not showing up in the client, that census line names every folder the server exposes, so the correct one to set as `draftsFolder` can be read off the log without a reproduction.

### Stable draft identity

Each composed draft is stamped with its own `Message-ID`, returned in the confirmation line as `draft id: <...>` alongside the Drafts `UID`. The UID is a fast hint, but it is not a stable handle on every server: Gmail's `[Gmail]/Drafts` renumbers a draft's UID when it is touched, re-synced, or superseded by a prior edit. So an edit addresses the draft by both — it tries the recorded UID first, and if that resolves nothing it re-locates the draft by a `Message-ID` header search before replacing it.

To get this resilience, pass the `draftMessageId` from the prior confirmation back into `email-draft-edit` along with `targetUid`. (`draftMessageId` is the draft's own id; the separate `messageId` parameter is the parent thread you are replying to — they are different headers.)

If neither the UID nor the Message-ID resolves a live draft, the prior draft is genuinely gone. The edit does not fail — it saves the replacement as a fresh draft and says so (`previous draft no longer present; saved a fresh draft`). The atomicity rule still holds: the replacement is always written before any old copy is removed, so a draft is never left with zero copies.

### Signature

`email-draft` and `email-draft-edit` apply the account's saved signature to every draft deterministically, so the model never adds signature text itself. The signature lives at a fixed path under the account directory:

- `email-signature.txt` is the plain-text signature, appended to the message body.
- `email-signature.html` is the HTML signature block, appended to the message html.
- `email-signature.font` is an optional single-line CSS font-family; when present the html part is wrapped in one `font-family` div.

The plain-text signature is appended whenever it exists. The html signature and the font wrap apply only when the caller supplied html, because font has no plain-text analogue. The caller passes body and html without the signature, and the tool appends it. Each draft compose logs `[email-draft] op=signature accountId=<id8> applied=<bool> asset=<path>`; `applied=false` on a turn that produced a draft is the failure signal, meaning the asset was missing or the account directory did not resolve.

### Transient connection drops

A single transient transport drop (socket timeout, unexpected close, connection-not-available) during a draft write is retried once on a fresh connection. Authentication failures and other permanent errors are not retried — they surface immediately.

## Deleting

`email-delete` removes one or more messages by moving them to the mailbox's Trash folder. This is recoverable: the message leaves the inbox, but it is not expunged. Permanent removal is left to the operator's own mail client or the provider's Trash auto-purge — the tool never permanently deletes and never falls back to an expunge.

Pass the exact `uids` you already obtained from `email-read` or `email-search`, scoped to the `folder` they came from (`inbox` or `sent`, default `inbox`). The tool deletes only the UIDs you name; it never deletes by search criteria. A `uid` that is already gone is counted not-moved without failing the rest, and the result reads `Moved N of M message(s) to <Trash>`.

Trash is resolved per provider: the folder the server advertises with the `\Trash` special-use flag, else the first existing of `Trash`, `[Gmail]/Trash`, `Deleted Items`, `Deleted Messages`. `Junk` is never treated as Trash — it is the spam folder, so a mailbox whose only candidate is `Junk` is treated as having no Trash. When no Trash folder resolves, the call errors and nothing is moved; there is no silent expunge.

Like every operational tool, `email-delete` takes an optional `mailbox` selecting which configured mailbox to act on (see § Multiple Mailboxes); omit it when a single mailbox is configured. Each call emits one post-condition log line, `[email] op=delete email=<addr> folder=<src> requested=<m> moved=<n> trash=<name> ok=true` — including when every named UID was already gone, which logs `ok=true moved=0`. Only the no-Trash-folder refusal logs `[email] op=delete email=<addr> folder=<src> ok=false reason=no-trash-folder`. Neither line carries a credential.

## Alias Support

When `agentAddress` is set and differs from the auth email:

- **Sending:** emails are sent with the agent address as the From header. SMTP authentication still uses the auth email.
- **Reading/searching/fetching:** emails whose **To or Cc** header contains **either the login (auth email) or the agent address** are returned. `email-read`, `email-search`, and `email-fetch` apply the same `{login, alias}` recipient set, inspecting both To and Cc, so a reply that arrives at the login or Cc's the alias is not hidden. Pass an explicit `to=` (read/search) to narrow to one address.
- **OTP extraction:** OTPs addressed (To or Cc) to the login or the agent address are found.
- **Status:** reports both the agent address and the auth email.

When `agentAddress` is not set or matches the auth email, all tools behave as before — no recipient filter, From header uses the auth email.

## Email Persistence

Emails enter the graph as `:ConversationArchive {source:'email'}` thread archives (with `:Section` chunks bounded by gap-hour sessionisation) via the **operator-initiated ingestion flow** (see `skills/email-ingest/SKILL.md`). There is no background poll, cron, or auto-classifier — new messages are written only when the operator says "check my emails" and approves each one.

The two ingestion tools:

- **`email-fetch`** — lists new IMAP messages since the stored high-water mark (`lastFetchedUid` on the `:EmailAccount` node). Returns sender, subject, date, Message-ID, UID, and a body preview. Writes nothing to the graph; stages the batch in-memory for the follow-up `email-ingest` call.
- **`email-ingest`** — takes a `decisions` array (`messageId` + `ingest` or `discard` per message). Writes only the `ingest` subset to the graph, advances `lastFetchedUid` to the batch's max UID, and consumes the staged batch. Refuses if any pending Message-ID lacks a decision.

Properties of the persistence layer:

- **Operator-initiated only.** No `setInterval`, cron, or systemd timer ever invokes `email-fetch`. The operator drives every cycle.
- **Deduplication:** Each archive is keyed by `conversationIdentity` (sha256 over `accountId + sortedParticipantElementIds`). Re-ingesting the same thread delta-appends new sections rather than duplicating.
- **Alias filtering:** Emails whose To or Cc carries the agent's `agentAddress` **or** the login (auth email) are listed by `email-fetch` and ingested. The fetch path uses the same `{login, alias}` recipient set, inspecting To and Cc, as `email-read`/`email-search`; when no distinct alias is configured no recipient filter is applied and every message in the UID window is listed.
- **Semantic search:** Sections are vector-indexed via `section_embedding`, enabling natural-language search via `email-graph-query` that ranks parent archives by their best-matching section.
- **Isolation:** Each agent sees only archives whose participants resolve to its own account scope, even when sharing a catchall mailbox.

## Email Threading

Ingested messages are grouped into one `:ConversationArchive {source:'email'}` per thread, with operator-confirmed `:Person`/`:AdminUser` participants attached via `:PARTICIPANT_IN` edges. Thread grouping (`In-Reply-To`/`References` walk to the root Message-ID) happens in the email-plugin dispatcher before the conversation-archive pipeline sees the batch.

- **Out-of-order arrival:** If a reply is ingested before its parent, the dispatcher treats the reply as its own thread root; once the parent arrives the dispatcher re-groups under the older root and the conversation-archive writer delta-appends into the shared archive.
- **Per-message context:** Per-message envelopes are not stored in the graph. `email-read` and `email-search` surface a best-effort `Archive` / `Archive-Section` hint when the IMAP message's `receivedAt` falls inside a stored section's `firstMessageAt..lastMessageAt` window; this is a UI hint, not a primary key.
- **Scope:** Thread linkage never crosses account boundaries — every archive carries `accountId` and the writer refuses cross-account participants.

### Agent-Email Binding

Each email address is bound to exactly one agent. Each agent can have at most one email address. This is enforced during `email-setup`:

- The `agentSlug` parameter identifies which agent owns this email address (defaults to `"admin"`)
- If the address is already bound to a different agent, setup fails with a clear error naming the conflicting agent
- The binding is on `agentAddress` (the identity the agent presents), not the auth email

### Email Retrieval Paths

Three retrieval paths exist for email data — each scoped to a different purpose:

- **Live inbox** (`email-read`, `email-search`) — queries IMAP directly. Use for checking the inbox, reading recent messages, browsing folders, and real-time inbox state. Requires IMAP connectivity.
- **Graph list/search** (`email-graph-query`) — queries stored Email nodes in Neo4j. Use for email history, recall, and semantic search ("what emails are in memory?", "find emails about cabbages", "what did Sarah send about the contract?"). Works without IMAP. Supports date/sender/direction filters and natural-language semantic matching.
- **General knowledge** (`memory-search`) — searches the entire knowledge graph across all node types. Use when the user's question spans all memory, not just emails ("what do you know about contract renewals?").

When the user asks about stored emails, email history, or email recall — use `email-graph-query`. When they ask to check the inbox or read specific recent messages — use the IMAP tools. When the question is broader than emails — use `memory-search`.

## Inbound Email Screening

There is no automated screener. The operator screens by eye during the `email-ingest` skill flow: `email-fetch` lists the new messages with sender, subject, and a body preview; the operator picks which ones to ingest and which to discard. A `screening` property of `clean` is stamped on every operator-approved row as a provenance marker (operator-approved), and discarded messages never enter the graph.

The previous Haiku classifier was removed; the auto-respond binary that depended on it was removed alongside the screener. Both are intentionally absent — the doctrine is that every LLM-bearing pipeline is operator-initiated.

## Security

- Credentials stored as a file with restricted permissions — never in conversation
- IMAP and SMTP connections require TLS or STARTTLS — plaintext is rejected
- Only the agent's own dedicated account is accessed — never the user's personal email
- Email nodes have `scope: "admin"` — never visible to public agents
- Inbound messages enter the graph only when the operator approves them by eye via `email-ingest`

## Setup-Probe Hardening and Observability

No `email-setup` execution path — success, credential failure, unreachable host, slow host, or a late socket event minutes after the tool returned — may terminate the email MCP server-host process. Containment has two layers, both in `lib/setup-probe.ts`:

- **Per-resource.** Every IMAP client and SMTP transport created by a connection probe (`email-setup`, `email-status`) carries a persistent `'error'` listener for the object's full lifetime, and is closed on every path — including the failure path, where the IMAP client was previously abandoned half-connected. A late socket event is logged and contained instead of crashing the process via Node's unhandled-`'error'` rule. The IMAP probe additionally attaches a persistent listener to the **underlying transport socket** (`containProbeSocket`): `ImapFlow.close()` removes its own socket->client forwarder before destroying the socket, so on the auth-reject path a half-established TLS reset fired after the per-call census returned to baseline — its raw-socket stack does not reference `imapflow`, so the process-level guard would not attribute it either. The socket-level listener closes that window.
- **Process-level last resort.** `uncaughtException`/`unhandledRejection` handlers contain only errors attributable to a probe (stack references `imapflow`/`nodemailer`, or a probe is still open). Any other escaped error preserves the previous behaviour: dump and exit 1. Containment is always logged loudly — it never hides the defect.

Log lines (all on stderr, prefix `[email] setup-probe`, correlated by one per-call `token=`):

- `op=request domain=<d>` → `op=probe-imap ms=<t>` → `op=probe-smtp ms=<t>` → `op=result outcome=<ok|fail>` — the setup lifeline; a lifeline that stops mid-sequence names the failing step.
- `op=acquired` / `op=released source=<imap|smtp> socketCount=<n>` — the socket census; a count not returning to baseline is the leak signature.
- `op=late-event source=<imap|smtp|process> err=<msg> contained=true` — a contained late event; on a live box this line is the evidence trail for the crash-mechanism investigation.
- `op=late-socket source=imap err=<msg> contained=true` — a late `'error'` on a probe's underlying socket, contained at the socket (the path described in the per-resource bullet above).

Every IMAP probe also emits one structured attempt line (prefix `[email] imap-attempt`, no secret): `host=<h> port=<p> security=<tls|starttls> outcome=<ok|fail> reason=<auth|refused|not-found|tls|timeout|unknown|none>`. An operator can see what a failing `email-setup` tried, and why, without opening the session transcript.

**Failure classification reads ImapFlow's structured fields, not only `err.message`.** An authentication rejection arrives as a generic `Error("Command failed")` with the real signal in `authenticationFailed` / `serverResponseCode` (`"AUTHENTICATIONFAILED"`) / `responseText`; the transport cases (refused/not-found/timeout) carry `err.code`. The probe classifies on those fields first, so an auth failure returns the actionable app-password guidance (`reason=auth`) instead of the catch-all `IMAP connection failed: Command failed`.

**Never-connected standing signal.** A verified IMAP connection stamps `lastSuccessfulConnect` on the `:EmailAccount` node (from both `email-setup` and `email-status`). `email-status` reports `Last successful connect: <iso>` when the live probe succeeds, the stored timestamp when the probe now fails but a prior success exists, and `Last successful connect: never (never-connected)` when the mailbox is configured but has never authenticated — so "this mailbox has never worked" is observable without reproducing the failure. A live success always stamps, so a working mailbox that predates the field never reports `never-connected`.

Diagnostic path: `grep -E '\[email\] (setup-probe|imap-attempt)' ~/.<brand>/logs/mcp-email-*.log`, cross-referenced with `[mcp-helper] op=exit server=email` in `server.log` for the same window.

## OTP Integration

Other skills call `email-otp-extract` during service authentication (Anthropic OAuth, Cloudflare setup) with:
- `sender` — expected sender domain
- `subject_pattern` — optional regex for subject line
- `timeout` — how long to poll (default 60s)

The tool polls at short intervals, extracts the verification code, and returns it. On timeout, it returns a clear failure so the calling skill can ask the user to check manually.
