Scope and acceptance criteria for v0.1. This file is the source of truth for
what the product does. PLAN.md holds architecture and milestones; README.md
is user-facing. A feature not listed here is not in scope until this file changes
(same commit as the code).
Requirements are written as testable statements: FR-<group>.<n>. Groups:
A accounts/auth, S synchronisation, M mail, P contacts,
X search, J JMAP protocol contract, D deployment. Non-functional
requirements are NFR-<n>.
In v0.1
- JMAP Mail (RFC 8621) over HTTP for browsing, reading, flagging, moving, deleting, threading, drafting and importing raw messages against an IMAP backend.
- Sending via
EmailSubmission/set→ SMTP submission, filed to Sent on IMAP. - JMAP Contacts (RFC 9610) over CardDAV for the same account's provider.
- A local hybrid cache (headers/flags/structure always; bodies lazy) with an FTS5 index and search-driven body backfill.
- Docker/Kubernetes deployment behind a TLS reverse proxy; Gmail OAuth2.
- Multi-account, path-prefixed, single origin.
Explicitly out of scope (v0.1) — see also PLAN §15 roadmap:
- JMAP Calendar / CalDAV (roadmap),
Principal/sharing/ACL,VacationResponse,MDN,Quota, Sieve management,SearchSnippet,Email/parse, WebSocket push (SSE only),Email/queryChanges,Email/copy,ContactCard/query|queryChanges|copy,AddressBook/set,Identity/set,EmailSubmission/get|query(roadmap items are listed in PLAN §15). - Being an MTA/MDA: the bridge never accepts inbound mail.
- POP3. Multi-instance/high-availability operation (single writer by design).
- Gmail via app passwords (D-5: OAuth2 only).
Glossary
| Term | Meaning |
|---|---|
| bridge / this server | the jmap-bridge process |
| account | one configured upstream mailbox (IMAP+SMTP [+CardDAV]) served at /{account}/… |
| tier | one of the three IMAP sync strategies (QRESYNC / CONDSTORE / baseline) |
| hydration | fetching and parsing a message body on demand, then caching it |
| backfill | (a) initial header sync, (b) background body hydration triggered by search — context distinguishes them |
| foreign change | a change made by another client directly against the IMAP/CardDAV server |
| origin | scheme + host + port; clients authenticate only to one origin |
- FR-A.1 Configuration is a TOML file decoded strictly: unknown keys, wrong
types, and missing required fields are startup errors with the offending key
named. Validation covers
listen,base_url(absolute http-for-loopback or https),data_dir, and every account block. - FR-A.2 Secrets (IMAP/SMTP passwords, OAuth client secrets, refresh tokens,
client tokens) may come from config,
JMAP_BRIDGE_*environment variables, or mounted files. Secret values are redacted from logs and error strings, and are never written to the data volume unencrypted when a key is configured. - FR-A.3 Client-facing authentication is HTTP Basic on every endpoint
(session,
/jmap, upload, download, eventsource): username is any non-empty value, password is the account's token, compared in constant time against a stored hash.auth.mode = "none"is accepted only whenlistenbinds a loopback address; otherwise startup fails (FR-A.11). A token must carry at least 24 characters of entropy (startup error otherwise), and repeated failed authentication from one source is throttled and then locked out for a backoff window ([rate]), so the token cannot be guessed at request rate. - FR-A.4 Backend password authentication: accounts with
auth = "password"log in to IMAP and SMTP with the configured credentials (SASL PLAIN / LOGIN), over TLS by default; plaintext transport requires an explicit config flag. - FR-A.5 OAuth2 bootstrap: the bridge serves
GET /oauth/{account}/start(redirects to the provider consent screen with PKCE S256 and a randomstate) andGET /oauth/{account}/callback(exchanges the code, stores the refresh token, then starts/refreshes that account's sync). Both are served on thebase_urlorigin, which is the publicly reachable HTTPS endpoint documented in README §Deployment. - FR-A.6 The callback validates
state, binds the result to exactly the account in the path, shows a minimal success/failure page (no JavaScript dependencies), and never logs the authorization code or tokens. A failed or forged callback leaves existing credentials untouched. - FR-A.7 Refresh tokens are used to mint access tokens: refreshed proactively
before expiry, and on authentication failure with exactly one retry. XOAUTH2 is
used for IMAP
AUTHENTICATEand SMTPAUTHon accounts withauth = "oauth2". - FR-A.8 Credentials and refresh tokens are encrypted at rest with
AES-256-GCM using
JMAP_BRIDGE_SECRET_KEY(32 bytes, env/K8s Secret, never in the data volume). Without a key the bridge starts in plaintext mode and logs a prominent warning. Decryption auto-detects ciphertext prefixes so keys can be rotated by re-encrypting on next write. - FR-A.9 Provider profiles:
provider = "google"supplies Gmail endpoints and scopes (https://mail.google.com/, plus…/auth/carddavwhen contacts are configured);provider = "generic"takesauth_url,token_url,scopes,client_id,client_secretfrom config so other providers work without code changes. - FR-A.10 An account declared
auth = "oauth2"has no password fallback: misconfiguration is a startup error, never a silent downgrade to basic auth. - FR-A.11 Multi-account isolation: a token authenticates exactly one account;
a request under
/{account}/…is served only for that account's data; cross- account access returns 401/404 without revealing whether the other account exists. Startup validation rejects duplicate account ids and anoneauth mode on non-loopback listeners. - FR-A.12 All of the above fail closed: any authentication ambiguity (missing header, unknown token, wrong path/account pairing) results in no credentials being used and no data being served.
- FR-A.13 Account
backendselection:backend = "imap"(default) or"gmail_api", mutually exclusive with the other's block. Validation (strict, naming the key): an unknown value is an error;imapforbids[accounts.gmail_api];gmail_apiforbids[accounts.imap]and[accounts.smtp], requiresaddress, requires[accounts.oauth2]withprovider = "google"(unless a loopbackendpoint+ statictokenare used for the fixture gate), and requirespubsub_topic,pubsub_audienceandpubsub_service_accountunlesspush_allow_plainwhenwatch = "pubsub", defaultingwatchtopubsubwhen a topic and an httpsbase_urlare present andpollotherwise.quota_units_per_second/backfill_query/backfill_limitare optional and bounded. Switching a Google account between backends is a config change; the stored OAuth token is reused and no re-consent is needed when the existing scope covers the API (D-API-1, D-API-7).
- FR-S.1 On connect the bridge reads
CAPABILITY, lists folders with LIST-EXTENDED + SPECIAL-USE (mapping\Inbox \Sent \Drafts \Trash \Junk \Archiveto JMAP roles), reads NAMESPACE (prefix/hierarchy separator), and enablesUTF8=ACCEPTandCOMPRESS=DEFLATEwhen advertised. Results are logged and exposed in metrics. - FR-S.2 Tier selection: QRESYNC if advertised, else CONDSTORE if advertised, else baseline. The selected tier is recorded per folder per session and downgrade paths (server loses an extension after re-auth) are handled without restart.
- FR-S.3 Initial backfill fetches per folder, in batches,
UID, FLAGS, INTERNALDATE, RFC822.SIZE, ENVELOPE, BODYSTRUCTURE— never full bodies. It is resumable across restarts (no re-download of completed ranges) and reports progress. Ingest deduplicates by Message-ID across folders only when the copies' flag sets agree (ignoring\Recent) and never within one folder; copies whose flags differ stay separate emails — one JMAP object cannot truthfully carry two IMAP copies' keyword state. - FR-S.4 Liveness:
IDLEon INBOX and watched folders; on disconnect, exponential backoff reconnect; when IDLE is unavailable or unreliable, poll onsync.interval. - FR-S.5 Incremental sync per tier: tier 1 uses
SELECT (QRESYNC …)+FETCH (CHANGEDSINCE)+VANISHED; tier 2 usesUIDNEXTscans for new UIDs +UID FETCH (CHANGEDSINCE)for flag changes with UID-gap rescan for expunges; tier 3 diffs UID lists and refetches flags. Expunged messages become tombstones, not silent row deletions. - FR-S.6
UIDVALIDITYchange on a folder: remap survivors to fresh JMAP ids, drop stale mappings, surface the event as destroy/create in/changes. No client may ever observe an id that has been silently recycled. - FR-S.7 Foreign-change visibility: ≤ 2 s with IDLE healthy (IMAP) or push
healthy (Gmail API, FR-S.14); ≤
sync.intervalotherwise (FR-NFR-2). - FR-S.8 Body hydration is on demand, single-flight per message (concurrent requests for the same body wait on one IMAP fetch), cached permanently in the blob store, and re-parseable after a cache clear without data loss (bodies can be re-fetched from IMAP).
- FR-S.9 Optional prefetch: bodies for messages newer than
sync.prefetch_windoware hydrated in the background, rate-limited, and can be disabled (0). - FR-S.10 Gmail profile:
X-GM-LABELSvalues map to JMAP mailboxes in the Gmail label namespace;[Gmail]/All Mailmembership is preserved so that "archive" means "remove from INBOX only";X-GM-THRIDseeds local thread ids when present; label writes preferUID STORE +X-GM-LABELSover copy storms; Gmail's lack of QRESYNC must land on tier 2 and still satisfy FR-S.5. - FR-S.11 Compression (
COMPRESS=DEFLATE) is enabled opportunistically and its use is visible in metrics. - FR-S.12 Provider courtesy: bounded batch sizes, jittered backoff on errors
or untagged
BYE, no tight retry loops, and a grace window before treating an absent result as a deletion for changes we ourselves just wrote (Gmail's eventual consistency). - FR-S.13 Gmail API synchronisation (D-API-2, D-API-9): an account with
backend = "gmail_api"is synced through the Gmail REST API behind the samemailbackendseam. Discovery mapslabels.listto mailboxes (system labels to roles,CATEGORY_*/user labels as real membership,IMPORTANT/STARRED/UNREADas keywords,CHATexcluded) plus a syntheticAll Mailarchive with implicit membership (D-API-11); the initial header backfill usesmessages.list+ batchedmessages.get(format=metadata)and never downloads a body (D-2); incremental sync walkshistory.list(advancing a per-accounthistoryIdonly after the whole page set commits, golden rule 5) and a404on an expired history triggers a full resync that re-lists and tombstones without recycling ids; body hydration usesmessages.get(format=raw)through the existing parser;threadIdseeds stableg:thread keys;Email/get,Email/query,Thread/getand/changesread the same store and state strings as the IMAP path, so a client cannot tell the backend apart. Size is Gmail'ssizeEstimateuntil hydration (exact thereafter, D-API-10). Reads are paced by a per-account quota token bucket honouringRetry-After(FR-S.12). - FR-S.14 Gmail API push (D-API-3): with
watch = "pubsub"the backend registersusers.watchagainst the configuredpubsub_topic, renews the watch before its advertised expiration, and callsusers.stopon graceful shutdown. Gmail publishes to the topic and the Pub/Sub push subscription POSTs toPOST {base_url}/gmail/push/{account}; the endpoint authenticates the request (Pub/Sub OIDC verified withpubsub_audienceand thepubsub_service_accountemail claim, or, whenpush_allow_plainis set, a loopback shared secret equal to the account's client token) and fails closed on any other request. A verified push is only a hint: it wakes the engine, which readshistory.listfrom its own saved cursor, so a duplicate, out-of-order or forged-but-verified hint cannot inject state. Withwatch = "poll"no topic is registered and the engine pollshistory.listonsync.interval; FR-S.7's ≤ 2 s budget is a push-mode guarantee.
- FR-M.1
Mailbox/getreturns all mailboxes (or by id) with: id, name, parent, role, sortOrder, counts (total/unread emails and threads),may*rights, and the type's state string.nameis the leaf (e.g.2026forArchive/2026, the shape Fastmail answers); nesting travels inparentId, the IMAP folder path never leaves the server. - FR-M.2
Mailbox/querysupports filtering (parentId,role) and sorting bysortOrderascending, plusposition/limit/calculateTotal. - FR-M.3
Mailbox/changesreturns created/updated/destroyed ids sincesinceStatewithnewState, correct even when only counts changed (updatedPropertiesmay be omitted in v0.1). - FR-M.4
Email/gethonoursids(or "all"),properties,bodyPropertiesandfetchAllBodyValues: with summary properties it never touches bodies; with body properties it hydrates (FR-S.8), returnsbodyStructure, per-partpartIds,bodyValues,attachments(withblobId),textBody/htmlBodyselections, and the correctstate. - FR-M.5
Email/querysupports: filtersinMailbox,text,from,to,subject,after,before,hasKeyword,hasAttachment; sorts onreceivedAt,subject,from,size,hasAttachment(asc/desc);collapseThreads,position,limit,anchor/anchorOffset,calculateTotal, and a stablequeryState. - FR-M.6
Email/changesreturns created/updated/destroyed sincesinceStatewithnewState; tombstones remain visible for at least the window in NFR-4. - FR-M.7
Thread/getreturns{id, emailIds}per thread, ordered byreceivedAt; thread ids are stable across syncs and derived from Message-ID/References chains plus normalised subject (andX-GM-THRIDwhere available). - FR-M.8 Keyword/flag mapping is bijective and documented:
$seen↔\Seen,$draft↔\Draft,$flagged↔\Flagged,$answered↔\Answered,$deleted↔\Deleted; other keywords map to IMAP keywords verbatim (server permitting). Unknown server flags surface as JMAP keywords; unknown JMAP keywords are stored if the server allows custom keywords, otherwise the write fails withinvalidArgumentsnaming the keyword — never silently dropped. In Gmail API mode the writable keyword set is fixed —$seen,$flagged,$draft,$importantonly — and any other keyword is refused withinvalidArgumentsnaming it rather than dropped (D-API-5). - FR-M.9
Email/set updateapplieskeywords/<kw>(true/false/null) andmailboxIds/<id>(true/null) patches, IMAP-first (D-14): the local commit, modseq bump and SSE notification happen only after the server accepts.null— notfalse— removes, for both patch families. - FR-M.10
Email/set destroyremoves the message from all mailboxes (honouring UIDPLUSUID EXPUNGEwhere available) and creates a tombstone. Destroying an already-gone message is a no-op success, not an error. - FR-M.11
Email/set createbuilds an RFC 5322 draft from the JMAP Email (headers,textBody/bodyValues, attachments referenced byblobId),APPENDs it to the Drafts mailbox with\Draft+$draft, and returns the new id. - FR-M.12
Mailbox/setcreates, renames and deletes IMAP folders (CREATE/RENAME/DELETE), rejects renames that would break hierarchy when the server disallows it, and refreshes SPECIAL-USE role detection. - FR-M.13 Mutation failure semantics: any IMAP failure yields
notUpdated/notDestroyed/notCreatedwith aserverFail(or more specific) SetError and leaves local state unchanged; a batch is not atomic across independent members (per RFC 8620) but each member is all-or-nothing. State strings returned after a mutation reflect that mutation (read-your-writes). - FR-M.14
Identity/getreturns at least one identity per account built fromaccounts.address(+ optional configured display name), withmayDelete: falseand a stable id; it is only served when the submission capability is advertised. - FR-M.15
EmailSubmission/set(create): resolves#referenceor directemailId, builds the envelope from the identity and recipients (To, Cc and Bcc alike, with Bcc stripped from the transmitted bytes), submits over SMTP, and on acceptance files the message in Sent in exactly one way — the caller'sonSuccessUpdateEmailwhen it moves the Email into a mailbox (never alsoAPPEND, which would leave two copies), otherwiseAPPENDing the message with\Seen. TheonSuccess*effects are performed by one implicitEmail/setwhose response follows (RFC 8621 §7.5), and the response carriescreatedwithundoStatus: "final"(andsendAtonly when delayed send is requested — not in v0.1). On SMTP rejection:notCreatedcarrying the SMTP response, and no Sent copy; nothing after a successful submission is ever reported as a failed create (the client would send twice).onSuccessDestroyEmailis rejected withinvalidProperties(client-side fallback exists in jmap-tui). Gmail API mode (backend = "gmail_api", FR-A.13) submits through the provider instead:drafts.sendwhen the Email is a Gmail draft, otherwisemessages.send. Gmail files its own Sent copy, so the bridge neverAPPENDs a second one; it records the accepted message in the cache. An ambiguous outcome (timeout/reset/5xx after dispatch) is reconciled by arfc822msgid:search for the bridge-generatedMessage-IDbefore the create may be reported as failed, so an accepted message is neither reported as failed nor retried into a duplicate. - FR-M.16 Blob upload (
POST /{account}/upload/, the session's{accountId}expanding into the account path prefix) accepts a body up to the configured cap, rejects unsupported/oversized content with RFC 8620 error codes, stores it, and returns{accountId, blobId, type, size}. - FR-M.17 Blob download (
GET /{account}/download/{blobId}/{name}) serves stored blobs with correct media type andContent-Disposition, expands all RFC 8620 template placeholders ({accountId},{blobId},{name},{type}in the session's template), and rejects ids from another account. - FR-M.18 Gmail mail semantics: label add/remove behaves as mailbox
add/remove for clients; archive removes from INBOX only; messages remain listed
in All Mail; Sent/Drafts use Gmail's special folders (
[Gmail]/Sent Mail,[Gmail]/Drafts) discovered by SPECIAL-USE or well-known names; Gmail's eventual consistency must not cause FR-M.9/FR-M.10 to report failure when the server accepted the command. - FR-M.19
Email/import: imports an RFC 5322 message already in the blob store into one or more mailboxes withkeywordsandreceivedAt(RFC 8621 §4.8): the raw bytes are placed on the server first (APPEND to the first mailbox, membership for the rest through the normal write path, FR-M.9), then committed locally; the response carriescreated(id/blobId/threadId/size) and per-membernotCreated. A missing blob or unknown mailbox isinvalidProperties;ifInStatemismatch isstateMismatch. The bytes are linked as the message's raw blob soEmail/getanswersblobId. - FR-M.20 Gmail API mail semantics: in API mode mailbox membership is Gmail
label membership — adding/removing a mailbox id is
messages.modifyon the labels, archiving removesINBOXonly, and the syntheticAll Mailarchive (may_remove_items=false) holds every message implicitly. Destroy is permanent (messages.delete; a already-gone message is a no-op success). Only$seen/$flagged/$draft/$importantare writable keywords (FR-M.8 amended); drafts are a Gmail draft resource created viadrafts.create.Mailbox/setmaps to label create/rename/delete, andonDestroyRemoveEmails=trueis refused withinvalidPropertiesnaming it (a label never owns its messages, so the API cannot express it). Providers that accept a write but surface it late must not make FR-M.9/FR-M.10 report failure (the FR-S.12 grace window applies).
- FR-P.1 Discovery: locate CardDAV services via
/.well-known/carddav(RFC 6764) or the configured URL, resolve the principal (RFC 5397), readaddressbook-home-set, enumerate address books (displayname, sort order, description, privileges,getctag/sync-token). Discovery failures disable contacts (FR-P.3) rather than failing the account. - FR-P.2 Incremental sync per address book:
REPORT sync-collection(RFC 6578) with the stored sync-token; when unsupported, fall back togetctagchange → full listing diff by ETag; fetch card bodies viaaddressbook-multigetor directGET. Deletes, moves and ETag changes are all detected. - FR-P.3 Capability gating:
urn:ietf:params:jmap:contactsappears in the session only when at least one account has CardDAV configured and its first sync succeeded. Without it, contact methods returnunknownMethod/noAccountsshaped errors rather than half-working data — and clients degrade. - FR-P.4
AddressBook/getreturns all books (or by id) with id, name, description, sortOrder,mayRead/mayWrite/mayShare/mayDelete, and state. - FR-P.5
AddressBook/changesreturns created/updated/destroyed books sincesinceState, driven by the per-book sync-tokens. - FR-P.6
ContactCard/getreturns cards (all or by id) with the summary property set (id,addressBookIds,kind,name, plus addressing fields), and with full properties when requested — includingblobIdfor media. - FR-P.7
ContactCard/changesreturns created/updated/destroyed cards sincesinceStatewithnewState, including changes applied by FR-P.2. - FR-P.8
ContactCard/setcreate: builds a vCard from the JSContact object,PUTs it withIf-None-Match: *into the target book, and returns the id (= vCard UID). A UID collision yieldsexists. - FR-P.9
ContactCard/setupdate: content properties are immutable in JMAP (RFC 9610);updatepatches only the mutable set (keywords-style patches are limited here — v0.1 supports full-object replacement via create+destroy semantics used by clients, andaddressBookIdsmembership moves).PUTusesIf-Match: <etag>; a 412 triggers one refetch-retry, thenoverwritten. - FR-P.10
ContactCard/setdestroy:DELETEwithIf-Match; already-gone is success.AddressBook/setis out of scope, but attempting to delete a non-empty book through any path returnsaddressBookHasContents. - FR-P.11 Media/photos:
ContactCard/setwith ablobIdphoto stores a recognised image type only (JPEG/PNG/WebP/GIF) within the size cap, written as vCardPHOTO; non-image types are rejected withinvalidProperties. - FR-P.12 Conversion fidelity: vCard 3.0 (RFC 2426) and 4.0 (RFC 6350) ⇄ JSContact (RFC 9553) conversion follows RFC 9554 so no registered property is silently dropped; unknown/extension properties are preserved where the formats allow and round-trip in golden-pair tests.
- FR-P.13 Groups and membership: cards with
kind: "group"andmembersmap to/from vCardKIND:group+MEMBER; a card belongs to exactly one address book in v0.1 (CardDAV cannot express multi-book membership) — setting multipleaddressBookIdskeeps the first and reports the rest innotUpdated/notCreatedwithinvalidArguments. Google's missing group support is a documented provider gap (README matrix), not an error path.
- FR-X.1 An FTS5 index covers subject and address fields for every message, and body text as bodies hydrate; the index is rebuilt incrementally (never full-rebuild on restart) and stays consistent with tombstones.
- FR-X.2
textqueries match subject/from/to/body tokens; address tokens are indexed in a way that supports prefix/domain matching forfrom/tofilters. - FR-X.3 Query evaluation covers FR-M.5's filter and sort sets with correct
total,position, and stable ordering (ties broken deterministically by id). - FR-X.4
collapseThreadsreturns one exemplar per thread (the newest matching message), andanchor/anchorOffsetaddressing returns the requested window with the true starting position. - FR-X.5 Search-driven backfill (D-6): a query whose candidates include
non-hydrated messages returns header-matching results immediately, then
hydrates candidates in the background (
search.concurrency), updating the index and bumping query state so clients pick up late matches via/changesor SSE. Result correctness never depends on hydration having finished. - FR-X.6 Backfill is bounded and controllable:
search.backfillon/off, concurrency limit, resumable across restart, and it never starves interactiveEmail/getbody requests (hydration queue is shared and prioritised). - FR-X.7
queryStatechanges exactly when results may have changed — including when hydration adds body tokens — and is stable otherwise. - FR-X.8 With
search.backfill = falsethe bridge behaves identically except that body-only matches are absent; this degradation is documented in README and does not error.
- FR-J.1
GET {base}/.well-known/jmapreturns the session resource withapiUrl,accounts,primaryAccounts(mail → this account),capabilities,username, andstate— plusuploadUrl,downloadUrlandeventSourceUrl(RFC 8620 §2 URI templates) once those endpoints exist (FR-J.5). API responses echo the session'sstateassessionState(RFC 8620 §3.4) — all on the configured origin. - FR-J.2
POST {apiUrl}accepts a batch of method calls and returns responses in order with matchingmethodCallId; unknown methods produce anerrorinvocation of typeunknownMethod, never a HTTP 500. - FR-J.3 Result references (RFC 8620 §3.7) resolve correctly, including
#idsreference arguments (Email/getreading/idsfrom anEmail/queryresult) and/createdIdswhere used. - FR-J.4 Set responses use the RFC 8620 §5.3 shapes exactly: absent
collections are
null(not[]/{}),createdis anId[Foo]map,updatedanId[Foo|null]map (never an array of ids),destroyedanId[]list, and error maps carry per-id SetError objects withtype/properties/description. - FR-J.5 Capabilities advertised match what is actually implemented and
configured (mail always; submission when SMTP configured; contacts per FR-P.3;
core always).
uploadUrl/downloadUrl/eventSourceUrlare only advertised when the corresponding endpoint exists. - FR-J.6 Every endpoint (session, API, upload, download, eventsource, oauth)
authenticates uniformly through FR-A.3; failures return 401 with
WWW-Authenticate. - FR-J.7 State strings (
stateon gets,sinceStateon/changes) are monotonic per type per account, never reused after reset, and/changescorrectly reports created/updated/destroyed for Email, Mailbox, Thread, AddressBook and ContactCard. - FR-J.8 Server-Sent Events push (RFC 8620 §7.3): the eventsource endpoint
expands
{types}/{closeafter}/{ping}placeholders, emitsStateChangeevents (type,changed[account][Type].newState) when modseq bumps, honours ping intervals andcloseafter, and survives reverse-proxy buffering when configured per README. - FR-J.9 Error taxonomy:
invalidArguments,invalidProperties,unknownMethod,notFound,serverFail,tooLarge,unsupportedCapability(or equivalent) with human-readabledescriptions; HTTP status is 400 for malformed requests, 401 for auth, 413 for size caps. - FR-J.10 One-origin rule: session, API, upload, download and eventsource
URLs share
base_url's origin; no endpoint ever redirects a client to a different origin (the client's credential policy would drop auth there).
- FR-D.1 A container image runs as a non-root user with
/configand/datavolumes, one entrypoint, and a documenteddocker runthat works from the README on a clean host. - FR-D.2 Session URLs are derived from
base_url, never from requestHostheaders, so operation behind a reverse proxy (Caddy/nginx/Ingress) needs no per-request magic;X-Forwarded-Protois honoured only for building absolute redirect URLs where required. - FR-D.3 The OAuth callback URL is constructed from
base_urland documented as the value to register with the provider; the bridge refuses to start anoauth2account whosebase_urlis cleartext non-loopback. - FR-D.4
GET /healthz(process liveness) andGET /readyz(every account completed a sync pass; false while any account is in auth failure) for probes. - FR-D.5 Graceful shutdown on SIGTERM: stop accepting HTTP, cancel IDLE and hydration, checkpoint SQLite, exit within a bounded grace period.
- FR-D.6 Metrics in Prometheus text format (optional endpoint): sync lag per account, tier per account, hydration queue depth, method counts/errors, IMAP reconnects, SSE clients.
- FR-D.7 Kubernetes artifacts: Deployment (with probes from FR-D.4), PVC for
data_dir, Service, Ingress example with TLS, Secret examples forJMAP_BRIDGE_SECRET_KEYand credentials;kubectl port-forwarddocumented as the cleartext-loopback dev path. - FR-D.8 Reverse-proxy documentation covers SSE requirements (buffering off, long read timeouts) and upload size limits, for both Caddy and nginx.
- FR-D.9 Data directory layout is stable and documented (
bridge.db*,blobs/, config path); forward-only schema migrations run at startup with a version check; backup = copy the directory while stopped (or SQLite backup API), documented. - FR-D.10 Twelve-factor configuration: every secret has an env-var or file alternative; the container runs with no interactive steps after first OAuth consent.
- FR-D.11 Deployment documentation includes the Gmail path end to end: DNS +
TLS + reverse proxy +
base_url+ Google OAuth client (redirect URI, enabled APIs, scopes) + contacts caveats (no groups). - FR-D.12 Structured logs (
log/slog) with request ids and per-account sync events, level configurable, and log output containing no secrets or message bodies (verified by a redaction test). - FR-D.13 The bridge serves two unauthenticated static pages on
base_url's origin for the Google OAuth consent screen:GET /(an application home page describing the bridge and linking to the privacy policy) andGET /privacy(a privacy policy disclosing how Google user data is accessed, used, stored and shared, per Google's API Services User Data Policy). Neither page carries account information, and an unauthenticated request to an unknown path still returns 404. - FR-D.14 Gmail API mode deployment additions are documented end to end:
enabling the Gmail API on the OAuth client's Cloud project, creating a
Pub/Sub topic with
roles/pubsub.publisherforgmail-api-push@system.gserviceaccount.com, creating a push subscription tohttps://<base_url>/gmail/push/<account>with OIDC (service account + audience), thepubsub_service_account/pubsub_audienceconfig, and thewatch = "poll"alternative for self-hosted deployments without a public HTTPS origin. PLAN §17 records how to exercise push against a production bridge.
- NFR-1 Performance (binding; revalidated at M5 per §11): warm
Email/queryover a 100k-message folder answers in < 150 ms p95 on a developer laptop; session → first mailbox list in < 2 s warm; interactive body hydration ≥ 15 messages/s per account under normal RTT. Metadata footprint ≤ ~5 KB per message. - NFR-2 Live latency: foreign changes visible to a connected client within
2 s (IDLE healthy on IMAP, or push healthy on Gmail API per FR-S.14) and
within
sync.intervalotherwise. - NFR-3 Storage policy: the metadata cache is bounded and documented; body
cache grows only with messages actually read (or within
prefetch_window) and the growth policy is documented in README; no message content in logs/DB rows other thanemail_content/blobs. - NFR-4 Durability/consistency: crash at any point leaves SQLite consistent
(WAL) and sync resumable; no acknowledged JMAP mutation is ever lost; tombstones
retained ≥ 30 days so
/changesafter a client's absence still works. - NFR-5 Security: TLS terminated at the proxy (documented); tokens hashed and
compared constant-time; credentials encrypted at rest when keyed; request body
size caps (JSON ≤ 32 MiB, upload ≤ 64 MiB, confirmed); no path
traversal via blob ids or download names; no server-side fetch of
client-supplied URLs; auth failures indistinguishable across accounts.
Client tokens carry a minimum entropy and failed authentication is
rate-limited per source+account; the session's advertised
maxConcurrentRequests/maxConcurrentUploadare enforced and EventSource connections are capped per account and in total;/queryresults are server-capped; the HTTP server sets read/idle timeouts, a header-size cap andnosniff/CSP/Referrer-Policyheaders; account existence is not observable through response timing. - NFR-6 Observability: health, metrics and structured logs sufficient to diagnose a failed sync without a debugger (FR-D.4/6/12).
- NFR-7 Compatibility: works against jmap-tui's live suite (session quirks in
PLAN §2.1) and passes the
JMAP-TestSuitesubset covering the implemented surface by M7 — the deliberate out-of-scope methods and feature gaps are enumerated in PLAN §12's "Known conformance gaps"; degrades honestly where a backend lacks an extension. - NFR-8 Resource use: idle RSS target < 150 MB for a 100k-message account with 10k hydrated bodies (verify at M5); no goroutine or connection leaks after account reconnect cycles (race detector clean in soak).
- NFR-9 Operability: restart resumes all work automatically; migrations are forward-only and never require manual SQL; no step in normal operation needs a shell inside the container.
- NFR-10 Portability: single static binary, no cgo requirement, amd64+arm64 images.
| Requirements | Milestone | Gate summary |
|---|---|---|
| FR-J.1–.6, FR-A.1–.4, FR-A.11–.12, FR-D.1 | M0 | jmap-tui browses fixture mail over loopback (FR-A.4 is config-level here: the first live IMAP/SMTP login lands with M1/M3) |
| FR-S.1–.9, FR-M.1–.8, FR-J.7–.8 | M1 | read-only live account; foreign flag change ≤ 2 s |
| FR-M.9–.13 | M2 | triage round-trips; second client sees it |
| FR-M.14–.17 | M3 | compose → send → Sent + delivered; attachments exact |
| FR-A.5–.10, FR-S.10, FR-S.12, FR-M.18 | M4 | live Gmail end-to-end |
| FR-X.1–.8, FR-S.11, NFR-1, NFR-2, NFR-8 | M5 | search correctness + 100k soak |
| FR-P.1–.13 | M6 | contacts live suite green against real CardDAV |
| FR-M.19, FR-J.9–.10, FR-D.2–.12, NFR-3–.7, NFR-9–.10 | M7 | documented install works; JMAP-TestSuite subset for the implemented surface green (deliberate gaps enumerated in PLAN §12) |
| FR-A.13, FR-S.13, FR-M.1–.8 | M10 | Gmail API read path: jmap-tui browses a fixture Gmail account in API mode; live read-only Gmail gate (roles/labels/counts/threads correct; a web-UI flag change appears) |
| FR-M.9–.13, FR-M.20 | M11 | Gmail API write path: fixture-backed Email/set/destroy/draft/Mailbox/set tests; live write gate on a throwaway account (label both ways, archive, star, move, draft, destroy; onDestroyRemoveEmails=true refused) |
| FR-M.14–.17 | M12 | Gmail API submission: fixture-backed compose/send/patch tests; live self-send gate (exactly one Sent copy + delivery, no draft, ambiguous-send reconciliation) |
| FR-S.14, NFR-2 | M13 | Gmail API push (Pub/Sub): fixture end-to-end foreign change ≤ 2 s, watch renewal across an expiry, forged push rejected, poll fallback |
| FR-D.14, NFR-3–.7 | M14 | API-mode deployment docs; packaging/conformance re-run with API mode included |
(If PLAN.md's traceability column ever disagrees with this table, this table wins. This table maps requirements to milestones only — completion state is recorded nowhere but PLAN §12's Status column.)
Resolved items are recorded as locked decisions in PLAN §1 (D-16 size caps,
D-17 AddressBook/set stays roadmap, D-18 body cache grow-only, D-19 perf
targets binding). Remaining open:
- NFR-1 and NFR-8 numbers are binding as written and revalidated by measurement at M5; any revision is a REQUIREMENTS edit in the same commit as the milestone sign-off.