# smail: a temporary email service that runs entirely on Cloudflare Workers

> smail is the open source behind smail.pw, a disposable inbox built on Workers, D1, R2 and Durable Objects. It pushes new mail over a WebSocket instead of polling, keeps the address in a signed cookie rather than an account, and accepts incoming mail only. Hosting your own copy means editing a single config file and wiring a catch-all rule in the Cloudflare dashboard.

**akazwz/smail** — Temporary email on Cloudflare Workers, with a real-time inbox. 临时邮箱

- Repository: https://github.com/akazwz/smail
- Website: https://smail.pw
- Stars: 930 · Forks: 189
- Language: TypeScript
- License: not declared
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/akazwz-smail

## The address lives in a signed cookie, not in an account

smail hands out an address without any signup step, and that address stays the same until the visitor replaces or deletes it. There is no account table, and nothing is stored per visitor. Instead a signed cookie carries the address itself, and the Worker signs that cookie with a value the operator supplies through the SESSION_SECRETS environment variable.

Because the address travels in the cookie rather than in a database row, losing or replacing the secret is destructive. Every visitor loses the address they were using, because the cookie can no longer be verified. Rotation is handled by listing several values separated by commas: the leftmost one signs new cookies, and the remaining ones are only used to verify older ones. That keeps the session stateless on the storage side, but it also ties the lifetime of every mailbox to a secret file rather than to a record the operator can query.

## D1 holds the metadata row, R2 holds the raw message

Storage is split across two Cloudflare products, and the split is not cosmetic. D1 holds one row of metadata per email in a table called emails, plus the messages sent through the contact form in a messages table. R2 holds the raw email object itself, keyed by the email's id.

When a message arrives, the raw payload is written to the bucket first and a metadata row follows into D1. A message larger than 5 MB is stored as body only. Since D1 is a relational database and R2 is an object store, neither one holds a complete email on its own: a row in emails is a pointer that resolves to an object in the bucket. Reading a message therefore costs two lookups rather than one, and a missing bucket object turns a database row into a dead link.

## The InboxHub Durable Object carries the ping, not the payload

The live inbox is a push path rather than a polling path. Email Routing hands every message for the domain to the Worker's email handler, the Worker tells the InboxHub Durable Object, and the Durable Object signals the pages that currently have that address open. The page then calls GET /api/inbox again for the actual list.

So the WebSocket carries only the notification. The mailbox content is refetched over a second request every time something arrives. That split keeps the socket message small and turns the Durable Object into a simple fan-out registry of open sessions, and the documented result is that a new message shows up in the page within a few seconds, with no refresh and no polling loop running in the browser. Opening one message calls GET /api/email/:id, which checks that the email belongs to the address in the session, parses the raw message out of R2 and returns its HTML.

## Deploying your own copy starts and ends with site.config.ts

Cloning the repository and authenticating against Cloudflare is the whole of step one. The project ships its own copy of Cloudflare's new cf CLI as a dependency rather than asking for a global install, and the CLI is currently in beta. All cf commands run from the worker/ directory.

```bash
git clone https://github.com/akazwz/smail.git
cd smail
pnpm install
cd worker
pnpm exec cf auth login
```

Next you create the two storage backends. The d1 create command returns a uuid that you need to keep, because the config file references the database by both name and id.

```bash
pnpm exec cf d1 create --name my-smail
pnpm exec cf r2 buckets create --name my-smail
```

Everything else lives in one file. site.config.ts carries the domain, the Worker name, the database name and id, and the bucket name. The site name shown on the pages, the URLs in the sitemap and the generated addresses all derive from the domain value in this file.

```ts
export const site = {
	domain: "example.com", // your domain
	worker: "my-smail", // the Worker's name
	database: { name: "my-smail", id: "the uuid from step 2" },
	bucket: "my-smail",
};
```

Then the session secret, which signs the cookie holding a visitor's address.

```bash
cp .env.example .env.production
```

You replace SESSION_SECRETS in .env.production with a long random string, for instance the output of openssl rand -base64 32. The first deploy uploads that secret alongside the Worker.

```bash
cd ..
pnpm run deploy:first
```

That script builds the frontend with every page prerendered, builds the Worker, creates the tables and uploads both, and it prints a workers.dev address. The site already opens and can generate addresses at that point. The domain only comes into play afterwards, in the dashboard.

## No deploy button, and a beta CLI that needs a manual Ctrl+C

There is no Deploy to Cloudflare button, and the reason is structural: Cloudflare's button needs a Wrangler configuration file, and this project does not use one. The previous generation of the code, built on React Router with a wrangler.jsonc file, is kept under the v3 release tag, so any walkthrough written for that older setup does not describe the current tree.

The current tree is a pnpm workspace with the Solid frontend at the repository root and the Worker in worker/. The root package.json exposes deploy, deploy:first, deploy:dry-run and deploy:staging, each of which runs a Vite build before the Worker deploy. An update to an existing instance is a pull, an install and a deploy.

```bash
git pull
pnpm install
pnpm run deploy
```

New database migrations are applied automatically by that path, and deploy omits the secret upload that the first deploy performs. Local development needs Node.js 22.18 or newer and pnpm, plus a local migration before the dev servers start.

```bash
pnpm install
cp worker/.env.example worker/.env
pnpm --filter smail-worker run migrate:local
pnpm run dev
```

The migrate:local step creates the tables in the local database, and the beta cf CLI may keep running after it prints its result, so press Ctrl+C once it has finished. On maintenance, the last push to the repository was on 2026-10-09, release v3 was published on 2026-10-08, and the previous v2 dates from 2026-03-01.

## The catch-all rule is where a fresh install usually stalls

The last configuration step happens entirely in the Cloudflare dashboard rather than in the repository, and three things have to line up.

Attach the domain first: Workers and Pages, then your Worker, then Settings, then Domains and Routes, then Add, then Custom domain, entering the domain from site.config.ts. Menu names in the dashboard may differ slightly from the names written in the documentation, so expect to hunt for the labels.

Second, enable Email Routing for the domain. Cloudflare adds the DNS records needed to receive mail.

Third, set the catch-all. Under Routing rules, give the Catch-all address the action Send to a Worker, pick your Worker, and enable the rule. The email addresses themselves come from this catch-all, so without it the domain resolves and pages render, but no message ever reaches the Worker.

The check is quick. Open your domain, press Generate address, and send a message to it from any mailbox. It should appear on the page within a few seconds, without a refresh. If nothing arrives, look first at whether the catch-all rule is enabled and points at your Worker, then at the Worker's logs in the dashboard. Since the routing half of the system is configured only in the dashboard, that is also where to look when the frontend looks perfectly healthy.

## Receive only, and attachments never reach the reader

smail is deliberately one-directional. It is receive only: there is no sending, and attachments are not shown. An inbox that can receive but never reply rules out any workflow that confirms a signup by replying to the sender, and it rules out forwarding a document out of the mailbox.

The size limit adds a second edge. A message over 5 MB is stored as body only, so the raw object in R2 is deliberately incomplete for large attachments, and a reader who expects a full MIME payload will not get one.

Rendering is the security-sensitive part of the design. Emails are displayed in a sandboxed iframe, scripts inside them do not run, and links open in a new tab. For a service whose purpose is receiving untrusted mail from strangers, that is a sensible default, and it is also what lets the frontend be prerendered into plain static files. All pages are static files, and the Worker runs only for the inbox API, the push connection and incoming mail.

## Rebranding leaves the privacy policy talking about smail.pw

Making the instance your own is not only a matter of setting the domain. The site name in the header, footer and share cards follows the domain in site.config.ts, and the icons live in public/ as favicon.svg, favicon.ico, apple-touch-icon.png and og.png. But the written content under app/md, app/blog and app/i18n/locales still says smail.pw, including the privacy policy and the terms.

```bash
git grep -lz "smail\.pw" -- app/md app/blog app/i18n/locales | xargs -0 perl -pi -e 's/smail\.pw/example.com/g'
```

The README is explicit that you read the privacy policy and terms afterwards, because those documents describe how smail.pw is run rather than how your instance is run. Leaving them untouched means your site publishes someone else's operational description of a service you now control.

One more leftover sits in public/_redirects, which holds redirects for smail.pw's own old URLs. Remove the ones you do not need. On licensing, the licence field on the repository does not name a licence, so treat the terms as unconfirmed before you put this in front of users.

## Conclusion

smail suits operators who already own a domain on Cloudflare and want a disposable inbox they control, with no accounts to manage and no server to run. It is the wrong tool for anyone who needs to send mail, show attachments, or run outside Cloudflare, and the beta cf CLI plus the licence field being unset are reasons to verify those two points before deploying. Check first that your domain's catch-all rule points at the Worker, because that single dashboard setting decides whether any message ever reaches the inbox.

## FAQ

### How do I host my own smail temporary email instance?

Clone the repository, run pnpm install, authenticate with pnpm exec cf auth login from the worker/ directory, create a D1 database and an R2 bucket, then fill in site.config.ts with your domain, Worker name, database id and bucket name. Run pnpm run deploy:first from the repository root and finish the setup in the Cloudflare dashboard by attaching the domain, enabling Email Routing and pointing the catch-all rule at your Worker.

### Can smail send emails or show attachments?

No. smail is receive only: it has no sending capability and attachments are not shown. A message larger than 5 MB is stored as body only, so large attachments never reach the reader.

### Where does smail store received emails?

D1 holds one metadata row per email in the emails table, plus contact form messages in messages, while R2 holds the raw email object keyed by the email's id. The visitor's address is kept in a signed session cookie rather than in a database row.

### Why does smail not have a one-click Deploy to Cloudflare button?

Cloudflare's button needs a Wrangler configuration file, and this project does not use one. The previous generation built on React Router with wrangler.jsonc is archived under the v3 tag, and the current tree is deployed with the cf CLI from the repository scripts instead.

## Sources

- [akazwz/smail on GitHub](https://github.com/akazwz/smail)
- [Issues](https://github.com/akazwz/smail/issues)
- [Project website](https://smail.pw)
- [README](https://github.com/akazwz/smail/blob/main/README.md)
- [Releases](https://github.com/akazwz/smail/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/akazwz-smail
