Not production ready. This is a v0.1 scaffold. Some JMAP methods are still missing (see Status), there are no security audits, and the API and storage formats may change without notice. Use it for development, testing, and self-hosted experiments only.
IMAP / SMTP / ManageSieve to JMAP gateway. Lets any JMAP client talk to Gmail, your hosting provider's mailbox, or any RFC 3501 IMAP server.
Front: JMAP for Mail (RFC 8620 + RFC 8621). Back: IMAP4rev1, SMTP Submission, ManageSieve.
v0.1. Working:
- JMAP
Sessionwith the full capability table - Batched
methodCalls, back-references (#ref, including*wildcard) Mailbox/get|query|queryChanges|changes|set(folder create/rename/delete/subscribe)Email/get|query|set|changes|queryChanges|copy|parse|importEmailSubmission/get|query|changes|set(recent submissions cached for/get)Thread/get|changes(degenerate one-message-per-thread until we add an index)SearchSnippet/get(returns null snippets — IMAP doesn't surface match offsets)Identity/get|set|changes(name, replyTo, signatures persisted in SQLite)VacationResponse/get|set|changes(full body, dates, htmlBody round-tripped via Sieve)AddressBook/get|changes,ContactCard/get|query|changes|queryChangesvia CardDAVPushSubscription/get|set(stub: empty list, creates rejected — no webhook delivery yet)- File upload at
/jmap/upload/{accountId}/, advertised viauploadUrl - IMAP via imapflow, single connection per account
- ManageSieve (RFC 5804), used by
VacationResponse - SMTP submission (nodemailer)
- Auth: PLAIN, LOGIN, XOAUTH2. HMAC session tokens. AES-256-GCM vault
- better-sqlite3 state store
- EventSource endpoint (connect + keepalive ping; no change events yet)
- Docker image + compose files (prod, integration)
- Vitest unit suite, jmap-test-suite runner
Not done yet:
- IMAP IDLE → SSE publishing: the EventSource endpoint accepts connections but never emits change events.
- WebSocket transport:
@fastify/websocketis installed but no/jmap/wshandler is registered. */changesandEmail/queryChangesreturncannotCalculateChangeswhenever the state has moved (no CONDSTORE-backed log yet). Listed in the compliance allowlist.ContactCard/setandAddressBook/setreturnforbidden— CardDAV writes (MKCOL/PUT/DELETE) aren't implemented.- Multi-mailbox membership: messages can live in exactly one IMAP folder.
- Attachments in
Email/set { create }: the upload endpoint exists but the MIME builder doesn't yet pull attachment blobs into the message body. UseEmail/importfor that path today.
Sort: only receivedAt is advertised and supported (IMAP without SORT can
only deliver UID order). hasAttachment filter is rejected for the same
reason.
You need Docker. Two ways to run it:
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 host
docker compose -f compose.prod.yml up -dcurl http://localhost:8080/healthz returns ok if it's up. JMAP clients
connect to 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 and providers.json. It won't clobber
existing files. Pass -- --force to overwrite.
Trade IMAP credentials for a JMAP session token:
curl -s http://localhost:8080/api/login \
-H 'content-type: application/json' \
-d '{"username":"you@example.com","password":"...","provider":"generic"}'Use the returned token as Authorization: Bearer ... on JMAP requests.
Gmail wants an App Password
(2FA must be on). Use "provider": "gmail". XOAUTH2 works too if you bring
your own tokens.
The proxy serves plain HTTP. Put Caddy, Traefik, or nginx in front of it
and set PUBLIC_URL to the URL clients see. The Session resource bakes
URLs from PUBLIC_URL, so a wrong value breaks every JMAP client silently.
| env var | default | notes |
|---|---|---|
PORT |
8080 |
HTTP listen port |
PUBLIC_URL |
http://localhost:$PORT |
URL clients see; baked into Session |
DATA_DIR |
/data |
SQLite and blob cache |
VAULT_KEY |
required | base64 32-byte AES-GCM key |
SESSION_HMAC_KEY |
required | base64 32-byte HMAC-SHA-256 key |
DEFAULT_PROVIDER |
generic |
provider key when /api/login omits |
PROVIDERS_FILE |
/etc/legacy-proxy/providers.json |
provider catalogue |
LOG_LEVEL |
info |
pino level |
providers.example.json ships Gmail and a generic
$IMAP_HOST/$SMTP_HOST/$SIEVE_HOST template.
npm test # unit tests
npm run test:integration # needs compose.test.yml; gated by RUN_INTEGRATION=1
npm run test:compliance # jmap-test-suite against a live proxy
npm run test:alltest:compliance clones jmap-test-suite
into vendor/jmap-test-suite/ and runs it against PROXY_URL. Allow-listed
upstream failures live in test/compliance/known-failures.txt, mostly
things the IMAP server can't offer (e.g. queryChanges without CONDSTORE).
src/
server.ts fastify bootstrap
jmap/ session, router, capabilities, methods
imap/ imapflow pool, fetcher, search compiler
smtp/ nodemailer submission
sieve/ ManageSieve client, vacation
auth/ session tokens, credential vault, providers
mapping/ IMAP <-> JMAP id codecs, body structure, flags
state/ SQLite store, opaque state strings
push/ eventsource, websocket
AGPL-3.0