# Attachments API

Two families of endpoints handle attachments:

- **Reading inbound attachments** — a safe extracted-text preview, or a raw byte
  download, of an attachment on a received message.
- **Sending outbound attachments** — a stage-then-reference flow: upload bytes to
  get a single-use **handle**, then pass the handle's `id` on a send, reply, or
  draft.

This page is the per-operation reference for those routes. For the machine-readable
contract, see [`/docs/openapi.json`](/docs/openapi.json). For the agent-integration
narrative — exposure tiers, the full staging → poll → consume-once lifecycle, and
its failure-mode matrix — see [/agents/attachments](/agents/attachments). For the
human conceptual overview, see [/docs/guides/attachments](/docs/guides/attachments).

## Classification legend

Every operation below carries a classification tag:

| Tag | Meaning |
|---|---|
| **read-only** | Returns data; changes no state. |
| **mutating** | Creates or removes server state (a handle, an enablement flag). |
| **send-triggering** | Can put a message on the wire. |
| **secret-revealing** | Hands back raw attachment bytes, which may contain sensitive content. |

## Auth model

All attachment routes accept a Bearer API key (admin or mailbox-scoped agent) or a
dashboard session cookie, except where noted. What an **agent** key can read is
governed by the owning mailbox's attachment exposure mode
(`metadata_only`, `derived_content`, `raw_download_selected_types`) and the
account's feature entitlements; admin keys and session callers pass the same
hard-safety gates but bypass the agent exposure-mode gate. The exposure mode is
configured per mailbox — see [/docs/api/mailboxes](/docs/api/mailboxes) — and the
entitlement tiers are described in [/docs/limits](/docs/limits).

## Reading attachment metadata

There is no standalone metadata route. Attachment metadata for a message — each
entry's `filename`, sniffed `content_type`, `size`, `av_verdict`, `policy_action`,
retention flags, and `preview_status` — is delivered inline in the `attachments[]`
array of the message-detail response. See
[/docs/api/messages](/docs/api/messages) for that projection. Use each attachment's
`preview_status` there to decide when to call the preview route below.

---

## GET /v1/messages/:id/attachments/:idx/preview

Return a safe, extracted **text preview** of an attachment — UTF-8 text inline, no
raw bytes. Supported source types are plain text, CSV, PDF, and Office Open XML
(DOCX / PPTX / XLSX). Office previews surface visible text only (comments, speaker
notes, hidden sheets, embedded media, and macros are excluded). The preview is
generated asynchronously and cached; this endpoint serves the cached extraction.

**Classification:** read-only

**Auth:** Bearer (admin / agent) or session. Agent keys require the mailbox to be
in `derived_content` or `raw_download_selected_types` mode with the matching
entitlement; admin and session callers may read any ready preview that passes the
hard-safety gates.

**Path params:**

- `:id` — message id.
- `:idx` — 0-based index into the message's `attachments[]` array.

**Response (200):**

```json
{
  "attachment": {
    "filename": "invoice.pdf",
    "content_type": "application/pdf",
    "size": 12345,
    "hash": "sha256:..."
  },
  "preview": {
    "kind": "text",
    "content": "extracted text ...",
    "truncated": true,
    "char_count": 8123,
    "page_count": 4,
    "extractor": "pdfjs-dist",
    "generated_at": "2026-04-27T18:00:00.000Z"
  }
}
```

- `content` is hard-capped at the shared body/scanner cap (20,000 characters by
  default).
- `char_count` is the extracted source size **before** the cap.
- `truncated` is `true` when either extraction or the inline cap dropped content.
- `page_count` is non-null only for PDFs (Office and text derivatives carry `null`).
- `extractor` identifies the extraction path — `plaintext` (text/CSV),
  `pdfjs-dist` (PDF), or `ooxml-text` (Office).

**Errors:**

| HTTP | `code` | When |
|---|---|---|
| 400 | `VALIDATION_ERROR` | `:idx` is not a non-negative integer. |
| 403 | `ATTACHMENT_PREVIEW_DISABLED` | Agent key on a mailbox whose effective mode is `metadata_only` (or lacking the entitlement). |
| 403 | `ATTACHMENT_PREVIEW_BLOCKED` | The extracted-text scan rejected the content. |
| 404 | `NOT_FOUND` | Unknown message, unbound-agent mask, or `:idx` out of range. |
| 404 | `ATTACHMENT_PREVIEW_NOT_AVAILABLE` | Extraction failed, or no derivative exists (unsupported type or held source). |
| 409 | `ATTACHMENT_PREVIEW_PENDING` | Extraction is still in flight — retry later. |
| 500 | `PREVIEW_STORAGE_ERROR` | Internal storage error while fetching the preview. |

**Polling.** Don't poll this endpoint while `preview_status` is `pending` — read
the (cheaper) message detail until `preview_status` flips to `ready`. See the full
code catalog and remediation at [/agents/errors](/agents/errors).

---

## GET /v1/messages/:id/attachments/:idx

Download a single attachment as **raw bytes**. Returns a short-lived presigned
download URL (5-minute expiry); the client fetches the bytes directly from object
storage, not through the API. The presigned URL forces
`Content-Disposition: attachment` and sets the trusted (sniffed) `Content-Type`.

**Classification:** read-only · secret-revealing

**Auth:** Bearer (admin / agent) or session. Agent keys require the mailbox to be
in `raw_download_selected_types` mode, the account to hold the approved-download
entitlement, and the attachment's file family (`pdf | text | csv | image`) to be in
the mailbox allowlist; raw **image** download additionally requires an explicit
image-risk acknowledgement. Admin and session callers use the human hard-safety
gate, under which clean, individually-stored attachments are downloadable.

**Path params:**

- `:id` — message id.
- `:idx` — 0-based index into the message's `attachments[]` array.

**Downloadable only when** the message is in state `available` or `delivered`, the
attachment's AV verdict is `clean`, and its per-attachment disposition is not a
hold or block. Quarantined / blocked messages and non-clean AV verdicts are
refused. (For the state and verdict vocabulary, see
[/agents/messages](/agents/messages).)

**Response (200):**

```json
{
  "url": "https://storage.example.com/signed?token=...",
  "expires_at": "2026-04-16T12:05:00.000Z",
  "content_type": "application/pdf",
  "filename": "invoice.pdf",
  "size": 52400,
  "av_verdict": "clean"
}
```

- `content_type` is the **trusted, sniffed** MIME type, not the declared header.
- `av_verdict` is `clean` / `infected` / `error` / `skipped`, or `null` on legacy
  messages predating AV integration.

**Errors:**

| HTTP | `code` | When |
|---|---|---|
| 400 | `VALIDATION_ERROR` | Invalid attachment index. |
| 403 | `ATTACHMENT_ACCESS_DISABLED` | Agent key without the approved-download capability for this mailbox / tier. |
| 403 | `ATTACHMENT_IMAGE_RISK_NOT_ACCEPTED` | Image family without the separate image-risk acknowledgement. |
| 403 | `ATTACHMENT_BLOCKED` · `ATTACHMENT_QUARANTINED` · `MESSAGE_NOT_AVAILABLE` · other hard-safety codes | Per-attachment or message-state safety gate. |
| 404 | `NOT_FOUND` | Unknown message, unbound-agent mask, or index out of range. |
| 404 | `ATTACHMENT_NOT_STORED` | The raw bytes were never individually stored, or have been discarded under retention — authoritative even if a stale storage key lingers. |

See [/agents/errors](/agents/errors) for the full catalog and remediation.

---

## Sending attachments

Outbound attachments use a **stage-then-reference** flow. Bytes are provider-managed
encrypted at rest and TLS-protected in transit, and the platform scans attachment
content — so this is **not** end-to-end / zero-access encryption. A handle expires
**24 hours after upload**, is **consumed once** at send, and is **scoped to a single
mailbox** (a handle referenced from a different mailbox resolves as
`404 ATTACHMENT_NOT_FOUND`).

Per-message caps: **10 MB per file**, **10 attachments per message**, **15 MB total
raw bytes per message**, and filenames ≤ 255 printable-ASCII characters (no path
separators, quotes, or control characters).

### Prerequisite: enablement (dashboard-managed)

Outbound attachments are enabled **per mailbox** by a human account owner in the
dashboard, which requires a session plus fresh TOTP/password re-auth and the Pro+
outbound-attachments capability. A Bearer API key cannot enable it — the enablement
route returns `403 REAUTH_REQUIRES_SESSION`. Once a mailbox is enabled, agent and
admin keys stage and send handles normally.

**Classification:** mutating (dashboard-managed; not an agent-API operation)

---

## POST /v1/attachments

Stage one attachment and return a single-use handle.

**Classification:** mutating

**Auth:** Bearer (admin or mailbox-scoped agent) or session.

**Request:** `multipart/form-data` with exactly two parts —

- a text field named `mailbox_id` (the mailbox to scope the handle to; name or
  UUID), and
- a single file part (the attachment bytes).

A second file part is rejected with `400 ATTACHMENT_MULTIPLE_FILES`.

**Response (201):**

```json
{
  "id": "uuid",
  "filename": "invoice.pdf",
  "content_type": "application/pdf",
  "size": 18234,
  "hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "scan": null,
  "content_scan_status": "pending"
}
```

- `id` is **the handle** — place it in the `attachment_ids` array on a send / reply
  / draft.
- `content_type` is the **server-sniffed** type (magic-byte), not the client MIME.
- `hash` is the SHA-256 hex of the bytes.
- `scan` is `null` on a clean upload (as shown); it is a populated scan summary
  **only** when the synchronous filename scan found something.
- `content_scan_status` is `pending | clean | flagged | error` and is **always
  `pending`** at 201 — the deep content scan runs asynchronously. Poll
  `GET /v1/attachments/:id` for the terminal status, or just send (the resolver
  enforces scan state at send time).

**Errors:**

| HTTP | `code` |
|---|---|
| 400 | `VALIDATION_ERROR` (missing `mailbox_id` or file part) |
| 400 | `ATTACHMENT_TOO_LARGE` (over the 10 MB per-file cap) |
| 400 | `ATTACHMENT_MULTIPLE_FILES` |
| 400 | `OUTBOUND_ATTACHMENT_TYPE_NOT_ALLOWED` (sniffed family not in the outbound allowlist) |
| 400 | `OUTBOUND_ATTACHMENT_FILENAME_INVALID` |
| 400 | `ATTACHMENT_BLOCKED` (type disallowed by policy) |
| 400 | `ATTACHMENT_INFECTED` · `ATTACHMENT_AV_ERROR` (AV scan) |
| 400 | `OUTBOUND_IMAGE_DISCLAIMER_REQUIRED` (image staged without image-risk acceptance; `details.required_version`) |
| 403 | `OUTBOUND_ATTACHMENTS_DISABLED` (mailbox not enabled) |
| 403 | `TIER_LIMIT` (account lacks the outbound-attachments capability) |
| 404 | `NOT_FOUND` (mailbox not found, or agent key not bound to it) |
| 429 | `OUTBOUND_ATTACHMENT_STAGING_QUOTA_EXCEEDED` (too many un-consumed staged handles) |

A filename or content secret/PII hit is a **finding**, not a `4xx` — it flows into
the message verdict at send time. See [/agents/errors](/agents/errors).

---

## GET /v1/attachments/:id

Fetch the status of a staged handle. Returns one of two shapes, discriminated by
the presence of `status`.

**Classification:** read-only

**Auth:** same as upload.

**Response (200) — active handle** (no `status` field):

```json
{
  "id": "uuid",
  "filename": "invoice.pdf",
  "content_type": "application/pdf",
  "size": 18234,
  "hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "scan": { "verdict": "warning", "categories": [{ "category": "secret_detected", "decision": "allow_with_warning" }], "findings": [] },
  "content_scan_status": "flagged"
}
```

`scan` (a scan summary) is present only when a finding exists; its `verdict` channel
(`clean` / `warning` / `review_required` / `blocked` / `quarantined`) is distinct
from the `content_scan_status` channel (`pending` / `clean` / `flagged` / `error`).

**Response (200) — consumed handle** (has `status`):

```json
{
  "id": "uuid",
  "status": "consumed",
  "consumed_at": "2026-06-01T00:00:05.000Z",
  "consumed_message_id": "uuid"
}
```

**Errors:** `404 NOT_FOUND` — unknown handle, or a handle scoped to a mailbox the
caller cannot see.

---

## DELETE /v1/attachments/:id

Delete an un-consumed staged handle.

**Classification:** mutating

**Auth:** same as upload.

**Response (204):** no body.

**Errors:**

| HTTP | `code` |
|---|---|
| 404 | `NOT_FOUND` (unknown handle) |
| 409 | `ATTACHMENT_ALREADY_CONSUMED` (already spent by a send — cannot be deleted) |

---

## Referencing a handle on a send

Pass handle ids in an `attachment_ids` array (UUIDs, `uniqueItems`) on
`POST /v1/messages/send`, `POST /v1/messages/:id/reply`, or a draft; each handle is
consumed once at dispatch, attachment sends are **synchronous only** (async and
scheduled combinations are refused with `400 ATTACHMENTS_REQUIRE_SYNC_SEND`), and a
`flagged` attachment folds into the message verdict rather than returning a `4xx`.
The full consume-once lifecycle and its resolver failure modes
(`404 ATTACHMENT_NOT_FOUND`, `409 ATTACHMENT_ALREADY_CONSUMED`,
`409 ATTACHMENT_SCAN_PENDING`) are the machine contract at
[/agents/attachments](/agents/attachments); the send routes themselves are
documented at [/docs/api/messages](/docs/api/messages).

**Classification:** send-triggering
