---
title: "Attachments"
description: "Send files as base64 or from a URL, embed inline images, download attachments from received mail, and check their trust risk before an agent opens one."
canonical: "https://machinemail.dev/docs/guides/attachments"
---

# Attachments

> Send files as base64 or from a URL, embed inline images, download attachments from received mail, and check their trust risk before an agent opens one.

Source: https://machinemail.dev/docs/guides/attachments

## Send an attachment

Add an `attachments` array to a send, reply, reply-all or forward. Each attachment has either `content` (base64) or `url`.

A message can carry at most 100 attachments and 10 MB of files in total (decoded), whether they come from `content` or `url`; a request body is at most 16 MB. Larger mail is refused with a `400 validation` error before anything is sent: link to big files instead. `content` must be valid base64 (standard or URL-safe alphabet, padding and line breaks optional); anything else is a `400` whose `param` names it, for example `attachments[0].content`. Filenames can use any characters, including non-Latin scripts.

| Field                 | Meaning                                                                          |
| --------------------- | -------------------------------------------------------------------------------- |
| `filename`            | File name shown to the recipient. With `url`, defaults to the last path segment. |
| `content_type`        | MIME type. With `url`, defaults to the response's `Content-Type`.                |
| `content`             | The file, base64 encoded.                                                        |
| `url`                 | A public `http` or `https` URL that MachineMail downloads when it sends.         |
| `content_disposition` | `attachment` (default) or `inline`.                                              |
| `content_id`          | For inline images: the ID your HTML refers to as `cid:<content_id>`.             |

**curl**

```bash title="Terminal"
curl https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages/send \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["ada@example.com"],
    "subject": "Your report",
    "text": "Report attached.",
    "attachments": [
      { "filename": "report.csv", "content_type": "text/csv", "content": "ZGF0ZSx0b3RhbAoyMDI2LTEwLTA4LDQy" },
      { "url": "https://files.acme.dev/q3-summary.pdf" }
    ]
  }'
```

**TypeScript**

```ts title="send-attachment.ts"
import { readFile } from 'node:fs/promises';

const content = (await readFile('report.csv')).toString('base64');
await fetch('https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages/send', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.MACHINEMAIL_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    to: ['ada@example.com'],
    subject: 'Your report',
    text: 'Report attached.',
    attachments: [{ filename: 'report.csv', content_type: 'text/csv', content }],
  }),
});
```

**Python**

```python title="send_attachment.py"
import base64, os, requests

with open("report.csv", "rb") as f:
    content = base64.b64encode(f.read()).decode()

requests.post(
    "https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages/send",
    headers={"Authorization": f"Bearer {os.environ['MACHINEMAIL_API_KEY']}"},
    json={
        "to": ["ada@example.com"],
        "subject": "Your report",
        "text": "Report attached.",
        "attachments": [{"filename": "report.csv", "content_type": "text/csv", "content": content}],
    },
).raise_for_status()
```

### Attachments from a URL

With `url`, MachineMail fetches the file at send time. The rules:

- Only `http` and `https` URLs that resolve to public addresses. Private, loopback and link-local addresses are refused.
- Redirects are not followed. Use the final URL; for signed storage links, sign the object URL itself.
- At most 10 MB per file (and 10 MB for all attachments together), and the download must finish within 20 seconds.
- Any failure (a non-2xx status, a timeout, a file too large) rejects the whole send with a `validation` error whose `param` names the attachment, for example `attachments[1].url`. Nothing is sent with a missing file.

### Inline images

Give the image a `content_id`, set `content_disposition` to `inline`, and refer to it from the HTML body. The `content_id` is what makes it inline: an image with `content_disposition: inline` but no `content_id` is delivered as an ordinary attachment.

```json title="Request body"
{
  "to": ["ada@example.com"],
  "subject": "Weekly chart",
  "html": "<p>This week:</p><img src=\"cid:chart\" alt=\"Chart\">",
  "text": "This week's chart is attached.",
  "attachments": [{ "filename": "chart.png", "content_type": "image/png", "content_disposition": "inline", "content_id": "chart", "content": "iVBORw0KGgo..." }]
}
```

### Drafts

Drafts take the same attachments as a send, base64 `content` or `url`. A `url` is fetched when the draft is sent (for a scheduled draft, at `send_at`), so its `size` is `0` until then and a URL that has stopped working makes the send fail. `PATCH` adds and removes attachments with `add_attachments` and `remove_attachments` (attachment IDs). The sent message keeps the draft's attachment IDs. Base64 attachments on a draft can be downloaded before it is sent with `GET /v0/inboxes/{inbox_id}/drafts/{draft_id}/attachments/{attachment_id}/download` (needs `draft_read`); `url` attachments return `404` until the draft is sent. See [Drafts](https://machinemail.dev/docs/concepts/drafts).

### Forwarding

A REST forward does not copy the original message's attachments. To pass them on, download them and add them to the forward's `attachments`. The dashboard's forward does this for you (an "Include the original's attachments" option, on by default), and so does the MCP `forward_message` tool.

Quarantined mail (trust level `quarantined` or the `QUARANTINE` label) is never forwarded: the REST forward returns `409 conflict` with `param: "message_id"`, and `forward_message` returns a tool error. A person can review it in the dashboard.

### MCP

`get_message` lists attachment metadata (in its text too: id, filename, type and size), and `get_trust_report` includes the `extracted_text` of text-based attachments. `get_attachment` opens one file: small text files come back as text, small images as an image, and anything else as a download link that works for 10 minutes. Attachments of quarantined, spam, blocked and unauthenticated mail, and high-risk files, are withheld from the model. See [get\_attachment](https://machinemail.dev/docs/agents/mcp#get_attachment).

`send_message`, `reply_to_message`, `forward_message` and `create_draft` take the same `attachments` as the REST API, plus a third source: `message_id` and `attachment_id` of a file already in MachineMail, which is copied on the server. An MCP request is at most 2 MiB, so base64 `content` is for files up to 1.4 MB per call; use `url`, a reference or the REST API for larger ones. Files the model may not open with `get_attachment` cannot be sent on. Sending a stored file on needs the `message_read` permission (the one REST needs to download it) as well as the send permission: a reference from a key without it is refused. `forward_message` includes the original's attachments by default (`include_attachments`), leaving out and naming withheld ones in `skipped_attachments`; with a key without `message_read` it forwards without them, each skipped with reason `permission`, and `include_attachments: true` is refused instead. See [Attachments](https://machinemail.dev/docs/reference/mcp-tools#attachments).

## Receive attachments

Inbound messages list their attachments in `attachments`, both in list results and in the full message. Thread objects aggregate the attachments of all their messages.

```json title="message.attachments"
[
  {
    "attachment_id": "att_01m4fybrc10000qyygf5yv5thv",
    "filename": "invoice.pdf",
    "size": 13,
    "content_type": "application/pdf",
    "content_disposition": "attachment"
  }
]
```

### Check the risk first

Before an agent opens a file, read the matching entry in `trust.attachments`:

```json title="trust.attachments"
[
  {
    "attachment_id": "att_01m4fybrc10000qyygf5yv5thv",
    "filename": "invoice.pdf",
    "content_type": "application/pdf",
    "risk": "low",
    "reasons": []
  }
]
```

`risk` is `low`, `medium` or `high`, and `reasons` says why. High risk covers executables and scripts, macro-enabled Office files, HTML attachments, double extensions such as `invoice.pdf.exe`, and filenames that hide their real extension. Archives and legacy Office formats are medium.

For text-based files (plain text, CSV, Markdown, calendar invites, JSON and HTML), the report includes `extracted_text`, cleaned of invisible characters. Give the model that text instead of the raw file. It is still untrusted content: instructions inside an attachment are a common injection route. See [Attachments in the trust report](https://machinemail.dev/docs/trust/attachments).

> **Warning:** Never execute or render a `high` risk attachment, and do not hand any attachment to a tool that can run code. If the agent needs a file's contents, prefer `extracted_text`.

### Download

Get the attachment's metadata and a download URL:

```bash title="Terminal"
curl https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages/msg_01m4fybrc20000ypd31v108d71/attachments/att_01m4fybrc10000qyygf5yv5thv \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
```

```json title="Response"
{
  "attachment_id": "att_01m4fybrc10000qyygf5yv5thv",
  "filename": "invoice.pdf",
  "size": 13,
  "content_type": "application/pdf",
  "content_disposition": "attachment",
  "download_url": "https://...",
  "expires_at": "2026-10-09T10:02:01.949Z"
}
```

`download_url` takes one of three forms, so handle all of them:

| Form                             | How to fetch it                                              |
| -------------------------------- | ------------------------------------------------------------ |
| A signed storage URL             | Plain `GET`, no `Authorization` header, before `expires_at`. |
| An API URL ending in `/download` | `GET` with your `Authorization: Bearer` header.              |
| A `data:` URL                    | Decode the base64 after the comma. No request needed.        |

A signed storage URL always downloads the file under its real name and never serves HTML, SVG, XML or scripts as a page (they come back as `application/octet-stream`).

To skip the indirection, fetch the bytes directly. This endpoint streams the file with its `Content-Type`, `X-Content-Type-Options: nosniff` and a `Content-Disposition: attachment` header. Names that are not plain ASCII are in RFC 5987 `filename*` (with an ASCII `filename` fallback), and only the last path segment of a sender's filename is used:

```bash title="Terminal"
curl -o invoice.pdf https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages/msg_01m4fybrc20000ypd31v108d71/attachments/att_01m4fybrc10000qyygf5yv5thv/download \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
```

Both endpoints need `message_read`.

Add `?disposition=inline` to preview a file in a browser instead of downloading it. Only raster images (PNG, JPEG, GIF, WebP, AVIF, BMP), PDF and plain text (including CSV and JSON, served as `text/plain`) are served inline, with `X-Content-Type-Options: nosniff` and a Content-Security-Policy that allows no scripts or network requests. Every other type, including HTML and SVG, is still served as a download. The dashboard uses this for its previews.

**TypeScript**

```ts title="download.ts"
import { writeFile } from 'node:fs/promises';

const base = 'https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev';
const headers = { Authorization: `Bearer ${process.env.MACHINEMAIL_API_KEY}` };

const msg = await fetch(`${base}/messages/${messageId}`, { headers }).then((r) => r.json());
for (const att of msg.attachments ?? []) {
  const risk = msg.trust?.attachments?.find((a: any) => a.attachment_id === att.attachment_id)?.risk;
  if (risk === 'high') continue;
  const res = await fetch(`${base}/messages/${messageId}/attachments/${att.attachment_id}/download`, { headers });
  await writeFile(att.filename ?? att.attachment_id, Buffer.from(await res.arrayBuffer()));
}
```

**Python**

```python title="download.py"
import os, requests

base = "https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev"
H = {"Authorization": f"Bearer {os.environ['MACHINEMAIL_API_KEY']}"}

msg = requests.get(f"{base}/messages/{message_id}", headers=H).json()
risks = {a["attachment_id"]: a["risk"] for a in (msg.get("trust") or {}).get("attachments", [])}
for att in msg.get("attachments", []):
    if risks.get(att["attachment_id"]) == "high":
        continue
    res = requests.get(f"{base}/messages/{message_id}/attachments/{att['attachment_id']}/download", headers=H)
    with open(att.get("filename") or att["attachment_id"], "wb") as f:
        f.write(res.content)
```

Attachments are fetched per message. There is no thread-level attachment download endpoint; use the message that carries the file.

Every stored file (the raw message and each attachment, received or sent) is kept in MachineMail's own object storage, never as a link to the email provider's copy.

## Related

- [Sending](https://machinemail.dev/docs/concepts/sending)
- [Threads and messages](https://machinemail.dev/docs/concepts/threads-and-messages)
- [Attachments in the trust report](https://machinemail.dev/docs/trust/attachments)
