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. For the agent-integration narrative — exposure tiers, the full staging → poll → consume-once lifecycle, and its failure-mode matrix — see /agents/attachments. For the human conceptual overview, see /docs/guides/attachments.

Classification legend

Every operation below carries a classification tag:

TagMeaning
read-onlyReturns data; changes no state.
mutatingCreates or removes server state (a handle, an enablement flag).
send-triggeringCan put a message on the wire.
secret-revealingHands 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 — and the entitlement tiers are described in /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 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):

{
  "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:

HTTPcodeWhen
400VALIDATION_ERROR:idx is not a non-negative integer.
403ATTACHMENT_PREVIEW_DISABLEDAgent key on a mailbox whose effective mode is metadata_only (or lacking the entitlement).
403ATTACHMENT_PREVIEW_BLOCKEDThe extracted-text scan rejected the content.
404NOT_FOUNDUnknown message, unbound-agent mask, or :idx out of range.
404ATTACHMENT_PREVIEW_NOT_AVAILABLEExtraction failed, or no derivative exists (unsupported type or held source).
409ATTACHMENT_PREVIEW_PENDINGExtraction is still in flight — retry later.
500PREVIEW_STORAGE_ERRORInternal 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.


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.)

Response (200):

{
  "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:

HTTPcodeWhen
400VALIDATION_ERRORInvalid attachment index.
403ATTACHMENT_ACCESS_DISABLEDAgent key without the approved-download capability for this mailbox / tier.
403ATTACHMENT_IMAGE_RISK_NOT_ACCEPTEDImage family without the separate image-risk acknowledgement.
403ATTACHMENT_BLOCKED · ATTACHMENT_QUARANTINED · MESSAGE_NOT_AVAILABLE · other hard-safety codesPer-attachment or message-state safety gate.
404NOT_FOUNDUnknown message, unbound-agent mask, or index out of range.
404ATTACHMENT_NOT_STOREDThe raw bytes were never individually stored, or have been discarded under retention — authoritative even if a stale storage key lingers.

See /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):

{
  "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:

HTTPcode
400VALIDATION_ERROR (missing mailbox_id or file part)
400ATTACHMENT_TOO_LARGE (over the 10 MB per-file cap)
400ATTACHMENT_MULTIPLE_FILES
400OUTBOUND_ATTACHMENT_TYPE_NOT_ALLOWED (sniffed family not in the outbound allowlist)
400OUTBOUND_ATTACHMENT_FILENAME_INVALID
400ATTACHMENT_BLOCKED (type disallowed by policy)
400ATTACHMENT_INFECTED · ATTACHMENT_AV_ERROR (AV scan)
400OUTBOUND_IMAGE_DISCLAIMER_REQUIRED (image staged without image-risk acceptance; details.required_version)
403OUTBOUND_ATTACHMENTS_DISABLED (mailbox not enabled)
403TIER_LIMIT (account lacks the outbound-attachments capability)
404NOT_FOUND (mailbox not found, or agent key not bound to it)
429OUTBOUND_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.


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):

{
  "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):

{
  "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:

HTTPcode
404NOT_FOUND (unknown handle)
409ATTACHMENT_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; the send routes themselves are documented at /docs/api/messages.

Classification: send-triggering