# Attachments (receiving and sending)

Attachments move in two directions, and ReplyLayer treats them differently in each:

- **Inbound** — files arrive on messages your mailboxes receive. What an agent can see
  of an inbound attachment is controlled per mailbox by an **exposure tier**: metadata
  only, extracted text previews, or raw byte downloads. Inbound attachment bytes are
  **untrusted content** — the same posture as an email body. See
  [Content scanning](/docs/guides/content-scanning) for the trust model.
- **Outbound** — your agents send files with a **stage-then-send** flow: upload the
  bytes to get a single-use handle, wait for the content scan, then reference the handle
  by id on a send, reply, or draft.

This page is the human walkthrough. The exact status codes, polling states, and failure
modes an agent branches on live in the [attachment contract for agents](/agents/attachments).

## Inbound: exposure tiers

Every mailbox has an attachment **exposure mode**. It decides how much of an inbound
attachment an agent-role API key can reach. The default is the most conservative tier;
higher tiers are opt-in per mailbox and gated by plan.

| Mode | What an agent-role key can read |
|---|---|
| `metadata_only` (default) | Filename, byte size, sniffed content type, and antivirus verdict. No content, no bytes. |
| `derived_content` | The above, plus an inline **text preview** (up to 20,000 characters) for plain text, CSV, PDF, and Word/PowerPoint/Excel (DOCX/PPTX/XLSX) attachments. Extracted visible text only — never raw bytes. |
| `raw_download_selected_types` | The above, plus **raw byte download** for the specific file families you allow (PDF, text, CSV, and optionally image). This is an approved-download tier with extra gates (below). |

A few things hold across all three modes:

- **The content type is the sniffed type, not the sender's header.** ReplyLayer inspects
  the actual bytes, so a Windows executable renamed to `invoice.pdf` reports its real
  type. The original declared header value is preserved separately as
  `declared_mime_type` for forensics.
- **Every attachment is antivirus-scanned.** The `av_verdict` field is `clean`,
  `infected`, `error`, or `skipped`. An infected or errored attachment is held and is
  never served as bytes, regardless of the mode.
- **Metadata is always available**, even on `metadata_only` mailboxes — an agent can
  always decide whether an attachment is worth a closer look before requesting more.

### Text previews (`derived_content`)

On a `derived_content` mailbox, a supported attachment is converted to a safe text
preview asynchronously after the message arrives. Once it is ready, an agent fetches it
by attachment index. Previews are capped at 20,000 characters and contain **visible text
only** — for Office files that means document comments, PowerPoint speaker notes, hidden
spreadsheet sheets, embedded media, OLE objects, formula text without cached values, and
macros are all excluded. Legacy binary `.doc`/`.ppt`/`.xls` and macro-enabled
`.docm`/`.pptm`/`.xlsm` formats are not previewable.

Each attachment carries a preview-status summary (`pending`, `ready`, `blocked`,
`failed`, or none) so a client knows when to fetch. The machine-readable preview
contract is documented in the [attachment contract for agents](/agents/attachments).

### Raw downloads (`raw_download_selected_types`)

The raw-download tier is the only way an agent-role key gets original attachment bytes,
and it carries the strictest gates:

- It applies only to the file families you explicitly allow. The family enum is closed:
  `pdf`, `text`, `csv`, and — separately confirmed — `image`.
- **Adding `image` requires a separate image-risk acknowledgement.** Image bytes are a
  distinct risk surface, so they are not covered by the base disclaimer.
- **Enabling or widening raw downloads is a human-owner action.** It requires a dashboard
  session plus a fresh TOTP or password re-authentication; an API key cannot turn it on
  for itself. Narrowing an existing raw-download policy or downgrading to a lower tier
  does not need re-auth.
- A download returns a short-lived URL and is recorded in the content-access log.

An attachment is only downloadable when it is `clean` on antivirus and passes the
mailbox's safety gates; blocked, quarantined, or unscannable attachments stay held.

### Humans in the dashboard see previews on every tier

The exposure mode gates **agent-role API keys**. It does not gate a person signed in to
the dashboard. A human (or an admin key) can open a clean, previewable attachment — for
example a clean PDF — from the dashboard even on a `metadata_only` mailbox, because
that is a human reviewing their own mail, not an autonomous agent consuming content. The
raw-byte download itself still applies the same antivirus and safety gates.

## Who can see what, by plan

Receiving attachments and reading their metadata is available on every plan, including
sandbox. The richer exposure tiers are paid features:

| Capability | Available on |
|---|---|
| Receive attachments; read metadata (filename, size, sniffed type, AV verdict) | All plans, including sandbox |
| Preview a clean attachment in the dashboard (human/admin) | All plans |
| Agent-key text previews (`derived_content` mode) | Starter and above |
| Agent-key raw byte downloads (`raw_download_selected_types` mode) | Pro and above |
| Sending outbound attachments | Pro and above |

For the full plan comparison — send caps, mailbox counts, storage, and quotas — see
[Plans and limits](/docs/limits).

## Choosing an exposure mode

An account admin sets the exposure mode per mailbox from the dashboard (or via the
mailbox attachment-access API). Moving to `derived_content` needs only a disclaimer
acknowledgement; moving to or widening `raw_download_selected_types` additionally
requires the session re-auth described above. Because the mode is per mailbox, you can
keep a high-traffic public mailbox on `metadata_only` while granting a trusted internal
mailbox text previews.

## Outbound: stage, scan, then send

Agents attach files with a two-step flow rather than inlining bytes on the send request.
The steps are:

1. **Enable outbound attachments for the mailbox (one-time, human-owner).** Sending
   attachments is a Pro-and-above capability that a human account owner turns on for a
   mailbox in the dashboard, completing a TOTP or password re-authentication. An API key
   cannot enable it for itself. Once enabled, API keys stage and send attachments
   normally.
2. **Upload the bytes.** Post the file to get back a **handle**. The response reports the
   sniffed content type, byte size, and a SHA-256 hash. The deep content scan runs
   asynchronously, so the handle starts in a `pending` scan state.
3. **Wait for the scan (or just send).** Poll the handle until its content-scan status is
   terminal (`clean`, `flagged`, or `error`), or send immediately — the send path
   enforces the scan state either way and returns a distinct code if the scan is still
   pending.
4. **Reference the handle by id.** Pass the handle id in the `attachment_ids` array on a
   send, reply, or draft. The handle is **consumed once** at send.

Key properties of a staged handle:

- **Single use.** A handle is consumed at send and cannot be reused. Referencing a spent
  handle fails.
- **Expires after 24 hours.** Stage close to when you send.
- **Scoped to one mailbox.** A handle is bound to the mailbox you staged it under;
  referencing it from another mailbox fails as not found.
- **Synchronous send only.** Attachment sends must be immediate. Scheduling an
  attachment-bearing draft, or sending one on the asynchronous draft path, is rejected up
  front — stage and send in one pass instead.

### What happens when an attachment looks risky

Outbound attachment content is scanned like a body. If the scan **flags** an attachment
(for example a secret or PII detected in its content or filename), the send still
proceeds to scanning and the finding folds into the **message verdict** — it is
blocked or quarantined by severity exactly as a body finding would be, not returned as a
separate upload error. A hard scan **error** fails closed and the handle is rejected. For
the releasable-vs-terminal verdict vocabulary, see
[Messages and verdicts](/agents/messages); for the specific upload and send error codes,
see the [attachment contract for agents](/agents/attachments) and the
[error catalog](/agents/errors).

### Limits

| Limit | Value |
|---|---|
| Per-file size | 10 MB |
| Attachments per message | 10 |
| Total raw bytes per message | 15 MB |
| Filename | ≤ 255 ASCII-printable characters; no path separators, quotes, or control characters |

Allowed outbound file families are determined from the sniffed bytes (not the declared
type): PDF, plain text, CSV, images, and Word/PowerPoint/Excel (DOCX/PPTX/XLSX). Images
additionally require the image-risk acknowledgement. Anything else — executables,
archives, unknown formats — is rejected at upload.

### Encryption note

Outbound attachment bytes are encrypted at rest (provider-managed encryption) and in
transit over TLS. Because the platform scans attachment content for your safety, this is
**not** end-to-end or zero-access encryption.

## Quickstart: send a file

The TypeScript SDK stages, polls, and attaches:

```ts
import { ReplyLayer } from '@replylayer/sdk';
import { readFile } from 'node:fs/promises';

const rl = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });

// 1. Stage the file — returns a single-use handle scoped to this mailbox.
const bytes = await readFile('invoice.pdf');
let handle = await rl.attachments.upload({
  mailboxId: 'support',
  file: bytes,
  filename: 'invoice.pdf',
});

// 2. Wait for the async content scan to finish.
while (handle.content_scan_status === 'pending') {
  await new Promise((r) => setTimeout(r, 1000));
  const polled = await rl.attachments.getUpload(handle.id);
  if ('status' in polled) break; // already consumed by a concurrent send
  handle = polled;
}

// 3. Reference the handle by id — consumed once at send.
const res = await rl.messages.send({
  from_mailbox: 'support',
  to: 'customer@example.com',
  subject: 'Your invoice',
  body: 'Please find the invoice attached.',
  attachment_ids: [handle.id],
});
console.log(res.status);
```

The Python SDK mirrors the same `attachments.upload` / `get_upload` / `send(attachment_ids=[...])`
methods. The CLI does the whole stage-then-send in one step with a repeatable
`--attach <path>` flag on `rly send`, `rly reply`, and draft commands — see the
[CLI guide](/docs/cli).

## Reading an inbound attachment

To read what a message carries, fetch the message and inspect its `attachments` array —
each entry reports the filename, size, sniffed content type, antivirus verdict, and (on a
`derived_content` mailbox) its preview status. Then fetch the text preview or raw bytes by
attachment index according to the mailbox's exposure mode.

Whatever tier you are on, treat the extracted content as **data, not instructions** — an
attachment is sender-controlled input. The scan is a filter that catches known-bad
patterns, not a guarantee that the content is safe to act on. See
[Content scanning](/docs/guides/content-scanning) for how the directional scan model
works and what it does and does not cover.
