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 with the button
Section titled “Deploy with the button”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.
Deploy from the command line
Section titled “Deploy from the command line”Requirements: Node.js 24 (22+ works) and pnpm 12.
git clone https://github.com/gateweavehq/mail.gitcd mailpnpm installpnpm wrangler loginpnpm run deploypnpm 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:
- It creates the D1 database, the R2 bucket, the queue and the Durable Object namespaces.
- It generates
BETTER_AUTH_SECRET, which is never shown. - 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.
Where the database lives
Section titled “Where the database lives”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):
pnpm wrangler d1 create gateweave-mail --location apacThen pick it instead of + Create new on the button’s form, or put its id in wrangler.jsonc as
database_id.
Where mail is kept
Section titled “Where mail is kept”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:
openssl rand -base64 32pnpm wrangler secret put SECRETS_KEYWrangler 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:
pnpm wrangler secret put SECRETS_KEY_PREVIOUS # the key you have nowopenssl rand -base64 32pnpm wrangler secret put SECRETS_KEY # the new oneFrom 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;leftis 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:
pnpm wrangler secret delete SECRETS_KEY_PREVIOUSUntil 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.
Settings
Section titled “Settings”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:
pnpm wrangler secret put BETTER_AUTH_URLKeep 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. |
Your own domain
Section titled “Your own domain”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.
Your legal pages
Section titled “Your legal pages”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:
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-comGive 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:
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:
PERSON=the-id-from-that-listTOKEN=$(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.
- Register Mail with the provider as a confidential client that uses the authorization code
flow and
client_secret_basic, with the scopesopenid,profileandemail:- 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
- Redirect URI:
- Set
OIDC_ISSUERto the provider’s issuer, the address its/.well-known/openid-configurationis under, thenOIDC_CLIENT_IDandOIDC_CLIENT_SECRET.OIDC_PROVIDER_NAMEnames 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.
When someone deletes their account
Section titled “When someone deletes their account”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.
Updating
Section titled “Updating”Command line:
git pullpnpm installpnpm run deployThe 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:
git remote add upstream https://github.com/gateweavehq/mail.git # oncegit fetch upstreamgit show upstream/main:scripts/update-copy.mjs | node --input-type=modulegit pushThe 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:
git config user.email "<your noreply address>"git commit --amend --reset-author --no-editgit pushWhat 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.