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 /healthz on 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 tokens table, 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 /metrics on management port)
  • Sync lag per account in metrics
  • Error rate tracking: jmap_method_errors counter 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_event to mark active=0
  • Recurrence expansion: CalendarEvent/get handles uid/recurrenceId IDs; _expand_occurrence merges 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_* and send_email boilerplate into JMAP::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 in JMAP::OAuth::PKCE; OIDC token generation in JMAP::OAuth::OIDC
  • Shared _api_init helper (begin + get_user + accountId check)
  • Shared _classify_changes helper (created/updated/destroyed classification)
  • Shared _check_since_state helper (sinceState + jdeletedmodseq validation)
  • Shared _limit_changes helper (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: /upload and /raw (download) endpoints accept any caller who knows an accountId — no authentication check
  • downloadUrl template: {type} variable missing from Session object template; Content-Disposition: attachment; filename="..." header never set on download responses
  • Request-level errors: notJSON/notRequest return plain text, not RFC 7807 JSON ({"type":"urn:ietf:params:jmap:error:notJSON","status":400,...})
  • unknownCapability: using array 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 IMAP GETQUOTAROOT); /changes and /queryChanges return cannotCalculateChanges
  • SSE ping event: sends {servertimestamp:...} instead of required {interval:N}
  • StateChange @type: push object missing required "@type":"StateChange" field
  • anchor/anchorOffset: added _apply_window helper 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

  • primaryAccounts now includes calendars and contacts entries when account has CalDAV/CardDAV
  • Unknown sort → unsupportedSort error (Email/query, Mailbox/query)
  • Cache-Control: no-cache, no-store added to /session response (RFC 8620 §2)
  • maxCallsInRequest (16) / maxSizeRequest (10MB) limits enforced in do_jmap
  • PushSubscription/get|set|changes: notImplemented stubs (SSE covers web clients)
  • Blob/copy: implemented via parent orchestration (filesystem hardlinks)
  • Unknown filter → unsupportedFilter error; shared _check_filter helper 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:

MethodStatusNotes
Blob/copyimplementedparent-level orchestration via fetch_blobs/store_blob
Email/copyimplementedfetch_blobs + store_blob + Email/import
CalendarEvent/copyimplementedCalendarEvent/get + CalendarEvent/set
ContactCard/copyimplementedContactCard/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:mail accountCapabilities: now returns all 6 required fields (maxMailboxesPerEmail, maxMailboxDepth, maxSizeMailboxName, maxSizeAttachmentsPerEmail, emailQuerySortOptions, mayCreateTopLevelMailbox)
  • onDestroyRemoveEmails: renamed from onDestroyRemoveMessages in PARAM_SCHEMA and Mailbox/set handler
  • ifInState: enforced in Mailbox/set, Email/set, and Email/import
  • Email keyword sort comparators: changed from non-spec "keyword:$kw" format to {"property":"hasKeyword","keyword":"$kw"} Comparator object (hasKeyword, allInThreadHaveKeyword, someInThreadHaveKeyword)
  • EmailSubmission/changes hasMoreChanges: removed duplicate hash key — was always false due to Perl last-wins overwrite
  • EmailSubmission/query sort: changed from non-spec string format to Comparator object
  • VacationResponse/get typo: fixed 'VacationReponse/get''VacationResponse/get'
  • Identity replyTo: now returns EmailAddress[] (or undef) instead of plain string
  • Mailbox/queryChanges: implemented; canCalculateChanges now true in Mailbox/query
  • Email/parse: implemented; fetches blob via get_blob, parses with Data::JSEmail::parse, applies property filtering; JMAP-only fields (id, mailboxIds, keywords, receivedAt, threadId) set to null; notFound/notParsable correctly populated
  • Identity/changes: returns empty changes (state always 'dummy'; cannotCalculateChanges otherwise)
  • Identity/set: creates return forbiddenFrom; destroys return forbidden; updates persist name/textSignature/htmlSignature/replyTo/bcc in juserprefs; Identity/get reads them back
  • VacationResponse/set: stores isEnabled/fromDate/toDate/subject/textBody/htmlBody in juserprefs; VacationResponse/get reads back; state is SHA1 of stored payload

Moderate / Nice-to-have

  • Mailbox/query: sortAsTree (depth-first pre-order, siblings in sort order) and filterAsTree (add ancestors of matched mailboxes) implemented
  • Mailbox/query: name (case-insensitive exact) and role filter conditions added
  • Thread/get with ids:null now returns all threads (RFC 8620 §5.1)
  • %ROLE_MAP duplicate 'junk' key removed (was silently mapping to 'spam')
  • Email/copy — implemented via parent orchestration (see Cross-account /copy section)
  • SearchSnippet/get: subject/preview now null when no text search terms match
  • subParts: structural recursion preserved for bodyStructure; leaf parts return [] when explicitly requested
  • EmailSubmission/query filter identityIds: schema v9 adds identity column to jsubmission; saved on create; _submission_match predicate 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@..."}) using account.email
  • Calendar/set onDestroyRemoveEvents: enforced; when false (default) and calendar has active events → calendarHasEvent SetError; when true → events destroyed first via destroy_calendar_events, then calendar deleted
  • CalendarEvent isOrigin: computed from organizerCalendarAddress vs account email; true if no organizer or organizer matches account; false for invited events
  • CalendarEvent/query expandRecurrences: implemented — recurring events are expanded per recurrenceRules/recurrenceRule using DateTime::Event::ICal; occurrences returned as uid/recurrenceId IDs, sorted by actual start; moved overrides included; non-recurring events filtered by start; inCalendars filter applied; canCalculateChanges: false set in response. Also fixed: create_calendar_events/update_calendar_events now normalize RFC 8984 recurrenceRules (plural array) → recurrenceRule (singular) for Net::CalDAVTalk / Text::JSCalendar compatibility before CalDAV PUT.
  • CalendarEvent/set error handling: create/update/occurrence-update wrapped in eval; CalDAV failures now return serverFail in notCreated/notUpdated instead of crashing the worker

Moderate

  • Calendar: description, timeZone, defaultAlertsWithTime, defaultAlertsWithoutTime added to jcalendars (schema v8); returned in get; persisted via set create/update
  • CalendarEvent/get: isDraft (false) and baseEventId (null) in default response; utcStart/utcEnd computed when explicitly requested (per spec, not in default set)
  • CalendarEvent/set create: auto-sets created/updated to current UTC time if absent; honors client-provided uid (falls back to new UUID); sequence handled by CalDAVTalk
  • CalendarEvent/set update: auto-sets updated to current UTC time if not in patch; sequence increment handled by CalDAVTalk _updateEvent
  • CalendarEvent/set update calendarIds: now issues CalDAV MOVE to the new collection (via Net::CalDAVTalk::MoveEvent) and follows up with content PUT if other fields also changed
  • ContactCard/set create: honors client-provided uid (falls back to new UUID)
  • CalendarEvent/set sendSchedulingMessages=false: passes Schedule-Reply: false HTTP header (RFC 6638 §8.1) and sets scheduleAgent=client on all participants (RFC 6638 §7.1 SCHEDULE-AGENT=CLIENT on ATTENDEE properties); _no_schedule flag upstreamed to Net::CalDAVTalk 0.16 (no longer vendored); occurrence updates forwarded too
  • CalendarEvent/query filter conditions: uid, text, title, description, location, owner, attendee; proper date-range overlap (start < before AND end > after); recurring masters filtered before expansion; expandRecurrences path also applies all filters
  • CalendarEvent/query sort: start (loads payload via cache) and uid; unsupportedSort for unknown
  • CalendarEvent/queryChanges filter applied; filter validation added; jcalendarid fetched so _event_match can apply inCalendar/payload filters on changed rows
  • ParticipantIdentity/set error type wrong (notImplemented instead of forbidden); updates/destroys now also return forbidden
  • Top-level capabilities entry for calendars and contacts now {} (per-account caps carry the details)
  • Calendar myRights: added mayShare: false; mayWriteOwn now mayAddItems || mayModifyItems; isSubscribed uses isVisible (was hardcoded true)

Nice-to-have

  • CalendarEvent/parse: implemented via Text::JSCalendar::vcalendarToEvents
  • CalendarEvent/copy — implemented via parent orchestration (see Cross-account /copy section)
  • Principal/getAvailability for self — Principal/get returns id "me"; Principal/getAvailability computes 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:principals added to known capabilities
  • Principal/getAvailability for others — CalDAV free/busy REPORT + stub Principal list (moderate effort)
  • Full Principal model — user directory, sharing, delegates (non-goal for proxy)
  • CalendarEventNotification (all methods — requires sharing/Principal model)

RFC 9610 — JMAP Contacts

Blocking

  • AddressBook myRights: restructured to nested {mayRead, mayWrite, mayShare, mayDelete}
  • AddressBook isDefault: now returned (false for all until DB tracks it); isSubscribed added
  • AddressBook/set onSuccessSetIsDefault: implemented; isDefault BOOLEAN column added to jaddressbooks (schema v6 migration); AddressBook/get reads it; setting true for an ID clears all others and marks that one default; false just clears that one
  • AddressBook/set onDestroyRemoveContacts: enforced; addressBookHasContents SetError 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_filter bug fixed
  • ContactCard/copy: implemented via parent orchestration (see Cross-account /copy section)

Moderate

  • AddressBook description, sortOrder added to jaddressbooks (schema v7); returned in get; shareWith returned as null (no sharing model yet)
  • AddressBook/set ifInState not checked
  • AddressBook/set update: name via CardDAV backend; description, sortOrder persisted locally in DB; isSubscribed updates jaddressbooks.isVisible
  • ContactCard/set ifInState not checked
  • ContactCard/set addressBookIds on create: already resolved via href_by_jab lookup (was stale TODO)
  • ContactCard/set destroy_contacts not wrapped in eval — CardDAV error kills worker
  • ContactCard/query sort: created, updated, name, name/given, name/surname, name/surname2; unsupportedSort for unknown; stable tie-break by uid
  • ContactCard/query anchor/anchorOffset not implemented
  • ContactCard/set update addressBookIds: now issues CardDAV MOVE to the new collection and updates icards.iaddressbookid + jcontacts.jaddressbookid
  • Multiple address books per card / multiple calendars per event: both specs define maxAddressBooksPerCard / maxCalendarsPerEvent capability fields for exactly this. We now advertise 1 for both and return invalidProperties if 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.