# Agent email attachments: upload, virus scan and download
URL: https://emailforagents.ai/docs/attachments
Summary: Reserve an upload, stream the bytes, wait for a clean virus scan, then attach it to a message. Real limits, real statuses, and why a pending file cannot be sent.

1. [Home](https://emailforagents.ai/)
2. [Docs](https://emailforagents.ai/docs)
3. Attachments

Docs

## Send and receive files

An attachment belongs to one inbox in one project, and it must clear storage, type, size and safety checks before it can be sent or downloaded. Uploading a file does not make it an available attachment — that is the whole design.

Last reviewed September 21, 2026 against the
implementation in this repository.

Files are the part of the
[agent email API](https://emailforagents.ai/product/developers) where "it worked in testing" diverges most
from production, because a scan takes time and an agent will happily send before it finishes.
The flow below makes that impossible rather than unlikely.

### The model in one paragraph

An upload is reserved against an inbox before any bytes move, which is what lets storage and
quota be checked up front. The bytes are then streamed to a route that only accepts them into
that reservation. Completing the upload verifies the length, an optional checksum and the
detected media type, and queues a scan. Only after the scan returns clean can the
attachment ID be used on a message. Every step is addressed by ID; there are no pre-signed URLs
and no public object links anywhere in the system.

### Limits

| Limit | Value | Error when exceeded
| Bytes per file | 10 MiB | `payload_too_large` (413)
| Files per message | 10, and they must be distinct | `invalid_request` (400)
| Aggregate bytes per message | 15 MiB | `payload_too_large` (413)
| Filename | 255 characters on input; stored safe name capped at 180 | `invalid_request` (400)
| Reserved upload lifetime | 24 hours | The reservation is reaped and the bytes released
| Test-mode storage per workspace | 10 MiB, shared with message content | `test_limit_exceeded` (403)
| Free live storage per workspace | 100 MiB | `payment_required` or `quota_exceeded` (402)
| Inbound raw MIME accepted | 25 MiB for the whole message | The message is not ingested

The combined encoded message must also fit within the message limits, so several individually
valid files can still be too large together. Reserving counts the declared size
against your storage immediately; completing the upload with fewer bytes than declared releases
the difference.

### Sending a file from the console

1. Open Compose in the intended inbox and select your files.
2.
Wait for upload completion, then use **Check scan status** if a file is still
pending.

3. Review recipients, content and files together before submitting.
4.
In received conversations, download only files marked available. Pending, blocked and
quarantined files cannot be downloaded.

### Sending a file over the API

#### 1. Reserve the upload

Requires `attachments:write` and access to the target inbox. The inbox must be
active and in this project, or you get `not_found`.

```
POST /v1/projects/{project_id}/uploads
{
  "inbox_id": "ibx_8e7f6a5b-...",
  "filename": "receipt.pdf",
  "declared_bytes": 12345,
  "media_type": "application/pdf"
}
```

```
HTTP/1.1 201 Created

{
  "id": "upl_1c9d...",
  "attachment_id": "att_1c9d...",
  "upload_url": "/v1/projects/prj_.../uploads/upl_1c9d.../content",
  "expires_at": "2026-09-22T09:14:02.118Z",
  "scan_status": "pending",
  "status": "staging",
  "bytes": 12345,
  "filename": "receipt.pdf"
}
```

Declare the byte count accurately: it is a reservation, and the upload route refuses anything
larger. Note that the response already contains the `att_` ID you will eventually
put on the message — it is not usable yet.

#### 2. Stream the bytes, then complete

```
PUT /v1/projects/{project_id}/uploads/{upload_id}/content
Authorization: Bearer YOUR_API_KEY
Content-Type: application/pdf

<raw bytes>

POST /v1/projects/{project_id}/uploads/{upload_id}/complete
{ "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" }
```

Both calls are authenticated. Completion is where the file is actually checked, and it can
fail for four distinct reasons, all `400` except the size one:

- **No content, or zero bytes.** The `PUT` never arrived.
- **Stored size exceeds the reservation** — `payload_too_large`.
- **The `sha256` you supplied does not match the streamed bytes.**
Supplying it is optional and worth doing: it turns a silent truncation into an error.

- **The declared media type is incompatible with the detected one** (below).

#### 3. Wait for a clean scan

Completion sets the attachment to `ready` with `scan_status: "pending"`
and queues an isolated scan. Poll
`GET /v1/projects/{project_id}/uploads/{upload_id}` until
`scan_status` settles. There is no callback for this today.

#### 4. Attach the ID to a message

```
POST /v1/projects/{project_id}/inboxes/{inbox_id}/messages
Idempotency-Key: 6f2b...

{
  "to": [{ "email": "you@example.com" }],
  "subject": "Your receipt",
  "text": "Attached.",
  "attachment_ids": ["att_1c9d..."]
}
```

Including `attachment_ids` also requires `attachments:read`. Each ID must
belong to the sending inbox — knowing an ID from another inbox is not enough to attach it. At
send time the filename, size, checksum and content type are snapshotted onto the message, and
the send job re-reads the bytes and re-verifies size and digest before submitting. If the
stored content has changed underneath, the send fails rather than transmitting something other
than what was authorised.

**Do not resubmit a message with different attachments under an existing idempotency key.** Removing or swapping a file changes the canonical payload, so the key no
longer matches and you will get `idempotency_conflict`. That is correct: you are
authorising a different message. Mint a new key.

### Status and scan status

Two independent fields, and both must be right before a file moves anywhere.

| `status` | Meaning
| `staging` | Reserved, awaiting bytes or completion. Expires after 24 hours.
| `ready` | Bytes verified and stored. Still not sendable until the scan is clean.
| `rejected` | The scan quarantined or blocked it. Terminal.
| `purged` | The content has been removed. Terminal.

| `scan_status` | Meaning | Sendable / downloadable
| `pending` | Queued or in progress. | No
| `clean` | The scanner returned clean. | Yes
| `quarantined` | The scanner identified it as malicious. | No, permanently
| `blocked` | Refused without a verdict — for example, larger than the scanner will accept. | No
| `scan_failed` |
The scan could not complete: the stored bytes were missing or had changed, or the file
sat unscanned past its half-hour window.
| No

Note that `scan_failed` is not a soft pass. **If scanning is unavailable, the file stays unavailable.** Never treat a timeout as a clean result; the API will not,
and a send referencing that attachment is refused with `attachment_not_ready`
(HTTP 422, non-retryable).

The scanner is a signature-based engine running in isolation from the API, and it is exercised
end to end in test mode: upload a file containing the standard EICAR antivirus test string and
it comes back `quarantined`, which is a safe way to prove your error handling works
without going anywhere near real malware.

### Filenames and media types

The declared media type is checked against one detected from the leading bytes — PNG, JPEG,
GIF, PDF and ZIP signatures, plus an HTML sniff over the first 256 characters. The rule is
deliberately narrow:

- `application/octet-stream` as the declared type is always accepted.
- An exact match is accepted.
- Any `text/*` declared against any `text/*` detected is accepted.
Everything else is rejected. Declaring `image/png` and uploading an HTML document
fails at completion, which is the point: the stored type is what a recipient's client will
be told.

Filenames are sanitised, not rejected. Path separators and control characters become
underscores, `..` sequences are neutralised, leading dots are replaced and the
result is capped at 180 characters; an empty result becomes `attachment`. The safe
name is what is stored, what is sent, and what comes back in `filename` — so check
the response if the exact name matters to you.

### Downloading a received file

Message metadata lists each attachment with its ID, safe filename, size, content type, scan
status and a `downloadable` flag that is true only for a clean scan. Inline images
additionally carry a disposition and a content ID so they can be matched to
`cid:` references in the sanitised HTML body.

```
GET /v1/projects/{project_id}/attachments/{attachment_id}/download
```

Four things happen on that request:

1. **Authorisation.** `attachments:read`, and the attachment's inbox
must be inside your grant.

2. **Scan gate.** Anything other than `clean`, or a status of
`rejected` or `purged`, returns `attachment_not_ready`.

3. **Integrity re-check.** The stored bytes are re-hashed and compared with the
digest recorded at completion. A mismatch is refused, not served.

4. **Safe headers.** The response carries
`content-disposition: attachment` with the safe filename and
`x-content-type-options: nosniff`, so a browser downloads it rather than
rendering it.

There is no public object-store URL to leak, and no unauthenticated variant of this route.

### What a clean scan does and does not mean

A clean scan means a signature-based engine recognised nothing known-malicious in those bytes
at that moment. It does not mean:

- **that the file is safe to open automatically.** Novel malware has no signature
yet, by definition.

- **that instructions inside the document are trustworthy.** A PDF or spreadsheet
that tells your agent to email its contents somewhere is untrusted input, exactly like the
email body that carried it. Nothing here makes an agent resistant to that; apply your own
permissions before acting, and keep
[recipient controls and human approval](https://emailforagents.ai/docs/sending-controls) in front of
anything consequential.

- **that the sender is who they claim to be.** Authentication of the sending
domain is a separate question — see [custom domains and DNS](https://emailforagents.ai/docs/domains).

Related: [finding the message a file arrived on](https://emailforagents.ai/docs/search),
[requiring human approval when a message carries attachments](https://emailforagents.ai/docs/sending-controls), and [what an export includes about attachments (metadata only, never the bytes)](https://emailforagents.ai/docs/data-controls).

Longer form: [rehearsing the whole flow in a test project](https://emailforagents.ai/blog/test-agent-email-without-sending).

[Previous Custom domains](https://emailforagents.ai/docs/domains) [Next Search & pagination](https://emailforagents.ai/docs/search)
