# Outlook Plugin — Operator Guide

The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com via Microsoft Graph. Mail is read (list, search, full-body), reply, delete, and compose (draft, draft-edit, send); contacts are read-only; the calendar is read plus control (create, update, cancel, respond, free/busy). Per-account OAuth (device-code flow), encrypted token storage, automatic refresh.

## Quickstart

1. **No app to register.** The client ID ships with the brand (`brand.json#outlookClientId`), and the installer stamps it onto the session-manager env, so there is zero operator Azure work. On a brand that ships an empty value the plugin stays inert until its vendor app exists — see `platform/plugins/outlook/references/auth.md` for the one-time-per-brand vendor provisioning runbook (a vendor step, not an operator or end-user step), including setting **Allow public client flows** to Yes.
2. **Per account: register the Outlook account** — in admin chat, ask the agent to "register my Outlook account". The agent runs `outlook-account-register`, which returns a short user code and the URL `microsoft.com/devicelogin` and relays both to you.
3. **Consent on any device** — open `microsoft.com/devicelogin` on your phone or laptop, enter the code, sign in to your Microsoft account, and consent to the requested scopes (`offline_access`, `User.Read`, `Mail.ReadWrite`, `Mail.Send`, `Calendars.ReadWrite`, `Contacts.Read`). `Mail.Send` enables sending and `Calendars.ReadWrite` enables calendar control; a mailbox last consented on an older read-only scope set must re-register before send or any calendar write succeeds. Nothing else to do: the server finishes the sign-in automatically once you consent, usually within about half a minute, and the mailbox is then connected.
4. **Done.** Subsequent tool calls (mail, calendar, contacts) use the persisted refresh token transparently.
5. **Several mailboxes on one account.** Register again to add a second mailbox; it is stored alongside the first, not in place of it. Every operational tool then takes an optional `mailbox` argument — an email address or graphUserId. With one mailbox, omit it. With two or more, name it; omitting it returns a refusal listing the connected mailboxes. `outlook-mailbox-list` shows every connected mailbox. Only one registration runs at a time per account; starting another while one is live returns an in-progress signal.

## Tools

| Tool | Purpose |
|------|---------|
| `outlook-account-register` | Start the device-code flow for this account. Returns a user code + `microsoft.com/devicelogin` URL immediately; does not block. |
| `outlook-account-register-poll` | Finish the device-code flow. One check per call: returns pending, registered, expired, or denied. |
| `outlook-mail-list` | Mail newest-first. Default top=25, folder=Inbox. `folder` takes a well-known name or alias (case/space-insensitive): Inbox, Sent Items/sent, Drafts, Deleted Items/trash, Junk Email/junk/spam, Archive, Outbox — or a raw folder id. Custom folder names are not resolved. `since`/`before` (ISO date-times) narrow by receipt date. The 250-per-page figure is not a ceiling: the reply carries `nextCursor` when older mail remains — pass it back as `cursor` to page further back. Filter by sender/subject via `outlook-mail-search`. |
| `outlook-mail-search` | Microsoft Graph `$search` (KQL) over the mailbox. Preview-only, like `outlook-mail-list`, and pages the same way via `nextCursor`/`cursor`. Put sender/subject/recipient/date filters in the query: `from:`, `to:`, `subject:`, `received>=`. Graph forbids `$search`+`$filter`, so a strict date window is on `outlook-mail-list`. |
| `outlook-mail-fetch-body` | Complete body of one message by id (`GET /me/messages/{id}?$select=body`). HTML decoded to text, no preview cap. The full-read path — list/search return Microsoft's ~255-char preview only. Returns the envelope plus the whole body. |
| `outlook-mail-attachment` | List or download any message's attachments — received or draft. With `messageId` alone, LISTS them (`id`, `name`, `contentType`, `size`, `isInline`, and a `kind` of file/item/reference) via `GET /me/messages/{id}/attachments` and `$select` (no bytes). A draft is a message in the same collection, so passing a draftId lists what that draft carries; the returned `name`/`id` is what `outlook-draft-edit`'s `removeAttachments` takes. With `messageId`+`attachmentId`, DOWNLOADS one file attachment: its bytes are written to `{accountDir}/uploads/outlook/<messageHash>/<bytesHash>-<name>` at `0o600` and the saved path is returned, ready for the Read tool, `SendUserFile`, or an `outlook-mail-reply` attachment. Only file attachments download — item/reference kinds, empty payloads, and anything over 25 MB are refused with nothing written. `outlook-mail-list` / `outlook-mail-search` / `outlook-mail-fetch-body` report `hasAttachments` so the agent knows when to call this. Needs `Mail.Read`. |
| `outlook-mail-reply` | Reply in-thread (`createReply` / `createReplyAll` + send). Threads natively via conversationId. `replyAll`, added cc/bcc, and account-scoped file attachments (up to 25 MB per file; files over Graph's ~3 MB inline limit use a chunked upload session). Body is plain text, prepended above the quote. Needs `Mail.ReadWrite` + `Mail.Send`. |
| `outlook-mail-delete` | Move messages to Deleted Items (`POST /me/messages/{id}/move`). Recoverable — never hard-deletes. An id already gone is counted not-moved. Needs `Mail.ReadWrite`. |
| `outlook-mail-ingest` | Write operator-reviewed messages into the business graph as `:ConversationArchive {source:'email'}` threads (the analogue of the IMAP `email-ingest`). Input `decisions`: one `{messageId, disposition:'ingest'\|'discard'}` per listed message. Each `ingest` message's full body + envelope is fetched, threads group by Graph `conversationId`, and participants resolve closed-set against Neo4j (`:Person`/`:AdminUser` by `accountId`); an unresolved From/To/Cc address skips its whole thread and is returned to the operator (no `:Person` auto-create). Resolvable threads dispatch through the shared `conversation-archive-ingest.sh --source email`. Human-in-the-loop: nothing lands without an explicit per-message decision. The plugin's only Neo4j coupling. Needs `Mail.Read`. |
| `outlook-mail-otp-extract` | Poll the inbox for a one-time code from a sender (domain). Reads full bodies to extract it; optional subject regex and timeout (default 60 s). |
| `outlook-calendar-list` | Calendar events in next rangeDays days (default 7, max 365). |
| `outlook-calendar-event` | Full detail of a single event by id. |
| `outlook-calendar-create` | Book an event. Needs subject, start, end; optional body, attendees, location, isOnlineMeeting, recurrence. |
| `outlook-calendar-update` | Reschedule or edit an event. Pass eventId plus the fields to change. |
| `outlook-calendar-cancel` | Cancel a meeting (notifies attendees when the account organises it) or delete a personal appointment. Mode chosen automatically. |
| `outlook-calendar-respond` | Accept, decline, or tentatively accept an invite. |
| `outlook-calendar-freebusy` | Availability for one or more addresses over a window. |
| `outlook-contacts-list` | Top contacts. Default top=50. |
| `outlook-mailbox-info` | Health probe for one mailbox — auth state, refresh-window, folder count. Name the mailbox with `mailbox` when several are attached. |
| `outlook-mailbox-list` | Every connected mailbox with its email, scopes, and token health. Local only — no network — so it also flags a mailbox whose stored credentials cannot be read. |
| `outlook-mail-send` | Send a message. No attachments → `POST /me/sendMail`; with attachments → draft, attach, send (`/me/sendMail` carries attachments inline and cannot fit a large file). to/cc/bcc arrays, subject, body, isHtml, attachments (account-dir paths, 25 MB/file). Success asserted on Graph 202; no message id on either route. Needs `Mail.Send` (+ `Mail.ReadWrite` to attach). |
| `outlook-draft` | Create a draft in Drafts, either a new message or a threaded reply. Returns the draft id; never sends. Attachments are attached before the id is returned, so the draft is ready to send. Needs `Mail.ReadWrite`. **New message** (no `replyToMessageId`): `POST /me/messages`, requires `to` + `subject`, same body model as `outlook-mail-send`, threads under nothing. **Threaded reply** (`replyToMessageId` = the id of the message being answered): `POST /me/messages/{id}/createReply` — the draft carries the parent's conversationId, so it lands under the original. This is the only way to draft a reply without sending it: `outlook-mail-reply` threads but always sends. Graph sets the recipients and `RE:` subject from the original and composes the body above its own quote, matching what `outlook-mail-reply` sends, so the threaded path is **plain text only** — `to`, `subject`, and `isHtml: true` are **refused** (not ignored) when replying. `replyAll` includes everyone on the original; `cc`/`bcc` add recipients. |
| `outlook-draft-edit` | Update an existing draft in place (`PATCH /me/messages/{draftId}`) and/or add and remove attachments. Graph drafts are mutable — pass draftId plus the fields to change (to/cc/bcc/subject/body/isHtml/attachments/removeAttachments). `attachments` adds; `removeAttachments` takes files off by name or attachment id; both together replace a file in one call. Sent messages cannot be edited. Needs `Mail.ReadWrite`. |
| `outlook-draft-send` | Send an existing draft by id (`POST /me/messages/{id}/send`). Graph consumes the draft, returns 202. Needs `Mail.Send`. |

## Drafting to a Contact

When the admin agent drafts or sends a message to a known contact, it reads that contact's record first and applies any routing the record carries, for example a preferred send-to address, showing the result for your approval before anything is sent.

## Observability

All log lines start with `[outlook-mcp]` and write to `server.log`. They are key=value, account-scoped:

| Event | Line shape |
|-------|------------|
| Device code issued | `devicecode-issued account=<id> userCode=<code> verificationUri=<url> expiresSec=<N> intervalSec=<N>` |
| Poll (still pending) | `devicecode-poll account=<id> status=pending elapsedSec=<N>` |
| Device code expired | `devicecode-expired account=<id> elapsedSec=<N>` (terminal) |
| Consent denied | `devicecode-denied account=<id> elapsedSec=<N>` (terminal) |
| Pending-store census | `pending-store-census account=<id> live=<N>` |
| Pending cleared | `pending-cleared account=<id> verified=<bool>` |
| /me failed post-token | `me-failed account=<id> status=<N>` |
| Auth ok | `auth-ok account=<id> graphUserId=<id> mail=<addr> scopes=<csv> tokenExpSec=<N>` |
| Registration already in flight | `register-in-progress account=<id>` (start refused; one device-code flow at a time) |
| Credential resolve | `cred-resolve account=<id> mailbox=<id-or-empty> source=arg|sole|none|refused` — names the resolved mailbox and how it was chosen; `source=refused` is the ambiguity/not-found refusal |
| Token refreshed | `token-refreshed account=<id> oldExpSec=<N> newExpSec=<N>` |
| Refresh failed | `token-refresh-failed account=<id> reason=<err>` (terminal) |
| Mail list | `mail-list op=resolve account=<id> folderIn=<name> folderResolved=<token> asRawId=<bool> count=<N> elapsedMs=<N>` (on error, `error=<msg>` replaces `count`) |
| Mail search | `mail-search account=<id> query=<trunc-32> count=<N> elapsedMs=<N>` |
| Mail ingest (per message) | `mail-ingest op=ingest id=<id> conversationId=<id> disposition=ingest` |
| Mail ingest (per thread) | `mail-ingest op=thread-dispatched root=<key> messages=<N> participants=<N>` (or `op=thread-failed root=<key> status=<N> err=<msg>`; `status=null` with a non-`none` `err` is a spawn launch failure, not a script rejection) |
| Mail ingest (summary) | `mail-ingest op=summary ingested=<N> discarded=<N> threadsDispatched=<N> threadsSkipped=<N> unresolved=<N>` — one per call; `threadsSkipped` with `unresolved>0` is the closed-set resolution refusing an unknown participant, not a failure |
| Calendar list | `calendar-list account=<id> rangeDays=<N> count=<N> elapsedMs=<N>` |
| Calendar event | `calendar-event account=<id> eventId=<trunc-12> elapsedMs=<N>` |
| Event created | `cal-create-request account=<id> subj=<trunc-64>` then `event-created account=<id> eventId=<trunc-16> status=201`. A non-201 logs `event-create-failed ... status=<N>` and throws. A 201 whose body carried no readable id logs `event-created-id-unknown account=<id> status=201` and returns a created-but-id-unknown result (the event exists; do not retry) rather than a failure. |
| Event updated | `cal-update-request account=<id> eventId=<trunc-16>` then `event-updated ... status=200` |
| Event cancelled | `cal-cancel-request account=<id> eventId=<trunc-16> mode=<cancel-or-delete>` then `event-cancelled ... status=<N>` |
| Event responded | `cal-respond-request account=<id> eventId=<trunc-16> action=<a>` then `event-responded ... status=202` |
| Free/busy | `freebusy-request account=<id> requested=<N>` then `freebusy-result account=<id> requested=<N> resolved=<N>` (resolved counts error-free entries; resolved<requested flags a failed address) |
| Scope insufficient | `scope-insufficient tool=<t> account=<id> missing=Calendars.ReadWrite` (stale read-only consent) |
| Contacts list | `contacts-list account=<id> count=<N> elapsedMs=<N>` |
| Mailbox info | `mailbox-info account=<id> tokenWithinRefreshWindow=<bool> folderCount=<N>` |
| Mailbox list | `mailbox-list account=<id> count=<N> unreadable=<N>` (unreadable>0 flags an orphaned or corrupt mailbox store) |
| Graph error | `graph-error account=<id> status=<N> code=<graphErrorCode> retryAfterMs=<N-or-null>` — writes flow through the same `callGraph` loop, so a failed write logs `graph-error` too |
| On-prem rejected | `on-prem-rejected account=<id> mailServer=<host>` (terminal) |
| Scope insufficient | `scope-insufficient account=<id> tool=<t> missing=Mail.Send` (terminal — re-register to grant send) |
| Send request | `send-request account=<id> to=<N> subjLen=<N>` |
| Send args | `send-args account=<id> recipients=<N> hasBody=<bool>` |
| Sent | `sent account=<id> status=202 messageId=<id-or-null>` |
| Send failed | `send-failed account=<id> error=<msg>` |
| Draft request | `draft-request account=<id> threaded=<bool>` |
| Draft created | `draft-created account=<id> id=<draftId> threaded=<bool> convId=<id\|null>` |
| Draft failed | `draft-failed account=<id> threaded=<bool> error=<msg>` |
| Draft-send request | `draft-send-request account=<id> id=<draftId>` |
| Draft sent | `draft-sent account=<id> id=<draftId> status=202` |
| Draft-send failed | `draft-send-failed account=<id> id=<draftId> error=<msg>` |

## Diagnostic paths

```bash
# All outlook lines for one account, last 50
ssh laptop 'grep -E "^\[outlook-mcp\]" ~/.maxy/logs/server.log | grep "account=<id>" | tail -50'

# Token-leak audit — must always return zero
grep -rn -iE "Bearer |access_token=" ~/.maxy/logs/server.log | head

# Threading audit — a threaded write that reports no conversation LOST its thread.
# Must always return zero. Covers both outbound threaded paths (mail-reply and draft-created).
grep -E "^\[outlook-mcp\] (mail-reply|draft-created)" ~/.maxy/logs/server.log \
  | grep -E "threaded=true|op=reply" | grep -E "convId=(null)?( |$)" | head
```

Threading triage: **absence of a `convId` on a threaded write means threading was lost.** `draft-created threaded=false convId=null` is correct and expected — a new message has no parent conversation. `draft-created threaded=true convId=null` is a fault: Graph returned a reply draft that names no conversation, and the reply will land detached from the original.

Latency triage: `mail-list count=0 elapsedMs<200` consistent → permissions issue; `elapsedMs > 5000` → Graph slowness or DNS.

## Failure modes

| Operator-visible message | Cause | Fix |
|---|---|---|
| `Outlook not connected for account=X; run outlook-account-register` | Tokens never saved | Run register tool |
| `Outlook refresh token expired for account=X; run outlook-account-register` | >90 days since last refresh, or consent revoked | Run register tool |
| `Outlook token refresh failed for account=X; re-auth required` | Network down at refresh time, or refresh token invalidated | Verify network; re-register |
| `Outlook auth expired for account=X; run outlook-account-register` | Refresh-then-retry still got 401 | Re-register |
| `Outlook rate-limited without Retry-After hint` | Graph 429 with no backoff guidance | Wait + retry; if persistent, file bug |
| `Microsoft Graph does not support on-premises Exchange. Use earlier platform fixes (IMAP).` | Mailbox is on hybrid Exchange | Use the `email` plugin |
| `Outlook Calendars.ReadWrite scope not granted ...; Re-run outlook-account-register ...` | Mailbox last consented on the old read-only calendar scope | Re-register to grant calendar write |
| `Outlook Mail.Send scope not granted ...; Re-run outlook-account-register to grant send.` | Mailbox registered under the old read-only consent | Re-run `outlook-account-register` and consent to the send scopes |

## Out of scope

Mail move to arbitrary folders / flag, hard (permanent) delete, removing an attachment from a **sent** message (Graph forbids it), attachment-byte archival into the graph (`outlook-mail-ingest` lands text threads only; the IMAP `:DigitalDocument` archival is a separate concern), contacts write, recurring-series occurrence editing, the `scheduling` Neo4j calendar and any auto-sync with it, OneDrive / Files, push notifications, on-premises Exchange, M365 admin scopes (`User.Read.All`, `AuditLog.Read.All`), public-agent exposure, multi-tenant federation. Mail read, full-body read, inbound attachment list/download, reply, delete (to Deleted Items), and compose — all four outbound paths carrying attachments, with a draft's attachments editable in place (add, remove, replace) — are supported (`outlook-mail-fetch-body`, `outlook-mail-attachment`, `outlook-mail-reply`, `outlook-mail-delete`, `outlook-mail-ingest`, `outlook-mail-otp-extract`, `outlook-mail-send`, `outlook-draft`, `outlook-draft-edit`, `outlook-draft-send`), and the calendar is read plus control. See `platform/plugins/outlook/PLUGIN.md` for the full out-of-scope list.
