---
name: email
description: "Email — one or more dedicated email mailboxes per account, each addressable by a `mailbox` selector. Retrieval: email-graph-query for stored email-thread history as :ConversationArchive {source:'email'} with :Section chunks (recall, search, 'threads about X', 'what did Y send?'); email-read / email-search for live IMAP inbox previews (with best-effort per-archive section hints from the graph); email-fetch-body for the COMPLETE decoded body of one message (by uid or message_id) when a preview is not enough; email-fetch-attachment for the extracted TEXT of one attachment (PDF via pdftotext, text/* by decode) when data sits inside a file rather than the body. Ingestion (operator-initiated, human-in-the-loop): email-fetch lists new messages, email-ingest writes the operator-approved subset via the conversation-archive pipeline. Sending: email-send (new), email-reply (threaded — resolves the original via IMAP Message-ID search), email-draft (compose to the mailbox Drafts folder, unsent, for operator review; pass messageId for a threaded draft-reply), email-draft-edit (revise a stored draft, located by its Drafts UID or, when Gmail has renumbered it, its draft Message-ID), email-draft-send (dispatch a stored draft by Drafts UID, then remove it). Deleting: email-delete (move named UIDs from email-read/email-search to the mailbox Trash — recoverable, never an expunge). Config: email-provider-info (pre-password host + app-password lookup from an address alone), email-setup, email-status, email-otp-extract."
tools:
  - name: email-provider-info
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: email-setup
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: email-read
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: email-fetch-body
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: email-fetch-attachment
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: email-send
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: email-reply
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: email-draft
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: email-draft-edit
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: email-draft-send
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: email-search
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: email-delete
    publicAllowlist: false
    adminAllowlist: false
    riskClass: external
  - name: email-graph-query
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: email-fetch
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: email-ingest
    publicAllowlist: false
    adminAllowlist: false
    riskClass: write_local
  - name: email-otp-extract
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
  - name: email-status
    publicAllowlist: false
    adminAllowlist: false
    riskClass: read
always: false
embed: ["admin"]
metadata: {"platform":{}}
mcp:
  command: node
  args:
    - ${PLATFORM_ROOT}/lib/mcp-spawn-tee/dist/index.js
    - ${PLATFORM_ROOT}/plugins/email/mcp/dist/index.js
  env:
    MCP_SPAWN_TEE_NAME: email
    LOG_DIR: ${LOG_DIR}
    PLATFORM_ROOT: ${PLATFORM_ROOT}
    ACCOUNT_ID: ${ACCOUNT_ID}
    SESSION_ID: ${SESSION_ID}
mcp-manifest: auto
---

# Email

Manages the agent's own dedicated email account — IMAP for reading, SMTP for sending. Admin agent only.

**Identity boundary (operator-identity fix).** This plugin manages the AGENT's email channel: an `:EmailAccount` node with IMAP/SMTP credentials, polling state, and the agent's `agentAddress`. It is NOT the operator-identity store. The operator's own email lives on the OWNS-bound `:Person` (`Person.email`) and is captured via `profile-update` with `personFields`. Confusing the two surfaces (writing the operator's personal email to `EmailAccount.agentAddress`, or vice versa) breaks routing AND identity coverage. See [.docs/agents.md § Coverage-driven elicitation bias] for the operator-identity contract.

**Login vs alias — the default recipient set.** A config carries two addresses. `email` is the IMAP login: the address you authenticate as, and an address this mailbox owns. `agentAddress` is an *additional* alias to surface (often a per-agent address on a shared catch-all mailbox). When they differ, `email-read`, `email-search`, `email-otp-extract`, and `email-fetch` default to surfacing mail whose **To or Cc** carries **either** address — not the alias alone — because replies commonly arrive at the login address, and a reply or multi-party thread routinely Cc's the alias rather than To'ing it (a proposal sent from `info@` is answered to `info@`, not the `maxy@` alias; a thread copies `maxy@` on Cc). When no distinct alias is configured, no recipient filter is applied. Pass an explicit `to=` to narrow to a single address. Whenever the default filter hides messages from the fetched window, `email-read`/`email-search` append a `Recipient filter: kept N, hid M (to ∈ {…}); pass to="…" to change.` line — including when every message was hidden — so "I can't see it" is never indistinguishable from "it isn't there". `email-fetch` has no tool-output filter line; its kept-vs-hidden split is recorded in the stderr `diag("fetchSinceUid", "filter applied", …)` log instead.

**Multiple mailboxes.** An account holds any number of mailboxes at once. Each is its own `:EmailAccount` node keyed by `(accountId, email)`, and `email-setup` adds one without touching the others (no destructive re-point to act on a second inbox). Every operational tool takes an optional `mailbox` argument naming which configured mailbox to act on. Resolution order: the named mailbox if given; the single configured mailbox when only one exists (so single-mailbox installs pass nothing); otherwise the call is refused with a line listing the stored mailbox identities, so the agent picks one rather than guessing an address. The poll high-water mark and the pending fetch/ingest batch are per-mailbox. `email-status` lists every attached mailbox.

## Capabilities

- **Provider lookup:** `email-provider-info` — given just an email address, returns the inferred IMAP/SMTP hosts and the provider's app-password / trusted-application / IMAP-enable requirement, deterministically from the provider map. Call this BEFORE soliciting a password so the requirement is relayed first; most providers reject the normal login password. Returns "unknown" (operator supplies hosts) or "unsupported" (provider blocks IMAP/SMTP, e.g. Tuta).
- **Setup:** `email-setup` — collect credentials in plain chat and connect IMAP/SMTP. Hosts are auto-inferred for known providers; supports alias addresses (`agentAddress`). For hosts where the client-visible Drafts folder is not the one flagged `\Drafts`, pass `draftsFolder` to name it explicitly (e.g. `INBOX.Drafts`).
- **Read inbox:** `email-read` — metadata only (UID, sender, subject, date), plus a best-effort `Archive`/`Archive-Section` hint when the IMAP message's `receivedAt` falls inside a stored `:ConversationArchive {source:'email'}` section, plus an `Attachments (N): filename (mime, size); …` footer when the message carries enclosures. Supports pagination (`before_uid`), folders (`inbox`/`sent`), filtering by sender/date/subject. Metadata is derived from IMAP `bodyStructure` — no bytes downloaded.
- **Search inbox:** `email-search` — live IMAP search by sender, subject, body keyword, date range. Same per-archive hint and attachment footer as `email-read`.
- **Delete (move to Trash):** `email-delete` — move the messages named by `uids` (from a prior `email-read`/`email-search`, scoped to a source `folder` of `inbox` or `sent`) into the mailbox's Trash folder. **Recoverable, never an expunge** — the message leaves the inbox and permanent removal is left to the operator's own mail client or the provider's Trash auto-purge; this tool never permanently deletes. Trash is resolved per provider: the `\Trash` special-use folder if advertised, else the first existing of `Trash`, `[Gmail]/Trash`, `Deleted Items`, `Deleted Messages`. `Junk` is never treated as Trash. A mailbox with no resolvable Trash yields a typed error and moves nothing — there is no silent expunge fallback. A `uid` that is already gone is counted not-moved without failing the rest; the result reports `Moved N of M message(s) to <Trash>`. It deletes only the UIDs you name, never by search criteria.
- **Read one attachment's text:** `email-fetch-attachment` — the extracted text of a single inbox attachment, addressed by `uid` plus an optional `index` (1-based, matching the `Attachments (N): …` footer order) or `filename`. With one attachment and no selector, that attachment is read; with more than one and no selector, or an ambiguous filename, the call is refused with a numbered listing. `application/pdf` is extracted via `pdftotext`, `text/*` by decode; any other type reports that it cannot be extracted, and a PDF with no text layer reports it is a scanned image (no OCR), so "no data in the attachment" never reads the same as "the attachment could not be read". Bytes are downloaded once through the 25 MB-capped fetch path; inbox only. Use this when actionable data (a meeting date, an amount) may sit inside a PDF rather than the email body.
- **Read one whole message:** `email-fetch-body` — the COMPLETE decoded body of a single message addressed by `uid` (with optional `folder`) or `message_id` (resolved across inbox and sent). No 500-char preview cap. Downloads the full RFC822 source and decodes it with `mailparser`, so every MIME shape — flat text, single-part HTML, `multipart/alternative`, and nested `multipart/related`/`multipart/mixed` (Outlook's inline-image house style) — yields a body; the preview tools above intentionally stay preview-only. Use this when the operator wants to read or act on a message's actual content.
- **Send:** `email-send` — new outbound email. Pass `cc`/`bcc` (each an array of email addresses) to copy or blind-copy recipients — CC appears in the message header, BCC recipients receive the message without appearing in any header. Pass `attachments` (absolute file paths under the account directory, ≤25 MB each) to attach files as SMTP attachments; an invalid path fails the send with no email dispatched. The success message names the CC recipients and reports the BCC count without naming them.
- **Reply:** `email-reply` — threaded reply to an existing email by `messageId`. Resolves the original envelope via IMAP `SEARCH HEADER Message-ID` (INBOX then `\Sent`), so threading works for any message still present on the server even if the graph never ingested it. Supports `replyAll`, `cc`/`bcc`, and `attachments` (same path rules as `email-send`). Explicit `cc`/`bcc` are added in addition to whatever `replyAll` places in `To`, passed through as given (no overlap de-duplication against `To`).
- **Compose to Drafts:** `email-draft` — builds the same MIME message as `email-send` (same `cc`/`bcc`/`attachments` rules; unlike a dispatched send, the stored draft keeps its `Bcc:` header, because the draft bytes are the only recipient carrier) but APPENDs it to the mailbox's `\Drafts` special-use folder with the `\Draft` flag instead of dispatching it. Nothing is sent. Pass `messageId` to compose a threaded draft-reply: the parent is resolved via IMAP and the draft carries `In-Reply-To`/`References` plus a `Re:` subject from the parent, exactly like `email-reply` (the `subject` argument is then optional and ignored). The success message names the Drafts folder, the APPEND UID, and the draft's own `Message-ID` (`draft id`) so a saved draft is never indistinguishable from a silent no-op and a later edit has a stable handle. Fails naming the mailbox when the server advertises no `\Drafts` special-use folder — no fallback folder is picked, unless a per-account `draftsFolder` override is configured (via `email-setup`), which is honoured before special-use discovery for hosts whose client-visible Drafts is a differently-named mailbox. Use when the operator wants to review and send by hand.
- **Revise a draft:** `email-draft-edit` — replace a stored draft. The `targetUid` is a fast-path hint; the draft's own `Message-ID` (`draftMessageId`) is the stable identity, because Gmail renumbers a draft's UID when it is touched. The tool tries the UID first, then relocates by a `Message-ID` header search, then composes the replacement from the parameters (same shape as `email-draft`, plus `targetUid`, optional `draftMessageId`, and an optional `messageId` for a threaded revision), APPENDs it with `\Draft`, and deletes the resolved old copy — the replacement is always appended before the old copy is expunged, so a mid-cycle failure never leaves zero copies. When neither identity resolves a live draft, the prior draft is gone: the replacement is saved as a fresh draft (`created`) rather than failing. A single transient transport drop is retried on a fresh connection; auth and other permanent errors surface immediately. Nothing is sent. The confirmation names the folder, the new UID, and the new draft id.
- **Send a draft:** `email-draft-send` — dispatch a stored draft addressed by its Drafts UID via SMTP, then relocate it into `\Sent`. Recipients come from the draft's own To/Cc/Bcc headers (the `\Drafts` copy keeps its `Bcc:`), deduped, sent from the agent alias with an explicit SMTP envelope. The draft is relocated only after SMTP accepts; an SMTP failure leaves it in place and surfaces verbatim. A draft with no recipients is refused before sending. Operator-approved sends only.
- **The Sent copy is verified, not assumed.** All three send paths read `\Sent` back after SMTP acceptance and search it by Message-ID, then state what is actually there as a fact separate from the send. A folder and UID are named only when they were read back; "no copy was found", "this server advertises no `\Sent` folder", and "the Sent folder could not be checked" are reported as three distinct outcomes, never collapsed. A send whose copy cannot be located is still a successful send. Each send also appends its observed outcome to `config/email-sent-ledger.jsonl`, and the install heartbeat spawns `dist/scripts/sent-copy-sweep.js` hourly to ask `\Sent`, per mailbox, whether each Message-ID sent in the trailing 24 h is actually present (`[email-sent-sweep] op=reconcile …`). Presence is asked per Message-ID rather than by comparing folder totals, because `\Sent` also holds everything the owner sends from other clients. See [`references/email-reference.md`](references/email-reference.md).
- **Ingestion — fetch:** `email-fetch` — list new IMAP messages since the stored high-water mark. Metadata + body preview only — including attachment names, mimetypes, and sizes so the operator sees what's attached before approving. No bytes downloaded, no graph writes. Stages the batch in-memory for `email-ingest`.
- **Ingestion — apply:** `email-ingest` — apply the operator's per-message decisions to the staged batch. Each Message-ID must carry a disposition (`ingest` or `discard`). Approved messages land on `:ConversationArchive {source:'email'}` via the source-agnostic conversation-archive pipeline (one archive per thread, sessionised into `:Section` chunks). Attachment bytes are fetched, archived to `{accountDir}/archive/email/<uidValidity>-<uid>/<sha256>-<filename>` (0o600, 25 MB cap), and each attachment lands as a content-addressed `:DigitalDocument` keyed on `attachmentId = sha256(bytes)`; the `:HAS_ENCLOSURE` edge from the parent `:ConversationArchive` is wired by `memory-ingest` on its next pass via the `pendingArchiveEdges` queue. Advances the inbox high-water mark only after a successful write.
- **Recall/history:** `email-graph-query` — search stored email archives in Neo4j by natural language, participant address, or date range. Each thread is one `:ConversationArchive {source:'email'}` with `:Section` chunks; operator participants attach via `:PARTICIPANT_IN` edges from `:Person`/`:AdminUser`. List mode surfaces a per-archive summary built from concatenated `:Section.summary` values; query mode runs vector search over the `section_embedding` index and dedups to the parent archive.
- **Status / list:** `email-status` — connection health and configuration state for every attached mailbox (one health block each, prefixed by `Mailbox: <email>` when more than one is configured). Pass `mailbox` to report just one. This is the list surface the agent reads to see which mailboxes exist before naming one in a selector.
- **OTP:** `email-otp-extract` — poll for verification codes during service authentication.

## Ingestion model

Email ingestion is **operator-initiated and human-in-the-loop**. There is no background poll, cron, or auto-classifier. New messages enter the graph only when the operator says "check my emails" and approves each one. See `skills/email-ingest/SKILL.md` for the conversation flow and `skills/email-composition/SKILL.md` for drafting replies once a thread is stored.

## Retrieval paths

- **Live inbox** (`email-read`, `email-search`) — real-time IMAP queries. Use for checking the inbox or reading recent messages.
- **Graph history** (`email-graph-query`) — stored email-thread `:ConversationArchive {source:'email'}` rows (with `:Section` chunks) in Neo4j. Use for recall, semantic search, email history questions.
- **General knowledge** (`memory-search`) — cross-type knowledge graph. Use when the question spans all memory, not just emails.

**Naming a mailbox.** When more than one mailbox is configured on the account, `email-read` and `email-search` refuse a call that omits `mailbox`, naming every stored identity. This is deliberate — guessing between them would silently read the wrong mailbox. Call `email-status` to list the configured identities, then pass `mailbox` on every read. A single-mailbox account needs no `mailbox` argument.

**Both readers return the newest matches.** When more messages match than `limit` admits, the newest `limit` are returned and the output names the split, e.g. `Showing the newest 20 of 33 matches in the scanned window`. `33` counts matches inside the fetched window, not the whole mailbox. Treat that line as "there is more behind this" and page back with the `next_before_uid` printed alongside it — a truncated result always carries a cursor, and that cursor is the oldest message shown, so the next page lands exactly on what was dropped rather than skipping it. A result with no truncation line showed everything that matched in its window. This is reported separately from the recipient-filter line because the two do not coincide: truncation is only reachable when a recipient filter is active (that is what widens the window past `limit`), but a filter that hides nothing prints no recipient line while still truncating.

## Skills

| Skill | Purpose |
|-------|---------|
| [email-ingest](skills/email-ingest/SKILL.md) | Operator-initiated ingestion. Drives "check my emails" → list via `email-fetch` → summarise → ask the operator per message → write the approved subset via `email-ingest`. |
| [email-composition](skills/email-composition/SKILL.md) | Conversational wrapper for drafting replies, new emails, and inbox triage. Orchestrates the sending `email-*` tools with human-in-the-loop approval on every send; draft intent ("leave it in drafts", "don't send yet") routes to `email-draft`, threaded drafts pass `messageId`, and a stored draft is revised via `email-draft-edit` or sent via `email-draft-send`. |

## References

| Reference | Topics |
|-----------|--------|
| [email-reference.md](references/email-reference.md) | Setup form fields, pagination, filtering, replying, alias support, operator-initiated ingestion, threading, OTP integration |

Load the reference via `plugin-read` when detailed configuration or behaviour information is needed.
