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.
Before you begin
Section titled “Before you begin”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 needsMail.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:
curl --compressed --request GET \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/<MESSAGE_ID>' \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --header 'Content-Type: application/json'{ "request_id": "041d52e4-fe02-49d9-aeb6-3f0987654321", "data": { "id": "<MESSAGE_ID>", "subject": "Q3 invoice", "attachments": [ { "id": "<ATTACHMENT_ID_1>", "filename": "invoice-q3.pdf", "content_type": "application/pdf", "size": 184250, "grant_id": "<NYLAS_GRANT_ID>", "is_inline": false, "content_disposition": "attachment; filename=\"invoice-q3.pdf\"" }, { "id": "<ATTACHMENT_ID_2>", "filename": "signature.png", "content_type": "image/png; name=\"signature.png\"", "size": 4452, "grant_id": "<NYLAS_GRANT_ID>", "is_inline": true, "content_id": "ii_123456789", "content_disposition": "inline; filename=\"signature.png\"" } ] }}import Nylas from "nylas";
const nylas = new Nylas({ apiKey: "<NYLAS_API_KEY>", apiUri: "<NYLAS_API_URI>",});
async function fetchMessageById() { try { const message = await nylas.messages.find({ identifier: "<NYLAS_GRANT_ID>", messageId: "<MESSAGE_ID>", });
console.log("message:", message); } catch (error) { console.error("Error fetching message:", error); }}
fetchMessageById();from nylas import Client
nylas = Client( "<NYLAS_API_KEY>", "<NYLAS_API_URI>")
grant_id = "<NYLAS_GRANT_ID>"message_id = "<MESSAGE_ID>"
message = nylas.messages.find( grant_id, message_id,)
print(message)require 'nylas'
nylas = Nylas::Client.new( api_key: "<NYLAS_API_KEY>")
message, _ = nylas.messages.find(identifier: "<NYLAS_GRANT_ID>", message_id: "<MESSAGE_ID>")
puts messageimport com.nylas.NylasClient;import com.nylas.models.*;
public class ReturnMessage { public static void main(String[] args) throws NylasSdkTimeoutError, NylasApiError { NylasClient nylas = new NylasClient.Builder("<NYLAS_API_KEY>").build();
Response<Message> message = nylas.messages().find("<NYLAS_GRANT_ID>", "<MESSAGE_ID>"); System.out.println(message); }}// Import Nylas packagesimport com.nylas.NylasClient
fun main(args: Array<String>) {
// Initialize Nylas client val nylas: NylasClient = NylasClient( apiKey = "<NYLAS_API_KEY>" )
val message = nylas.messages().find( "<NYLAS_GRANT_ID>", "<MESSAGE_ID>")
println(message)}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_inline—truemeans the file is referenced from inside the message body (typically images via<img src="cid:...">).falsemeans it’s a standalone attachment.content_id— present on inline parts. This is what’s referenced fromcid: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:
curl --compressed --request GET \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/attachments/<ATTACHMENT_ID>?message_id=<MESSAGE_ID>' \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>'import Nylas from "nylas";
const nylas = new Nylas({ apiKey: "<NYLAS_API_KEY>", apiUri: "<NYLAS_API_URI>",});
async function fetchAttachmentById() { try { const attachment = await nylas.attachments.find({ identifier: "<NYLAS_GRANT_ID>", attachmentId: "<ATTACHMENT_ID>", queryParams: { messageId: "<MESSAGE_ID>", }, });
console.log("Attachment:", attachment); } catch (error) { console.error("Error fetching attachment:", error); }}
fetchAttachmentById();from nylas import Client
nylas = Client( "<NYLAS_API_KEY>", "<NYLAS_API_URI>")
grant_id = "<NYLAS_GRANT_ID>"folder_id = "<FOLDER_ID>"attachment_id = "<ATTACHMENT_ID>"
attachment = nylas.attachments.find( grant_id, attachment_id, query_params= { "message_id": "<MESSAGE_ID>", })
print(attachment)require 'nylas'
nylas = Nylas::Client.new( api_key: "<NYLAS_API_KEY>")
query_params = { message_id: "<MESSAGE_ID>"}
attachment = nylas.attachments.find(identifier: "<NYLAS_GRANT_ID>",attachment_id: "<ATTACHMENT_ID>", query_params: query_params)
puts attachmentimport com.nylas.NylasClient;import com.nylas.models.*;
public class attachment { public static void main(String[] args) throws NylasSdkTimeoutError, NylasApiError, NylasOAuthError { NylasClient nylas = new NylasClient.Builder("NYLAS_API_KEY").build(); FindAttachmentQueryParams queryParams = new FindAttachmentQueryParams("<MESSAGE_ID>"); Attachment attachment = nylas.attachments().find("<NYLAS_GRANT_ID>", "<ATTACHMENT_ID>", queryParams).getData();
System.out.println(attachment); }}import com.nylas.NylasClientimport com.nylas.models.FindAttachmentQueryParams
fun main(args: Array<String>) { val nylas: NylasClient = NylasClient( apiKey = "<NYLAS_API_KEY>" )
val queryParams = FindAttachmentQueryParams("<MESSAGE_ID>") val attachment = nylas.attachments().find("<NYLAS_GRANT_ID>", "<ATTACHMENT_ID>", queryParams)
print(attachment)}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.
Step 3: Download the binary content
Section titled “Step 3: Download the binary content”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.
curl --compressed --request GET \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/attachments/<ATTACHMENT_ID>/download?message_id=<MESSAGE_ID>' \ --header 'Authorization: Bearer <NYLAS_API_KEY>'import fs from "fs";import Nylas from "nylas";
const nylas = new Nylas({ apiKey: "<NYLAS_API_KEY>", apiUri: "<NYLAS_API_URI>",});
async function downloadAttachment() { try { const attachmentBytes = await nylas.attachments.downloadBytes({ identifier: "<NYLAS_GRANT_ID>", attachmentId: "<ATTACHMENT_ID>", queryParams: { messageId: "<MESSAGE_ID>", }, });
const fileName = "attachment"; await fs.promises.writeFile(fileName, attachmentBytes); console.log(`File saved as ${fileName}`); } catch (error) { console.error("Error fetching attachment:", error); }}
downloadAttachment();from nylas import Client
nylas = Client( "<NYLAS_API_KEY>", "<NYLAS_API_URI>")
grant_id = "<NYLAS_GRANT_ID>"attachment_id = "<ATTACHMENT_ID>"
attachment = nylas.attachments.download( grant_id, attachment_id, query_params= { "message_id": "<MESSAGE_ID>", })
with open("attachment", 'wb') as f: f.write(attachment.content)require 'nylas'
nylas = Nylas::Client.new( api_key: "<NYLAS_API_KEY>")
query_params = { message_id: "<MESSAGE_ID>"}
attachment = nylas.attachments.download(identifier: "<NYLAS_GRANT_ID>", attachment_id: "<ATTACHMENT_ID>", query_params: query_params)
File.open("./image.png", "wb") do |file| file.write(attachment)endimport com.nylas.NylasClient;import com.nylas.models.*;import okhttp3.ResponseBody;import java.io.FileNotFoundException;import java.io.FileOutputStream;import java.io.IOException;
public class attachment_download { public static void main(String[] args) throws NylasSdkTimeoutError, NylasOAuthError, IOException { NylasClient nylas = new NylasClient.Builder("<NYLAS_API_KEY>").build(); FindAttachmentQueryParams queryParams = new FindAttachmentQueryParams("<MESSAGE_ID>"); ResponseBody attachment = nylas.attachments().download("<NYLAS_GRANT_ID>", "<ATTACHMENT_ID>", queryParams);
try { FileOutputStream out = new FileOutputStream("src/main/resources/image.png");
out.write(attachment.bytes()); out.close(); } catch (FileNotFoundException e) { System.out.println("File not found"); } }}import com.nylas.NylasClientimport com.nylas.models.FindAttachmentQueryParamsimport java.io.File
fun main(args: Array<String>) { val nylas: NylasClient = NylasClient( apiKey = "<NYLAS_API_KEY>" )
val queryParams = FindAttachmentQueryParams("<MESSAGE_ID>") val attachment = nylas.attachments().download("<NYLAS_GRANT_ID>", "<ATTACHMENT_ID>", queryParams)
File("src/main/resources/Image.png").writeBytes(attachment.bytes())}The curl example uses --compressed, which is worth keeping — Nylas serves attachment downloads gzipped when the client asks. For large files this matters.
Step 4: Handle inline images
Section titled “Step 4: Handle inline images”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:
- Read the message body and find every
cid:CONTENT_IDreference. - For each, find the matching attachment by
content_id. - Download the bytes and either base64-encode them inline as a
data:URL or upload them to your own storage and rewrite thesrcattribute.
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.
The Agent Accounts variant
Section titled “The Agent Accounts variant”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 hashas_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=trueand 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.
Things to know
Section titled “Things to know”message_idis 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 theattachmentsarray. 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-streamfor everything. Don’t trustcontent_typealone for security decisions — check the actual file bytes (magic numbers) before parsing or executing.
What’s next
Section titled “What’s next”- Send messages with attachments — the outbound side of the same flow
- Using the Attachments API — the canonical reference for everything attachment-related
- Attachments API reference — endpoint specs
- Messages API reference — for finding the messages that hold the attachments
- Handle email replies in an agent loop — the webhook-driven inbound pattern for Agent Accounts