I added "save this email as a PDF" to a browser-based mail viewer. Everything runs locally: the message is parsed in a Web Worker and the PDF is built with pdf-lib. Text, headers and attachments were easy. Images were the hard part.
Pictures an email carries inside itself (cid: references and data: URIs) are simple: the bytes are already in the message. Pictures an email links to (<img src="https://…">) are another story. My first version skipped them on purpose. Then a newsletter printed with blank gaps where its pictures should be, and it read like a bug. So I changed my mind, with constraints. Here is what those constraints are and why.
Why not just fetch() every <img>?
Two reasons, one about privacy and one about the platform.
Privacy. Loading a remote image tells the sender's server that the mail was opened, and from where. That is how tracking pixels work. A tool that promises "your mail never leaves your device" must not turn into an open-tracker by accident. So the rules are strict:
- No proxy. The browser requests the image straight from the host that serves it; the request never goes through my site.
-
credentials: "omit"andreferrerPolicy: "no-referrer", so no cookies and no page address are sent. - Images that are clearly beacons are never requested:
widthorheightof 2px or less, ordisplay:none. - Requests to private-network IP literals and the local machine are refused outright. A mail should not be able to make the converter poke at
169.254.169.254orlocalhost.
The platform. From a page, fetch() of a cross-origin image is only readable if the host sends Access-Control-Allow-Origin. An <img> tag can display such a picture, but code can't get its bytes to embed in a PDF without CORS permission.
CORS makes a dead host and a refusing host look the same
When a CORS check fails, the browser gives your code a generic network error. You can't tell "this server is down" from "this server doesn't allow other sites to read its images" from "the picture moved". All look like a TypeError.
How common is it? I tried a set of popular image hosts on 2026-10-26. Roughly two thirds sent the header (GitHub, Substack, Amazon, LinkedIn, HubSpot, Klaviyo, Constant Contact, Shopify, Gravatar and googleusercontent did). Others did not: google.com/gstatic, SendGrid's S3 bucket, Campaign Monitor and apple.com. That is a small hand-picked sample, not a survey, but it is enough to say: a meaningful share of real newsletter images can't be embedded from a browser, and you can't fix that client-side.
So the design question became: what do you show in the PDF when a picture can't be had?
A box that says why, not a blank
Instead of a gap, the PDF gets a grey box: "Picture not included", the alt text if there is any, and a reason. The reasons I can distinguish:
- a network or CORS failure: "host does not let other websites download its pictures, or is offline" (deliberately both, since the browser can't separate them);
- HTTP 404 or 410: "host no longer has this picture";
- the reply is too large;
- the reply isn't an image;
- the format can't be put in a PDF;
- a
cid:the message doesn't contain.
Two refinements keep the output tidy:
- Adjacent pictures that fail for the same reason share one box, so a newsletter with twelve blocked banner slices doesn't become twelve grey rectangles.
- A failed picture with no size and no alt text is left out entirely. That is almost certainly an open beacon, and a box for it would be noise.
The fetch rules in code
The request options are the small part:
const res = await fetch(url, {
mode: "cors",
credentials: "omit",
referrerPolicy: "no-referrer",
signal: AbortSignal.timeout(10_000),
});
Around it sit the guardrails:
-
http:and protocol-relative URLs are upgraded tohttps:. - A 10-second timeout and a 10 MB cap. The cap is enforced while streaming the body, so an oversized response is abandoned rather than buffered first.
- The reply must carry an image content type or start with image magic bytes. A server can answer with something that isn't a picture, so the check doesn't rely on one signal alone.
- At most 100 pictures per message, six requests in flight at a time.
- A host that fails at the network level isn't asked again within that worker, so one dead host doesn't cost a timeout per picture.
- Pictures up to 512 KB are cached by address, since the same logo appears many times in a mailbox.
Order matters when you parallelise
Exporting a folder of newsletters means many messages, each waiting on its own pictures. Rendering them one after another makes the slowest host stall everything. The folder export renders four messages at once. The output order stays that of the folder, which is the part that's easy to break: results are collected by index, not by completion time.
Testing without trusting the network
Unit tests exercise the rules without real servers: the address rules (0.gravatar.com is allowed, 169.254.169.254 is refused), the request options, each failure kind and its wording, the streamed size cap, one request per address and none after a host has failed, and that adjacent same-reason failures merge. For the real-browser behaviour, an end-to-end script in Chrome against a production build checks one CORS-friendly host (a GitHub avatar is drawn) and one that refuses (google.com's logo becomes a box with its reason), and asserts both requests went out without a referrer.
What I'd tell you if you build something similar
- Decide your privacy rule first, then see which features survive it. "No proxy" is what forces the grey boxes.
- Don't pretend you know why a CORS failure happened. Say the two things it could be.
- Filter beacons before requesting, not after.
- Cap size while streaming, and verify the content, not just the header.
- Keep output order independent of completion order when you parallelise.
If you want to see how it behaves on your own .eml files, the page is the EML to PDF converter. Disclosure: I built it. The message is processed in your browser; the only requests it makes are for fonts and emoji pictures from the site itself, and for the pictures the message itself links to, which go to their own servers.
Top comments (0)