Handling email attachments without losing the £ sign
Received email attachments arrive as inline base64 that bloats every webhook, and a charset mismatch turns £ into £. Here's why it happens, how SendGrid Inbound Parse, Cloudflare Email Routing, and self-hosting each leave the decode to you, and what a parsed attachments[] array of signed URLs looks like instead, with runnable Node code.
Here is that whole problem in one picture: the same attached PDF, and everything that has to happen to its bytes before your app can read it, on the DIY side and on the parsed side.
The parsed side is the short one. Here is the entire inbound handler that acts on it. Not a fragment: this runs as pasted on Node 18+, npm install express mailkite.
// server.mjs — the entire inbound attachment handler.
import express from "express";
import { MailKite } from "mailkite";
const app = express();
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;
// Verify the EXACT bytes — a re-serialized object breaks the HMAC.
app.use("/hooks/mailkite", express.raw({ type: "application/json" }));
app.post("/hooks/mailkite", (req, res) => {
const sig = req.headers["x-mailkite-signature"];
if (!MailKite.verifyWebhook(sig, req.body, SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
res.sendStatus(200); // ack fast; senders retry, so don't block on the download
if (event.type === "email.received") {
for (const att of event.attachments ?? []) {
if (att.contentType !== "application/pdf") continue; // only fetch what you need
fetch(att.url) // signed, short-lived URL
.then((r) => r.arrayBuffer())
.then((buf) => {
const bytes = Buffer.from(buf); // real, decoded PDF bytes
console.log("got", att.filename, bytes.length, "bytes");
// …store it, scan it, hand it to your processor.
});
}
}
});
app.listen(3000);
Two rules save real debugging here. Verify against the raw request bytes: JSON round-tripping changes them and breaks the HMAC, which is why the handler acts on req.body (a Buffer) and not a re-parsed object. And ack fast: return 200, then download out of band, because a slow handler earns you a retry and a duplicate. The signed url is a capability, not a permalink, so a leaked webhook log doesn’t hand out your customers’ files forever. MailKite.verifyWebhook does the whole check in one synchronous call: it splits the x-mailkite-signature header (t=<ms>,v1=<hex>), recomputes HMAC-SHA256 over `${t}.${rawBody}` with your secret, enforces a 5-minute freshness window, and compares in constant time. The same handler exists for Python, Ruby, Go, PHP, and Java: see the receiving docs.
Prefer the SDK. If you genuinely can’t take the dependency, that sentence is the whole spec:
// Raw alternative — the x-mailkite-signature header is `t=<ms>,v1=<hex>`.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(header, rawBody, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() - Number(t)) > 5 * 60_000) return false; // freshness window
const mac = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const sent = Buffer.from(v1, "hex");
return mac.length === sent.length && timingSafeEqual(mac, sent);
}
The rest of this post is why attachments are genuinely hard, where the incumbents leave the decode to you, and what that parsed shape looks like on the wire.
Why attachments corrupt in the first place
An email isn’t a document. It’s a tree. A message with a PDF attached is usually multipart/mixed: one branch is multipart/alternative (the text and HTML bodies), another branch is the attachment. To find “the file,” you walk the tree, identify the leaf, read its Content-Transfer-Encoding, and decode accordingly. Miss a step and you get garbage.
Two failure modes dominate, and they’re worth naming because you will hit both:
- The base64 bloat. Attachments are almost always
Content-Transfer-Encoding: base64, inlined directly into the message. Base64 is a ~33% size tax, so a 10 MB PDF lands as ~13 MB of text glued into the payload. If your inbound webhook carries that inline, every attachment inflates the request body, and a big one blows straight through request-size limits. You didn’t ask to move a file through your HTTP handler; the format decided for you. - The
£→£bug. This is the classic, and it’s an encoding lie. The body says one charset in its header; the bytes are actually another.£is0xA3in latin-1 but two bytes (0xC2 0xA3) in UTF-8. Decode UTF-8 bytes as latin-1 and£becomes£. Decode the other direction and you get a replacement diamond. The email swears it’s ASCII while carrying 8-bit bytes. You’re not decoding text; you’re decoding a claim about text, and the claim is often wrong.
413 before your handler ever runs, and it reads like a signature bug. Fetching attachments by url keeps the webhook body tiny and sidesteps the limit entirely.None of this is your app’s problem. It’s plumbing sitting directly between you and the two things you actually wanted: the readable message, and the file.
Where the incumbents leave you holding it
SendGrid Inbound Parse is the one developers cite most here, and fairly: it does deliver inbound mail, and for plenty of simple text emails it’s fine. But it POSTs you multipart/form-data, a form upload rather than JSON. Attachments come back as file parts you pull out of a multipart body, and the encoding of the text fields is a recurring source of the exact £-becomes-garbage bug. The payload shape is under-documented enough that people reverse-engineer it with test emails, and the attachment/charset mangling shows up often enough to be a rite of passage. Credit where due: it exists, it’s battle-tested, and it forwards mail reliably. The problem isn’t reliability: it’s that the parsing and decoding are handed back to you.
Cloudflare Email Routing goes further in the wrong direction for this task: you get the raw MIME and parse it yourself inside an Email Worker, inside a CPU budget that a big base64 attachment will happily exhaust, against a 25 MB message cap. Great edge, wrong altitude for “give me the file.”
Self-hosting Postfix or Haraka means you own every leaf of the tree and every charset guess, plus deliverability as a second full-time job. Total control, total surface area.
| Approach | Inbound payload | Attachments | Who decodes the charset |
|---|---|---|---|
| SendGrid Inbound Parse | multipart/form-data | File parts you pull out | You |
| Cloudflare Email Routing | Raw MIME in an Email Worker | Walk the tree yourself | You |
| Self-host (Postfix / Haraka) | Raw MIME on your box | Every leaf is yours | You |
| MailKite | Parsed JSON webhook | attachments[] of signed URLs | Done at the edge |
The shape is always the same: someone has to own the tree-walk, the decode, and the storage. The only question is whether it’s you.
What it should look like: attachments as a URL, not a blob
We built MailKite’s inbound to parse the entire MIME tree at the edge and POST you one webhook with the message already extracted as JSON. Bodies come decoded; attachments come out of the payload entirely and arrive as a metadata array with a signed url:
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "ada@example.com" },
"to": [{ "address": "support@myapp.ai" }],
"subject": "Re: invoice #1042",
"text": "Looks good — approved!",
"html": "<p>Looks good — approved!</p>",
"threadId": "<a1b2c3@mail.example.com>",
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
"attachments": [
{
"id": "msg_2Hk9…:0",
"filename": "po.pdf",
"contentType": "application/pdf",
"size": 18213,
"url": "https://api.mailkite.dev/att/2Hk9…/0?exp=…&sig=…"
}
]
}
Notice what’s not there:
You fetch the file only if and when you need it.
That “on demand” part matters. Most inbound emails are replies and notifications where you never touch the attachment at all; carrying it inline would tax every single webhook to serve the few where you do. The URL model means the common case stays cheap and the file case stays a deliberate, explicit fetch.
Sending an attachment: hand over bytes, not a link
The same encoding discipline runs the other direction. When you send a file, base64-encode the bytes into the content field and let the SDK build the correct MIME part and charset headers, rather than assembling a multipart/mixed body yourself:
import { MailKite } from "mailkite";
import { readFile } from "node:fs/promises";
const mk = new MailKite(process.env.MAILKITE_API_KEY);
const pdf = await readFile("./invoice.pdf");
await mk.send({
from: "billing@myapp.ai",
to: "ada@example.com",
subject: "Invoice #1042",
text: "Invoice attached — the £ total is on page 2.",
attachments: [
{ filename: "invoice.pdf", contentType: "application/pdf", content: pdf.toString("base64") },
],
});
For a file too large to inline comfortably, upload it first: mk.uploadAttachment({ path: "./big.pdf" }) returns a signed URL you pass as { filename, url }.
url must resolve to real object storage. A send() that references our own API host as an attachment source is a same-zone subrequest and fails; uploadAttachment() hands you a URL that does not have that problem.FAQ
Why does £ turn into £ in received emails?
Because the message declared one charset but carried bytes from another, usually UTF-8 bytes decoded as latin-1. £ is one byte in latin-1 and two in UTF-8, so the mismatch surfaces the stray Â. A parsed inbound API decodes each part against its real encoding before you see it, so text and html arrive correct and you never write charset-guessing code.
Do attachments come inline in the MailKite webhook?
No, not by default. Each attachment is a metadata record with a short-lived signed url you fetch on demand, so a large file never bloats your webhook body or trips request-size limits. (Zero-retention and encrypted domains are the exception: there’s nothing stored to link to, so those receive attachment content inline as base64.)
How big an attachment can I receive?
The webhook stays small regardless of file size because the bytes live behind the url, not inline: you download only the attachments your logic actually needs, and skip the rest for free.
Is this different from SendGrid Inbound Parse?
Yes. Inbound Parse POSTs multipart/form-data with attachments as file parts and is known to mangle certain encodings, so you own the form-parsing and re-decoding. Here the message arrives already parsed as JSON with decoded bodies and attachments as signed URLs.
Can I trust the sender before I open the file?
Check the auth object first. It reports SPF, DKIM, DMARC, and a spam verdict, so you can decide how much to trust an attachment before you fetch and process it, rather than trusting a forgeable From: header.
Attachments are where inbound email quietly turns into a file-handling and character-encoding project you didn’t sign up for. It doesn’t have to. Point a domain at MailKite and your next inbound email arrives as decoded JSON with its attachments one signed fetch away.
Related: Receiving email is the part nobody warns you about (the full case for why inbound is the hard direction) and the honest SendGrid Inbound Parse alternative.