Pre-1.0. The wire shapes, storage formats, and config keys can change without notice. There is no third-party security audit. Run it for development, testing, and self-hosted experiments.
legacy-proxy is a translation layer that puts a JMAP for Mail server in
front of a classic IMAP / SMTP / ManageSieve / CardDAV stack. It speaks
RFC 8620 + RFC 8621 to clients, and standard mailbox protocols to whatever
server already holds the user's mail. No new mail store, no migration: the
mail keeps living in the existing IMAP server, and a modern JMAP client
sees the account as if it were native.
The motivation is simple. JMAP is a much better fit for modern clients than
IMAP. It batches operations into a single HTTP round-trip, ships diffs
through */changes instead of forcing clients to walk every UID, pushes
state notifications over EventSource and Web Push, and exposes contacts
and vacation responders as first-class objects instead of out-of-band Sieve
scripts and CardDAV trees the user has to discover. But almost nobody
operates a JMAP backend. Gmail, Fastmail aside, most hosting providers, ISPs,
and self-hosted setups still ship IMAP only. This proxy lets a JMAP client
target any of them without the operator having to swap their mail server.
What that means concretely:
- A JMAP client (Bulwark webmail, JMAP-enabled mobile apps, custom tooling) authenticates against this proxy. The proxy holds an IMAP connection open to the real mail server and translates each JMAP method into the equivalent IMAP / SMTP / ManageSieve / CardDAV operation.
- State changes get fanned out through both Server-Sent Events and the
RFC 8620 §7.2 push subscription mechanism. A dedicated IDLE socket per
active account turns IMAP
EXISTS/EXPUNGE/FETCH FLAGSnotifications into JMAPEmailDelivery/Email/Mailboxstate bumps in real time. - Credentials are sealed into an AES-256-GCM vault stored in SQLite, so the proxy can keep working with the upstream server across restarts without asking the user to log in again.
- Auth on the front accepts either a Bearer token minted by the proxy's
/api/loginendpoint or plain HTTP Basic, which is enough to run the upstream JMAP compliance suite straight against it.
It is built primarily for Bulwark Mail's webmail,
but the proxy is independent of any one client: anything that speaks
RFC 8621 should work, and the test suite exercises it with the official
jmapio/jmap-test-suite.
JMAP method coverage:
| Type | Methods |
|---|---|
| Core | Core/echo, Blob/copy (rejects with fromAccountNotFound) |
| Mailbox | get, query, queryChanges, changes, set |
get, query, queryChanges, changes, set, copy, import, parse |
|
| SearchSnippet | get (returns null snippets; IMAP exposes no match offsets) |
| Thread | get, changes (persistent header index in SQLite, updated incrementally per folder) |
| Identity | get, set, changes |
| EmailSubmission | get, query, changes, set (with onSuccessUpdateEmail / onSuccessDestroyEmail) |
| VacationResponse | get, set, changes (full body + dates round-tripped through Sieve) |
| PushSubscription | get, set (verification handshake, relay forwarding, expiry caps) |
| AddressBook | get, changes, set (extended MKCOL / PROPPATCH / DELETE via CardDAV) |
| ContactCard | get, query, queryChanges, changes, set (PUT / DELETE via CardDAV) |
| Quota | get (stub returning empty list, so probing clients don't error) |
Capabilities advertised on the Session resource:
urn:ietf:params:jmap:coreurn:ietf:params:jmap:mailurn:ietf:params:jmap:submissionurn:ietf:params:jmap:vacationresponseurn:ietf:params:jmap:contacts(only when the active provider has CardDAV)urn:bulwark:params:jmap:sieve(vendor capability used by the vacation handler)
Transport:
POST /jmap,GET /jmap/session,/.well-known/jmapredirect.GET /jmap/download/{accountId}/{blobId}/{type}/{name}for both IMAP-backed message blobs and previously-uploaded blobs.POST /jmap/upload/{accountId}with a 24h retention sweep.GET /jmap/eventsource(RFC 8620 §7.3). Realstateevents on every counter bump, withtypes,closeafter, andpingquery params.PushSubscription/setruns a one-shotPushVerificationPOST against the subscriber URL; once verified, every state change is forwarded as aStateChangePOST. 404 / 410 responses retire the subscription; 8 consecutive non-2xx responses also retire it.- A push URL must be
httpsand its host must resolve to public addresses only. This is checked when the subscription is created and again for every POST: the host is resolved once and the POST goes to the address that was checked. Redirects are not followed, and a failed delivery logs the host, never the URL. A relay on a private network, or one reached over plainhttp, needsPUSH_ALLOW_PRIVATE_URLS=1. When upgrading from a version without this check: a subscription whose URL ishttpstops being delivered unlessPUSH_ALLOW_PRIVATE_URLS=1is set. Each delivery then fails inside the proxy (logged with the host), and after 8 failed deliveries the proxy removes the subscription. A released Bulwark webmail then tries to create a new one at its daily push resync, is refused ("push URL must use https") and doesn't tell the user, so push stays off. Before upgrading, serve the push relay overhttpsand point the webmail's relay URL at it, or setPUSH_ALLOW_PRIVATE_URLS=1(which also lifts the private-address check). Users get push back at the webmail's next resync, or at once with Re-register under "Notifications when Bulwark is closed" in its settings. - IMAP IDLE: the proxy keeps a dedicated IMAP socket per account that has at
least one verified push subscription or an open event stream
(
/jmap/eventsource), watching INBOX. After the account's last stream closes the socket stays two more minutes, so clients that reconnect (or usecloseafter=state) don't log in again each time. New arrivals bumpEmailDelivery(andEmail,Mailbox); other-device flag changes bumpEmail; expunges bumpEmailandMailbox.
Backends:
- IMAP via imapflow, one connection per account in a request-path pool (separate from the IDLE socket).
- ManageSieve (RFC 5804) for the vacation autoresponder.
- SMTP Submission via nodemailer.
- CardDAV (RFC 6352) for AddressBook and ContactCard. Reads are live
PROPFIND /
addressbook-multiget; writes arePUTwithIf-None-Match: *(create) orIf-Match(update),DELETE, extendedMKCOL(RFC 5689) andPROPPATCH. Cards are re-serialised as vCard 4.0 on update; properties the JSContact projection doesn't model (PHOTO, IMPP, X-*, …) are carried over untouched. A CardDAV account with no collections at all (a fresh Radicale user, for example) gets aContactsaddress book created on the firstContactCard/set. Requests, and the user's credentials, go only to the configuredcarddavhost, port and scheme. An href or a redirect that points anywhere else is not followed, during discovery too; a redirect on the same host is (at most four hops). A provider whose/.well-known/carddavredirects to another host (iCloud-style setups) therefore has no contacts until that host is configured directly: sethost,portandsecureto the server the redirect leads to, andbasePathto the DAV root there, orprincipalPathto the user's principal to skip discovery.
Auth and storage:
- IMAP-side mechanisms:
PLAIN,LOGIN,XOAUTH2. Bring-your-own-token works for OAuth providers. - HTTP-side:
Authorization: Bearer <token>(HMAC-SHA-256 session tokens) andAuthorization: Basic ...(probed against IMAP, then cached for 5 min). - Credentials sealed with AES-256-GCM and stored in SQLite.
- State, mailboxes, identities, vacation cache, push subscriptions, and the
upload table all live in a single better-sqlite3 database under
DATA_DIR.
Sort and filter:
- Server advertises
emailQuerySortOptions: ["receivedAt"]. A purereceivedAtsort (what clients send by default) is answered from UID order with no per-message FETCH. The handler also acceptssize,from,to,subject,sentAt, andhasKeyword(those pay a per-match FETCH). hasAttachmentfilter is rejected: IMAP without a server-side flag for it cannot answer cheaply.*/changesandEmail/queryChangesuse a real change log seeded by IDLE /Email/set/Mailbox/set, so a client with a recentsinceStategets a precise diff. When the log has rotated past the requested state, the proxy returnscannotCalculateChanges.
- WebSocket transport (
@fastify/websocketis in the deps tree but no/jmap/wshandler is registered, so the capability is not advertised). - CardDAV cards live in exactly one collection, so
ContactCard/setrejectsaddressBookIdschanges (moving a card between books) withinvalidProperties.AddressBook/setonly persistsnameanddescription;isDefault,sortOrder,isSubscribedandcolorhave no CardDAV equivalent and are accepted but ignored. No sharing (shareWith). - The JSContact ⇄ vCard translation covers name, nicknames, emails, phones, organisations, titles, addresses, notes, links, anniversaries, kind and group members. Other JSContact properties sent on create (media, onlineServices, …) are dropped; on update the corresponding vCard lines are preserved as-is.
- Multi-mailbox membership: an Email lives in exactly one IMAP folder. JMAP operations that try to add or remove a mailbox membership treat the move as a copy + expunge, which produces a new id rather than preserving the old one. The compliance allowlist documents the affected upstream tests.
Thread/changesfor the case where the last email of a thread is destroyed (the index has no live thread to look up; allow-listed).- Cross-account
Blob/copy(no shared blob namespace between IMAP accounts). - Web Push payload encryption (
keyson PushSubscription is accepted but ignored; the Bulwark relay re-encrypts with its own VAPID key).
You need Docker.
mkdir legacy-proxy && cd legacy-proxy
cat > .env <<EOF
VAULT_KEY=$(openssl rand -base64 32)
SESSION_HMAC_KEY=$(openssl rand -base64 32)
EOF
chmod 600 .env
curl -fsSLo providers.json https://raw.githubusercontent.com/bulwarkmail/legacy-proxy/main/providers.example.json
curl -fsSLo compose.prod.yml https://raw.githubusercontent.com/bulwarkmail/legacy-proxy/main/compose.prod.yml
$EDITOR providers.json # point the `generic` entry at your IMAP/SMTP/Sieve/CardDAV hosts
docker compose -f compose.prod.yml up -dcurl http://localhost:8080/healthz returns {"ok":true} once the server is
up. Clients connect via http://localhost:8080/.well-known/jmap. The compose
files publish port 8080 on 127.0.0.1 only; other machines reach the proxy
through a reverse proxy (see TLS).
git clone https://github.com/bulwarkmail/legacy-proxy.git
cd legacy-proxy
npm run setup
docker compose up -dnpm run setup writes .env with fresh keys and copies providers.example.json
to providers.json. It refuses to clobber existing files; pass -- --force
to overwrite both.
For local development without Docker:
npm install
npm run setup
npm run dev # tsx watch, reads .env automaticallyTwo flows are supported.
curl -s http://localhost:8080/api/login \
-H 'content-type: application/json' \
-d '{"username":"you@example.com","password":"...","provider":"generic"}'The response carries { token, accountId, apiUrl }. Use the token as
Authorization: Bearer <token> on subsequent JMAP requests. The login endpoint
opens a probe IMAP session with the supplied credentials, seals them into the
vault, and only mints a token if IMAP accepts.
A token is valid for 7 days. It ends earlier when the password behind it is
replaced. When a sign-in (through /api/login or HTTP Basic) succeeds with a
password other than the stored one, the proxy answers it at once and then
checks the stored password at the mail server until the server accepts or
refuses it:
- If the server refuses the stored password, the new one replaced it: the proxy
stores the new password and moves the account's credential generation. From
then on every session made with the old password is answered
401, the IMAP connections that logged in with it, the push IDLE connection included, are closed, its event streams end, and the push subscriptions made before the change end. Sessions made with the new password, and the subscriptions and streams they opened, keep working. - If the server still accepts the stored password, both are valid (one app password per device, for example): nothing changes, and each device's Basic header is cached as usual. The stored password is not checked again for that second password for an hour.
Until the check has an answer, only sessions made with the new password are
served, and the proxy logs in with the new password for its own connections.
Every other session of the account, and a sign-in with yet another password,
waits for the answer while an attempt is due within 15 seconds and is then
answered 401 or served as the answer says; otherwise it gets 503 with
Retry-After. Event streams those sessions opened before end when the check
starts; they can be opened again once it has its answer. Push subscriptions
made before the check get nothing while it is open; they deliver again if it
ends "accepted" and end if it ends "refused". A sign-in with the stored
password answers the check itself: both are valid. A sign-in with the new
password changes nothing about it. While a check opened by another password is
open, this holds every other session of the account, including one the owner
makes with a password they have just set: normally for milliseconds, up to an
hour if the check is in its hourly phase, and up to 24 hours if the mail server
gives no verdict.
Only a refusal of the credentials answers "refused". An attempt that gets no answer (the server can't be reached, answers "try again later" with one of the codes listed below, or no login slot is free) is repeated after 5 seconds, 30 seconds, 1, 2 and 5 minutes, five times every 10 minutes, then once an hour: at most 11 attempts in the first hour and one an hour after that, so a server that answers a locked account with "try again later" gets too few logins to keep the lock going. The attempts are the proxy's own logins with the password it holds: no client's address is counted for them and no client's hold delays them. They have 5 login slots of their own across all accounts, apart from the 20 for callers.
A check that still has no answer 24 hours after it opened (after at most 34
attempts) counts as refused: the new password stays, and every device that
signed in with the old password is signed out (401) and loses its push
subscriptions. Its owner signs in again; if the old password still works at
the mail server, that sign-in checks the new one in the same way. The check is
kept in the database, so a restart holds the same sessions and goes on with the
attempts, and with the 24 hours, where it left off.
Signing in again with the same password ends nothing. Tokens issued by versions before this check carry no generation and keep working until the account's password first changes.
The proxy also notices a password that was changed at the mail server. When the
server refuses the stored password three times in a row (when the push IDLE
worker reconnects, or the pool opens a new connection), the stored credentials
are marked refused: the proxy stops logging in with them, every token issued
before is answered 401 and its event streams end, and the account's push
subscriptions end, so its push IDLE worker stops. After a refusal the
stored password is tried again only two and a half minutes later, so the three
refusals span at least five minutes and a server that refuses logins for a
moment marks nobody. After the first refusal, one request made after it may
try once right away, so a moment's hiccup does not fail new connections for
minutes; if that try is refused too, requests that need a new connection fail
without a login until the five minutes are up. The IDLE worker keeps the
spacing. The mark is kept in the database across restarts and cleared
by the next successful sign-in; the push worker starts again once the client
subscribes again. A
server that cannot be reached, or answers "try again later" (UNAVAILABLE,
SERVERBUG, INUSE, LIMIT, or CONTACTADMIN, which Stalwart sends for a
store error), is not a refusal and is retried with backoff, at most once a
minute.
POST /api/logout with Authorization: Bearer <token> ends that one token,
closes the event streams opened with it, and answers 204; the account's other
sessions and their streams stay. Ended tokens are
kept in the database until they would have expired, so a restart does not
bring them back.
provider is the key into providers.json. When omitted, the proxy picks it
from the email domain of username (see Provider selection),
falling back to DEFAULT_PROVIDER. For OAuth providers, pass accessToken
instead of password and the proxy will use XOAUTH2.
Every field is a string: username (up to 512 characters), password or
accessToken, an optional provider that must be a key of providers.json,
and an optional mech (PLAIN, LOGIN or XOAUTH2). Any other body is
answered 400. A failed login is answered 401 without the mail server's
reply, which goes to the log instead.
Authorization: Basic <base64(user:pass)> works on every JMAP endpoint.
The first request in a 5 minute window costs one IMAP probe; subsequent
requests reuse the cached account. Useful for compliance suite runs and
servers that already terminate auth at a reverse proxy.
The cache is kept in memory and keyed by the decoded user name and password, so every spelling of one header shares an entry. A lapsed entry is kept for a day as a sign that a client re-validating it signed in before (see Login limits), then forgotten; the cache holds at most 10,000 entries and drops the ones validated longest ago first.
A 401 offers Basic (WWW-Authenticate: Basic realm="legacy-proxy", Bearer)
only to callers that are not browsers. A request that carries an Origin
header or any Sec-Fetch-* header (a page's fetch, or a browser opening the
URL directly) is offered Bearer alone, so a browser never puts up its own
password prompt for the proxy; a web client that sends Basic credentials in the
Authorization header itself is still accepted.
Basic auth carries no explicit provider, so the proxy selects one from the email domain of the username (see Provider selection). This is what lets a JMAP client like the Bulwark webmail front several IMAP backends through one proxy without any client-side change: the user just types their email, and the domain routes them to the right provider.
Every login the proxy makes at the mail server for a caller without a session
(/api/login and HTTP Basic) is checked first:
- 10 failed logins for one user name from one client address within 15 minutes
hold further logins for that pair, and 50 failed logins from one address,
across user names, hold the address. A held login is answered
429withRetry-After, and nothing is sent to the mail server, the right password included, until the oldest of those failures is 15 minutes old. A successful login clears its pair's count. - Only a refusal of the credentials counts. A mail server that cannot be reached, or answers "try again later", does not.
- At most 20 such logins run at once; more wait up to 5 seconds and are then
answered
503. A Basic client re-validating a login the proxy validated before (every 5 minutes, when its cache entry lapses) uses 20 slots of its own, so first logins from unknown callers cannot keep signed-in clients waiting. One re-validation runs per user name and password at a time, and requests with the same credentials from any address wait for it; one account re-validates at most 3 logins at once, and a fourth is answered503withRetry-After. - A held pair gets
429for HTTP Basic even when the header is in the 5-minute cache or a login for it is still running; the same header from another address is a different pair and keeps working. A Basic client that shares an address with someone who keeps failing (one NAT, one IPv6 /64, or everyone behind a reverse proxy withoutTRUST_PROXY) is held with them. - Requests with a Bearer token are not limited, so a user with a session keeps working while their login is held.
- A request with a valid Gmail bridge password or sign-in token is never
limited and takes no login slot. Failed bridge lookups on the JMAP routes
have a budget of their own: the first 50 from one address within 15 minutes
are answered
401, further ones429withRetry-Afteruntil the oldest is 15 minutes old. They never hold mail logins from that address, and a valid bridge login is never held. - The bridge's
/oauth/tokenis not limited: every refused request gets its own400or401, however many came before. A mail client's server redeems refresh tokens for all its users from one address, so a hold there would answer users whose sign-in ended with429instead of telling them to sign in again; codes and tokens are 256-bit random values, so there is nothing to guess.
The counters live in memory, are bounded in size and start from zero when the
proxy restarts. IPv6 clients are counted per /64. The limits count by client
address, so behind a reverse proxy set TRUST_PROXY (see
Configuration): without it every caller has the reverse
proxy's address and all of them share one budget. The same goes for a webmail in
front of the proxy, which checks every user's sign-in from its own server: put
the webmail server's address in TRUST_PROXY too.
Every login resolves to exactly one provider, in this order:
- an explicit
providerin the/api/loginbody, if present; - the provider in
providers.jsonwhosedomainslist contains the username's email domain (case-insensitive). This mirrors RFC 8620 §2.2, which uses the email domain as the routing key for service autodiscovery; - with discovery turned on, the mail servers the domain publishes itself;
DEFAULT_PROVIDERotherwise.
If none of these gives a provider (the domain is not listed, discovery is off
or finds nothing, and DEFAULT_PROVIDER names no entry in providers.json),
the login is answered 401 and counts as a failed login. The log says
login refused: the email domain is not in providers.json ... with the
domain.
Give each provider a domains array to enable step 2:
{
"posteo": { "domains": ["posteo.de", "posteo.net"], "imap": { ... }, ... },
"mailbox-org": { "domains": ["mailbox.org"], "imap": { ... }, ... }
}See providers.two-servers.example.json for a full two-provider catalogue.
If two backends share one email domain, that domain can only map to a single
provider. Use the explicit /api/login provider field for the exception.
Discovery finds the IMAP and SMTP servers of an email domain that
providers.json does not list, so users on their own domain at a regular
hoster can sign in without the operator adding every domain. It is off by
default. Turn it on with DISCOVERY:
DISCOVERY=autoconfig,srv
# optional: only use mail servers under these domains
DISCOVERY_ALLOWED_HOSTS=hoster.net,mail.example.orgThe sources are tried in this order, whatever order DISCOVERY lists them in.
The first one that gives both an IMAP and an SMTP server wins:
autoconfig: the Mozilla autoconfig file athttps://autoconfig.<domain>/mail/config-v1.1.xml, then athttps://<domain>/.well-known/autoconfig/mail/config-v1.1.xml. The proxy does not ask the Thunderbird ISP database.srv: the RFC 6186 SRV records_imaps._tcpand_imap._tcpfor IMAP, and_submissions._tcpand_submission._tcpfor SMTP, by priority and weight.
Discovery runs before DEFAULT_PROVIDER, because DEFAULT_PROVIDER is
generic unless set, so a default nearly always exists. A domain that is
listed in providers.json never runs discovery. If the server behind
DEFAULT_PROVIDER hosts domains of its own, list them in its domains when
you turn discovery on, or their users may be sent to the servers their domain
publishes. Users who type a name without @ always get DEFAULT_PROVIDER.
A discovered provider is used like an entry in providers.json, with IMAP and
SMTP only: no ManageSieve (vacation replies) and no CardDAV. Accounts on it are
stored under the key discovered:<domain>. Once the mail server accepts a
login, the provider is kept in the database, so the domain's accounts keep
working after a restart and while discovery has no answer. A later discovery
that finds other servers replaces it after the next successful login. With
DISCOVERY unset, kept providers are not used. If you later add the domain to
providers.json, its users get new accounts under that provider's key, as
with any provider change.
The proxy is public, so what a domain publishes is not trusted:
- Only encrypted servers are taken: IMAP with implicit TLS or STARTTLS on any port, and SMTP submission with implicit TLS on port 465 or STARTTLS on port 587. Certificates must be valid for the host name.
- Every host must be a plain host name. In autoconfig files
%EMAILDOMAIN%in a host name is filled in; other placeholders, IP addresses and ports are refused. The login name may be%EMAILADDRESS%(the default) or%EMAILLOCALPART%; servers that ask for any other login name are skipped. - Every host, the autoconfig web servers included, must resolve to public addresses only. A host with any private, loopback, link-local, multicast or reserved address is refused. The same check as for push URLs is used.
- Each IMAP and SMTP connection to a discovered server resolves the host again, checks the address, and connects to that address with the host name kept for the certificate check. A name that changes its address between the check and the connect reaches nothing else.
DISCOVERY_ALLOWED_HOSTS, when set, limits the IMAP and SMTP hosts to the listed names and the names below them (hoster.netallowsimap.hoster.net). It does not limit where the autoconfig file is fetched from. Kept providers that no longer match are not used.- The autoconfig fetch has a 5 second timeout and reads at most 64 KB. It follows redirects only to https on the same host and port. DNS lookups time out after 3 seconds, and one domain's whole discovery after 10 seconds.
- Answers are cached in memory per domain: found servers for an hour, nothing found for 5 minutes, at most 1,000 domains. Logins for one domain at the same time share one discovery.
- Discovery runs only after a login took a login slot, and a login that finds no provider counts as a failed login (see Login limits), so a caller can't make the proxy look up domains without limit.
Gmail wants an App Password
(2FA must be on). Use "provider": "gmail". XOAUTH2 also works if you bring
your own access token.
This opt-in backend exposes Gmail labels, messages, threads and downloads over
JMAP using the Gmail API, with read-only consent by default. The existing gmail IMAP
provider remains available separately. This is an experimental compatibility
backend, not a complete RFC 8621 implementation.
Nothing in this repository is tied to a particular Google project: every operator brings their own OAuth client, and Google's rules for that client decide who can connect and for how long. In the Google Cloud console:
-
Create a project and enable the Gmail API (and Pub/Sub if you want push, see below).
-
Configure the OAuth consent screen with the scope you intend to use:
https://www.googleapis.com/auth/gmail.readonlyfor read-only, orhttps://www.googleapis.com/auth/gmail.modifyfor management and composition. Both are restricted scopes in Google's classification. -
Choose the user type and publishing status deliberately:
- Internal (Google Workspace organisations only): anyone in the organisation can connect, no verification, tokens do not expire. The best option when the proxy serves one company.
- External, Testing: only addresses listed as test users (max 100) can connect, and Google expires refresh tokens after 7 days: every account must be reconnected weekly. Fine for a first try, not for daily use.
- External, In production (press Publish app; no verification request needed): refresh tokens no longer expire, users see Google's "unverified app" warning once and continue via Advanced, and the app is capped at 100 users. This is the practical setting for personal and small self-hosted deployments.
- Google's app verification (security assessment for restricted scopes) is only required to remove the warning or exceed 100 users, i.e. to run a public service.
-
Create an OAuth client of type Web application and register this exact redirect URI, replacing the origin with the proxy's
PUBLIC_URL:https://bridge.example.com/auth/google/callback
Store the downloaded client JSON outside the repository, readable only by the service account. Configure the proxy:
PUBLIC_URL=https://bridge.example.com
GMAIL_OAUTH_CLIENT_FILE=/etc/legacy-proxy/google-oauth.json
GMAIL_ALLOWED_EMAILS=tester@gmail.comGMAIL_ALLOWED_EMAILS is a required comma-separated allowlist of addresses and/or
whole domains written as @example.com (for instance
GMAIL_ALLOWED_EMAILS=@example.com,someone@gmail.com). The Gmail profile
returned by Google determines whether the account is allowed; form values and
login hints are not trusted. Visit /auth/google/start and click Connect Gmail.
The flow uses PKCE, a browser-bound HttpOnly cookie, and single-use state expiring
after ten minutes. Callback logs are suppressed and no tokens are returned to
the browser. Restarting the service invalidates pending authorization flows;
start again if this happens during consent.
Self-service onboarding: the first successful consent for an account issues a bridge
password, and the result page shows server, username and password exactly once,
bound to that browser and expiring after two minutes. Reconnecting later (for example
after a revoked grant or Testing's seven-day expiry) keeps the existing password
unless "Issue a new bridge password" is ticked, which replaces it and signs every
device out: the previous password stops working, and in the same step every sign-in
token and every push subscription of the account are deleted and its open event
streams end. That is what to do after losing a device; each device then signs in again
and sets push up again. A pending consent
lives in an encrypted, browser-bound cookie rather than in server memory, so abandoned
or scripted starts cannot block other users. npm run gmail:password remains
available for operators.
Register the mail client with GMAIL_OAUTH_CLIENTS and the bridge also becomes an
OAuth 2.0 authorization server for it (authorization code with PKCE S256, public
clients, metadata at /.well-known/oauth-authorization-server):
GMAIL_OAUTH_CLIENTS='[{"id":"bulwark","redirectUris":["https://webmail.example.com/*/auth/callback"]}]'Redirect URIs match exactly, except that * stands for one path segment (Bulwark
puts the locale there). The client sends the user to /oauth/authorize; the user
picks a Google account, and the bridge checks it against GMAIL_ALLOWED_EMAILS. An
account whose Gmail access is already stored signs in without a consent screen. Any
other account is sent to Google's consent once, and its grant is stored as by
Connect Gmail. The client then gets a single-use code, valid for a minute, and
exchanges it at /oauth/token for a bridge access token (gmat_…, one hour, Bearer
only) and a refresh token (gmrt_…). Every refresh answers with a new refresh token,
which the client stores in place of the old one. The token that was used still answers
for 60 seconds after its first use, so two tabs refreshing together both carry on and a
lost answer can be asked for again, but at most three times in all. A fourth try inside
the minute, or any try after it, is refused and every token of that sign-in stops
working (a copy of it is in use); the account's other sign-ins carry on. A refresh
token unused for 180 days lapses, and a sign-in ends 365 days after it was made,
however often it was refreshed: the user then signs in again. /oauth/revoke with a
refresh token ends its sign-in. Only keyed hashes of these tokens are stored (see Data at rest). They are separate
from the bridge password: every signed-in client has its own and signing in again
revokes nothing. Issuing a new bridge password and disconnecting the account delete
them all. Google tokens never
leave the bridge.
In Bulwark, give the bridge's server entry an OAuth client ID equal to the
registered id, and optionally a button label such as "Sign in with Google".
The Google grant, cached mail (metadata, message bodies, attachment bytes) and
uploads are sealed with VAULT_KEY in DATA_DIR/gmail.sqlite3; see
Data at rest for what stays in clear. Cached values expire and are
bounded to 256 MiB of logical data per account (SQLite may retain free pages).
Google Testing refresh tokens with Gmail scopes expire after seven days; reconnect
when consent expires or is revoked.
To remove an account, revoke its Google grant and delete everything the bridge stored for it (tokens, bridge password, cache, draft mappings, send ledger and queues):
npm run gmail:disconnect -- tester@gmail.comAlso remove the address from GMAIL_ALLOWED_EMAILS if it should not connect again.
After building, verify the saved connection (including token refresh when needed):
npm run gmail:check -- tester@gmail.comThis refreshes the profile/label snapshot and prints only counts. JMAP reads retry temporary Google rate/server errors at most twice with exponential backoff and a shared account cooldown. Long Retry-After delays return an error immediately; permanent permission errors are not retried. The diagnostic snapshot command itself does not retry. Token refresh is coalesced within one process; run a single writer per data directory during this experimental stage. Google requests have timeouts, and failed checks retain the last snapshot.
Create a dedicated JMAP password after consent, using a new private output path:
npm run gmail:password -- tester@gmail.com /private/path/jmap-login.jsonThe file contains serverUrl, username and password for the client's custom
JMAP account. Only a keyed hash of this randomly generated password is stored.
Reissuing it immediately revokes the previous bridge password and signs every device
out the same way as the page does: sign-in tokens and push subscriptions of the account
are deleted. Event streams the running server holds open for the account end within
five minutes. Basic auth with
that username/password or Bearer auth with the bridge password is supported;
Google tokens stay on the server. Only passwords starting with gmap_ select the
Gmail API backend; any other password for the same address, such as an IMAP App
Password, is handled by the regular IMAP backend. A credential that starts with
gmap_, gmat_ or gmrt_ and is not a valid bridge login (a sign-in token sent
as a Basic password, a refresh token sent as a Bearer token, a revoked one) is
answered 401 and is never offered to an IMAP server, on /api/login neither. Removing the email from the
allowlist and restarting also blocks access, including to cached data.
Expose /jmap, /jmap/* and /.well-known/jmap alongside the OAuth routes at
the HTTPS reverse proxy. Account and mailbox rights stay read-only unless both
the operator enables writes and the account grants modify consent. Uploads and
submission remain unavailable until composition is enabled. Changes are synced
incrementally (see below); with push configured, clients also get EventSource
notifications instead of polling.
To enable mail management, add https://www.googleapis.com/auth/gmail.modify
to the Google consent configuration and set GMAIL_WRITE_ENABLED=true. Restart,
then reconnect through /auth/google/start and grant the requested permission.
The existing bridge password and account ID remain valid; reload the mail client
so it receives the new session rights. Setting the flag false disables writes
again without changing the password. Old or incomplete grants remain read-only.
Supported updates: $seen, $flagged, $important, mailbox membership for Inbox,
Spam, Trash and user labels; create/rename/delete flat user labels. Full keyword
maps and per-key JSON Pointer patches work. Unsupported custom keywords, draft
changes, nested label parents and permanent mail deletion are rejected.
Draft creation/uploads/sending require the additional compose flag below. Label deletion never deletes messages: nonempty labels
require onDestroyRemoveEmails=true to remove their membership from messages.
In management mode, All mail has the archive role for client interoperability.
Moving a message to All mail removes Inbox/Spam/Trash while retaining its user
labels. The All mail view still contains every message, including Inbox and
Trash. Other full mailbox replacements replace writable folder memberships;
keyword-backed/system memberships remain managed by their corresponding fields.
The server returns normalized mailboxIds/keywords after an update.
Writes are serialized per account and limited to 20 objects per set call. Each patch is validated before sending one Gmail modify request per message. Set responses report partial failures by ID. Ambiguous writes are not automatically retried (including label creation); refresh before manually retrying. Successful and uncertain writes invalidate caches and advance a persistent local revision; reads started before invalidation cannot repopulate those cache entries. Changes made in Gmail itself are picked up by the incremental sync described below.
Set GMAIL_COMPOSE_ENABLED=true together with GMAIL_WRITE_ENABLED=true to enable
composition. The existing verified gmail.modify grant is sufficient; reload the
client to discover the submission capability. By default the only identity is the
connected account address.
Set GMAIL_ALIASES_ENABLED=true to also expose the addresses configured under
Gmail's "Send mail as" (users.settings.sendAs, readable with the existing
gmail.modify grant, no new consent) as JMAP identities. The primary address keeps
its identity id and gains Gmail's display name and reply-to; every alias whose
verification status is accepted gets a stable id derived from the account and the
address. Pending or failed aliases are never offered. Identities are read-only:
create or edit aliases in Gmail or the Workspace admin console. Signatures are left
empty on purpose so the client's own signatures apply; Google signatures are not
imported. Settings are cached for five minutes; if Gmail settings are temporarily
unreachable, Identity/get degrades to the primary address. Drafts and MIME
imports may use any listed address in From. Before sending, the bridge re-reads
the settings: an alias removed meanwhile yields forbiddenFrom and the draft is
retained, and a draft whose From does not match the chosen identity is refused.
The compose path supports plain text, HTML, Cc/Bcc, reply headers, MIME body
structures, inline parts and uploaded or existing message attachments. New mail
must target Drafts. Draft saves use native Gmail drafts; Bulwark replaces an edited
draft by creating the replacement before discarding the old copy. Email/set destroy
can discard a draft, but cannot permanently delete received/sent mail. Email/import
into Drafts creates a native Gmail draft (From must be one of the account's
identities). Email/import into any other writable mailbox (Inbox, Spam, Trash,
user labels, or All mail alone to archive) files the MIME through
users.messages.import with GMAIL_WRITE_ENABLED alone: any From is accepted,
$seen, $flagged and $important map to Gmail's labels, other keywords are
dropped, Gmail orders the message by its Date header (receivedAt is ignored)
and the message is never marked as spam. Sent and Drafts cannot be import
targets except through the draft path.
Uploads preserve exact bytes (including JSON attachments), are scoped to the account, expire after 24 hours and are capped at 25 MB each / 100 MB total per account. Complete encoded MIME is capped at 25 MB; the session advertises an 18 MB attachment allowance to leave room for MIME encoding. Upload/draft metadata lives in protected SQLite, separately from the disposable read cache. Do not publish it.
Submission uses drafts.send and Gmail's native Sent filing. A durable intent is
recorded before calling Google; requests to send that same draft are not replayed
after an uncertain outcome, even across restarts. Only timeouts, dropped connections
and 5xx answers count as uncertain: a send Google provably never received (expired or
revoked authorization, rate limiting, a full request queue) or refused with a 4xx
answer releases the intent, so the draft can be sent again once the cause is fixed.
Check Sent before composing a new copy if the outcome is unknown; a draft whose send
is uncertain can still be discarded. This is per-draft deduplication, not
deduplication of separately created messages. Successfully sent drafts keep a stable JMAP email ID
through a persisted mapping to Gmail's new message ID. Existing drafts edited
outside the bridge are checked before sending/discarding to avoid acting on a
replacement the client has not seen.
Set GMAIL_SCHEDULE_ENABLED=true to add a persistent delayed-send queue (RFC 4865
FUTURERELEASE: HOLDFOR seconds or HOLDUNTIL timestamp in the envelope
mailFrom parameters). The session then advertises maxDelayedSend
(GMAIL_MAX_DELAYED_SEND, default 30 days) and submissionExtensions.FUTURERELEASE,
which is what Bulwark's "schedule send" and "undo send" use. A held submission
records account, identity, recipients, the draft's thread and a hash of the draft
MIME in protected SQLite and answers undoStatus: pending with sendAt; nothing is
sent to Google at that point and the draft stays a native Gmail draft. A worker
(every 5 s) leases due entries atomically and, before calling drafts.send,
re-checks composition, the identity (fresh send-as read), the draft's existence
and hash, and the send ledger. A draft edited or deleted in the meantime, a removed
identity, or an entry that comes due while the bridge is down for longer than
GMAIL_SCHEDULE_LATE_TOLERANCE (default 900 s) is suspended, never sent: the
submission becomes final with a per-recipient deliveryStatus explaining why and the
draft remains in Drafts to be sent again by hand. Transient Google errors before the
send call leave the entry pending for the next tick, and so does a send Google
refused for authorization or rate-limit reasons; a send Google rejects outright is
suspended.
EmailSubmission/set update: {id: {undoStatus: "canceled"}} cancels a pending
entry atomically (cannotUnsend once it is being handed to Google or already
final); EmailSubmission/query lists newest first. Bulwark's reschedule creates
the replacement before cancelling the original, so several pending entries for one
draft are allowed: the first to send wins and the others end up canceled
(superseded). An unconfirmed drafts.send marks the entry uncertain (final,
delivered: unknown) and blocks any further send of that draft, exactly like
immediate sends. Entries found in sending after a restart are reconciled through
the ledger: sent, uncertain when drafts.send was already in flight, and otherwise
back to pending (the late tolerance still applies). onSuccessUpdateEmail filing patches on a held
submission answer forbidden in the implicit Email/set: Gmail files the message
when it is actually sent. /healthz exposes per-status queue counters and nothing
else.
Without the flag, only immediate sends are supported (maxDelayedSend=0): no delayed send, undo,
custom SMTP envelope recipients/sender, SMTP parameters, delivery reports or
post-send deletion. Explicit envelopes must match the MIME recipients and account
sender. Submission/get exposes the most recent 100 successful bridge submissions;
no incremental submission changes are implemented. Sent draft IDs cannot be
discarded through the draft-delete path; a draft whose send outcome is uncertain can.
The Gmail backend reads users.history.list when the observed profile history
advances. It commits the cursor only after all pages have been read, invalidates
changed message/thread caches, and retains unchanged message bodies and attachment
bytes. The cursor is stored in SQLite and survives process restarts. Google history
expiry triggers cache reset and on-demand reload of the currently viewed mail;
there is no full-account body download. History work is bounded to 100 pages / 50,000
records; larger gaps use the same reload path.
Email/changes returns coalesced created/updated/destroyed IDs, including stable
sent-draft aliases. Mailbox/changes compares the last 32 persisted snapshots,
including counts and label renames. Unknown/expired states or changes exceeding the
caller's maxChanges return cannotCalculateChanges; the client reloads the current
view. Thread/changes and Email/queryChanges still fall back to requery. Without
push configured (see below), sync is triggered by client requests/polling (profile
cache up to 30 seconds). Direct bridge writes still invalidate the account cache conservatively.
Transient read network errors receive bounded retries; writes never automatically retry. Revoked/expired grants return a sanitized reconnect instruction, without exposing Google tokens or upstream errors. Uncertain sends explicitly instruct the user to check Sent and leave the durable submission intent in place across restarts. Reply composition resolves the parent Message-ID in the same account and supplies Gmail's native thread ID only when the parent header and normalized subject match.
Set GMAIL_PUSH_TOPIC=projects/<project>/topics/<topic> to replace polling with Gmail
push; it also works with read-only consent. One-time Google Cloud setup, in the
project that owns the OAuth client: enable the Pub/Sub API, create the topic, grant
roles/pubsub.publisher on it to gmail-api-push@system.gserviceaccount.com, and
create a push subscription for https://<PUBLIC_URL>/gmail/push (expose that path
through your reverse proxy). Authenticate the subscription in one of two ways:
- Authenticated push (recommended): enable authentication on the subscription with
a service account, set
GMAIL_PUSH_AUDIENCEto the subscription's audience (the endpoint URL unless you chose another) andGMAIL_PUSH_SERVICE_ACCOUNTto that service account's email. Every request must then carry a Google-signed OIDC token for that account, and no secret appears in URLs or access logs. - Shared token: set a random
GMAIL_PUSH_TOKEN(24+ characters) and usehttps://<PUBLIC_URL>/gmail/push?token=<GMAIL_PUSH_TOKEN>as the endpoint. The bridge never logs the query string, but a reverse proxy may, so keep its access logs private.
The bridge calls users.watch for every connected account at startup and re-issues it
every 24 h (Google expires watches after 7 days); failures are counted and retried
hourly.
Each notification is authenticated, persisted before it is acknowledged, and coalesced per account (1.5 s) into one run of the
existing incremental engine. Duplicates and out-of-order deliveries are harmless:
the engine always starts from the persisted history cursor. With push configured
the session advertises eventSourceUrl; GET /jmap/eventsource streams RFC 8620
StateChange events (Email/Thread/Mailbox states) after a sync actually changed
something, so open clients update without polling. Accounts with open streams are
also re-synced every 5 minutes as a safety net for notifications Google delays or
drops. /healthz reports watches, renewal failures, notification counts and open
streams; addresses and message contents are never logged.
With push configured the backend also accepts PushSubscription/get and
PushSubscription/set (RFC 8620 §7.2), which is what a webmail needs for system
notifications while every tab is closed. A subscription must use an https URL
on a public address (typically a push relay that forwards to the browser's push
service), checked and connected to as on the IMAP backend; the bridge
POSTs a PushVerification to it on create and only delivers once the client
returns that code through an update. Expiry defaults to 90 days and is capped at
a year, at most 20 subscriptions per account, and keys are accepted but never
stored or echoed back. Deliveries carry StateChange for the subscribed types
only; EmailDelivery fires exclusively for genuine arrivals, so label changes,
sends, drafts and anything Gmail files into Spam or Trash stay silent. When a
history record omits labels the bridge asks Gmail rather than guess. Endpoints
answering 404/410, or failing eight times in a row, are dropped.
Mailbox/query accepts the standard filters (role, hasAnyRole, name,
parentId, isSubscribed) so a client can resolve the Inbox by role. Webmails
do this when building a notification preview; rejecting the filter reads to them
as "this account has no Inbox" and silences notifications.
Ordinary newest-first folder pages list by label id, use exact label/profile counts
and fetch only the required ID pages. Searches, oldest-first ordering, anchors and
collapsed thread queries enumerate matching IDs before slicing, which can be slow on
large accounts. Gmail's estimated search total is never returned as an exact total.
Profiles refresh after 30 seconds, label details after at most 60 seconds; cached
queries are keyed by observed history state. Gmail does not provide a transactional
snapshot across pages, so concurrent mailbox changes can still affect pagination.
Email IDs are stable across labels. The synthetic All mail mailbox includes
spam and trash, matching the account-wide profile counts. Label names are
flat. Gmail's unread, starred, important and Chat labels are not listed as mailboxes
(the $seen, $flagged and $important keywords carry them; $junk and
$notjunk move mail into and out of Spam); system labels get
readable names, and labels hidden in Gmail's label list are unsubscribed. Only
receivedAt sorting is accepted, using Gmail's native list order (or its reverse);
strict timestamp ordering is not guaranteed by the list API. Supported search
conditions are inMailbox, inMailboxOtherThan (treated as "in none of these",
because every message is also in All mail), the four standard mapped keywords,
address fields, subject, free text (every word must match; quoted phrases stay
phrases), dates, sizes and attachment presence, combined with AND/OR/NOT. Searches
inherit Gmail token matching and date granularity; other conditions are rejected.
Header/body projections and on-demand MIME part downloads are supported; downloads
are capped at 50 MB. Email/get calls that ask only for header-derived properties use
Gmail's lighter metadata format, and Thread/get fetches up to four threads at once.
The gateway limits each account to four concurrent Google requests and budgets
4,800 quota units/minute using the current method costs.
For a proxy behind a reverse proxy on the same host, LISTEN_HOST=127.0.0.1
restricts the HTTP listener to loopback (the default remains 0.0.0.0).
Google setup references: OAuth web flow, Gmail scopes.
The proxy only speaks plain HTTP. Put Caddy, Traefik, or nginx in front of it
and set PUBLIC_URL to whatever URL clients see. The Session resource bakes
URLs from PUBLIC_URL into apiUrl, downloadUrl, uploadUrl, and
eventSourceUrl, so a wrong value silently breaks every client. Set
TRUST_PROXY to the reverse proxy's address or network too (see
Configuration), so the login limits count by
the client's address and not the reverse proxy's.
With a reverse proxy in front, port 8080 should not be reachable from anywhere
else: a caller that bypasses the reverse proxy skips its TLS. The compose files
publish it on 127.0.0.1 only, which is enough for a reverse proxy on the same
host; one in another container reaches the proxy over their shared Docker
network and needs no published port at all. Publish it on all interfaces
("8080:8080") only when nothing is in front.
| env var | default | notes |
|---|---|---|
PORT |
8080 |
HTTP listen port |
PUBLIC_URL |
http://localhost:$PORT |
URL clients see; baked into the Session resource |
DATA_DIR |
./data (or /data in Docker) |
SQLite database, vault entries, upload bodies |
VAULT_KEY |
required | base64 of 32 bytes; AES-256-GCM credential vault |
SESSION_HMAC_KEY |
required | base64 of 32 bytes; HMAC-SHA-256 over session tokens |
DEFAULT_PROVIDER |
generic |
provider key when /api/login omits one |
PROVIDERS_FILE |
/etc/legacy-proxy/providers.json |
provider catalogue |
DISCOVERY |
unset (off) | autoconfig, srv or both; see Discovery |
DISCOVERY_ALLOWED_HOSTS |
unset (every public host) | host names discovered mail servers must be under |
LOG_LEVEL |
info |
pino level |
MAX_CONCURRENT_REQUESTS |
10 |
per account, advertised and enforced (see below) |
MAX_OBJECTS_IN_GET |
500 |
advertised and enforced (see below) |
MAX_OBJECTS_IN_SET |
500 |
advertised and enforced (see below) |
MAX_SIZE_UPLOAD |
50_000_000 (50 MB) |
upload endpoint body limit, advertised in caps |
MAX_UPLOAD_STORAGE_PER_ACCOUNT |
100_000_000 (100 MB) |
pending uploads one account may have stored |
MAX_SIZE_REQUEST |
10_000_000 (10 MB) |
JMAP POST body limit, advertised in caps |
MAX_CALLS_IN_REQUEST |
64 |
per-envelope method-call cap |
JMAP_DEBUG |
unset | set to 1 to log every request/response shape |
LOG_SUBMISSION_ADDRESSES |
unset | set to 1 to log sender and recipients of each send |
PUSH_ALLOW_PRIVATE_URLS |
unset | set to 1 to allow push URLs on private networks and plain http (IMAP backend) |
TRUST_PROXY |
unset | reverse proxy addresses or ranges; see below |
The limits the Session resource advertises are the ones the proxy enforces:
- A
/getwith moreidsthanMAX_OBJECTS_IN_GET(counted after result references are resolved;SearchSnippet/getcountsemailIds,Email/parsecountsblobIds) and a/setwhosecreate,updateanddestroytogether exceedMAX_OBJECTS_IN_SET(Email/copycountscreate,Email/importcountsemails) are answeredrequestTooLargebefore the method runs.ids: nullis left to the method. - A
POST /jmapwhile the account already hasMAX_CONCURRENT_REQUESTSrequests running is answered400with theurn:ietf:params:jmap:error:limitproblem and"limit": "maxConcurrentRequests", before any method runs, so the client can retry it. - Uploads: one account may have at most
MAX_UPLOAD_STORAGE_PER_ACCOUNTbytes of pending uploads stored (they expire after 24 hours). An upload past that is answered413with theurn:ietf:params:jmap:error:limitproblem and"limit": "maxUploadStoragePerAccount". - Event streams: at most 10 open per account. The eleventh closes the oldest.
The Gmail backend advertises and enforces its own object limits (100 per get, 20 per set).
TRUST_PROXY names the reverse proxies whose X-Forwarded-For entries the
proxy believes, as a comma-separated list of IP addresses and CIDR ranges. The
login limits count by the client address this gives:
- Unset, empty,
false,0,nooroff: the socket's address is the client's. Behind a reverse proxy that is the proxy's address, so everyone behind it shares one budget: ten wrong passwords from anyone hold that user name for everyone, and 50 hold every login. Docker's userland proxy (Docker Desktop, published IPv6 ports) hides client addresses the same way. - The reverse proxy's own address or network: a request that arrives from one
of them is counted by the address that proxy appended to
X-Forwarded-For(Caddy and Traefik do this by default, nginx withproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for). A request from any other address is counted by its own address, whatever header it sends.
A reverse proxy on the same host, with the proxy listening on loopback
(LISTEN_HOST=127.0.0.1, or the compose files' 127.0.0.1 port):
TRUST_PROXY=127.0.0.1,::1A reverse proxy in front of the container: requests reach the container from
its Docker network, whether the reverse proxy is another container on that
network or runs on the host and uses the published port (Docker forwards those
from the network's gateway). Trust that network's range, which
docker network inspect <network> --format '{{range .IPAM.Config}}{{.Subnet}} {{end}}'
prints (compose names the network after the folder: legacy-proxy_default in
the quickstart):
TRUST_PROXY=172.18.0.0/16A reverse proxy on another host: put that host's address in TRUST_PROXY, and
publish port 8080 on the address that host reaches, not on all interfaces (for
example "10.0.0.5:8080:8080" in the compose file, with TRUST_PROXY=10.0.0.4).
A webmail in front of the proxy: Bulwark checks every sign-in from its own
server and names the user's address in X-Forwarded-For, so all its users reach
the proxy from the webmail server's address. Put that address in TRUST_PROXY
as well, next to any reverse proxy, and run the webmail itself behind a reverse
proxy it trusts, so the address it passes on is the user's. For a webmail on
10.0.0.7 and a reverse proxy on the proxy's own host:
TRUST_PROXY=127.0.0.1,::1,10.0.0.7Without it, 50 wrong passwords through the webmail within 15 minutes hold every sign-in that goes through it, for every user.
Private, loopback and link-local ranges may be given whole (10.0.0.0/8,
172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, 127.0.0.0/8,
169.254.0.0/16, fc00::/7, fe80::/10, ::1). Ranges in public space may be
at most a /8 for IPv4 and a /32 for IPv6; no reverse proxy's network is
larger. Anything else stops the proxy at start with one line that says what to
write instead: a hop count such as 1 (the proxy couldn't tell the reverse
proxy from a caller that reaches port 8080 directly and writes its own header),
true or yes, a wider public range, an IPv6 range that covers
IPv4-mapped addresses (::ffff:0:0/96; write the IPv4 address or range
itself), entries that together cover every address, a host name, or an empty
entry. If you set TRUST_PROXY=1 or TRUST_PROXY=true before, replace it with
the reverse proxy's address or network when you upgrade.
providers.example.json ships entries for Gmail and a generic
$IMAP_HOST / $SMTP_HOST / $SIEVE_HOST / $CARDDAV_HOST template.
A null for any of sieve or carddav is allowed; the corresponding JMAP
methods will then either return empty results or, for vacation, reject with
the underlying ManageSieve error. An optional domains array on a provider
opts it into domain-based provider selection;
providers.two-servers.example.json shows two providers wired up that way.
CardDAV logs in with the user's mail credentials. When the DAV server wants
a different login (mailbox.org app passwords, for example, are scoped to one
service), set username, password and forUser on the carddav entry:
"carddav": { "host": "dav.mailbox.org", "port": 443, "secure": true,
"username": "$DAV_USERNAME", "password": "$DAV_PASSWORD",
"forUser": "me@example.org" }A provider's settings apply to everyone who logs in through it, so the DAV
login is used only for the mail login named in forUser; everyone else keeps
their own. Setting some of the three keys but not all is an error.
Each imap, smtp and sieve entry in providers.json says how the proxy
reaches that server:
"secure": true: implicit TLS from the first byte (IMAP 993, SMTP 465)."starttls": true: a plain connection that must be upgraded with STARTTLS before the proxy logs in. If the server does not offer STARTTLS, or the upgrade fails, the connection is dropped and no credentials are sent.- neither: a plain connection, and the password crosses the network in clear.
When secure is left out it means implicit TLS for IMAP, implicit TLS on port
465 for SMTP, and plain for ManageSieve; with "starttls": true it means plain
plus the required upgrade. "secure": true together with "starttls": true is
a configuration error. For carddav, "secure": true means https; anything
else means Basic auth over plain http.
A plain connection is accepted only when the host is loopback (localhost,
127.0.0.0/8, ::1) or the entry says "allowPlaintext": true. The providers
file is checked at start, and an entry that would send a password in clear stops
the proxy with a message that names the provider and the service. An existing
deployment with such an entry (IMAP on 143 with "secure": false, or SMTP on
587 without "starttls": true, which used to upgrade only when the server
offered it) has three ways out:
- implicit TLS:
"secure": trueon the server's TLS port; - required STARTTLS:
"starttls": true, with"secure": falseor nosecure; - a plain connection on purpose:
"allowPlaintext": true, for a server on a network you trust.
Everything lives in DATA_DIR: proxy.db for the IMAP backend and
gmail.sqlite3 for the Gmail bridge. Both files are readable and writable by
the proxy's user only (mode 0600): they are created that way and set to it at
every start. Where the volume doesn't support file modes (some network or
Windows mounts), the proxy logs one warning naming the files and starts anyway;
restrict access to DATA_DIR there in another way. On Windows the folder's
permissions decide. Protect DATA_DIR and its backups anyway, because not
everything in them is sealed.
Sealed with AES-256-GCM: the stored mail credentials of each IMAP account, and on
the Gmail bridge the Google grant, the cache and uploaded files. Each table has a
key of its own derived from VAULT_KEY (HKDF-SHA256), and every value is bound
to its row: the table, the account and the row's key go in as associated data, so
a value copied into another account's row does not open there. Values sealed by
earlier versions (with VAULT_KEY itself and no binding) are rewritten on the
first start of this version, in one transaction per database: stored IMAP
credentials and Google grants are sealed again for their row (users stay signed
in), Gmail cache entries are dropped and fetched again, and uploads stored in
clear are sealed for their row. That happens once; afterwards a value in the
earlier format opens nowhere, also one copied in from an old backup. The start
that rewrites anything logs one warning per database with how many values of
each kind it rewrote (no values, no key): expected once after the upgrade or
after restoring an old backup. If it shows up at any other time, find out what
was written into DATA_DIR.
In clear: the IMAP backend's caches of message metadata (envelope, structure,
headers), previews and attachment bytes, and its pending uploads; on the Gmail
bridge the profile and label snapshot, draft and send-ledger records, scheduled
sends with their recipients, and push subscription URLs. Bridge passwords and
sign-in tokens are stored only as keyed hashes: an HMAC-SHA256, under a key derived
from VAULT_KEY, of the secret's SHA-256, with a MAC that ties each row to its
account, its sign-in date and its expiry. Without the key nobody can write a row
that a password or token of their choosing opens, or move their own row to
another account. The first start of this version keys the plain SHA-256 hashes
earlier versions stored, so existing bridge passwords and sign-ins keep working.
Each database keeps a value sealed under the key. The proxy checks both databases
against VAULT_KEY before it opens either. A key that opens neither that value
nor any stored credential of a database that holds sealed data stops the start
with one line, legacy-proxy: VAULT_KEY does not open the sealed values in <file>: ..., and no file is changed; npm run gmail:password stops the same
way. A database that holds nothing sealed takes whatever key it is started with,
so a mistyped key on a fresh or empty database is simply replaced by the right
one at the next start.
Keep VAULT_KEY safe and apart from the backups of DATA_DIR: a backup stored
together with its key gives whoever takes it everything the proxy sealed. Without
the key nothing sealed can be restored: every IMAP user signs in again, every
Gmail account reconnects at /auth/google/start and gets a new bridge password,
every bridge sign-in signs in again, the Gmail cache is fetched again and
pending uploads are attached again. To start over without the key,
move proxy.db and gmail.sqlite3 out of DATA_DIR.
- The first start of this version rewrites what earlier versions sealed and keys the hashes of bridge passwords and sign-in tokens (see above); nothing else to do.
- Gmail bridge sign-ins made before the upgrade are dated at the upgrade, so all of them end in the same minute one year after it; those users then sign in again.
- Rolling back to an earlier version: it can't open what this version sealed, so IMAP users sign in again, Gmail accounts reconnect, the cache refills, and uploads from the last day are attached again. It doesn't find this version's keyed hashes either: bridge passwords must be issued again and bridge sign-ins sign in again. (The retired refresh tokens this version keeps, up to 180 days so that a copy can be recognised, are among them: an earlier version doesn't rotate refresh tokens and would accept them again if it could find them.) Coming back to this version after that: what the earlier version sealed or issued in the meantime doesn't open (sign in, reconnect, issue a bridge password, attach again).
npm test # unit tests (vitest)
npm run test:integration # vitest, gated by RUN_INTEGRATION=1; requires compose.test.yml
npm run test:compliance # jmapio/jmap-test-suite against a live proxy
npm run test:allThe integration compose stack runs Stalwart locally on non-default ports and points the proxy at it.
test:compliance clones jmap-test-suite
into vendor/jmap-test-suite/, generates a config.local.json from
PROXY_URL + JMAP_USER_PRIMARY / JMAP_PASS_PRIMARY (and an optional
secondary user), runs it, then triages the report against
test/compliance/known-failures.txt. Anything failing outside the allowlist
is treated as a regression.
src/
server.ts fastify bootstrap, auth, upload/download/eventsource routes
backends/ legacy transport bindings for authenticated JMAP requests
jmap/ session, router, capabilities, errors, refs, eventsource hub
methods/ per-type handlers (mailbox, email, threads, identity,
submission, vacation, contacts, push)
imap/ imapflow client/pool, fetcher, search compiler, header parsing
smtp/ nodemailer submission
sieve/ ManageSieve client, vacation script generator
carddav/ CardDAV client + vCard / JSContact translation
push/ PushDispatcher (SSE + relay fan-out), PushIdleManager
auth/ session tokens, AES-256-GCM credential vault, providers
mapping/ IMAP <-> JMAP id/blobId codecs, flag map, body structure,
MIME builder
state/ SQLite store, opaque state strings, change log
util/ config loader, pino log
The JMAP dispatcher accepts a request-bound method table, a call limit, and
an opaque session state. backends/legacy.ts binds the existing handlers to
the authenticated account and its IMAP/SMTP/ManageSieve/CardDAV resources;
server.ts selects that table for each request. The dispatcher owns result
references, capability gates, mutation barriers, and response ordering without
importing a mail transport or the account store.
This is a method-dispatch boundary only. Login, session capabilities, blob
routes, and IDLE are still wired to the legacy backend in server.ts. Adding
another backend also requires adapting those entry points; a new method table
alone does not enable a provider. The existing gmail provider still uses IMAP.
AGPL-3.0