MailKite
Get started
All posts
Gabe 6 min read

Handling email attachments without losing the £ sign

Inbound attachments are where email parsing goes to die — inline base64 that bloats every webhook, and charset bugs that turn £ into £. Here's why it happens, why SendGrid Inbound Parse is infamous for it, and what a clean attachments[] array with signed URLs looks like instead.

When you receive an email attachment programmatically, the file arrives as inline base64 stuffed inside the same MIME payload as the message body — so a 10 MB PDF becomes a ~13 MB string your webhook has to carry, decode, and store, and any charset mislabel in the surrounding parts turns a £ into £. A parsed inbound API like MailKite hands you decoded text/html plus an attachments[] array of short-lived signed URLs you fetch on demand, so files never ride inline and encoding is resolved before you see it.

That’s the whole problem in one paragraph. The rest of this post is why it’s genuinely hard, why the incumbents make you feel the pain, and what the clean version looks like.

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. £ is 0xA3 in 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.

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.

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: no MIME tree, no inline base64, no charset to guess. text and html are already decoded — the £ is a £. Each attachment is a small record — id, filename, contentType, size, and a short-lived signed url — so a 13 MB PDF never rides along in your webhook body. 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.

Fetching the file when you actually need it

The handler is: verify the signature against the raw bytes, read the fields, and pull the attachment URL only when your logic calls for it.

// Express
import express from "express";
import { MailKite } from "mailkite";

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", async (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
      const file = await fetch(att.url);                     // signed, short-lived
      const bytes = Buffer.from(await file.arrayBuffer());   // real decoded PDF bytes
      // …store it, scan it, hand it to your processor.
      console.log("got", att.filename, bytes.length, "bytes");
    }
  }
});

app.listen(3000);

Two rules that save real debugging: verify against the raw request bytes (JSON round-tripping changes them and breaks the signature), 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 short-lived by design: it’s a capability, not a permalink, so a leaked webhook log doesn’t hand out your customers’ files forever. The same handler exists for Python, Ruby, Go, PHP, and Java — see the receiving docs.

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.

Discuss this post: Hacker News Share on X

Related posts