# Graph Surfaces

Microsoft Graph endpoints used by each tool, with the response shape and the fields selected.

## Item id contract

Outlook item ids are **not** stable by default. Graph rotates an item's id when the item is moved or re-saved — including by the operator's own mail client, which no code here controls. A rotated id returns `404 ErrorItemNotFound`, so a handle stored moments earlier can stop resolving with nothing wrong on our side. This is exactly how a live draft appeared to "vanish" from an operator's Drafts folder: our `PATCH` returned `200`, the client rotated the id, and the next call by that id 404'd.

The plugin therefore opts into **immutable ids**, sending `Prefer: IdType="ImmutableId"` on every request. It is set once, in `createGraphClient` — the single factory behind every Graph call — so every tool inherits it.

**This is plugin-wide by necessity, not preference.** The immutable and default namespaces carry *different values for the same item*, so an id produced by a request that omitted the header cannot address that item on a request that sent it. Never add or remove the header per tool or per call. Two `fetch()` calls sit outside the factory and are correctly unaffected: `account-register`'s `/me` lookup (a directory id, not an Outlook item id) and the attachment upload-session `PUT` (a pre-authenticated URL, not a Graph resource path).

A per-request `.header()` (`outlook-mail-search`'s `ConsistencyLevel`) merges with the central header rather than displacing it — asserted in `graph-client.test.ts`, because a silent drop there would leave search alone emitting regular ids. A per-request header of the *same name* is a different matter: `Prefer` is also how Graph carries `outlook.timezone` and `odata.maxpagesize`, and setting it per-call would replace the id opt-in for that call. `Prefer` is comma-separated — append to `IMMUTABLE_ID_PREFER`, never set `Prefer` alone.

**Across a plugin restart**, ids and opaque cursors (`nextCursor`) an agent is still holding were minted in the other namespace and will not resolve. This self-heals on the next list or create and affects one conversation; nothing persists a Graph id today.

## outlook-mail-list

**Endpoint (Inbox):** `GET /me/mailFolders/Inbox/messages?$top={N}&$orderby=receivedDateTime DESC&$select=id,subject,from,receivedDateTime,bodyPreview,isRead,hasAttachments`

**Endpoint (other folder):** `GET /me/mailFolders/{well-known-token-or-folderId}/messages?...`

**Date window:** `since`/`before` (ISO date-times) add `$filter=receivedDateTime ge {since} and receivedDateTime le {before}`. The filter orders by the same `receivedDateTime` property, so `$filter`+`$orderby` is accepted here — a `$filter` on `from`/`toRecipients` combined with this `$orderby` is *not* (Graph: "the restriction or sort order is too complex"), which is why sender/subject filtering lives on `outlook-mail-search` (KQL), not here.

**Pagination:** the response's `@odata.nextLink` (a URL carrying a `$skiptoken`) is returned to the caller as `nextCursor`. Passing it back as `cursor` re-issues that exact URL (`client.api(cursor)`), so folder/filter/order/top/skiptoken are preserved and every other arg is ignored. `nextCursor` is `null` when the page is the last. The 250 `$top` is a per-page size, not a reach ceiling — page with the cursor or narrow with the date window to reach older mail.

The `folder` arg is normalized before the path is built. Graph's `{id}` segment accepts only a well-known token (single word, no spaces) or a real folder id — a display name like "Sent Items" is rejected with "Id is malformed". The tool maps the full well-known set plus common aliases, case- and space-insensitively, to the Graph token:

| Input (any case/spacing) | Graph token |
|---|---|
| `Inbox`, `` (empty) | `Inbox` |
| `Sent Items`, `sent` | `sentitems` |
| `Drafts` | `drafts` |
| `Deleted Items`, `trash` | `deleteditems` |
| `Junk Email`, `junk`, `spam` | `junkemail` |
| `Archive` | `archive` |
| `Outbox` | `outbox` |

Any value not in this table is treated as a raw folder id and passed through unchanged. A custom folder's display name (e.g. "Projects") is therefore not resolved — resolving custom folders by name would need a `/me/mailFolders` lookup and is out of scope.

**Returned shape:**
```typescript
{
  items: Array<{
    id: string;
    subject: string | null;
    from: string | null;          // emailAddress.address
    receivedDateTime: string;     // ISO 8601
    bodyPreview: string;          // Microsoft's short preview (~255 chars); the code's .slice(0,1024) never bites. Full body: outlook-mail-fetch-body
    isRead: boolean;
  }>;
  nextCursor: string | null;      // @odata.nextLink for the next older page; null when exhausted
}
```

## outlook-mail-search

**Endpoint:** `GET /me/messages?$search="{query}"&$top={N}&$select=id,subject,from,receivedDateTime,bodyPreview,isRead,hasAttachments`

**Required header:** `ConsistencyLevel: eventual` (Graph requirement for `$search`).

**Query filters (KQL):** put sender/subject/recipient/date in the `query` — `from:addr`, `to:addr`, `subject:text`, `received>=2024-01-01`, or free text. Graph forbids combining `$search` with a `$filter`/`$orderby`, so a strict `receivedDateTime` window is on `outlook-mail-list`; date-narrowing here uses the `received:` KQL term.

**Pagination:** same as `outlook-mail-list` — the `@odata.nextLink` is returned as `nextCursor`; pass it back as `cursor` and it re-issues via `client.api(cursor)` with the `ConsistencyLevel: eventual` header re-applied (the header is not part of the URL). `cursor` supersedes `query`.

**Returned shape:** identical to `outlook-mail-list` (`{ items, nextCursor }`).

## outlook-mail-fetch-body

**Endpoint:** `GET /me/messages/{id}?$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,conversationId,isRead,hasAttachments,body`

Reads the COMPLETE body (not `bodyPreview`). `body.contentType` is `html` or `text`; HTML is decoded to plain text (`lib/html-to-text.ts`). No preview cap — this is the full-read path that `outlook-mail-list`/`-search` (preview only) do not provide. Never falls back to `bodyPreview`; the logged `bytes` surfaces a shortfall.

**Returned shape:**
```typescript
{
  id: string;
  subject: string | null;
  from: string | null;          // from.emailAddress.address
  fromName: string | null;      // from.emailAddress.name (additive) — the sender display name outlook-mail-ingest carries into the archive
  to: string[];                 // toRecipients[].emailAddress.address
  cc: string[];
  receivedDateTime: string;
  conversationId: string | null;
  isRead: boolean;
  hasAttachments: boolean;      // Graph flag; true when the message carries enclosures
  contentType: "html" | "text";
  body: string;                 // full, decoded
}
```

`hasAttachments` is also on `outlook-mail-list` / `outlook-mail-search` items (each `$select` now carries it), so the preview tools flag an enclosure before the agent fetches the body or the attachment.

## outlook-mail-attachment

**List endpoint:** `GET /me/messages/{id}/attachments?$select=id,name,contentType,size,isInline` — metadata only; `$select` omits `contentBytes`, so no bytes are downloaded. Each item's `@odata.type` maps to a `kind`: `#microsoft.graph.fileAttachment` → `file`, `#microsoft.graph.itemAttachment` → `item`, `#microsoft.graph.referenceAttachment` → `reference` (anything else → `unknown`).

**Download endpoint:** `GET /me/messages/{id}/attachments/{attachmentId}` — the full attachment object, including base64 `contentBytes` for a `fileAttachment`. The bytes are decoded and written to `{accountDir}/uploads/outlook/<sha256(messageId)[:16]>/<sha256(bytes)[:16]>-<safeName>` at mode `0o600`; the write is re-stat-verified before the path is returned. `resolveAccountDir` (shared with `outlook-mail-reply`) locates the account directory; the 25 MB cap is `OUTLOOK_ATTACHMENT_MAX_BYTES`.

**Refusals (nothing written):** a non-`file` kind (`Cannot download <kind> attachment …`), an empty/absent `contentBytes` (`… returned no bytes`), a declared or decoded size over the 25 MB cap (`… too large …`), and no resolvable account directory. Each refusal logs `[mail-attachment] op=refuse … reason=<reason>`.

**Returned shapes:** list → `{ items: [{ id, name, contentType, size, isInline, kind }] }`; download → `{ path, name, contentType, bytes }`.

**Scope:** `Mail.Read` (read-only; the same scope the read tools use).

## outlook-mail-reply

**Endpoints (in order):**
1. `POST /me/messages/{id}/createReply` (or `/createReplyAll` when `replyAll`) with `{ comment }` → 201 with the reply draft (`id`, `conversationId`, prefilled recipients). The `comment` is prepended above the quoted original.
2. `PATCH /me/messages/{draftId}` with `{ ccRecipients, bccRecipients }` — only when `cc`/`bcc` given; `ccRecipients` is the union of the draft's existing cc and the supplied cc.
3. Per file, by size (paths validated under the account directory; 25 MB per-file cap, parity with the email plugin):
   - **≤ 3 MB (Graph inline limit):** `POST /me/messages/{draftId}/attachments` — `#microsoft.graph.fileAttachment` with base64 `contentBytes`.
   - **> 3 MB and ≤ 25 MB:** `POST /me/messages/{draftId}/attachments/createUploadSession` → chunked `PUT` to the returned pre-authenticated `uploadUrl` in 320 KiB multiples (`Content-Range` per chunk; Graph returns 200 per intermediate chunk, 201 on the final one).
4. `POST /me/messages/{draftId}/send` → 202.

Threading is native (the draft carries the original `conversationId`). Attachment paths are validated before step 1 so a bad path leaves no orphan draft; inline bytes are read at step 3 and large-file bytes stream chunk-by-chunk through the upload session. Body is plain text; new HTML mail stays on `outlook-mail-send`.

**Returned shape:** `{ conversationId: string | null; to: string[]; replyAll: boolean; attachments: string[] }`.

## outlook-mail-delete

**Endpoint (per id):** `POST /me/messages/{id}/move` with `{ "destinationId": "deleteditems" }`.

Recoverable — moves to Deleted Items, never hard-deletes or expunges. An id already gone (Graph 404) is counted `notMoved` without failing the rest; any other Graph error aborts the batch.

**Returned shape:** `{ moved: string[]; notMoved: string[] }`.

## outlook-mail-ingest

**No direct Graph mutation.** Per approved message it calls `runMailFetchBody`'s `GET /me/messages/{id}?$select=…,body` to read the full body + envelope (including `conversationId` and the additive `fromName`). The graph write is delegated: threads are grouped by `conversationId`, participants resolve closed-set against Neo4j (`UNWIND $addresses … WHERE (p:Person OR p:AdminUser) AND p.accountId=$accountId AND toLower(p.email)=addr`), and each resolvable thread is written by spawning `plugins/memory/bin/conversation-archive-ingest.sh <thread.json> --source email --participant-person-ids <elementIds> --scope admin --session-id outlook-<uuid>`. The thread JSON is `[{from:{name,email}, date, bodyText}]`, the shape the shared `email` normaliser consumes. An unresolved From/To/Cc address skips its whole thread; no `:Person` is auto-created. This is the plugin's only Neo4j coupling (`neo4j-driver`, `lib/neo4j.ts`).

**Input:** `{ decisions: [{ messageId, disposition: "ingest" | "discard" }], mailbox? }`. Refuses an empty array and a duplicate messageId.

**Returned shape:** a plain-text summary — ingested/discarded counts, threads dispatched/skipped, and any unresolved addresses.

## outlook-mail-otp-extract

**Endpoints:** `GET /me/mailFolders/Inbox/messages?$top=15&$orderby=receivedDateTime DESC&$select=id,subject,from,receivedDateTime` to list candidates, then `outlook-mail-fetch-body`'s `GET /me/messages/{id}?$select=…,body` per sender-matched candidate. Scans immediately, then polls every 5 s until a code is found or `timeout` seconds (default 60) elapse. `extractOtpFromText` is shared verbatim with the `email` plugin.

**Returned shape:** `{ success: boolean; code?: string; message: string; from?: string; subject?: string }`.

## outlook-draft-edit

**Endpoints (in order):**
1. `PATCH /me/messages/{draftId}` (`ResponseType.RAW`; success asserted on HTTP 200) with only the supplied fields (`subject`, `body` → `{ contentType, content }`, `toRecipients`, `ccRecipients`, `bccRecipients`). Graph drafts are mutable, so no delete-and-reappend. **Skipped entirely** when the call supplies attachments and no patchable field. A PATCH on a sent (non-draft) message returns a Graph error surfaced unchanged.
2. Per file, by size — identical to `outlook-mail-reply` step 3 (shared helper). Attachments edit in place: `attachments` adds files to what the draft already carries, `removeAttachments` takes files off via `DELETE /me/messages/{draftId}/attachments/{attachmentId}`, and the two together replace a file.

**Removal resolution.** Each `removeAttachments` entry is matched against `GET /me/messages/{draftId}/attachments` (the same paginated call `outlook-mail-attachment` uses — shared as `lib/attachment-list.ts`): an exact attachment-id match wins, otherwise an exact name match. Zero matches refuses `not-on-draft` and names what the draft carries; two or more name matches refuses `ambiguous-name` and names the ids that disambiguate. Both fire before the PATCH — the listing is a GET — so a bad removal leaves the draft untouched. Resolution completing before any write is what makes a same-name swap unambiguous: the old file's id is pinned before the new file exists. Two distinct targets addressing the same attachment (an id and a name for one file) are collapsed to one removal rather than refused, so each attachment is deleted at most once. The DELETEs and attaches target the id the PATCH reported, while the listing is necessarily read from the caller's id; see [Item id contract](#item-id-contract).

**Removal observability.** `op=resolve requested=<n> resolved=<m>` is emitted once every target has resolved (`m` below `n` means targets were deduped), then one `op=detach id=<name> attachmentId=<id>` per file **after** its DELETE returns. A detach count below `resolved` together with a terminal failure is the partial-removal signature: `draft-edit` is non-atomic across PATCH, DELETE and attach, so a DELETE failing on file 2 of 3 leaves the draft matching neither the before nor the requested after while the caller is told the edit failed.

At least one patchable field, **one attachment or one removal** is required. Attachment paths are validated, and removals resolved, before step 1 — so a bad path or an unresolvable removal leaves the draft untouched rather than half-edited.

Step 2 targets the id step 1 **reported**, not the caller's `draftId`. Ids are immutable plugin-wide so the two normally match, but a rotated id means the caller's handle no longer addresses the draft — see [Item id contract](#item-id-contract).

**Returned shape:** `{ draftId: string; updated: true; attachments: string[]; removed: string[] }` — `attachments` names the files added, `removed` the files taken off. `draftId` is the id Graph reported for the updated draft, which is authoritative over the id passed in. The tool refuses rather than report `updated: true` for an id it has not read back. An attachments-only or removal-only edit issues no PATCH, so it reports the caller's id.

## outlook-mail-send

Two transports, chosen by whether the message carries a file. The `op: "transport"` log line names the route actually taken.

**Route A — no attachments. Endpoint:** `POST /me/sendMail`

**Request body:**
```json
{
  "message": {
    "subject": "…",
    "body": { "contentType": "Text | HTML", "content": "…" },
    "toRecipients": [{ "emailAddress": { "address": "a@example.com" } }],
    "ccRecipients": [ … ],
    "bccRecipients": [ … ]
  },
  "saveToSentItems": true
}
```

**Success:** HTTP `202 Accepted`, empty body. No message id is returned. The tool asserts on the 202 — an empty body is not proof of send.

**Route B — with attachments. Endpoints (in order):**
1. `POST /me/messages` with the `message` object above (no `saveToSentItems` wrapper) → 201 with the created draft; `id` is used for steps 2-3 only.
2. Per file, by size — identical to `outlook-mail-reply` step 3 (shared helper).
3. `POST /me/messages/{draftId}/send` → 202. Graph consumes the draft and saves to Sent Items.

The switch is forced, not stylistic: `/me/sendMail` carries attachments inline in the request body and is bounded well below the plugin's 25 MB per-file cap, so a large file can only reach the wire through a draft's upload session. A `route=sendMail` line with `attachments>0` is the mis-wire signature and must never appear.

Route B is **not atomic**, unlike route A: an attach or send failing Graph-side leaves a draft in the Drafts folder — the same exposure `outlook-mail-reply` carries, and not cleaned up here either. Attachment paths are validated before step 1, so the common failure (a bad path) leaves nothing behind.

**Finding an orphan.** Step 1 emits `op:"draft-created" id=<draftId>` the moment a draft exists, mirroring `outlook-draft`'s `draft-created` / `outlook-draft-send`'s `draft-sent` pairing. The orphan signature is a `draft-created` line with **no** terminal `sent` line, and the `id` on it is the handle needed to go delete the draft. The `op:"transport"` line alone is not the signature: it is emitted before the create, so a transport line with no `draft-created` means the create itself failed and stranded nothing.

**Returned shape:** `{ status: "sent"; httpStatus: number; messageId: string | null; attachments: string[] }` — `messageId` is `null` on **both** routes. Route A returns no body; on route B the draft id is consumed by `/send` and would 404 if handed back — see [Item id contract](#item-id-contract).

## outlook-draft

**Endpoints (in order):**
1. `POST /me/messages` with the `message` object above (no `saveToSentItems` wrapper).
2. Per file, by size — identical to `outlook-mail-reply` step 3 (shared helper). Attachment paths are validated before step 1, so a bad path leaves no orphan draft.

**Success:** HTTP `201 Created` with the created message JSON; `id` is the handle passed to `outlook-draft-send`, and the returned draft already carries the files. It is durable only because the plugin opts into immutable ids — see [Item id contract](#item-id-contract).

**Returned shape:** `{ draftId: string; attachments: string[] }`.

## outlook-draft-send

**Endpoint:** `POST /me/messages/{id}/send`

**Request body:** none.

**Success:** HTTP `202 Accepted`, empty body. Graph consumes the draft (it moves out of Drafts on send).

## outlook-calendar-list

**Endpoint:** `GET /me/calendarView?startDateTime={now}&endDateTime={now+rangeDays}&$top={N}&$orderby=start/dateTime ASC&$select=id,subject,start,end,location,attendees,isAllDay,organizer`

**Returned shape:**
```typescript
Array<{
  id: string;
  subject: string | null;
  start: { dateTime: string; timeZone: string };
  end:   { dateTime: string; timeZone: string };
  location: string | null;
  attendees: Array<{ email: string; name: string | null; response: string }>;
  isAllDay: boolean;
  organizer: string | null;
}>
```

## outlook-calendar-event

**Endpoint:** `GET /me/events/{eventId}?$select=id,subject,start,end,location,attendees,isAllDay,organizer,bodyPreview,body,webLink`

**Returned shape:** as `outlook-calendar-list` plus:
```typescript
{
  bodyPreview: string;       // 1024 chars
  body: string;              // up to 16384 chars (HTML or text per content type)
  webLink: string | null;
}
```

## outlook-calendar-create

**Endpoint:** `POST /me/events` (`ResponseType.RAW`; success asserted on HTTP 201).

**Request body:** `subject`, `start`/`end` (`{ dateTime, timeZone }`), and optionally `body` (`{ contentType: "HTML", content }`), `location` (`{ displayName }`), `attendees` (`{ emailAddress: { address, name }, type }`), `isOnlineMeeting`, `recurrence` (Graph recurrence object, passed through).

**Returned shape:** `{ eventId: string; webLink: string | null }`.

## outlook-calendar-update

**Endpoint:** `PATCH /me/events/{eventId}` (`ResponseType.RAW`; success asserted on HTTP 200).

**Request body:** only the supplied fields, same shapes as create.

**Returned shape:** `{ eventId: string; updated: true }`.

## outlook-calendar-cancel

**Endpoint:** determined by a pre-GET on `GET /me/events/{eventId}?$select=isOrganizer,attendees`.
- Organiser of a meeting with attendees: `POST /me/events/{eventId}/cancel` with `{ Comment }` (HTTP 202); attendees are notified.
- Otherwise: `DELETE /me/events/{eventId}` (HTTP 204).

**Returned shape:** `{ eventId: string; mode: "cancel" | "delete" }`.

## outlook-calendar-respond

**Endpoint:** `POST /me/events/{eventId}/{accept|decline|tentativelyAccept}` with `{ Comment, SendResponse }` (HTTP 202). `action=tentative` maps to `tentativelyAccept`.

**Returned shape:** `{ eventId: string; action: "accept" | "decline" | "tentative" }`.

## outlook-calendar-freebusy

**Endpoint:** `POST /me/calendar/getSchedule` with `{ schedules, startTime, endTime, availabilityViewInterval }` (HTTP 200).

**Returned shape:**
```typescript
{
  requested: number;
  resolved: number;              // count of error-free entries; resolved < requested flags a failed address
  availability: Array<{
    address: string;             // scheduleId
    availabilityView: string;
    busy: Array<{ start: string; end: string; status: string }>;
    error: string | null;        // Graph FreeBusyError message when the address could not be resolved
  }>;
}
```

## outlook-contacts-list

**Endpoint:** `GET /me/contacts?$top={N}&$select=id,displayName,emailAddresses,businessPhones,homePhones,mobilePhone,jobTitle,companyName`

**Returned shape:**
```typescript
Array<{
  id: string;
  displayName: string | null;
  emailAddresses: string[];   // .address values flattened
  phones: string[];           // business + home + mobile concatenated
  jobTitle: string | null;
  companyName: string | null;
}>
```

## outlook-mailbox-info

**Endpoint:** `GET /me/mailFolders?$top=50&$select=id` (best-effort — folder count is null if Graph is unreachable)

**Returned shape:**
```typescript
{
  registered: boolean;
  graphUserId: string | null;
  scopes: string[];
  tokenExpSec: number | null;
  refreshTokenExpSec: number | null;
  tokenWithinRefreshWindow: boolean;
  folderCount: number | null;
}
```

## outlook-account-register

Starts the device-code flow. No Graph endpoint of its own:

- `POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/devicecode` (client_id + scope)

Returns `{ status: "pending", userCode, verificationUri, verificationUriComplete, expiresInSec, intervalSec }` immediately and writes an encrypted pending entry. Does not block.

## outlook-account-register-poll

One token poll per call:

- `POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code`

Returns `pending` / `registered` / `expired` / `denied`. On `registered`, the tool fetches `GET /me?$select=id,mail,userPrincipalName` to record `graphUserId`, persists tokens, and clears the pending entry.

## Error classification

All tool failures route through `lib/graph-client.ts:classifyGraphError`. Decisions key off **status code + Graph `error.code` field + headers**, never the error `message` body. Read tools use `callGraph` (the Graph SDK throws on non-2xx); write tools use `postGraph` / `patchGraph` / `deleteGraph` (one `writeGraph` core), which request `ResponseType.RAW` so a non-2xx status is returned rather than thrown, normalize it into the shape `classifyGraphError` reads, and re-throw it through `callGraph`'s loop — success is asserted on the status code.

| HTTP status | Graph `error.code` | Class | Action |
|---|---|---|---|
| 401 | (any) | `auth` | Refresh once, retry; if still 401 → terminal `auth-required` |
| 403 | (any) | `scope-insufficient` | Terminal: name the scope the failing tool needs (`Mail.Send`, `Mail.ReadWrite`, or `Calendars.ReadWrite` for a calendar write) and route the operator to `outlook-account-register` |
| 429 | (any) — header `Retry-After` present | `rate-limit-recoverable` | Backoff per header, retry once |
| 429 | (any) — header `Retry-After` absent | `rate-limit-terminal` | Terminal abort |
| 503 | `MailboxNotEnabledForRESTAPI` | `on-prem` | Terminal: route operator to IMAP plugin |
| 5xx | (any) | `5xx` | Retry once with 500 ms; then terminal |
| any | (any) | `other` | Re-throw |
