Attachments

There are two independent attachment surfaces, and they never share a byte or a code:

  • Inbound — reading attachments on mail sent to you. What an agent can see depends on the mailbox's exposure tier. Raw bytes are the most-restricted tier; a safe text preview is the middle tier; metadata only is the default.
  • Outbound — sending attachments from your mailbox by a two-phase stage-then-reference flow: upload the bytes once to get a single-use handle, then pass the handle id in attachment_ids on a send / reply / draft. Handles are content-scanned, expire in 24 hours, and are consumed once.

Attachment bytes are stored encrypted at rest (provider-managed) and in transit over TLS, and the platform scans attachment content. This is not end-to-end or zero-access encryption.


Inbound: reading attachments

Every attachment appears in the attachments[] array of the message detail (GET /v1/messages/:id) and thread reads with per-attachment metadata: filename, content_type (server-sniffed magic-byte type, not the declared MIME), size, hash, plus a preview-summary projection (below). What you can retrieve — a safe text preview or the raw bytes — is gated by the mailbox's exposure tier and your key role.

Exposure tiers, as an agent sees them

Mailbox tierMetadata (in attachments[])Text preview (agent key)Raw bytes (agent key)
metadata_only (default)Yes403 ATTACHMENT_PREVIEW_DISABLED403 ATTACHMENT_ACCESS_DISABLED
derived_contentYesYes, when ready403 ATTACHMENT_ACCESS_DISABLED
raw_download_selected_typesYesYes, when readyYes, for allowed families

The tier is set by a human account owner in the dashboard. An agent cannot change it. Dashboard sessions and admin keys are governed by a separate human-safety gate and can read clean previews even on a metadata_only mailbox; the table above describes agent-role keys, which follow the mailbox tier exactly.

The preview machine — preview_status

A safe text preview is extracted asynchronously after a message arrives. Poll the per-attachment preview_status on the message detail (GET /v1/messages/:id) or thread read to decide when to fetch — do not poll the preview endpoint while a derivative is still pending.

attachments[i] carries these preview-summary fields:

FieldTypeMeaning
preview_statuspending | ready | blocked | failed | nullDerivative lifecycle state; null when no preview was ever attempted (unsupported type, outbound, legacy).
preview_kindtext | nulltext once ready (the only kind today), else null.
preview_reason_codestring | nullPopulated for blocked / failed (e.g. extraction_timeout, parser_failure, encrypted, scanner_error, unsupported_office_format, source_too_large).
preview_char_countinteger | nullExtracted source character count (before the inline cap).
preview_page_countinteger | nullNon-null only for PDF. Office derivatives persist null (slides/sheets are not pages).
preview_truncatedbooleanTrue if truncated at extraction or at the inline cap.
preview_generated_atISO timestamp | nullNon-null only when preview_status = 'ready'.

Supported source types: text/plain, text/csv, application/pdf, and Office Open XML (DOCX / PPTX / XLSX). Office previews carry visible text only — comments, speaker notes, hidden sheets, embedded media, OLE objects, uncached formula results, and macros are excluded. Legacy binary .doc/.ppt/.xls and macro-enabled variants are unsupported (preview_status stays null).

Fetching a preview — GET /v1/messages/:id/attachments/:idx/preview

Call only when preview_status === 'ready'. Returns extracted UTF-8 text inline, hard-capped at 20,000 characters (the shared body delivery/scanner cap), never raw bytes.

{
  "attachment": { "filename": "invoice.pdf", "content_type": "application/pdf", "size": 12345, "hash": "sha256:..." },
  "preview": {
    "kind": "text",
    "content": "extracted text, capped ...",
    "truncated": true,
    "char_count": 8123,
    "page_count": 4,
    "extractor": "<opaque engine identifier>",
    "generated_at": "2026-04-27T18:00:00.000Z"
  }
}

content is capped at 20,000 chars; char_count is the pre-cap source size; truncated: true means the worker truncated at extraction OR the inline cap was hit. extractor is an opaque identifier for the extraction engine used.

Preview error matrix (code → meaning → action):

HTTPcodeMeaningAction
400VALIDATION_ERROR:idx is not a non-negative integerFix the index.
403ATTACHMENT_PREVIEW_DISABLEDAgent key on a metadata_only mailbox (or a tier below the required entitlement)Config/entitlement change; a human raises the mailbox tier. Don't retry as-is.
403ATTACHMENT_PREVIEW_BLOCKEDExtracted-text scan rejected the contentTerminal for this attachment. Do not retry.
404NOT_FOUNDUnknown message, out-of-range idx, or an unbound agent key (masked)Verify the id and your key's mailbox binding.
404ATTACHMENT_PREVIEW_NOT_AVAILABLEDerivative failed, or no derivative exists (unsupported type)Terminal. Read preview_reason_code on the message detail.
409ATTACHMENT_PREVIEW_PENDINGExtraction still in flightRetry after a short delay, or wait for the preview webhook. Poll message detail, not this endpoint.
500PREVIEW_STORAGE_ERRORStorage/invariant fault (never masked as 404)Transient infra error — retry with backoff.

Preview lifecycle webhook events fire as a derivative transitions (queued / ready / blocked / failed) and carry metadata only, never content — see Webhooks for the event catalog.

Raw byte download — GET /v1/messages/:id/attachments/:idx

Returns a short-lived presigned download URL (Content-Disposition: attachment, sniffed Content-Type). For an agent key this requires the mailbox on raw_download_selected_types, the attachment's family in the allowlist, and a Pro+ entitlement; otherwise 403 ATTACHMENT_ACCESS_DISABLED. Raw image downloads additionally require a separate image-risk acknowledgement (403 ATTACHMENT_IMAGE_RISK_NOT_ACCEPTED until accepted). A gate denial by message/attachment state returns 403 with a specific code (ATTACHMENT_BLOCKED, ATTACHMENT_QUARANTINED, ATTACHMENT_UNVERIFIED, ATTACHMENT_LEGACY_UNSCANNED, MESSAGE_NOT_AVAILABLE); 404 ATTACHMENT_NOT_STORED means the bytes were never individually stored or were discarded after retention. Inline image rendering (.../attachments/:idx/inline-data-url) is a dashboard-session surface — all Bearer keys (agent and admin) get 403.

The Pro+ entitlement and per-tier availability live in Limits.


Outbound: staging attachments to send

Outbound attachments use a stage-then-reference state machine. You never attach bytes directly to a send body — you stage them, then reference the handle.

POST /v1/attachments (multipart)
      │  content_scan_status = "pending"
      ▼
[handle staged]  ──(24h TTL)──►  expires → 409 ATTACHMENT_ALREADY_CONSUMED
      │
      │  poll GET /v1/attachments/:id → content_scan_status: clean | flagged | error
      ▼
send / reply / draft with attachment_ids: [handle_id]
      │
      ▼
[consume once]  ──re-reference──►  409 ATTACHMENT_ALREADY_CONSUMED

Prerequisite: the mailbox must be enabled (an agent cannot self-enable)

Outbound attachments are off by default. Enabling them (POST /v1/mailboxes/:id/outbound-attachments) is a dashboard-session + fresh re-auth action performed by a human account owner — a Bearer API key is rejected with 403 REAUTH_REQUIRES_SESSION before any state change. It also requires a Pro+ account. Once a human has enabled the mailbox, agent keys stage and send handles normally. If your uploads fail with 403 OUTBOUND_ATTACHMENTS_DISABLED, the mailbox has not been enabled; if 403 TIER_LIMIT, the account is below Pro+. (OUTBOUND_ATTACHMENTS_INELIGIBLE is a distinct code from the enablement route — it means the account is suspended, not below-tier.)

Step 1 — stage: POST /v1/attachments

multipart/form-data with exactly two parts: a text field mailbox_id (name or UUID, the mailbox the handle is scoped to) and a single file part.

{
  "id": "8b1f...-handle-uuid",
  "filename": "invoice.pdf",
  "content_type": "application/pdf",
  "size": 18234,
  "hash": "9f86d081...",
  "scan": null,
  "content_scan_status": "pending"
}
  • id is the handle — put it in attachment_ids.
  • content_type is the server-sniffed type, not your declared MIME. The declared MIME never admits a family.
  • scan (a ScanSummary) is null on a clean upload — as shown above. It is a populated object only when the synchronous filename scan found something.
  • content_scan_status is always "pending" at 201 — the deep content scan runs asynchronously.

Upload error matrix (code → meaning → action):

HTTPcodeMeaningAction
400VALIDATION_ERRORMissing/empty mailbox_id or file partFix the request.
400ATTACHMENT_TOO_LARGEFile exceeds the 10 MB per-file capShrink or split.
400ATTACHMENT_MULTIPLE_FILESMore than one file partOne file per upload; call again per file.
400OUTBOUND_ATTACHMENT_TYPE_NOT_ALLOWEDSniffed family not in the outbound allowlistUse an allowed type (declared MIME is ignored).
400OUTBOUND_ATTACHMENT_FILENAME_INVALIDFilename >255 chars, non-ASCII-printable, or has path separators / quotes / control charsRename.
400ATTACHMENT_BLOCKEDFilename/type disallowed by policyTerminal for this file.
400ATTACHMENT_INFECTEDSynchronous AV returned infectedTerminal.
400ATTACHMENT_AV_ERRORAV scanner errored (fail-closed)Transient — retry.
400OUTBOUND_IMAGE_DISCLAIMER_REQUIREDAn image staged before the image-risk disclaimer was accepted; details.required_versionA human accepts the image-risk disclaimer for the mailbox.
403OUTBOUND_ATTACHMENTS_DISABLEDMailbox not enabled for outbound attachmentsA human enables it (see above).
403TIER_LIMITAccount below the outbound_attachments (Pro+) tierSee Limits.
404NOT_FOUNDMailbox not found, or agent key not bound to itCheck the selector and key binding.
429OUTBOUND_ATTACHMENT_STAGING_QUOTA_EXCEEDEDToo many un-consumed staged handles for the mailboxConsume or DELETE pending handles first.

Step 2 — poll status: GET /v1/attachments/:id

Returns one of two shapes, discriminated by the presence of status.

Active handle (no status field):

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

Consumed handle (status: "consumed"):

{ "id": "8b1f...", "status": "consumed", "consumed_at": "2026-06-01T00:00:05.000Z", "consumed_message_id": "uuid" }

content_scan_status is a separate channel from the scan.verdict. Its terminal values decide what happens at send:

content_scan_statusMeaningAt send time
pendingDeep content scan still running409 ATTACHMENT_SCAN_PENDING — poll, then retry the send.
cleanNo findingsSends normally.
flaggedA secret/PII finding in content or filenameSendable. Findings fold into the message verdict (block/quarantine by severity, like a body finding) — not a 4xx.
errorScan could not completeFail-closed: 400 ATTACHMENT_SCAN_ERROR at send.

You don't have to poll to clean before sending — the send resolver enforces the scan state itself. A flagged handle is not an error; it is a signal that the finished message may be quarantined or blocked by the send gate.

Step 3 — attach and consume: attachment_ids

Pass the handle id(s) in attachment_ids (a string[] of UUIDs, uniqueItems) on POST /v1/messages/send, POST /v1/messages/:id/reply, or POST /v1/drafts. Each handle is consumed once at send and must be scoped to the send's mailbox (a cross-mailbox reference resolves as 404 ATTACHMENT_NOT_FOUND). On a draft the handles are held at create time and consumed at dispatch (POST /v1/drafts/:id/send), so they must still be within the 24h window then.

A flagged attachment does not produce a send-time 4xx — its findings fold into the message verdict and are blocked or quarantined by severity exactly like a body finding. See Message states & verdicts for the verdict vocabulary and Send gates for the decision tree.

Send-resolver error matrix (code → meaning → action):

HTTPcodeMeaningAction
404ATTACHMENT_NOT_FOUNDUnknown / cross-account / cross-mailbox / expired handleRe-stage; reference from the owning mailbox.
409ATTACHMENT_ALREADY_CONSUMEDHandle already sent, or its 24h window elapsedRe-stage a fresh handle.
409ATTACHMENT_SCAN_PENDINGContent scan not yet terminalPoll GET /v1/attachments/:id, then retry the send.
400ATTACHMENT_SCAN_ERRORScan ended in error (fail-closed)Re-stage the file.
400ATTACHMENT_BLOCKED / ATTACHMENT_INFECTED / OUTBOUND_ATTACHMENT_TYPE_NOT_ALLOWEDPolicy / AV / type rejection surfaced at sendTerminal for this file.
400ATTACHMENT_LIMIT_EXCEEDEDMore than 10 attachments on the messageReduce count.
400ATTACHMENT_TOTAL_SIZE_EXCEEDEDTotal raw bytes over 15 MB (or over the provider's encoded cap)Reduce total size.
502ATTACHMENT_FETCH_FAILEDStored bytes could not be fetchedTransient — retry; re-stage if it persists.
403OUTBOUND_ATTACHMENTS_DISABLED / TIER_LIMITMailbox disabled or account downgraded between stage and sendRe-enable / upgrade.

Consume-once and delete

  • Referencing a spent handle again → 409 ATTACHMENT_ALREADY_CONSUMED.
  • GET /v1/attachments/:id on a spent handle returns the consumed shape (status: "consumed" + consumed_message_id).
  • DELETE /v1/attachments/:id removes an un-consumed handle (204); deleting a consumed handle → 409 ATTACHMENT_ALREADY_CONSUMED.

Attachment sends are synchronous

Attachment sends must be synchronous in v1 (a single-use handle expires 24h after upload and is consumed at dispatch, so a deferred dispatch could fire after the handle is gone). POST /v1/messages/send and POST /v1/messages/:id/reply ignore Prefer: respond-async, so the case never arises there. The only async send path is POST /v1/drafts/:id/send with Prefer: respond-async, and an attachment-bearing draft sent that way is rejected with 400 ATTACHMENTS_REQUIRE_SYNC_SEND. The scheduled-send combination is likewise rejected up front: POST /v1/drafts refuses send_at + attachment_ids together, and PATCH /v1/drafts/:id refuses setting send_at on an attachment-bearing draft (or adding attachments to a scheduled draft) — all 400 ATTACHMENTS_REQUIRE_SYNC_SEND.


Caps

LimitValueExceeded →
Per-file size10 MB400 ATTACHMENT_TOO_LARGE (stage), 400 ATTACHMENT_TOTAL_SIZE_EXCEEDED (send total)
Attachments per message10400 ATTACHMENT_LIMIT_EXCEEDED
Total raw bytes per message15 MB400 ATTACHMENT_TOTAL_SIZE_EXCEEDED
Filename≤255 ASCII-printable chars; no path separators, quotes, or control chars400 OUTBOUND_ATTACHMENT_FILENAME_INVALID
Handle lifetime24 hours from upload409 ATTACHMENT_ALREADY_CONSUMED on reference
Preview text20,000 characters (default)truncated: true in the preview response
Files per upload1400 ATTACHMENT_MULTIPLE_FILES

  • Message states & verdicts — how a flagged attachment's verdict resolves.
  • Send gates — the outbound decision tree a consumed attachment feeds.
  • Errors — the shared error-envelope shape.
  • Webhooks — preview lifecycle and message events.
  • Limits — tier entitlements for previews and outbound attachments.