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:

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 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:

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.