---
title: "Using Email as an Interface"
description: "A SaaS with no website: email images in, get a PDF back."
metaDescription: "Building img-to-pdf, a SaaS with no website: users email images and get a merged PDF back, using Cloudflare Email Routing and Workers as the entire interface."
publishedAt: "2026-05-31"
tags: ["Cloudflare", "Emails"]
readingTime: 6
---

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](https://www.cloudflare.com/products/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**

<svg aria-label="The img-to-pdf pipeline: an inbound email is received, the sender is identified and authorized, the MIME body is parsed, the images are converted into a PDF, and the result is sent back as a reply. The first and last steps — both emails — are the entire interface." role="img" viewBox="8 12 514 156" xmlns="http://www.w3.org/2000/svg"><title>The img-to-pdf pipeline: an inbound email is received, the sender is identified and authorized, the MIME body is parsed, the images are converted into a PDF, and the result is sent back as a reply. The first and last steps — both emails — are the entire interface.</title><defs><marker id="diagram-arrow-_R_0_-EmailPipeline" markerHeight="6" markerUnits="userSpaceOnUse" markerWidth="6" orient="auto" refX="0" refY="3"><path fill="#a3a3a3" d="M0,0 L6,3 L0,6 Z"></path></marker></defs><g><line stroke="#a3a3a3" marker-end="url(#diagram-arrow-_R_0_-EmailPipeline)" stroke-width="1.5" x1="170" x2="184" y1="47" y2="47"></line><line stroke="#a3a3a3" marker-end="url(#diagram-arrow-_R_0_-EmailPipeline)" stroke-width="1.5" x1="340" x2="354" y1="47" y2="47"></line><line stroke="#a3a3a3" marker-end="url(#diagram-arrow-_R_0_-EmailPipeline)" stroke-width="1.5" x1="435" x2="435" y1="70" y2="104"></line><line stroke="#a3a3a3" marker-end="url(#diagram-arrow-_R_0_-EmailPipeline)" stroke-width="1.5" x1="360" x2="346" y1="133" y2="133"></line><line stroke="#a3a3a3" marker-end="url(#diagram-arrow-_R_0_-EmailPipeline)" stroke-width="1.5" x1="190" x2="176" y1="133" y2="133"></line><g><rect fill="#171717" height="46" rx="9" stroke-width="1.25" width="150" x="20" y="24"></rect><text font-family="ui-monospace, monospace" fill="#fafafa" dominant-baseline="middle" font-size="12" text-anchor="middle" x="95" y="43">receive</text><text fill="#a3a3a3" dominant-baseline="middle" font-size="9.5" text-anchor="middle" x="95" y="57">inbound email</text></g><g><rect fill="#f5f5f5" stroke="#d4d4d4" height="46" rx="9" stroke-width="1.25" width="150" x="190" y="24"></rect><text font-family="ui-monospace, monospace" fill="#0a0a0a" dominant-baseline="middle" font-size="12" text-anchor="middle" x="265" y="43">identify</text><text fill="#737373" dominant-baseline="middle" font-size="9.5" text-anchor="middle" x="265" y="57">From header</text></g><g><rect fill="#f5f5f5" stroke="#d4d4d4" height="46" rx="9" stroke-width="1.25" width="150" x="360" y="24"></rect><text font-family="ui-monospace, monospace" fill="#0a0a0a" dominant-baseline="middle" font-size="12" text-anchor="middle" x="435" y="43">authorize</text><text fill="#737373" dominant-baseline="middle" font-size="9.5" text-anchor="middle" x="435" y="57">tier check</text></g><g><rect fill="#f5f5f5" stroke="#d4d4d4" height="46" rx="9" stroke-width="1.25" width="150" x="360" y="110"></rect><text font-family="ui-monospace, monospace" fill="#0a0a0a" dominant-baseline="middle" font-size="12" text-anchor="middle" x="435" y="129">parse</text><text fill="#737373" dominant-baseline="middle" font-size="9.5" text-anchor="middle" x="435" y="143">MIME body</text></g><g><rect fill="#f5f5f5" stroke="#d4d4d4" height="46" rx="9" stroke-width="1.25" width="150" x="190" y="110"></rect><text font-family="ui-monospace, monospace" fill="#0a0a0a" dominant-baseline="middle" font-size="12" text-anchor="middle" x="265" y="129">convert</text><text fill="#737373" dominant-baseline="middle" font-size="9.5" text-anchor="middle" x="265" y="143">images → PDF</text></g><g><rect fill="#171717" height="46" rx="9" stroke-width="1.25" width="150" x="20" y="110"></rect><text font-family="ui-monospace, monospace" fill="#fafafa" dominant-baseline="middle" font-size="12" text-anchor="middle" x="95" y="129">reply</text><text fill="#a3a3a3" dominant-baseline="middle" font-size="9.5" text-anchor="middle" x="95" y="143">PDF attached</text></g></g></svg>

*The img-to-pdf pipeline: an inbound email is received, the sender is identified and authorized, the MIME body is parsed, the images are converted into a PDF, and the result is sent back as a reply. The first and last steps — both emails — are the entire interface.*

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:

1. The envelope sender, which Cloudflare exposes as `message.from`.
2. The `From` header, which is the address most email clients show.
3. The `Reply-To` header, 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](#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:

```ts
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.

> [!TIP]
>
> [Magika](https://github.com/google/magika), from Google, is a useful tool for working out the real type of a file. I haven't used it here, but if you do, import from `magika` and **not** `magika/node`.

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:

```txt
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.

> [!NOTE]
>
> HEIC files from iPhones are identified from their bytes, but converting them depends on [Cloudflare Images](https://developers.cloudflare.com/images/optimization/transformations/bindings/) being available in the Worker runtime.

## 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:

```ts
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.

> [!NOTE]
>
> Premium means "no hourly limit" rather than unlimited. Depending on traffic, that might need to change.

## 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**

<svg aria-label="The mail loop. The service replies to a sender whose mailbox has an auto-responder; that auto-reply arrives as a new inbound email, which would trigger another reply, and so on. Detecting automated mail diverts it to a silent drop, breaking the cycle." role="img" viewBox="28 12 494 240" xmlns="http://www.w3.org/2000/svg"><title>The mail loop. The service replies to a sender whose mailbox has an auto-responder; that auto-reply arrives as a new inbound email, which would trigger another reply, and so on. Detecting automated mail diverts it to a silent drop, breaking the cycle.</title><defs><marker id="diagram-arrow-_R_0_-MailLoop" markerHeight="6" markerUnits="userSpaceOnUse" markerWidth="6" orient="auto" refX="0" refY="3"><path fill="#a3a3a3" d="M0,0 L6,3 L0,6 Z"></path></marker></defs><g><line stroke="#a3a3a3" marker-end="url(#diagram-arrow-_R_0_-MailLoop)" stroke-width="1.5" x1="198" x2="346" y1="46" y2="46"></line><g><rect fill="#f5f5f5" height="15" rx="3" width="49.199999999999996" x="250.4" y="27"></rect><text fill="#737373" font-size="10" text-anchor="middle" x="275" y="38">replies</text></g><path stroke="#a3a3a3" d="M435,68 V142 H356" fill="none" marker-end="url(#diagram-arrow-_R_0_-MailLoop)" stroke-width="1.5"></path><g><rect fill="#f5f5f5" height="15" rx="3" width="66" x="402" y="93"></rect><text fill="#737373" font-size="10" text-anchor="middle" x="435" y="104">auto-reply</text></g><path stroke="#a3a3a3" d="M200,142 H115 V74" fill="none" marker-end="url(#diagram-arrow-_R_0_-MailLoop)" stroke-width="1.5"></path><g><rect fill="#f5f5f5" height="15" rx="3" width="82.8" x="73.6" y="93"></rect><text fill="#737373" font-size="10" text-anchor="middle" x="115" y="104">not automated</text></g><line stroke="#a3a3a3" marker-end="url(#diagram-arrow-_R_0_-MailLoop)" stroke-dasharray="4 3" stroke-width="1.5" x1="275" x2="275" y1="172" y2="182"></line><g><rect fill="#f5f5f5" height="15" rx="3" width="60.4" x="279" y="173"></rect><text fill="#737373" font-size="10" text-anchor="start" x="283" y="184">automated</text></g><g><rect fill="#171717" height="44" rx="9" stroke-width="1.25" width="150" x="40" y="24"></rect><text font-family="ui-monospace, monospace" fill="#fafafa" dominant-baseline="middle" font-size="12" text-anchor="middle" x="115" y="46">service</text></g><g><rect fill="#f5f5f5" stroke="#d4d4d4" height="44" rx="9" stroke-width="1.25" width="150" x="360" y="24"></rect><text font-family="ui-monospace, monospace" fill="#0a0a0a" dominant-baseline="middle" font-size="12" text-anchor="middle" x="435" y="46">auto-responder</text></g><g><rect fill="#f5f5f5" stroke="#d4d4d4" height="44" rx="9" stroke-width="1.25" width="150" x="200" y="120"></rect><text font-family="ui-monospace, monospace" fill="#0a0a0a" dominant-baseline="middle" font-size="12" text-anchor="middle" x="275" y="142">detect automated</text></g><g><rect fill="#f5f5f5" stroke="#d4d4d4" height="44" rx="9" stroke-dasharray="4 3" stroke-width="1.25" width="150" x="200" y="196"></rect><text font-family="ui-monospace, monospace" fill="#737373" dominant-baseline="middle" font-size="12" text-anchor="middle" x="275" y="214">drop</text><text fill="#737373" dominant-baseline="middle" font-size="9.5" text-anchor="middle" x="275" y="228">silently</text></g></g></svg>

*The mail loop. The service replies to a sender whose mailbox has an auto-responder; that auto-reply arrives as a new inbound email, which would trigger another reply, and so on. Detecting automated mail diverts it to a silent drop, breaking the cycle.*

`img-to-pdf` ignores anything that looks automated:

- Bounces and delivery status notifications.
- Out-of-office replies.
- Messages with an `Auto-Submitted` header or an automated `Precedence`.
- 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:

1. Never use `Reply-To` for anything that touches the account.
2. Send results and confirmations only to the canonical sender address.
3. Reject messages whose sender cannot be parsed.
4. Treat SPF, DKIM, and DMARC results as risk signals, not decoration.
5. 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:

1. The user emails "delete my account".
2. The system creates a pending deletion and emails a confirmation to the account address.
3. The user replies to that confirmation.
4. The system checks that the reply's `In-Reply-To` references the confirmation's `Message-ID`.
5. Only then does the deletion run.

**The confirmation handshake**

<svg aria-label="The confirmation handshake. An attacker spoofs the victim&#x27;s address to request a destructive action. The service creates a pending action and sends a confirmation to the canonical victim address — never the attacker. Only a reply that references the confirmation&#x27;s Message-ID completes the action, so the spoofer can start it but never receive the confirmation needed to finish it." role="img" viewBox="26 2 518 288" xmlns="http://www.w3.org/2000/svg"><title>The confirmation handshake. An attacker spoofs the victim&#x27;s address to request a destructive action. The service creates a pending action and sends a confirmation to the canonical victim address — never the attacker. Only a reply that references the confirmation&#x27;s Message-ID completes the action, so the spoofer can start it but never receive the confirmation needed to finish it.</title><defs><marker id="diagram-arrow-_R_0_-ConfirmationHandshake" markerHeight="6" markerUnits="userSpaceOnUse" markerWidth="6" orient="auto" refX="0" refY="3"><path fill="#a3a3a3" d="M0,0 L6,3 L0,6 Z"></path></marker></defs><g><line stroke="#a3a3a3" stroke-dasharray="3 4" stroke-width="1" x1="90" x2="90" y1="54" y2="278"></line><line stroke="#a3a3a3" stroke-dasharray="3 4" stroke-width="1" x1="290" x2="290" y1="54" y2="278"></line><line stroke="#a3a3a3" stroke-dasharray="3 4" stroke-width="1" x1="480" x2="480" y1="54" y2="278"></line><g><rect fill="#f5f5f5" stroke="#d4d4d4" height="36" rx="9" stroke-width="1.25" width="104" x="38" y="14"></rect><text font-family="ui-monospace, monospace" fill="#0a0a0a" dominant-baseline="middle" font-size="12" text-anchor="middle" x="90" y="32">Attacker</text></g><g><rect fill="#171717" height="36" rx="9" stroke-width="1.25" width="104" x="238" y="14"></rect><text font-family="ui-monospace, monospace" fill="#fafafa" dominant-baseline="middle" font-size="12" text-anchor="middle" x="290" y="32">Service</text></g><g><rect fill="#f5f5f5" stroke="#d4d4d4" height="36" rx="9" stroke-width="1.25" width="104" x="428" y="14"></rect><text font-family="ui-monospace, monospace" fill="#0a0a0a" dominant-baseline="middle" font-size="12" text-anchor="middle" x="480" y="32">Victim</text></g><g><rect fill="#f5f5f5" height="15" rx="3" width="200.39999999999998" x="92" y="67"></rect><text fill="#737373" font-size="10" text-anchor="start" x="96" y="78">1. &quot;delete account&quot; · spoofed From</text></g><line stroke="#a3a3a3" marker-end="url(#diagram-arrow-_R_0_-ConfirmationHandshake)" stroke-width="1.5" x1="90" x2="284" y1="86" y2="86"></line><path stroke="#a3a3a3" d="M290,108 h28 v16 h-22" fill="none" marker-end="url(#diagram-arrow-_R_0_-ConfirmationHandshake)" stroke-width="1.5"></path><g><rect fill="#f5f5f5" height="15" rx="3" width="144.39999999999998" x="320" y="109"></rect><text fill="#737373" font-size="10" text-anchor="start" x="324" y="120">2. create pending action</text></g><g><rect fill="#f5f5f5" height="15" rx="3" width="166.79999999999998" x="292" y="139"></rect><text fill="#737373" font-size="10" text-anchor="start" x="296" y="150">3. confirmation · Message-ID</text></g><line stroke="#a3a3a3" marker-end="url(#diagram-arrow-_R_0_-ConfirmationHandshake)" stroke-width="1.5" x1="290" x2="474" y1="158" y2="158"></line><g><rect fill="#f5f5f5" height="15" rx="3" width="133.2" x="292" y="171"></rect><text fill="#737373" font-size="10" text-anchor="start" x="296" y="182">4. reply · In-Reply-To</text></g><line stroke="#a3a3a3" marker-end="url(#diagram-arrow-_R_0_-ConfirmationHandshake)" stroke-dasharray="4 3" stroke-width="1.5" x1="480" x2="296" y1="190" y2="190"></line><path stroke="#a3a3a3" d="M290,214 h28 v16 h-22" fill="none" marker-end="url(#diagram-arrow-_R_0_-ConfirmationHandshake)" stroke-width="1.5"></path><g><rect fill="#f5f5f5" height="15" rx="3" width="172.39999999999998" x="320" y="215"></rect><text fill="#737373" font-size="10" text-anchor="start" x="324" y="226">5. verify reference · execute</text></g><g><rect fill="#f5f5f5" height="15" rx="3" width="66" x="57" y="251"></rect><text fill="#737373" font-size="10" text-anchor="middle" x="90" y="262">✗ no inbox</text></g></g></svg>

*The confirmation handshake. An attacker spoofs the victim's address to request a destructive action. The service creates a pending action and sends a confirmation to the canonical victim address — never the attacker. Only a reply that references the confirmation's Message-ID completes the action, so the spoofer can start it but never receive the confirmation needed to finish it.*

> [!CAUTION]
>
> The reply **must** reference the confirmation's `Message-ID`. Without that check, an attacker could send a spoofed "yes" on its own, or a reply to one request could be read as confirming another.

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](https://github.com/features/issues) lets you reply to issue and pull request notifications. [Intercom](https://www.intercom.com/) lets users open and reply to tickets. [Linear](https://linear.app/) 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.
