Almost every SaaS product starts with a website. img-to-pdf doesn’t have one.
You email it some images, and it replies with a single PDF. There is no account page, no upload screen, and no web app. The email address is the interface.
Inspiration
I built a small app for my mother to combine images into a single PDF. She kept emailing me the images instead.
So I wondered whether the email itself could reply with what she wanted.
The address is the account
Cloudflare Email Routing forwards every incoming email to a Worker. From there, the Worker identifies the sender and checks that they’re allowed to run a merge. It pulls the images out of the attachments, builds the PDF, and replies to the original email with it attached.
The email pipeline
The sender’s address is the account identifier. There is no sign-up, so the first email from a new address creates the account.
An incoming message carries three addresses, and the choice matters:
- The envelope sender, which Cloudflare exposes as
message.from. - The
Fromheader, which is the address most email clients show. - The
Reply-Toheader, which tells clients where to send replies.
img-to-pdf normalises the From header and uses it to look up or create the user. It never uses Reply-To for anything that touches the account. If it did, an attacker could put the victim’s address in From, their own address in Reply-To, and receive the victim’s results. I come back to spoofing under Every email is an untrusted request.
The address is an account key, not a session. Nothing that costs money or destroys data runs from a single inbound email.
Every attachment is untrusted until decoded
An attachment can claim to be a JPEG, use a .jpg filename, and contain something else entirely. This is the wrong way to find out what a file is:
TypeScript
if (attachment.mimeType === "image/jpeg") {
// process image as JPEG
}The filename and the declared Content-Type are hints for error messages, nothing more. img-to-pdf reads the first bytes of each attachment to work out the real format and dimensions. Anything that isn’t a supported image is skipped, and the reply says which files were skipped and why.
Size is the second problem. Cloudflare rejects messages over 25 MiB before they reach the Worker, but a small compressed image can decode into a huge pixel buffer. Twenty of them can exhaust the Worker’s memory. So the limits apply before any decoding happens:
const MAX_ATTACHMENTS = 20;
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
const MAX_PIXELS = 8_000_000;A request that breaks a limit is rejected outright. A request with one bad file among good ones continues, and the reply lists the skipped file.
Every image gets a page that fits it
Phone photos arrive with the raw pixels sideways and an EXIF tag saying which way is up. If you trust the raw dimensions, a portrait photo lands on a landscape page.
img-to-pdf applies the EXIF orientation first, then picks the page orientation from the corrected dimensions. Portrait images get portrait pages, landscape images get landscape pages, and one PDF can contain both.
A square image fits neither, so the system compares how the image would fit on each page shape and takes the better one, with portrait as the fallback. The same comparison stops a small 300x200 image from forcing a landscape page when it fits on a portrait one without scaling.
EXIF metadata is stripped before embedding. It can include camera details, timestamps, and location, and none of that belongs in a PDF that the user might forward.
I chose not to compress images. A photo compresses well as a JPEG, but a scanned document or a screenshot turns into a blur. Until the system can tell the two apart, quality wins.
Billing is a command
On a website, billing is a button. With email there is no session and nowhere to put the button, so billing became a command.
Free users get three conversions per hour. Anyone who wants more replies with the word BILLING, and the system answers with a link to a Stripe Billing Portal:
TypeScript
const BILLING_COMMAND_REGEX = /^\s*BILLING\s*$/i;
export const isBillingCommand = (parsedEmail: ParsedEmail): boolean =>
BILLING_COMMAND_REGEX.test(
(parsedEmail.text ?? "")
.split("\n")
.find((line) => line.trim().length > 0) ?? ""
);Three decisions made this work without a dashboard.
Every account starts on a Stripe subscription. The first email from a new address creates a Stripe customer with a free-tier subscription. Because the subscription already exists, the portal can upgrade, downgrade, cancel, and update payment details, and the product never needs a checkout flow of its own.
The limit is checked before the MIME body is parsed. Parsing attachments is the expensive part, so the hourly check runs first, using only the From header. A user over the limit gets a reply saying when the limit resets and how to upgrade, and nothing else runs.
Billing replies are deduplicated per thread. A user who replies BILLING five times in the same thread would otherwise get five portal emails. The system records billing replies by sender and thread and stays quiet while an existing portal session is active.
A Stripe webhook keeps the local state current. When a subscription with the premium price becomes active, the user is marked premium. Otherwise they stay on the free tier.
Every email is an untrusted request
An inbound email can trigger MIME parsing, image decoding, PDF generation, billing checks, database writes, and outbound mail. It is an unauthenticated API call with attachments, and it needs the same defences.
Mail loops
If the service replies to an auto-responder, the auto-responder replies back, and the two of them talk forever. That burns compute, damages deliverability, and fills someone’s inbox.
The mail loop — and where it breaks
img-to-pdf ignores anything that looks automated:
- Bounces and delivery status notifications.
- Out-of-office replies.
- Messages with an
Auto-Submittedheader or an automatedPrecedence. - Messages from a mailer daemon.
- Messages from its own outbound addresses.
Ignoring means no reply at all. For these messages, silence is the safe response.
Rate limiting
Spamming an email address is as easy as spamming an unprotected API, and each email is more expensive to handle than most API calls. The hourly limit runs before the body is parsed for the same reason the billing check does.
Spoofing
From is the identity, and it can be forged. The rules that keep that survivable:
- Never use
Reply-Tofor anything that touches the account. - Send results and confirmations only to the canonical sender address.
- Reject messages whose sender cannot be parsed.
- Treat SPF, DKIM, and DMARC results as risk signals, not decoration.
- Require a confirmation round-trip for anything destructive.
The last rule turns a dangerous action into a handshake. The first email only creates a pending action:
- The user emails “delete my account”.
- The system creates a pending deletion and emails a confirmation to the account address.
- The user replies to that confirmation.
- The system checks that the reply’s
In-Reply-Toreferences the confirmation’sMessage-ID. - Only then does the deletion run.
The confirmation handshake
An attacker can start an action by spoofing From. They can’t receive the confirmation email, so they can’t finish it.
Where email stops working
Email suits img-to-pdf because the task is simple and asynchronous. The user sends files and gets one file back. Most products aren’t shaped like that.
There is no interactivity. No upload progress, no preview, no drag-to-reorder, and no inline validation. If something goes wrong, the system explains in a reply, and the user sends another email.
Email clients mangle attachments. They rename files, recompress images, strip metadata, and reorder things, so the reply has to be explicit about what was accepted, skipped, and rejected.
Generating the result and delivering it are two separate events. The PDF might be too large, the reply might bounce, or it might land in spam. On a website the user is still there when the result appears. Over email, they might never see it.
And an address is not a session. Aliases, forwarding, shared inboxes, and changed addresses all blur who the user is, which is why anything high-risk needs the confirmation handshake.
Final thoughts
Plenty of products already use email as an interface, but only for one corner of a larger product. GitHub lets you reply to issue and pull request notifications. Intercom lets users open and reply to tickets. Linear does the same for issues.
img-to-pdf was an experiment in making the corner the whole product. For a task this small, it works.