Skip to content

Self-hosting Mail

Gateweave Mail · unreleased · source on GitHub

Gateweave Mail runs on your own Cloudflare account as one Worker. It is early: today it has accounts, workspaces, a directory of addresses, and mailboxes that keep the mail sent to them and reply and write, formatted and with files, from people and agents. It uses:

  • a D1 database for accounts, the list of workspaces, and their domains, mailboxes and addresses;
  • an R2 bucket for mail as it arrived, one object per message;
  • a Durable Object per mailbox, which holds the index of its messages;
  • a Durable Object that counts rate limits, one per client address or recipient;
  • a Durable Object per workspace for its webhooks, which holds their secrets and what they are still to deliver;
  • a queue, gateweave-mail-events, for Email Service’s delivery events;
  • optionally, Cloudflare Email Service, to send password resets, verification and invitations, and the mail people write from mailboxes.

You need a Cloudflare account with the Workers Paid plan. Sign-in takes more CPU than the Free plan gives a request, and Email Sending is a Paid feature. Turn on R2 in the account before the first deploy, by opening R2 in the dashboard once and following its steps: until then, the bucket can’t be created, and the deploy fails.

Getting a domain’s mail to the Worker. Prove the domain under a workspace’s Settings, then turn on Email Routing for that same domain in the Cloudflare account that runs the Worker. Each domain or subdomain is proved on its own: proving example.com gives no addresses on mail.example.com. Then choose:

  • Send its catch-all to the Worker. The Worker decides which addresses exist, and a new one works at once. A zone has one catch-all, and it also catches every subdomain with Email Routing on, so use this where nothing else needs it.
  • Add a routing rule per address. Use this when something else holds the catch-all. A zone gets 200 rules in all, and a new one can take over a minute to work, with mail going to the catch-all until it does.

A domain whose DNS isn’t on that Cloudflare account can receive through Resend instead: see Email.

Mail is early, so think before doing this for a domain people rely on: what arrives is kept and shown, and a message to an address that doesn’t exist is refused. You can deploy without Email Routing. Proof stays after the TXT record is removed, but whoever runs the domain’s DNS decides who holds it: if another workspace on the same server proves it later, it moves to them, unless your record is still there too.

There are two ways to deploy: the button, which also sets up deploys on every push, or the command line.

Deploy to Cloudflare

The button copies this repository to your GitHub or GitLab account and connects the copy to Workers Builds, so every push to its main branch deploys. On the form:

  • Git account is the first field, the account the copy goes to. If nothing is connected yet, connect GitHub or GitLab there.
  • Project name names the Worker. It answers on <project name>.<your subdomain>.workers.dev.
  • D1 database: choose + Create new and give it a name. The form picks an existing database when one in your account has the name in wrangler.jsonc (gateweave-mail), so check it before you deploy.
  • R2 bucket holds the mail. Make a new one in the same way, and check that the form hasn’t picked an existing bucket named gateweave-mail.
  • Queue: the form creates gateweave-mail-events, which brings Email Service’s delivery events (see Email). Let it make a new one.
  • BETTER_AUTH_SECRET signs sessions. Use a long random string, for example from openssl rand -base64 32, and keep it secret. Changing it later signs everyone out, and Mail no longer knows the ids of mail sent before as its own, though their threads stay whole.
  • Leave the build and deploy commands as they are. The deploy command applies the database migrations before the new code goes live.

When the build finishes, set SECRETS_KEY, which encrypts mail (see Where mail is kept). Then open the Worker’s address, create an account and then a workspace. Anyone who can reach the address can sign up and create workspaces of their own; a workspace is only visible to its members.

Preview builds. The button turns them on: a push to any other branch builds a Preview with its own URL. A Preview gets none of the Worker’s bindings or secrets, so its address only says that it is not set up, and it cannot reach your data. To make Previews work, create another D1 database and apply the migrations to it, name it and the Durable Objects under previews in wrangler.jsonc as Cloudflare’s guide shows, and give them a secret of their own with pnpm wrangler preview base-config secret put BETTER_AUTH_SECRET. If you don’t need them, untick Enable Preview builds on the form, or turn them off later under Settings › Build › Branch control.

Dependabot. The copy brings this repository’s .github/dependabot.yml, so Dependabot opens a pull request on it within minutes, and weekly after that. You don’t need them: a release brings updates that were tested together, and what you merge can conflict with the next update. Delete the file unless you want them, and close what it opened.

Requirements: Node.js 24 (22+ works) and pnpm 12.

Terminal window
git clone https://github.com/gateweavehq/mail.git
cd mail
pnpm install
pnpm wrangler login
pnpm run deploy

pnpm wrangler runs the Wrangler version Mail pins, from the repository root. The Worker, its database and its bucket are named gateweave-mail. To use other names, change name, database_name and bucket_name in wrangler.jsonc before the first deploy.

The first deploy does three things:

  1. It creates the D1 database, the R2 bucket, the queue and the Durable Object namespaces.
  2. It generates BETTER_AUTH_SECRET, which is never shown.
  3. It applies the migrations.

Every later pnpm run deploy builds, applies new migrations, and only then deploys.

Then set SECRETS_KEY, which encrypts mail (see Where mail is kept), open the Worker’s address, create an account and then a workspace. Anyone who can reach the address can sign up and create workspaces of their own. A workspace is only visible to its members.

Every read and write of an account or a session goes to one place, the D1 database’s primary, and D1 puts it close to whoever asked for the database unless told otherwise. That may be far from your people. To choose, make the database yourself before the first deploy, with a location hint (wnam, enam, weur, eeur, apac or oc):

Terminal window
pnpm wrangler d1 create gateweave-mail --location apac

Then pick it instead of + Create new on the button’s form, or put its id in wrangler.jsonc as database_id.

Each message goes into the R2 bucket as it arrived, one object per message, and each mailbox’s Durable Object holds the index of its messages. A mailbox holds up to 1 GiB, which MAILBOX_BYTES changes; past that, a sender is told to try again later, and an owner or admin can delete messages to make room. Removing a mailbox, or deleting its workspace, removes its mail at once. The database keeps a record of each removal, and a daily run removes the mail again an hour or more later, for a removal that stopped part way, then forgets the record. Mail only touches its own mailboxes’ objects, so the bucket can hold other things too. Deleting an account leaves mail alone, since it belongs to the workspace.

Encryption. With SECRETS_KEY set, each message is encrypted (AES-256-GCM) before it reaches R2, under a key of its own that comes from SECRETS_KEY, so the bucket, or a copy of it, gives nobody the mail itself; R2 still shows each object’s name and size. Mail kept without it, or before encryption, is read as it is until a key is set, and then each mailbox encrypts it in the background, a few messages at a time, with no action from you. A mailbox starts when it next gets mail, is read, or has its alarm, so one nobody uses keeps its old mail as it came until then. The unencrypted original is deleted 15 minutes after its encrypted copy takes its place. Make 32 random bytes, keep them somewhere safe, such as a password manager, and give them to the Worker:

Terminal window
openssl rand -base64 32
pnpm wrangler secret put SECRETS_KEY

Wrangler never shows a secret again. Mail encrypted under one SECRETS_KEY can’t be read under another, so losing it loses that mail, and the workspaces’ Resend keys (see Email) with it. A key shorter than 32 bytes, or not base64, encrypts nothing, and the Worker’s log says so. Once mail is encrypted, update Mail forward only: a version from before encryption can’t read it, and one from before key changes can’t read what a later version encrypted.

Changing the key. To replace SECRETS_KEY, as when it may have leaked, keep the old one as SECRETS_KEY_PREVIOUS first, then set the new one:

Terminal window
pnpm wrangler secret put SECRETS_KEY_PREVIOUS # the key you have now
openssl rand -base64 32
pnpm wrangler secret put SECRETS_KEY # the new one

From then on, everything new is encrypted under the new key, and what is under the old one still opens. Each day the Cron Trigger encrypts again under the new key what is under the old one, and the Worker’s log says how far it got:

  • secrets: for the workspaces’ Resend keys and webhook secrets, as {"resealed":3,"unopened":0,"left":0}, up to 500 a run, which is usually all of them.
  • raw mail: for mail in R2, as {"asked":12,"messages":340,"unopened":0,"left":3}. The run asks up to 200 mailboxes a day what they hold under the old key, which wakes each to encrypt it again in the background, a few messages at a time; left is how many mailboxes haven’t said they are done. A mailbox that is read or gets mail starts on it too.

Once both say "left":0, nothing needs the old key, and it can go:

Terminal window
pnpm wrangler secret delete SECRETS_KEY_PREVIOUS

Until then, keep SECRETS_KEY_PREVIOUS set: removing it loses what is still under the old key, and so does changing the key a second time, as there is room for one previous key only. An unopened count is a secret or a message that neither key opens, which removing the old key doesn’t change: a secret is mended only by adding that workspace’s key again. If unopened is most of your mail, SECRETS_KEY_PREVIOUS is likely not the key you had: put the right one, and every mailbox is asked again. Setting a new key without SECRETS_KEY_PREVIOUS loses everything under the old one, as losing it does, and mail that arrives before SECRETS_KEY_PREVIOUS is set can be searched by its headers only.

R2 storage and Durable Object storage are billed past what the plan includes: see R2 pricing and Durable Objects pricing. R2 has no point-in-time recovery, so restoring D1 doesn’t bring back removed mail.

Everything except BETTER_AUTH_SECRET is optional. Set them in the Cloudflare dashboard (Workers & Pages › your Worker › Settings › Variables and Secrets), or from the repository root:

Terminal window
pnpm wrangler secret put BETTER_AUTH_URL

Keep tokens, keys and email addresses as secrets. Plain variables stay through a deploy too, because wrangler.jsonc sets keep_vars.

Name What it does
BETTER_AUTH_SECRET Signs sessions and the ids of the mail Mail sends. Required.
BETTER_AUTH_URL The address people use, such as https://mail.example.com. Inferred from each request when unset.
GITHUB_CLIENT_ID Turns on “Sign in with GitHub”, with GITHUB_CLIENT_SECRET.
GITHUB_CLIENT_SECRET The client secret of the same GitHub OAuth app.
OIDC_ISSUER Turns on an OpenID Connect provider, with the two below. See below.
OIDC_CLIENT_ID This deployment’s client ID at the provider.
OIDC_CLIENT_SECRET Its client secret there.
OIDC_PROVIDER_NAME What the sign-in button calls it. Unset: “single sign-on”.
EMAIL_FROM Turns on email (invitations, password reset), such as Gateweave Mail <mail@example.com>.
MAILBOX_BYTES How many bytes one mailbox may hold, as a whole number. Unset: 1 GiB.
SEND_PER_DAY How many messages one workspace may send from its mailboxes a day, as a whole number. Unset: 200.
DIRECT_DELIVERY off sends mail to this deployment’s own addresses through Email Service or Resend too. Unset: straight there.
SECRETS_KEY Encrypts mail in R2 and workspaces’ keys: 32 random bytes, base64. See Where mail is kept.
SECRETS_KEY_PREVIOUS The previous SECRETS_KEY, which only opens what it encrypted. See Where mail is kept.
EMAIL_SENDING_ACCOUNT_ID Your account ID, with the token below, so that removing a suppression also clears Email Service’s own list.
EMAIL_SENDING_API_TOKEN An API token for that account with Email Sending: Edit. See Email, under Suppressions.
LEGAL_NOTICE_URL The address of your legal notice. See below.
PRIVACY_POLICY_URL The address of your privacy policy.
TERMS_URL The address of your terms.

In the dashboard, open the Worker and add a custom domain under Settings › Domains & Routes. Or add "routes": [{ "pattern": "mail.example.com", "custom_domain": true }] to wrangler.jsonc. Then set BETTER_AUTH_URL to that address, and use it in the OAuth apps’ URLs. Sign-in needs HTTPS: session cookies are marked Secure, so a browser won’t keep them over plain HTTP. Turn on Always Use HTTPS for the domain so that nobody lands on the plain address.

Mail’s pages refuse scripts they did not name, so a zone setting that adds scripts to a page does nothing here or gets in the way. Web Analytics’ automatic setup and Rocket Loader are two of them: leave those off for this hostname.

Mail comes with no legal notice, privacy policy or terms, and never links to Gateweave’s: whoever runs a copy decides what applies to it. Set any of the three addresses above, and it is linked from the sign-in pages and the workspace menu. With TERMS_URL set, the sign-in page also says that continuing means agreeing to them.

Mail sends email through Cloudflare Email Service. Onboard the sender’s domain once with pnpm wrangler email sending enable example.com, then set EMAIL_FROM to an address on it. Without email, Mail works, but invitations have to be accepted from a link that an admin copies, and nobody can reset a forgotten password for themselves.

Mailboxes send through the same binding, from their own addresses, whether EMAIL_FROM is set or not. Onboard each domain whose addresses should send the same way. Until it is, a message from it is refused, and its copy in the thread says why. A workspace sends up to 200 messages a day, which SEND_PER_DAY changes; Email Service has limits of its own for the account, and won’t send to an address that bounced or complained before. An agent also has a day’s count of its own, which an owner or admin sets on the Agents page, and mail it writes that waits for approval counts against the workspace’s day only once it is approved.

A message whose every recipient is an address on this deployment, in any workspace, doesn’t leave it: it goes straight to their mailboxes, with no send and no spam score, and its copy shows each as delivered at once. Its sender’s domain counts as proved there. That needs DNS to agree today: every domain involved keeps its _gateweave-mail TXT record (the Domains page shows it under “Its TXT record”), and each recipient’s MX points where its mail reaches Mail. Without them, the message goes through the provider, as mail to anyone else does. A message that also goes elsewhere goes through Email Service or Resend to everyone, as the headers and the envelope are one list there. It still counts against the workspace’s day, and an agent’s still waits for approval as its policy says. Set DIRECT_DELIVERY to off to send it all through the provider, as when you test the real path.

Delivery events. Email Service says what each recipient’s mail server did with a message: delivered, delayed, bounced, failed, not sent, or marked as spam. Mail shows it under the message in its thread. The events reach the Worker through the gateweave-mail-events queue, which needs a subscription for each domain that sends through Email Service, with the domain’s zone id from its Overview page in the dashboard:

Terminal window
pnpm wrangler queues subscription create gateweave-mail-events --source email.sending \
--events message.delivered,message.deferred,message.bounced,message.failed,message.rejected,message.complained \
--zone-id <zone id> --domain example.com --name mail-events-example-com

Give each domain’s subscription a name of its own. A subscription takes a few minutes to start. Settings › Domains says when events last came for each domain that sends this way, so a missing one shows. Events for mail that other apps send from the same domain reach the queue too, and Mail drops them. Queue operations past what the plan includes are billed: see Queues pricing.

Suppressions. When mail to an address bounces for good, or its person marks it as spam, the events also put the address on the workspace’s suppression list, and its mailboxes stop sending to it: a send to it is refused, naming it. A complaint stays until someone removes it; a bounce, or a send Email Service refused for its own list, ends after a week, except a bounce for a mailbox or domain that doesn’t exist. Owners and admins see the list in Settings › Suppressions and can remove an address there. Email Service keeps a list of its own for the account, which can still refuse the address: see it in the dashboard under Compute > Email Service > Email Sending > Suppressions. With EMAIL_SENDING_ACCOUNT_ID and EMAIL_SENDING_API_TOKEN set, removing an address in Mail also deletes what Email Service listed after a bounce or a complaint for that workspace’s own domains, and the page says what stays there: an entry for the whole account, one added by hand, and one marked read-only, which only you can change. Every workspace’s domains share that list, so Mail never touches another domain’s entries. Make the token under My Profile > API Tokens, as a custom token with the account permission Email Sending: Edit for this account only. Leave both unset to keep that list yours alone: the page then tells admins that it may still have the address. Without the subscription, Mail’s list stays empty and only Email Service’s applies.

A domain whose DNS isn’t on the Cloudflare account that runs Mail can’t be onboarded there, so it can send through Resend instead, with each workspace’s own key, which SECRETS_KEY encrypts (see Where mail is kept): none can be added without it. Then, in the workspace, an owner or admin verifies the domain at Resend, adds a Resend key under Settings › Domains, and sets the domain to send with Resend. With a key that has full access, Mail makes a webhook in that Resend account, which tells it what became of each message and keeps the workspace’s suppression list (see below for the address it needs). Removing an address in Mail then takes it off that Resend account’s own list too, which holds for all its domains, and removing it in Resend’s dashboard takes it off Mail’s when the workspace’s mail put it there, unless Resend said it again since. A key that may only send still sends, but Mail then learns nothing of bounces or complaints, and can’t take an address off Resend’s list: the page says to do that in Resend. Change SECRETS_KEY only as Where mail is kept says, keeping the old one as SECRETS_KEY_PREVIOUS: the keys sealed with it can’t be opened with another, and would have to be added again. Resend’s own limits and quotas apply to that account.

Such a domain can receive through Resend too. In Resend, turn on receiving for the domain and add the MX record it shows; as a domain’s mail goes to one place, use a subdomain when the domain already gets mail elsewhere. The workspace’s key then needs full access, since a sending-only key can’t read received mail. Set the domain to receive with Resend under Settings › Domains: Mail makes the webhook in that Resend account, pointing at /api/inbound/resend/… on the server’s address (BETTER_AUTH_URL), which has to be reachable from the internet. Once no domain sends or receives with Resend, the webhook stays two weeks for what Resend still tells of mail sent before, then the daily Cron Trigger removes it. Resend turns off a webhook whose deliveries keep failing, as while the server is down for long; “Check the webhook” on the same page turns it back on. A key put in later, as one from another Resend account, gets the webhook made in its account. Resend takes mail for every address on the domain, so mail to an address that doesn’t exist in Mail is left out rather than refused, and stays in Resend.

As you hold the database, you can do it for them, from the repository root. Find their id in this list. Don’t paste an address someone sent you into a command, as it could run as part of it:

Terminal window
pnpm wrangler d1 execute DB --remote --command "SELECT id, email FROM user ORDER BY email"

Then make a link for that id. It works once, for an hour, and using it signs them out everywhere. Whoever opens it can choose the password, so send it only to them:

Terminal window
PERSON=the-id-from-that-list
TOKEN=$(openssl rand -hex 32)
pnpm wrangler d1 execute DB --remote --command \
"INSERT INTO verification (id, identifier, value, expires_at) VALUES ('$TOKEN', 'reset-password:$TOKEN', '$PERSON', (unixepoch() + 3600) * 1000)"
echo "https://your-server/reset-password?token=$TOKEN"

Sign in through an OpenID Connect provider

Section titled “Sign in through an OpenID Connect provider”

Any OpenID Connect provider can sign people in: Gateweave ID, Pocket ID, authentik, Keycloak, Okta and others. It adds a button to the sign-in page, and passwords keep working.

  1. Register Mail with the provider as a confidential client that uses the authorization code flow and client_secret_basic, with the scopes openid, profile and email:
    • Redirect URI: https://mail.example.com/api/auth/callback/oidc (your address)
    • Back-channel logout URI, if the provider has them: https://mail.example.com/api/auth/backchannel-logout/oidc
  2. Set OIDC_ISSUER to the provider’s issuer, the address its /.well-known/openid-configuration is under, then OIDC_CLIENT_ID and OIDC_CLIENT_SECRET. OIDC_PROVIDER_NAME names the button, as in “Continue with Acme”.

Someone new gets an account, named as the provider names them, and can change that name in Mail without the provider changing it back. Someone whose address already has one links to it only when the provider says the address is verified and they have verified it in Mail too, so anyone who could sign up at the provider with that address cannot take over the account. Without email nobody can verify an address in Mail, so existing accounts stay with their passwords.

If the provider is down, its button says so, and everything else keeps working. People already signed in stay signed in, because Mail asks the provider nothing except when someone uses that button or the provider signs someone out. With the back-channel logout URI registered, signing out at the provider ends the Mail sessions it started. Signing out of Mail leaves the provider’s session alone.

A member’s picture shows when it comes from the provider’s own host or from GitHub. From anywhere else, the page’s policy stops it and their initials show instead.

If Mail and the provider are both Workers, give each a Custom Domain. A Worker cannot reach another on its own zone through a route, and two on workers.dev cannot reach each other at all: Cloudflare answers with error 1042, and the button says it could not reach the provider. If they have to stay on workers.dev, add global_fetch_strictly_public to compatibility_flags in each one’s wrangler.jsonc.

People delete their own account on /settings/account, with no setting to turn on. The account, its sessions and sign-ins, its memberships, the invitations it sent and the invitations addressed to it are deleted from D1 at once. An account that is the only owner of a workspace is refused until someone else owns it or it is deleted. Workspaces they belonged to stay with the people still in them. Cloudflare keeps point-in-time recovery for D1 (30 days on Workers Paid, 7 on Free), and restoring D1 to a time before a deletion brings the person back.

Command line:

Terminal window
git pull
pnpm install
pnpm run deploy

The deploy applies new D1 migrations before the new code goes live.

Button: your copy does not follow this repository on its own, and shares no history with it, so git pull refuses. This takes the latest release into your copy instead, as one commit on top of it. A copy can skip releases, however old it is:

Terminal window
git remote add upstream https://github.com/gateweavehq/mail.git # once
git fetch upstream
git show upstream/main:scripts/update-copy.mjs | node --input-type=module
git push

The script pushes nothing, and git reset --hard HEAD~1 undoes it until you do. The push is what deploys: Workers Builds builds it and applies the D1 migrations.

Since delivery events came in, Mail also deploys a queue. If the build of an update stops, saying its API token can’t reach Queues, add the permission Account › Queues › Edit to that token (the Worker’s Settings › Build shows which one it is, under My Profile › API Tokens), then retry the build. The deploy stops before it changes anything.

If GitHub refuses the push with push declined due to email privacy restrictions, the commit carries the address your GitHub settings keep private. Use the noreply address those settings show you, and make the commit again:

Terminal window
git config user.email "<your noreply address>"
git commit --amend --reset-author --no-edit
git push

What your copy changed stays: the Worker’s and database’s names in wrangler.jsonc, the name in package.json, and having no workflows. If something else you changed conflicts with the release, the script names the files for you to fix before committing. To take a release other than the latest, end the third line with node --input-type=module - --to v1.2.3.

Mail is in early development. Read CHANGELOG.md before updating, as some releases change how things work.