JMAP Proxy Roadmap
May 11, 2026 · View on GitHub
Current State (April 2026)
The JMAP proxy syncs email, calendars, and contacts from IMAP/CalDAV/CardDAV backends and exposes them over the JMAP protocol (RFCs 8620/8621). It also supports direct JMAP-to-JMAP passthrough for backends that already speak JMAP.
- 132/132 JMAP TestSuite tests passing against Cyrus IMAP (87 Email/Mailbox/Thread + 2 Calendar/get + 26 CalendarEvent/AddressBook/ContactCard + 17 Identity/VacationResponse/Quota/Principal/SearchSnippet/MDN/EmailSubmission)
- Conversion logic extracted into standalone CPAN modules: Data::JSEmail (0.03), Text::JSCalendar (0.03), Text::JSContact (0.01)
- Docker image with single-process architecture and management UI
- Per-account SQLite databases, forked workers per account
- Live deployment at proxy.jmap.io with Bulwark webmail at webmail.jmap.io
Phase 1: Docker & Deployment ✅
Architecture
┌──────────────────────────────────┐
│ Docker container │
│ │
│ jmap-proxy.pl (parent process) │
│ ├─ :$JMAP_PORT AnyEvent HTTP │
│ ├─ :$MGMT_PORT Management UI │
│ └─ fork per account │
│ └─ blocking JSON worker │
│ └─ SQLite + IMAP │
│ or JMAP passthrough │
│ │
│ Volume: /data │
│ └─ accounts.sqlite3 │
│ └─ per-account .sqlite3 files │
└──────────────────────────────────┘
Done
- Single-process server (bin/jmap-proxy.pl)
- Non-blocking parent (AnyEvent HTTP), blocking forked children per account
- accounts dedicated child for accounts.sqlite3 CRUD
- Health check endpoint (
GET /healthzon mgmt port) - Graceful shutdown (SIGTERM/SIGINT: stop accepting, close children, exit)
- TLS termination documentation (nginx/Caddy examples in ARCHITECTURE.md)
- Idle timeout for backend children (
JMAP_IDLE_TIMEOUT, default 300s) - Dockerfile with OCI label, ghcr.io/jmapio/jmap-proxy (public)
- Management REST API (account CRUD, sync triggers, stats)
- Upload via tempfile (avoids binary in JSON socketpair)
Deployment at proxy.jmap.io
- Docker container with Caddy reverse proxy + auto TLS
- Webmail: Bulwark at webmail.jmap.io, TMail at tmail.jmap.io
- CORS headers for cross-origin webmail clients
- Static demo webmail at /demo/
Phase 2: Auth & Multi-Account ✅
Done
- JMAP Session (RFC 8620): GET /.well-known/jmap → /session
- Three auth methods: Basic (email:password), Bearer (token), cookie
- Token system: 256-bit random tokens in
tokenstable, accountid is UUID - Auth cache: 5 min TTL in parent process
- Account pools: poolid groups linked accounts
- Session response lists all pool accounts with capabilities
- sessionState: SHA1 of sorted pool accountIds (RFC 8620)
- Signup flow: DNS SRV auto-discovery (IMAP/SMTP/CalDAV/CardDAV)
- Accounts page: add/detach/delete, bearer token display
- POST /jmap with auth (standard); legacy /jmap/{accountid} endpoint removed
- Edit account settings (IMAP/SMTP/DAV credentials and hosts)
- Signup confirmation form: consistent nav and styling
- Token lifecycle: listing, revocation (via Accounts page)
- Schema versioning for SQLite DBs (PRAGMA user_version, versioned migrations)
- Human-readable timestamps on Accounts page ("2 hours ago")
- Stay-in-session when adding a second account (no new token if already logged in)
- Detach button hidden for the active account
Phase 3: JMAP Backend Passthrough ✅
When a backend already speaks JMAP (Cyrus, Fastmail, etc.), the proxy passes requests through directly instead of syncing via IMAP.
Done
- JMAP/JmapDB.pm — new backend type parallel to ImapDB.pm
- Signup: fetch upstream JMAP session, discover apiUrl + backendAccountId
- Request routing: worker branches on account type ('imap' vs 'jmap')
- accountId rewriting: proxy UUID ↔ backend accountId in JSON payloads
- Basic and Bearer auth to upstream
- Edit settings for JMAP accounts (re-verify session URL on save)
- needs_backfill=0 for JMAP accounts (no local sync needed)
Done (cont.)
- Blob upload/download proxying for JMAP passthrough accounts
- Upload: rewrite accountId in upload URL, proxy binary to upstream
- Download: proxy raw blob responses from upstream (downloadUrl URI template)
- 102/102 JMAP TestSuite tests passing (87 Email/Mailbox/Thread + 2 Calendar/get + 13 CalendarEvent/AddressBook/ContactCard)
- Normalise empty notCreated/notUpdated/notDestroyed to null (RFC 8620 §5.3) in passthrough
- JMAPProxy test adapter: cyrus_backend flag propagates Cyrus-specific TODO blocks
Phase 4: Push Notifications ✅
- GET /eventsource — Server-Sent Events endpoint (RFC 8620 §7.3)
- %PushMap routes state changes from workers to open SSE connections
- Per-pool subscriptions: one SSE connection covers all accounts in a pool
- Ping keepalive timer (client-configurable, 30s minimum)
- closeafter=state support
- X-Accel-Buffering: no for Caddy/nginx
Phase 5: Spec Compliance & Polish
Done
- Core/echo (RFC 8620 Section 4)
- sessionState in JMAP responses (RFC 8620)
- Null empty /set result fields (RFC 8620 Section 5.3)
- EmailSubmission state tracking (jstateEmailSubmission)
- Submission capability in Session (maxDelayedSend)
- Quota capability in Session (RFC 9425)
- Tolerate null keyword values in Email/import
- Message-ID uses email domain, not container hostname
- SRV lookup: _submissions._tcp (RFC 8314), skip null records
- SMTP submission error handling and reporting to client
- onSuccessUpdateEmail/onSuccessDestroyEmail in EmailSubmission/set
- backfill: separate worker process, needs_backfill flag with schema migration
- IMAP MYRIGHTS for real per-mailbox permissions (RFC 4314, lazy-cached in ifolders)
- MDN/send and MDN/parse (RFC 9007)
- Per-type creation ID mapping (idmap reset per request, createdIds returned in response)
- Move raw SQL out of API.pm into DB layer (EmailSubmission query methods on DB)
- JMAP TestSuite adapter: session-based URL discovery (GET /session) instead of hardcoded URLs
- JMAP TestSuite: added ifInState tests for Mailbox/set and Email/set; keyword sort comparator test
- JMAP TestSuite: added CalendarEvent, AddressBook, ContactCard entity/comparator/test classes (13 new tests)
Auth
- Email-first signup UX: email → auto-discovery → OAuth redirect or password form
- PACC discovery (draft-ietf-mailmaint-pacc-02): ua-auto-config.{domain}/.well-known/user-agent-configuration.json
- RFC 8414 OAuth metadata from PACC issuer URL
- Mozilla autoconfig XML fallback (IMAP/SMTP pre-fill, oAuth2 detection)
- PKCE support in OAuth2::Tiny (for public client flows per draft-ietf-mailmaint-oauth-public)
- Gmail OAuth2: GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET env vars → /cb/oauth callback
- Fastmail OAuth2 (OAUTHBEARER IMAP/SMTP via Mail::OAuthBearerTalk, PKCE flow)
- OIDC id_token generation (RS256, auto-generated or loaded RSA key) for webmail SSO
- Encrypted credential storage: pluggable backend (AES-256-GCM default, OpenBao Transit optional)
Monitoring
- Prometheus metrics endpoint (
GET /metricson management port) - Sync lag per account in metrics
- Error rate tracking:
jmap_method_errorscounter in Prometheus metrics
Still TODO
- queryChanges: currently sends spurious removals (spec-compliant but suboptimal) — fixed with jqueries snapshot caching
- Query result caching for proper queryChanges with filters — jqueries table (schema v10), save_query/load_query in DB.pm
- Move parsed message cache out of SQLite into flat files
Phase 6: CalDAV/CardDAV Sync ✅
The proxy syncs calendars and contacts from CalDAV/CardDAV backends and exposes them via the JMAP Calendars (JSCalendar) and Contacts (JSContact) extensions.
Done
- SRV-based CalDAV/CardDAV discovery (falling back to well-known URLs)
- IMAP hierarchy separator auto-detected from IMAP NAMESPACE (stored in iserver.imapSep)
- Calendar/get, Calendar/changes, Calendar/query, Calendar/set (create/update/destroy)
- CalendarEvent/get, CalendarEvent/changes, CalendarEvent/query, CalendarEvent/set (create/update)
- ContactCard/get, ContactCard/changes, ContactCard/query, ContactCard/set (create/update)
- AddressBook/get, AddressBook/changes, AddressBook/query, AddressBook/set (create/update/destroy)
- JSCalendar ↔ iCalendar conversion via Text::JSCalendar
- JSContact ↔ vCard conversion via Text::JSContact
- Fastmail CalDAV/CardDAV via OAuth Bearer token
- Gmail CalDAV/CardDAV via OAuth Bearer token
Done (cont.)
- CalendarEvent/set destroy: CalDAV DELETE + immediate
delete_eventto mark active=0 - Recurrence expansion: CalendarEvent/get handles
uid/recurrenceIdIDs;_expand_occurrencemerges override patches and strips master-only properties
Still TODO
- Free/busy queries
Phase 7: Code Architecture ✅
Internal refactoring to improve maintainability; no user-visible changes.
Done
- Sync providers: extract
connect_*andsend_emailboilerplate intoJMAP::Sync::Common(Standard/Gmail/Fastmail/AOL override only what differs via hook methods) - API.pm split: decomposed 4,900-line god object into 9 domain files (Mailbox, Email, Thread, Calendar, Contact, Submission, Preferences, StorageNode, MDN)
- OAuth extraction: Google/Fastmail/PACC flows into
JMAP::OAuth::*pure-computation modules; PKCE helpers inJMAP::OAuth::PKCE; OIDC token generation inJMAP::OAuth::OIDC - Shared
_api_inithelper (begin + get_user + accountId check) - Shared
_classify_changeshelper (created/updated/destroyed classification) - Shared
_check_since_statehelper (sinceState + jdeletedmodseq validation) - Shared
_limit_changeshelper (maxChanges sort/truncate with partial-state update)
Phase 9: RFC Compliance
Gap analysis (April 2026) against RFC 8620, RFC 8621, draft-ietf-jmap-calendars-26, and RFC 9610.
All referenced specs are in specs/.
RFC 8620 — JMAP Core
Blocking
- Security:
/uploadand/raw(download) endpoints accept any caller who knows anaccountId— no authentication check -
downloadUrltemplate:{type}variable missing from Session object template;Content-Disposition: attachment; filename="..."header never set on download responses - Request-level errors:
notJSON/notRequestreturn plain text, not RFC 7807 JSON ({"type":"urn:ietf:params:jmap:error:notJSON","status":400,...}) -
unknownCapability:usingarray not validated — unsupported capabilities silently accepted -
invalidResultReference:resolve_args(API.pm) emits type'resultReference'instead of'invalidResultReference' -
accountNotFound: already correctly implemented in all API methods (false alarm in review) - Quota capability: implement
Quota/get,/query(live from IMAPGETQUOTAROOT);/changesand/queryChangesreturncannotCalculateChanges - SSE ping event: sends
{servertimestamp:...}instead of required{interval:N} - StateChange
@type: push object missing required"@type":"StateChange"field -
anchor/anchorOffset: added_apply_windowhelper in API.pm; all query methods now support anchor/anchorOffset/anchorNotFound (Email and Mailbox already had it; CalendarEvent, Contact/ContactCard, EmailSubmission, Quota added)
Moderate / Nice-to-have
-
primaryAccountsnow includescalendarsandcontactsentries when account has CalDAV/CardDAV - Unknown sort →
unsupportedSorterror (Email/query, Mailbox/query) -
Cache-Control: no-cache, no-storeadded to/sessionresponse (RFC 8620 §2) -
maxCallsInRequest(16) /maxSizeRequest(10MB) limits enforced indo_jmap -
PushSubscription/get|set|changes:notImplementedstubs (SSE covers web clients) -
Blob/copy: implemented via parent orchestration (filesystem hardlinks) - Unknown filter →
unsupportedFiltererror; shared_check_filterhelper in API.pm validates condition properties and operator values recursively; applied to CalendarEvent/query, CalendarEvent/queryChanges, ContactCard/query
Cross-account /copy methods — DONE
RFC 8620/8621, draft-ietf-jmap-calendars, and RFC 9610 all define /copy
methods that move objects between accounts:
| Method | Status | Notes |
|---|---|---|
Blob/copy | implemented | parent-level orchestration via fetch_blobs/store_blob |
Email/copy | implemented | fetch_blobs + store_blob + Email/import |
CalendarEvent/copy | implemented | CalendarEvent/get + CalendarEvent/set |
ContactCard/copy | implemented | ContactCard/get + ContactCard/set |
All four are implemented via parent-level orchestration in bin/jmap-proxy.pl:
the parent intercepts /copy method calls before routing to workers, then
drives a multi-step async flow across the source and destination account workers.
Blobs are transferred via filesystem hardlinks (O(1), no data through the socket).
Pool accounts (same poolid) are required for cross-account access.
Tests in JMAP-TestSuite cover all four methods with pool_account_pair support.
RFC 8621 — JMAP Mail
Blocking
-
jmap:mailaccountCapabilities: now returns all 6 required fields (maxMailboxesPerEmail,maxMailboxDepth,maxSizeMailboxName,maxSizeAttachmentsPerEmail,emailQuerySortOptions,mayCreateTopLevelMailbox) -
onDestroyRemoveEmails: renamed fromonDestroyRemoveMessagesin PARAM_SCHEMA and Mailbox/set handler -
ifInState: enforced inMailbox/set,Email/set, andEmail/import - Email keyword sort comparators: changed from non-spec
"keyword:$kw"format to{"property":"hasKeyword","keyword":"$kw"}Comparator object (hasKeyword,allInThreadHaveKeyword,someInThreadHaveKeyword) -
EmailSubmission/changeshasMoreChanges: removed duplicate hash key — was alwaysfalsedue to Perl last-wins overwrite -
EmailSubmission/querysort: changed from non-spec string format to Comparator object -
VacationResponse/gettypo: fixed'VacationReponse/get'→'VacationResponse/get' - Identity
replyTo: now returnsEmailAddress[](orundef) instead of plain string -
Mailbox/queryChanges: implemented;canCalculateChangesnowtruein Mailbox/query -
Email/parse: implemented; fetches blob viaget_blob, parses withData::JSEmail::parse, applies property filtering; JMAP-only fields (id,mailboxIds,keywords,receivedAt,threadId) set tonull;notFound/notParsablecorrectly populated -
Identity/changes: returns empty changes (state always'dummy';cannotCalculateChangesotherwise) -
Identity/set: creates returnforbiddenFrom; destroys returnforbidden; updates persistname/textSignature/htmlSignature/replyTo/bccinjuserprefs;Identity/getreads them back -
VacationResponse/set: storesisEnabled/fromDate/toDate/subject/textBody/htmlBodyinjuserprefs;VacationResponse/getreads back; state is SHA1 of stored payload
Moderate / Nice-to-have
-
Mailbox/query:sortAsTree(depth-first pre-order, siblings in sort order) andfilterAsTree(add ancestors of matched mailboxes) implemented -
Mailbox/query:name(case-insensitive exact) androlefilter conditions added -
Thread/getwithids:nullnow returns all threads (RFC 8620 §5.1) -
%ROLE_MAPduplicate'junk'key removed (was silently mapping to'spam') -
Email/copy— implemented via parent orchestration (see Cross-account /copy section) -
SearchSnippet/get: subject/preview nownullwhen no text search terms match -
subParts: structural recursion preserved forbodyStructure; leaf parts return[]when explicitly requested -
EmailSubmission/queryfilteridentityIds: schema v9 addsidentitycolumn tojsubmission; saved on create;_submission_matchpredicate fixed (was broken latent bug)
draft-ietf-jmap-calendars-26 — JMAP Calendars
Blocking
-
ParticipantIdentity/get: now returns the user's own email as a scheduling address (id1,sendTo: {imip: "mailto:user@..."}) usingaccount.email -
Calendar/setonDestroyRemoveEvents: enforced; when false (default) and calendar has active events →calendarHasEventSetError; when true → events destroyed first viadestroy_calendar_events, then calendar deleted -
CalendarEventisOrigin: computed fromorganizerCalendarAddressvs account email;trueif no organizer or organizer matches account;falsefor invited events -
CalendarEvent/queryexpandRecurrences: implemented — recurring events are expanded perrecurrenceRules/recurrenceRuleusingDateTime::Event::ICal; occurrences returned asuid/recurrenceIdIDs, sorted by actual start; moved overrides included; non-recurring events filtered by start;inCalendarsfilter applied;canCalculateChanges: falseset in response. Also fixed:create_calendar_events/update_calendar_eventsnow normalize RFC 8984recurrenceRules(plural array) →recurrenceRule(singular) forNet::CalDAVTalk/Text::JSCalendarcompatibility before CalDAV PUT. -
CalendarEvent/seterror handling: create/update/occurrence-update wrapped ineval; CalDAV failures now returnserverFailinnotCreated/notUpdatedinstead of crashing the worker
Moderate
- Calendar:
description,timeZone,defaultAlertsWithTime,defaultAlertsWithoutTimeadded tojcalendars(schema v8); returned in get; persisted via set create/update -
CalendarEvent/get:isDraft(false) andbaseEventId(null) in default response;utcStart/utcEndcomputed when explicitly requested (per spec, not in default set) -
CalendarEvent/setcreate: auto-setscreated/updatedto current UTC time if absent; honors client-provideduid(falls back to new UUID); sequence handled by CalDAVTalk -
CalendarEvent/setupdate: auto-setsupdatedto current UTC time if not in patch; sequence increment handled by CalDAVTalk_updateEvent -
CalendarEvent/setupdatecalendarIds: now issues CalDAV MOVE to the new collection (viaNet::CalDAVTalk::MoveEvent) and follows up with content PUT if other fields also changed -
ContactCard/setcreate: honors client-provideduid(falls back to new UUID) -
CalendarEvent/setsendSchedulingMessages=false: passesSchedule-Reply: falseHTTP header (RFC 6638 §8.1) and setsscheduleAgent=clienton all participants (RFC 6638 §7.1SCHEDULE-AGENT=CLIENTon ATTENDEE properties);_no_scheduleflag upstreamed toNet::CalDAVTalk0.16 (no longer vendored); occurrence updates forwarded too -
CalendarEvent/queryfilter conditions:uid,text,title,description,location,owner,attendee; proper date-range overlap (start < before AND end > after); recurring masters filtered before expansion;expandRecurrencespath also applies all filters -
CalendarEvent/querysort:start(loads payload via cache) anduid;unsupportedSortfor unknown -
CalendarEvent/queryChangesfilter applied; filter validation added;jcalendaridfetched so_event_matchcan applyinCalendar/payload filters on changed rows -
ParticipantIdentity/seterror type wrong (notImplementedinstead offorbidden); updates/destroys now also returnforbidden - Top-level
capabilitiesentry for calendars and contacts now{}(per-account caps carry the details) - Calendar
myRights: addedmayShare: false;mayWriteOwnnowmayAddItems || mayModifyItems;isSubscribedusesisVisible(was hardcodedtrue)
Nice-to-have
-
CalendarEvent/parse: implemented viaText::JSCalendar::vcalendarToEvents -
CalendarEvent/copy— implemented via parent orchestration (see Cross-account /copy section) -
Principal/getAvailabilityfor self —Principal/getreturns id "me";Principal/getAvailabilitycomputes BusyPeriod list from local CalendarEvents (freeBusyStatus, status, privacy filtering; recurring expansion via existing_expand_event_occurrences; busyStatus from event.status and participant.participationStatus);currentUserPrincipalId: "me"in session capabilities;urn:ietf:params:jmap:principalsadded to known capabilities -
Principal/getAvailabilityfor others — CalDAV free/busyREPORT+ stub Principal list (moderate effort) - Full
Principalmodel — user directory, sharing, delegates (non-goal for proxy) -
CalendarEventNotification(all methods — requires sharing/Principal model)
RFC 9610 — JMAP Contacts
Blocking
-
AddressBookmyRights: restructured to nested{mayRead, mayWrite, mayShare, mayDelete} -
AddressBookisDefault: now returned (falsefor all until DB tracks it);isSubscribedadded -
AddressBook/setonSuccessSetIsDefault: implemented;isDefault BOOLEANcolumn added tojaddressbooks(schema v6 migration);AddressBook/getreads it; settingtruefor an ID clears all others and marks that one default;falsejust clears that one -
AddressBook/setonDestroyRemoveContacts: enforced;addressBookHasContentsSetError returned when book has contacts and flag is false; contacts destroyed first when flag is true -
ContactCard/query: fixed_event_filter→_contact_filter; implemented all RFC 9610 §3.3 filter conditions (inAddressBook,uid,text,name,name/given,name/surname,name/surname2,nickname,organization,email,phone,address) -
ContactCard/queryChanges: same_event_filterbug fixed -
ContactCard/copy: implemented via parent orchestration (see Cross-account /copy section)
Moderate
-
AddressBookdescription,sortOrderadded tojaddressbooks(schema v7); returned in get;shareWithreturned asnull(no sharing model yet) -
AddressBook/setifInStatenot checked -
AddressBook/setupdate:namevia CardDAV backend;description,sortOrderpersisted locally in DB;isSubscribedupdatesjaddressbooks.isVisible -
ContactCard/setifInStatenot checked -
ContactCard/setaddressBookIdson create: already resolved viahref_by_jablookup (was stale TODO) -
ContactCard/setdestroy_contactsnot wrapped in eval — CardDAV error kills worker -
ContactCard/querysort:created,updated,name,name/given,name/surname,name/surname2;unsupportedSortfor unknown; stable tie-break by uid -
ContactCard/queryanchor/anchorOffsetnot implemented -
ContactCard/setupdateaddressBookIds: now issues CardDAV MOVE to the new collection and updatesicards.iaddressbookid+jcontacts.jaddressbookid - Multiple address books per card / multiple calendars per event: both specs define
maxAddressBooksPerCard/maxCalendarsPerEventcapability fields for exactly this. We now advertise1for both and returninvalidPropertiesif a client sends >1 truthy entry. True multi-membership would require junction tables + DAV COPY semantics (copies diverge independently — unlike IMAP COPY which shares the blob).
Phase 8: Documentation & Developer Experience
- Landing page (proxy.jmap.io/): describes what the proxy is, signup form
- Setup guide (Docker, reverse proxy, connecting backends) — see SETUP.md
- API documentation for management endpoints — see API.md
Non-Goals
- Be a mail server: the proxy delegates storage to backends. Use Cyrus, Dovecot, or a hosted service for that.
- Replace Cyrus JMAP: Cyrus has a native JMAP implementation that's faster and more complete. The proxy is for adding JMAP to servers that don't have it, or for aggregating multiple servers.
- Webmail UI: the proxy speaks JMAP; pair it with any JMAP client.