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
- Bun 1.x installed (
curl -fsSL https://bun.sh/install | bash) - A MailAnvil account (free tier: 500 emails/month at mailanvil.com)
- An API key from the MailAnvil dashboard
- A verified sending domain
How attachments work in the MailAnvil API
Two ways to attach a file — exactly one per attachment:
content— the file, base64-encoded, sent inline in the JSON body. Best for files you already have on disk (invoice PDFs, CSV exports).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
- Templates — store the invoice HTML in MailAnvil and send with
template_id+template_datainstead of inline HTML - Webhooks — track
delivered/bouncedevents so failed invoice deliveries get flagged automatically - Dunning: if invoices go unpaid, see handling failed payment emails