← Back to all posts
2026-09-21 · MailAnvil Team

Send Email with Attachments in Bun + MailAnvil

Bun is a fast, batteries-included JavaScript runtime — a great fit for backend jobs that need to send email: nightly reports, invoice PDFs, export files. Bun's fetch and Bun.file() make attachments almost trivial because you don't need an SDK.

In this tutorial you'll build a small script that sends an invoice email with a PDF attachment using the MailAnvil API. Works in under 40 lines, no dependencies.

Prerequisites

How attachments work in the MailAnvil API

Two ways to attach a file — exactly one per attachment:

  1. content — the file, base64-encoded, sent inline in the JSON body. Best for files you already have on disk (invoice PDFs, CSV exports).
  2. path — a public URL the API fetches for you (SSRF-guarded). Best for files already hosted somewhere (S3/R2 objects, CDN links).

Limits: max 10 attachments, 25 MB combined. content_type is optional — the API falls back to application/octet-stream.

Project Setup

mkdir mailanvil-attachments && cd mailanvil-attachments
bun init

Store your API key in .env:

MAILANVIL_KEY=re_your_api_key_here

Send an email with a base64 PDF attachment

Create send-invoice.ts:

const API = "https://api.mailanvil.com/v1/send";
const key = process.env.MAILANVIL_KEY;

if (!key) throw new Error("MAILANVIL_KEY not set");

// Read the PDF and base64-encode it — no external library needed
const pdf = Bun.file("invoice-2026-09.pdf");
const pdfBase64 = Buffer.from(await pdf.arrayBuffer()).toString("base64");

const res = await fetch(API, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${key}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "[email protected]",
    to: ["[email protected]"],
    subject: "Invoice #2026-09-014 — PT Contoh Jaya",
    html: "<p>Attached is invoice #2026-09-014 for Rp 4.500.000, due 5 Oct 2026.</p><p>Terima kasih!</p>",
    attachments: [{
      filename: "invoice-2026-09-014.pdf",
      content: pdfBase64,
      content_type: "application/pdf",
    }],
  }),
});

if (!res.ok) {
  console.error("Send failed:", await res.text());
  process.exit(1);
}

const data = await res.json();
console.log("Sent:", data.id);

Run it:

bun run send-invoice.ts

The Bun.file() + Buffer.from(...).toString("base64") pattern is the whole trick. It works for any file type: PDFs, PNGs, CSVs, ZIPs. For multiple attachments, push more objects into the attachments array and stay under 25 MB combined.

Attach by URL instead

If the file already lives at a public URL, skip the base64 step:

attachments: [{
  filename: "report-september.csv",
  path: "https://r2.yourdomain.com/reports/report-september.csv",
}]

The API fetches the URL server-side, enforces the same 25 MB cap, and stores the bytes before queuing the send. Useful when the report is generated by another service and you don't want to download it just to re-upload it.

Retry safely with an Idempotency-Key

Send with an idempotency key so a network retry never double-sends an invoice:

headers: {
  "Authorization": `Bearer ${key}`,
  "Content-Type": "application/json",
  "Idempotency-Key": `invoice-2026-09-014`,
},

Same key + same body within the dedup window returns the original response instead of sending again.

Wrapping it in a scheduled job

Nightly invoice runs are a natural fit for bun cron:

# crontab — send unpaid invoices every night at 02:00
0 2 * * * cd /srv/mailanvil-attachments && MAILANVIL_KEY=re_xxx bun run send-invoices.ts >> /var/log/invoices.log 2>&1

Next steps