Skip to content
Skip to main content

How to read and download email attachments

When a message arrives with files attached — a PDF invoice, a screenshot, a signed contract — your app usually needs to do something with the bytes: save them to storage, run OCR over them, pipe them to another service. This recipe covers the full path from “I have a message” to “I have a file on disk.”

It works the same way for normal user grants and for Agent Accounts. The endpoints are identical; only the grant ID changes.

You’ll need:

  • A Nylas application with a valid API key.
  • A grant for the mailbox you want to read attachments from. This can be a normal user grant (Google, Microsoft, IMAP, etc.) or an Agent Account grant.
  • Read scope on the provider. Google needs at least gmail.readonly. Microsoft needs Mail.Read. Agent Accounts have the right access by default.

Step 1: Find the attachments on the message

Section titled “Step 1: Find the attachments on the message”

Attachments aren’t a separate resource you list — they live on the message object. Fetch the message and read the attachments array:

A few things to notice on each attachment entry:

  • id — what you’ll pass to the download endpoint.
  • size — bytes. Use this to decide if you want to download synchronously or push to a background job.
  • is_inlinetrue means the file is referenced from inside the message body (typically images via <img src="cid:...">). false means it’s a standalone attachment.
  • content_id — present on inline parts. This is what’s referenced from cid: URLs in the HTML body.

Step 2: Get attachment metadata on its own (optional)

Section titled “Step 2: Get attachment metadata on its own (optional)”

If you already have an attachment ID stored from earlier and just want metadata — without re-fetching the message — call the metadata endpoint directly:

The endpoint requires the message_id query parameter even when fetching by attachment ID. Attachments are scoped to the message they live on — the same file attached to two messages has two attachment IDs.

Use the download endpoint to fetch the actual bytes. The response is the raw file, not JSON — pipe it to disk or to whatever buffer your code uses.

The curl example uses --compressed, which is worth keeping — Nylas serves attachment downloads gzipped when the client asks. For large files this matters.

Inline images aren’t separate from the message body — they’re referenced from inside the HTML. If you’re rendering the message somewhere (a custom email viewer, a CRM activity feed, a PDF export), you need to either inline the bytes as data URLs or rewrite the cid: references to URLs your renderer can resolve.

The pattern most apps use:

  1. Read the message body and find every cid:CONTENT_ID reference.
  2. For each, find the matching attachment by content_id.
  3. Download the bytes and either base64-encode them inline as a data: URL or upload them to your own storage and rewrite the src attribute.
function inlineImagesAsDataUrls(html, attachments, bytesByCid) {
// Inline images reference content_id with <cid:something@whatever>.
// The Content-ID values may or may not be wrapped in angle brackets.
return html.replace(/cid:([^"'\s>]+)/g, (match, cid) => {
const att = attachments.find(
(a) => a.is_inline && stripBrackets(a.content_id) === stripBrackets(cid),
);
if (!att) return match;
const bytes = bytesByCid[stripBrackets(att.content_id)];
if (!bytes) return match;
const base64 = bytes.toString("base64");
return `data:${att.content_type.split(";")[0]};base64,${base64}`;
});
}
function stripBrackets(s) {
return s.replace(/^<|>$/g, "");
}

Data URLs work everywhere but bloat the HTML — they’re fine for rendering once and discarding. If you’re persisting the message (saving to a database, exporting to PDF on a schedule), upload the bytes to your own object storage and rewrite the src attributes to those URLs instead.

For Agent Accounts, the workflow is identical. Substitute the agent’s grant ID and use the same endpoints. The most common difference in practice is when you trigger the download:

  • Webhook-driven. Subscribe to message.created. When an inbound message has has_attachments: true (on the thread or on the message), fetch attachments and process them. Don’t try to use the webhook payload’s body directly — fetch the full message via the API. See Handle email replies in an agent loop for the inbound-handling pattern.
  • Bulk replay. If your agent processes a batch of mail on a schedule (for example, daily invoice extraction), iterate over messages with has_attachments=true and pull files for the ones it hasn’t seen before.

The Agent Account grant has the scopes it needs by default. You don’t need to manage provider OAuth.

  • message_id is required on the download endpoint. Even if you have the attachment ID alone, the endpoint will return an error without it. Cache (message_id, attachment_id) together when you store references.
  • Attachment IDs are not portable across grants. An attachment ID generated for grant A doesn’t work against grant B, even if it’s the same physical file. They’re scoped to the grant and message they came from.
  • Provider differences in IDs. Google attachment IDs are stable for the lifetime of the message. Microsoft attachment IDs are also stable but tend to be longer base64 strings. Either way, treat them as opaque — don’t parse or modify them.
  • Cloud-storage “attachments” aren’t attachments. Google Drive and Microsoft OneDrive files that show up in the Gmail or Outlook UI as “attachments” are actually links inside the message body, not file content on the message. Nylas reports them as part of the body (an <a> tag), not in the attachments array. To process them, your app needs separate Drive or OneDrive access.
  • Beware of size. A 25MB attachment is the upper limit most providers allow. If you’re downloading attachments in a request handler, push that work to a background job for anything above ~1MB — otherwise you’ll tie up the request thread and risk timeouts.
  • MIME type can be unreliable. Some senders set application/octet-stream for everything. Don’t trust content_type alone for security decisions — check the actual file bytes (magic numbers) before parsing or executing.