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_idson 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 tier | Metadata (in attachments[]) | Text preview (agent key) | Raw bytes (agent key) |
|---|---|---|---|
metadata_only (default) | Yes | 403 ATTACHMENT_PREVIEW_DISABLED | 403 ATTACHMENT_ACCESS_DISABLED |
derived_content | Yes | Yes, when ready | 403 ATTACHMENT_ACCESS_DISABLED |
raw_download_selected_types | Yes | Yes, when ready | Yes, 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:
| Field | Type | Meaning |
|---|---|---|
preview_status | pending | ready | blocked | failed | null | Derivative lifecycle state; null when no preview was ever attempted (unsupported type, outbound, legacy). |
preview_kind | text | null | text once ready (the only kind today), else null. |
preview_reason_code | string | null | Populated for blocked / failed (e.g. extraction_timeout, parser_failure, encrypted, scanner_error, unsupported_office_format, source_too_large). |
preview_char_count | integer | null | Extracted source character count (before the inline cap). |
preview_page_count | integer | null | Non-null only for PDF. Office derivatives persist null (slides/sheets are not pages). |
preview_truncated | boolean | True if truncated at extraction or at the inline cap. |
preview_generated_at | ISO timestamp | null | Non-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):
| HTTP | code | Meaning | Action |
|---|---|---|---|
400 | VALIDATION_ERROR | :idx is not a non-negative integer | Fix the index. |
403 | ATTACHMENT_PREVIEW_DISABLED | Agent 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. |
403 | ATTACHMENT_PREVIEW_BLOCKED | Extracted-text scan rejected the content | Terminal for this attachment. Do not retry. |
404 | NOT_FOUND | Unknown message, out-of-range idx, or an unbound agent key (masked) | Verify the id and your key's mailbox binding. |
404 | ATTACHMENT_PREVIEW_NOT_AVAILABLE | Derivative failed, or no derivative exists (unsupported type) | Terminal. Read preview_reason_code on the message detail. |
409 | ATTACHMENT_PREVIEW_PENDING | Extraction still in flight | Retry after a short delay, or wait for the preview webhook. Poll message detail, not this endpoint. |
500 | PREVIEW_STORAGE_ERROR | Storage/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_CONSUMEDPrerequisite: 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"
}idis the handle — put it inattachment_ids.content_typeis the server-sniffed type, not your declared MIME. The declared MIME never admits a family.scan(aScanSummary) isnullon a clean upload — as shown above. It is a populated object only when the synchronous filename scan found something.content_scan_statusis always"pending"at 201 — the deep content scan runs asynchronously.
Upload error matrix (code → meaning → action):
| HTTP | code | Meaning | Action |
|---|---|---|---|
400 | VALIDATION_ERROR | Missing/empty mailbox_id or file part | Fix the request. |
400 | ATTACHMENT_TOO_LARGE | File exceeds the 10 MB per-file cap | Shrink or split. |
400 | ATTACHMENT_MULTIPLE_FILES | More than one file part | One file per upload; call again per file. |
400 | OUTBOUND_ATTACHMENT_TYPE_NOT_ALLOWED | Sniffed family not in the outbound allowlist | Use an allowed type (declared MIME is ignored). |
400 | OUTBOUND_ATTACHMENT_FILENAME_INVALID | Filename >255 chars, non-ASCII-printable, or has path separators / quotes / control chars | Rename. |
400 | ATTACHMENT_BLOCKED | Filename/type disallowed by policy | Terminal for this file. |
400 | ATTACHMENT_INFECTED | Synchronous AV returned infected | Terminal. |
400 | ATTACHMENT_AV_ERROR | AV scanner errored (fail-closed) | Transient — retry. |
400 | OUTBOUND_IMAGE_DISCLAIMER_REQUIRED | An image staged before the image-risk disclaimer was accepted; details.required_version | A human accepts the image-risk disclaimer for the mailbox. |
403 | OUTBOUND_ATTACHMENTS_DISABLED | Mailbox not enabled for outbound attachments | A human enables it (see above). |
403 | TIER_LIMIT | Account below the outbound_attachments (Pro+) tier | See Limits. |
404 | NOT_FOUND | Mailbox not found, or agent key not bound to it | Check the selector and key binding. |
429 | OUTBOUND_ATTACHMENT_STAGING_QUOTA_EXCEEDED | Too many un-consumed staged handles for the mailbox | Consume 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_status | Meaning | At send time |
|---|---|---|
pending | Deep content scan still running | 409 ATTACHMENT_SCAN_PENDING — poll, then retry the send. |
clean | No findings | Sends normally. |
flagged | A secret/PII finding in content or filename | Sendable. Findings fold into the message verdict (block/quarantine by severity, like a body finding) — not a 4xx. |
error | Scan could not complete | Fail-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):
| HTTP | code | Meaning | Action |
|---|---|---|---|
404 | ATTACHMENT_NOT_FOUND | Unknown / cross-account / cross-mailbox / expired handle | Re-stage; reference from the owning mailbox. |
409 | ATTACHMENT_ALREADY_CONSUMED | Handle already sent, or its 24h window elapsed | Re-stage a fresh handle. |
409 | ATTACHMENT_SCAN_PENDING | Content scan not yet terminal | Poll GET /v1/attachments/:id, then retry the send. |
400 | ATTACHMENT_SCAN_ERROR | Scan ended in error (fail-closed) | Re-stage the file. |
400 | ATTACHMENT_BLOCKED / ATTACHMENT_INFECTED / OUTBOUND_ATTACHMENT_TYPE_NOT_ALLOWED | Policy / AV / type rejection surfaced at send | Terminal for this file. |
400 | ATTACHMENT_LIMIT_EXCEEDED | More than 10 attachments on the message | Reduce count. |
400 | ATTACHMENT_TOTAL_SIZE_EXCEEDED | Total raw bytes over 15 MB (or over the provider's encoded cap) | Reduce total size. |
502 | ATTACHMENT_FETCH_FAILED | Stored bytes could not be fetched | Transient — retry; re-stage if it persists. |
403 | OUTBOUND_ATTACHMENTS_DISABLED / TIER_LIMIT | Mailbox disabled or account downgraded between stage and send | Re-enable / upgrade. |
Consume-once and delete
- Referencing a spent handle again →
409 ATTACHMENT_ALREADY_CONSUMED. GET /v1/attachments/:idon a spent handle returns the consumed shape (status: "consumed"+consumed_message_id).DELETE /v1/attachments/:idremoves 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
| Limit | Value | Exceeded → |
|---|---|---|
| Per-file size | 10 MB | 400 ATTACHMENT_TOO_LARGE (stage), 400 ATTACHMENT_TOTAL_SIZE_EXCEEDED (send total) |
| Attachments per message | 10 | 400 ATTACHMENT_LIMIT_EXCEEDED |
| Total raw bytes per message | 15 MB | 400 ATTACHMENT_TOTAL_SIZE_EXCEEDED |
| Filename | ≤255 ASCII-printable chars; no path separators, quotes, or control chars | 400 OUTBOUND_ATTACHMENT_FILENAME_INVALID |
| Handle lifetime | 24 hours from upload | 409 ATTACHMENT_ALREADY_CONSUMED on reference |
| Preview text | 20,000 characters (default) | truncated: true in the preview response |
| Files per upload | 1 | 400 ATTACHMENT_MULTIPLE_FILES |
Related
- 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.