attachments.md
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
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
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
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
httpandhttpsURLs 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
validationerror whoseparamnames the attachment, for exampleattachments[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.
{
"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.
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.
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.
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.
[
{
"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:
[
{
"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.
Warning: Never execute or render a
highrisk attachment, and do not hand any attachment to a tool that can run code. If the agent needs a file's contents, preferextracted_text.
Download
Get the attachment's metadata and a download URL:
curl https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages/msg_01m4fybrc20000ypd31v108d71/attachments/att_01m4fybrc10000qyygf5yv5thv \
-H "Authorization: Bearer $MACHINEMAIL_API_KEY"
{
"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:
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.
TypeScript
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
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.