---
name: email-ingest
description: "Operator-initiated email ingestion. Activate when the operator asks to check, triage, or pull in new email ('check my emails', 'any new mail?', 'pull in the latest emails', 'what came in overnight'). Drives a list → summarise → ask → ingest loop using the email-fetch and email-ingest tools. Human-in-the-loop: nothing enters the graph without an explicit operator decision per message."
---

# Email ingestion

Invoked from `specialists:personal-assistant`.

Operator-initiated, human-in-the-loop ingestion of the agent's IMAP inbox into the graph. New messages enter the knowledge graph **only** when the operator says so, and only the ones they pick. There is no background poll, no automatic classifier, no silent writes.

## When to activate

Activate when the operator wants to bring new email into the agent's memory. Typical phrasings:

- "Check my emails."
- "Any new mail?"
- "Pull in the latest emails."
- "What came in overnight?"

Do **not** activate for:

- Searching, reading, or recalling email already in the inbox or the graph — those are `email-search`, `email-read`, `email-graph-query`.
- Drafting or sending — that's `email-composition`.
- Setup, status, or OTP extraction — those are direct tool calls.

## Flow

### 1. List

Call `email-fetch`. It pulls new messages since the stored high-water mark (or a `since_uid` if the operator asked for a wider window) and stages them in-memory for the follow-up ingest. **It writes nothing to the graph.**

If `email-fetch` returns "No new messages", say that and stop. There is nothing to ingest.

An account can carry more than one mailbox. When it does, every IMAP call refuses unless it names one, and the refusal lists the configured identities. That refusal means "say which mailbox", not "there is no mail". Resolve it with `email-status`, which lists what is attached, then pass `mailbox` and carry on. Never report a mailbox as empty or unreadable on the strength of a refusal you have not resolved.

Reads are capped, and a capped result says so. A line reporting that it showed the newest N of a larger match count means more is sitting behind it, and the cursor printed with it pages onto exactly what was held back. That count covers the stretch of mailbox that was scanned, not the whole mailbox, so it is a floor and never a total. Absence of that line is the only thing that means you saw everything in range.

### 2. Summarise

Present the listed messages to the operator as a numbered or per-`Message-ID` summary. For each, surface:

- Sender (name + address).
- Subject.
- A one-line gist drawn from the body preview (not the full preview — keep it tight).
- A suggested disposition where the message is obviously one or the other (e.g., "newsletter — looks like discard", "thread reply from a known correspondent — looks like ingest"). The suggestion is a hint, never an action; the operator decides.

Do not classify suspicious vs clean — there is no screener. If a message looks like phishing, surface that observation in plain English so the operator can discard it by eye.

### 3. Ask

Ask the operator to confirm a disposition for **every** message in the list. Either:

- A blanket call ("ingest all", "discard everything"), which you turn into the per-message map.
- An explicit per-message choice ("ingest 1, 2, 4; discard 3").

If the operator answers ambiguously ("the work ones"), restate the messages you think they mean and confirm before proceeding. Never guess.

### 4. Ingest

Call `email-ingest` with a `decisions` array containing one entry per Message-ID returned by `email-fetch`. The tool refuses if any pending Message-ID is missing a decision — that's the safeguard against silent drops, not an error to retry around. If it refuses, go back to the operator and ask about the missing ones.

On success the tool returns a one-line summary (ingested / discarded / thread counts) and advances the inbox high-water mark.

### 5. Confirm

Echo the tool's summary back to the operator in one sentence. If they want to draft a reply to anything that was just ingested, hand off to `email-composition`.

## Edge cases

| Situation | Behaviour |
|-----------|-----------|
| `email-fetch` returns zero new messages | Tell the operator and stop. Do not call `email-ingest`. |
| A listed message has no `Message-ID` header | Surface it by UID and ask the operator how to handle it. `email-ingest` cannot apply a per-message decision to it, so either widen the search or skip it explicitly. |
| Operator wants to ingest some now and review the rest later | Refuse cleanly. The high-water mark advances on every successful ingest, so a partial decision would silently drop the rest. Ask them to commit one way or the other on every message, or to wait. |
| Operator abandons mid-flow | Do not call `email-ingest`. The staged batch stays in memory until the next `email-fetch` overwrites it; the high-water mark does not move. |
| `email-fetch` errors (IMAP down, auth failure) | Surface the error verbatim. Do not retry silently. |

## Out of scope

- **Automatic re-screening or LLM classification.** The screener was removed; the operator is the screener now.
- **Scheduled or recurring ingestion.** There is no cron, no interval, no background trigger. The operator initiates every cycle.
- **Per-thread or per-sender allowlists.** Each message gets a fresh decision.
- **Other channels.** WhatsApp and the public web have their own ingestion paths.

## Observability

Each tool emits a single tagged log line:

- `[email-fetch] tag=email-fetch listed=<n> sinceUid=<uid>` — one per list call.
- `[email-ingest] tag=email-ingest ingested=<n> discarded=<m>` — one per ingest call.

A complete cycle is greppable as the pair `email-fetch` → `email-ingest` for the same session.
