Sign-in and discovery
You sign in at app.mailopus.net, but your account lives on your own Worker. This page explains how the UI finds your Worker, where your passkey and session live, and what mailopus.net can and can't see.
Finding your Worker
When you type sam@example.com on the sign-in page, the browser:
- takes the domain,
example.com - asks Cloudflare's public DNS-over-HTTPS resolver,
cloudflare-dns.com, for the TXT record at_mailopus.example.com - uses the first answer that parses as an
https://URL, keeping only its origin
That origin is your Worker. Every call after that goes from your browser straight to it. The code is lookup in public/auth/backend.js.
With no usable record, the page falls back to app.mailopus.net itself. Your passkey isn't there, so sign-in fails with "Sign-in failed."
The claim page does the same lookup. It also takes the Worker in the link's fragment, which helps while DNS is still spreading:
https://app.mailopus.net/auth/claim#server=https://mailopus.<your subdomain>.workers.dev
Passkeys
Mailopus has no passwords for the web UI. You sign in with a passkey.
- The passkey ceremony runs on app.mailopus.net with relying party id
mailopus.net. That's whatAPP_ORIGINandRP_IDinwrangler.jsoncsay, and the two go together. - Your Worker stores the passkey's public key and checks each sign-in against it. mailopus.net stores nothing.
- Passkeys are discoverable, so the sign-in page doesn't send your address to the Worker. It only uses the address to find the Worker.
- Your device has to unlock the passkey with a fingerprint, face or PIN. Mailopus requires user verification.
Because the passkey is bound to mailopus.net, it works only through app.mailopus.net. If you change APP_ORIGIN and RP_ID to serve the UI from somewhere else, your existing passkeys stop working there, and you need a new one through an invite link, as described below.
Sessions
A successful sign-in starts a 30-day session on your Worker.
- At app.mailopus.net, which is a different origin from your Worker, the session is a bearer token. The browser keeps it in the Cache API and in localStorage, so the app's service worker can use it too. Safari drops cookies on cross-site requests, which is why it isn't a cookie.
- WebSockets can't carry headers, so the live connection at
/jmap/wsoffers the token as a WebSocket subprotocol. - On a same-origin setup, where the UI comes from your own Worker, the session is a
__Host-cookie instead.
Settings > Account > Sign out ends the session on the Worker and clears the token from the browser.
What the gate checks
Every request to your Worker passes server/lib/gate.ts first, static files included.
/api/*,/jmap/*and/.well-known/jmapneed a valid session, or on JMAP paths an app password.- Writes and WebSockets must come from
APP_ORIGIN. Requests from other sites are refused with 403. - Cross-origin calls get CORS headers only when their
OriginisAPP_ORIGIN. - Sign-in, invite and claim endpoints are open, but each IP gets 20 requests a minute, set by
AUTH_LIMIT. Past that the Worker answers 429 and the page says "Too many attempts. Wait a minute." - The app's HTML gets a strict Content Security Policy, and every response gets
noindexand no-frame headers.
app.mailopus.net shows anonymous visitors a landing page instead of the app. The app loads only for browsers that carry a session cookie or the mailopus_pass cookie the sign-in pages set. That cookie isn't a credential. It keeps crawlers off the app bundle. The real checks are on your Worker.
What mailopus.net sees
- It serves the HTML and JavaScript of the UI.
- It never sees your mail, your session token or your passkey's private key. Your browser sends those to your Worker only.
- The DNS lookup goes to Cloudflare's public resolver, not to mailopus.net.
- If you opt in to the hosted spam rules, your Worker sends each inbound message's header block to spam.mailopus.net. See Spam.
Add a passkey or another device
Passkeys saved to iCloud Keychain or Google Password Manager sync to your other devices on their own. For a separate passkey, such as a security key or a device outside that sync, make an invite link from your clone:
MAILOPUS_DB=mailopus node scripts/invite.mjs sam@example.com
It prints a one-time link valid for 24 hours, like https://app.mailopus.net/auth/invite#token=.... The script doesn't add your Worker to the link, so add it yourself before you open it:
https://app.mailopus.net/auth/invite#token=...&server=https://mailopus.<your subdomain>.workers.dev
Open it on the new device and create the passkey. The link works once. A third argument changes how many hours it lasts, and the second changes the base URL.
To add a new person, run it with their name in NAME, as in Other people on your server.
Lost every passkey
If no passkey works anymore, make an invite link as above. It needs only your Cloudflare login, not a Mailopus sign-in. That's the recovery path, so keep access to your Cloudflare account safe.