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 server in front of a
classic IMAP / SMTP / ManageSieve / CardDAV / CalDAV stack. It speaks
RFC 8620 + RFC 8621 (mail), RFC 9610 (contacts), RFC 9661 (Sieve scripts)
and draft-ietf-jmap-calendars 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 (the login identity plus any the account adds; the MTA decides which senders a login may use) |
| 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) |
| SieveScript | get, set (incl. onSuccessActivateScript), validate, changes via ManageSieve |
| Calendar | get, changes, set (MKCALENDAR / PROPPATCH / DELETE via CalDAV) |
| CalendarEvent | get, query (time-range REPORT), queryChanges, changes, set, parse |
| ParticipantIdentity | get, set (single identity derived from the login) |
| CalendarEventNotification | get, query, set (stubs returning empty lists) |
| 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:ietf:params:jmap:calendars(only when the active provider has CalDAV)urn:ietf:params:jmap:sieve(only when the active provider has ManageSieve; the capability object lists the extensions the server announced)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./dav/cal/{username}/…and/dav/card/{username}/…: authenticated pass-through to the CalDAV / CardDAV home set, mirroring Stalwart's paths so the Bulwark webmail's own WebDAV proxy (used forMKCALENDARwith a component set) works unchanged.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.- IMAP IDLE: the proxy keeps a dedicated IMAP socket per account that has at
least one verified push subscription, watching INBOX. New arrivals bump
EmailDelivery(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 and for RFC 9661
SieveScript/*. ManageSieve servers run one active script, while JMAP for Sieve (and the Bulwark filter editor) expect a server-managedvacationscript to run alongside the user's active script. With theincludeextension the proxy bridges that through a master script namedmain, following the convention in CONVENTION.md so that other webmails can share the slot. The master is also the registry of the scripts the proxy owns: the lines it writes there end with# jmap-legacy-proxy(include :personal :optional "vacation";and the include of the active owned script), an owned script switched off stays registered as# jmap-legacy-proxy off: include :personal "…";, and nothing else in the master is ever edited. Masters written by earlier versions (header naming the proxy,# legacy-proxytag) are still read and are rewritten in the current form on their next change. JMAP clients see only the scripts registered that way, so a script another webmail manages (Roundcube's, RainLoop's, a hand-written one) is neither listed nor reachable and keeps running from its own include; its name is still taken. When the active script is already such a master (a hand-writtendefaultthat includesroundcube, say) it is adopted as is; when a plain script is active, the proxy writes its own master, namedmain, and carries that script along untagged. The master is hidden from clients either way. Withoutincludeit falls back to plainSETACTIVE: every script is then in reach, and activating a filter script silences the autoresponder and vice versa. - 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 (X-*, GEO, TZ, …) 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. - CalDAV (RFC 4791) for Calendar and CalendarEvent. Calendars are the
collections under
calendar-home-set; events are.icsresources, one UID per resource, translated to and from JSCalendar (RFC 8984) with ical.js. Recurrence rules, EXDATE / RDATE and RECURRENCE-ID overrides map torecurrenceRules/recurrenceOverrides; ATTENDEE / ORGANIZER toparticipants; VALARM toalerts. Time-range queries arecalendar-queryREPORTs, so the server does the recurrence-aware overlap test and the client expands occurrences (expandRecurrencesis not implemented). Every TZID a resource references gets a synthesised VTIMEZONE. Calendar properties CalDAV cannot hold (isVisible,sortOrder, default alerts, …) live in the proxy's SQLite database. An account with no calendar gets aCalendarcollection created on the firstCalendarEvent/set. - iMIP (RFC 6047) over the SMTP submission backend when a client sets
sendSchedulingMessages: trueonCalendarEvent/set: as organizer,REQUESTto the attendees on create / update andCANCELto dropped attendees and on destroy; as attendee,REPLYto the organizer when the user's ownparticipationStatuschanges. Participants withscheduleAgent: "client"/"none"are skipped. A mail failure is logged and never fails the calendar write.
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.
-
Scheduling beyond iMIP:
Principal/*, free/busy and the scheduling inbox are not exposed. Incoming iMIP mail is not applied to the calendar automatically; the client parses it (CalendarEvent/parse) and saves it. -
CalendarEvent/queryexpandRecurrences. The client is expected to expand recurring events itself; the probe Bulwark uses to detect server-side expansion is answered withinvalidPropertiesso it keeps doing so. -
Sieve scripts can only be activated one at a time on top of
vacation; the master script handling covers exactly that pair. Scripts the proxy did not create are invisible to JMAP by design; an expert user edits them from the tool that owns them. -
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/setpersistsnameanddescriptionon the collection and the default book (onSuccessSetIsDefault) in the proxy's preference table;sortOrder,isSubscribedandcolorhave no CardDAV equivalent and are accepted but ignored. No sharing (shareWith). -
The JSContact ⇄ vCard translation follows RFC 9555: name, nicknames, emails, phones, online services, preferred languages, organisations, titles, addresses, anniversaries, personal info, notes, media (photo, logo, sound), crypto keys, directories, links, calendar and scheduling URIs, related cards, keywords, pronouns and grammatical gender, kind and group members. Object keys travel in
PROP-ID, labels and title/organisation links ride on vCard property groups, and the JSContact values vCard has no property for (kind: "other") go throughJSPROP. Name and address ordering hints (isOrdered,defaultSeparator) are not stored. -
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.
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.
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.
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.
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 resolves to exactly one provider key from providers.json, in this
order:
- an explicit
providerin the/api/loginbody, if present; - the provider whose
domainslist 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; DEFAULT_PROVIDERotherwise.
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.
Gmail wants an App Password
(2FA must be on). Use "provider": "gmail". XOAUTH2 also works if you bring
your own access token.
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.
| 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 |
LOG_LEVEL |
info |
pino level |
MAX_CONCURRENT_REQUESTS |
10 |
advertised on coreCapabilityProps |
MAX_OBJECTS_IN_GET |
500 |
advertised on coreCapabilityProps |
MAX_OBJECTS_IN_SET |
500 |
advertised on coreCapabilityProps |
MAX_SIZE_UPLOAD |
50_000_000 (50 MB) |
upload endpoint body limit, advertised in caps |
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 |
providers.example.json ships entries for Gmail and a generic
$IMAP_HOST / $SMTP_HOST / $SIEVE_HOST / $CARDDAV_HOST / $CALDAV_HOST
template. A null (or absent) sieve, carddav or caldav is allowed; the
corresponding capability is then not advertised and the JMAP methods either
return empty results or, for vacation, reject with the underlying ManageSieve
error. carddav and caldav share the same shape (host, port, secure,
basePath, optional principalPath) and usually point at the same server —
Radicale, Baïkal, SOGo, Nextcloud, Stalwart… The DAV backend must accept the
user's IMAP credentials: the proxy replays them (Radicale's
[auth] type = dovecot or imap does exactly that). 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.
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
jmap/ session, router, capabilities, errors, refs, eventsource hub
methods/ per-type handlers (mailbox, email, threads, identity,
submission, vacation, sieve, contacts, calendar, push)
imap/ imapflow client/pool, fetcher, search compiler, header parsing
smtp/ nodemailer submission
sieve/ ManageSieve client, capability probe, wrapper-script manager,
vacation script generator
carddav/ CardDAV client + vCard / JSContact translation
caldav/ CalDAV client, iCalendar / JSCalendar translation, Intl-based
time-zone arithmetic + VTIMEZONE synthesis, iMIP planner
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
AGPL-3.0