Configuration
Everything your server reads comes from wrangler.jsonc, from Worker secrets, or from rows in D1. This page lists all of it, then covers the optional features: notifications, Swiftly signatures, Gmail import, backups and the JMAP endpoint for other clients.
After editing wrangler.jsonc, deploy again with pnpm run deploy. Secrets take effect without a deploy.
Variables
Set these under vars in wrangler.jsonc.
| Variable | Default | What it does |
|---|---|---|
APP_ORIGIN | https://app.mailopus.net | Where the UI runs. Passkey sign-ins must come from this origin, writes and WebSockets from any other origin are refused, and CORS answers only this origin. |
RP_ID | mailopus.net | The passkey domain. It must be APP_ORIGIN's host or a parent of it. Change it only together with APP_ORIGIN. |
IMAP_HOST | unset | The IMAP proxy's hostname, shown in Settings next to app passwords. Unset means Settings says there's no proxy. See Mail apps. |
SMTP_HOST | unset | The SMTP hostname, shown with IMAP_HOST. Ignored without it. |
VAPID_PUBLIC_KEY | unset | Web Push public key. Set it only together with the VAPID_PRIVATE_KEY secret. See Notifications. |
DEV_ORIGIN and SWIFTLY_BASE also exist, for local development against a dev server or a local Swiftly. Leave them unset in production.
Serving the UI yourself
Your Worker serves the full UI too, not only the API. If you set APP_ORIGIN to your Worker's own origin and RP_ID to its host or parent domain, you can use Mailopus at your Worker's URL with no mailopus.net involved. On workers.dev, RP_ID has to be the full host, mailopus.<your subdomain>.workers.dev, because workers.dev is a public suffix. A custom domain such as mail.example.com can use example.com.
Passkeys made for mailopus.net don't work at the new origin. Make a new one with an invite link whose base URL is the new origin:
MAILOPUS_DB=mailopus node scripts/invite.mjs sam@example.com https://mail.example.com
This setup works in the code, but the supported and tested path is app.mailopus.net.
Secrets
Set each with npx wrangler secret put <NAME> from your clone. Wrangler asks for the value, so it stays out of your shell history. Remove one with npx wrangler secret delete <NAME>.
| Secret | What it does |
|---|---|
SPAM_CLASSIFIERS | JSON list of remote spam classifiers. See Spam. |
VAPID_PRIVATE_KEY | Web Push private key as a JWK string, paired with VAPID_PUBLIC_KEY. |
INGEST_SECRET | Shared with the relay Worker for domains in another Cloudflare account. See Addresses. Without it, /api/ingest refuses everything. |
GOOGLE_SA_<NAME> | A Google service account key, as JSON, for Gmail import. See Gmail import. |
Bindings and the rest of wrangler.jsonc
| Key | Value | What it's for |
|---|---|---|
name | mailopus | The Worker's name, and part of its workers.dev URL |
main | ./.output/server/index.mjs | The built Worker. nuxt build makes it. |
workers_dev | true | Serves the Worker at mailopus.<your subdomain>.workers.dev. Your _mailopus TXT record can name that URL. |
preview_urls | false | No per-version preview URLs |
ratelimits AUTH_LIMIT | 20 a minute | Caps sign-in, invite and claim calls per IP, and failed app-password sign-ins per username |
compatibility_date, compatibility_flags | 2026-11-01, nodejs_compat | Workers runtime version and Node.js APIs |
assets ASSETS | ./.output/public, run_worker_first: true | The UI's static files. run_worker_first sends every request, static files included, through the auth gate first. |
observability | on | Logs in the dashboard. npx wrangler tail streams them. |
d1_databases DB | mailopus | Accounts, addresses, message index, sessions, passkeys, settings |
durable_objects ACCOUNT | class Account | One per account. Runs every write to that account in order, holds the live WebSockets and sends scheduled mail. |
migrations | v1, SQLite class Account | The Durable Object class's own migration, separate from D1's |
send_email EMAIL | Outbound mail through Email Sending | |
queues IMPORT_QUEUE | mailopus-import | Gmail import, one page per message, up to 4 at once, 10 retries |
triggers crons | */5 * * * * | Every 5 minutes: start and restart import jobs, sync Swiftly signatures, backfill old rows |
r2_buckets MAIL | mailopus-mail | Each message's raw .eml, under raw/<account id>/ |
The template has no services entry. app.mailopus.net binds its private spam Worker there. See Spam.
Settings in D1
Some state lives in the instance_settings table:
| Key | What it is |
|---|---|
claimed | When the server was claimed. Its presence keeps the claim page closed. |
vapid | The Web Push key pair the server made for itself |
swiftly-connect:<state> | A Swiftly connection in progress, deleted after 10 minutes |
Per-account settings, such as blocked senders, address groups and highlight exclusions, live in their own tables and are edited in Settings.
Notifications
Settings > This device > Notifications turns on a push notification for each new Inbox message, per browser. On iPhone and iPad, add Mailopus to the home screen first. Safari only allows notifications there.
Mail filed in Junk, mail you sent yourself, and imported mail never notify. The message is encrypted for your browser, so Apple's and Google's push services carry it without being able to read it.
Push needs no setup. On first use the server makes a VAPID key pair and keeps it in D1. Set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY only to bring an existing key pair, for example when moving to a new server with existing subscriptions. Changing the keys breaks every existing subscription, so each browser has to turn notifications off and on again.
Swiftly signatures
Swiftly keeps email signatures up to date across a team. Mailopus can let Swiftly manage an address's signature.
- Settings > Addresses, then an address's menu > Signature…
- Pick Swiftly and connect. Your browser goes to swiftly.biz to approve, then comes back.
- Choose whether Swiftly manages this address only or all your addresses.
The connection uses an authorization code with PKCE. The Swiftly token stays on your Worker and never reaches the browser. The 5-minute cron syncs each managed address at most every 30 minutes.
While Swiftly manages a signature, the dialog shows it read-only. Swiftly refuses to sync over an edit made somewhere else. Turning Swiftly on keeps what you wrote, and Swiftly adds its own part. Disconnect stops the updates and leaves the signatures as they are.
Gmail import
Mailopus can copy a Google Workspace mailbox into an account. It's built for Workspace, not personal Gmail. It reads mail through a Google service account with domain-wide delegation, which only a Workspace admin can grant. There's no UI. You drive it with scripts/import.mjs.
What comes over:
- Every message, as its original raw file
- Read and starred state, and drafts
- Inbox, Sent, Drafts, Spam and Trash into the matching mailboxes
- Your Gmail labels as labels
It's safe to run again. Each message is stored once per account, so a rerun skips what's already there.
1. Make a service account
In Google Cloud:
- Create a project, or use one you have.
- Enable the Gmail API.
- Create a service account with no roles. Create a JSON key for it and download it.
- Note the service account's numeric client ID.
2. Grant domain-wide delegation
As the Workspace's admin, open admin.google.com > Security > Access and data control > API controls > Manage domain-wide delegation. Add the client ID with exactly one scope:
https://www.googleapis.com/auth/gmail.readonly
This lets the key read every mailbox in the Workspace. Treat the key file like a password, and delete it after the next step.
3. Store the key as a secret
Pick a short name for the Workspace, such as work. From your clone:
npx wrangler secret put GOOGLE_SA_WORK < path/to/key.json
Then delete the downloaded key file.
4. Start an import
MAILOPUS_DB=mailopus node scripts/import.mjs start work sam@company.example sam@example.com
The arguments are the secret's name without GOOGLE_SA_, the Gmail address to read, and any address the target Mailopus account sends as. The 5-minute cron picks up the job, and the queue fetches 100 messages per page.
Watch and control it
MAILOPUS_DB=mailopus node scripts/import.mjs status
| Command | What it does |
|---|---|
status | Lists jobs with progress, counts and the last error |
pause <job id> | Stops after the current page |
resume <job id> | Continues where it stopped |
rescan <job id> | Walks the mailbox again from the start. Stored mail is skipped, so only missing messages are fetched. |
A job id prefix is enough. Every command needs MAILOPUS_DB=mailopus.
When you're done, delete the secret with npx wrangler secret delete GOOGLE_SA_WORK and remove the delegation in the admin console.
Backups
Mailopus has no backup feature of its own. Your data is in two places, and both need covering:
- D1 holds accounts, addresses, the message index, flags, mailboxes, sessions and passkeys.
- R2 holds each message's raw file.
The Account Durable Objects hold only scheduling state, nothing you'd need to restore.
D1
D1 Time Travel keeps a restore point for every minute of the last 30 days on Workers Paid. To undo a mistake:
npx wrangler d1 time-travel info mailopus
npx wrangler d1 time-travel restore mailopus --timestamp=<unix time or RFC 3339>
A restore replaces the whole database. For a copy outside Cloudflare, export it to a file on a schedule:
npx wrangler d1 export mailopus --remote --output=mailopus-$(date +%F).sql
R2
R2 keeps no versions of its own. To copy the bucket somewhere else, make an R2 API token with read access in the dashboard under R2 > Manage API tokens, then use any S3 tool. With rclone set up for R2 as a remote called r2:
rclone sync r2:mailopus-mail /path/to/backup/mailopus-mail
Each file is a standard .eml that any mail program opens. Together with a D1 export, that's a full copy of your mail.
JMAP for other clients
Mailopus speaks JMAP, RFC 8620 and RFC 8621, so a JMAP client can use it directly with no proxy.
| Session | https://<your worker>/.well-known/jmap, also at /jmap/session |
| API | /jmap/api |
| Upload | /jmap/upload/{accountId}/ |
| Download | /jmap/download/{accountId}/{blobId}/{name} |
| Auth | HTTP Basic with your email address and an app password |
Methods: Mailbox/get, Mailbox/changes, Mailbox/set, Thread/get, Email/get, Email/query, Email/changes, Email/set, Email/import, Identity/get, Identity/set, EmailSubmission/get and EmailSubmission/set.
Limits: 25 MB per upload, 10 MB per request, 32 calls per request, 500 objects per get or set, 4 concurrent requests.
What's missing or partial:
- The session gives its URLs as paths, not full URLs. Clients that expect absolute URLs may fail.
- It lists an
eventSourceUrl, but/jmap/eventsourceisn't served yet, so push over EventSource doesn't work. - The web UI gets live updates over a WebSocket at
/jmap/ws. The session doesn't announce the JMAP WebSocket capability, so other clients won't find it. Mailbox/setrenames and destroys labels. It can't create one, and the standard mailboxes can't be renamed or destroyed. Labels appear when mail is filed into them.- Calendars and contacts aren't there.
Browsers can't call the API from another site. CORS answers only APP_ORIGIN. Native and server-side clients aren't affected.