|
| 1 | +# jmap — a minimal JMAP server over a Solid pod |
| 2 | + |
| 3 | +A JMAP server ([RFC 8620](https://www.rfc-editor.org/rfc/rfc8620) core + a |
| 4 | +useful slice of [RFC 8621](https://www.rfc-editor.org/rfc/rfc8621) mail) as |
| 5 | +a #206 loader plugin. Messages are plain JSON resources in the caller's own |
| 6 | +pod; mailboxes are pod containers; the JMAP access token is the caller's |
| 7 | +pod Bearer, verbatim. |
| 8 | + |
| 9 | +```js |
| 10 | +import { createServer } from 'javascript-solid-server/src/server.js'; |
| 11 | + |
| 12 | +createServer({ |
| 13 | + idp: true, // the token the client sends is a pod bearer from /idp/credentials |
| 14 | + plugins: [{ |
| 15 | + id: 'jmap', |
| 16 | + module: 'plugins/jmap/plugin.js', |
| 17 | + prefix: '/jmap', |
| 18 | + config: { |
| 19 | + baseUrl: 'https://pods.example', // public origin (session urls, redirect) |
| 20 | + loopbackUrl: 'http://127.0.0.1:3000', // how the plugin reaches its own host |
| 21 | + }, |
| 22 | + }], |
| 23 | +}); |
| 24 | +``` |
| 25 | + |
| 26 | +No `appPaths` widening is needed (see Findings #3): everything lives under |
| 27 | +the one `prefix` except `/.well-known/jmap`, which rides core's blanket |
| 28 | +`/.well-known/*` exemption. |
| 29 | + |
| 30 | +## The mapping |
| 31 | + |
| 32 | +| JMAP | pod | |
| 33 | +|---|---| |
| 34 | +| Account | the caller's pod; `accountId` = stable hash of the WebID | |
| 35 | +| Mailbox | a container under `<pod>/private/mail/` — `Inbox`, `Drafts`, `Sent`, `Trash` (ids `inbox`/`drafts`/`sent`/`trash`; created by the pod on first PUT) | |
| 36 | +| Email | one JSON resource `<mailbox>/<id>.json` holding the JMAP Email fields | |
| 37 | +| move (Email/set update `mailboxIds`) | PUT into the new container, DELETE from the old | |
| 38 | +| destroy | DELETE | |
| 39 | +| state strings | a hash of the four mailbox listings (coarse — see Findings #2) | |
| 40 | + |
| 41 | +Auth is the token bridge, as in mastodon/ bluesky/ matrix/ micropub/: a |
| 42 | +JMAP token and a pod bearer are the same kind of thing, so the bridge is |
| 43 | +the identity function. Every pod read/write goes over loopback with the |
| 44 | +caller's own `Authorization` forwarded — real WAC decides every operation, |
| 45 | +not this shim. |
| 46 | + |
| 47 | +## A curl session |
| 48 | + |
| 49 | +```sh |
| 50 | +# mint a pod bearer (the JMAP token) |
| 51 | +TOKEN=$(curl -s -X POST http://localhost:3000/idp/credentials \ |
| 52 | + -H 'content-type: application/json' \ |
| 53 | + -d '{"username":"alice","password":"..."}' | jq -r .access_token) |
| 54 | + |
| 55 | +# autodiscovery: RFC 8620 §2.2 — 301 to the Session resource |
| 56 | +curl -si http://localhost:3000/.well-known/jmap | grep -i location |
| 57 | +# location: http://localhost:3000/jmap/session |
| 58 | + |
| 59 | +# the session object: capabilities, one account, apiUrl |
| 60 | +curl -s http://localhost:3000/jmap/session -H "authorization: Bearer $TOKEN" | jq . |
| 61 | + |
| 62 | +# create a message in the Inbox |
| 63 | +curl -s -X POST http://localhost:3000/jmap/api \ |
| 64 | + -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{ |
| 65 | + "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], |
| 66 | + "methodCalls": [["Email/set", { "create": { "m1": { |
| 67 | + "mailboxIds": { "inbox": true }, |
| 68 | + "from": [{ "name": "Alice", "email": "alice@example.org" }], |
| 69 | + "to": [{ "name": "Bob", "email": "bob@example.org" }], |
| 70 | + "subject": "Hello JMAP", |
| 71 | + "bodyValues": { "b": { "value": "Mail as JSON in a pod." } }, |
| 72 | + "textBody": [{ "partId": "b", "type": "text/plain" }] |
| 73 | + }}}, "c1"]] |
| 74 | +}' | jq '.methodResponses[0]' |
| 75 | + |
| 76 | +# query the Inbox (receivedAt desc), read the message back |
| 77 | +curl -s -X POST http://localhost:3000/jmap/api \ |
| 78 | + -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{ |
| 79 | + "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], |
| 80 | + "methodCalls": [ |
| 81 | + ["Email/query", { "filter": { "inMailbox": "inbox" } }, "q"], |
| 82 | + ["Email/get", { "ids": ["<id from q>"] }, "g"] |
| 83 | + ] |
| 84 | +}' | jq '.methodResponses' |
| 85 | + |
| 86 | +# move to Trash, then destroy |
| 87 | +# Email/set update: { "<id>": { "mailboxIds": { "trash": true } } } |
| 88 | +# Email/set destroy: ["<id>"] |
| 89 | +``` |
| 90 | + |
| 91 | +The stored resource is readable as a plain pod document too — `GET |
| 92 | +/alice/private/mail/Inbox/<id>.json` is the same message. |
| 93 | + |
| 94 | +## What maps |
| 95 | + |
| 96 | +- **Session** (`GET /jmap/session`, authed): capabilities |
| 97 | + (`urn:ietf:params:jmap:core`, `urn:ietf:params:jmap:mail`), exactly one |
| 98 | + account (the caller's pod), `apiUrl`, coarse `state`. |
| 99 | +- **`POST /jmap/api`** with `{ using, methodCalls }` → |
| 100 | + `{ methodResponses, sessionState }`, request-level failures as RFC 7807 |
| 101 | + problem+json (`notJSON`, `notRequest`, `unknownCapability`). |
| 102 | +- **Mailbox/get** — the four role mailboxes with live `totalEmails`. |
| 103 | +- **Email/get** — ids → objects, with `properties` filtering. |
| 104 | +- **Email/query** — `filter: { inMailbox }`, `receivedAt` sort (default |
| 105 | + newest-first), `position`/`limit` paging, `total`. |
| 106 | +- **Email/set** — create (PUT into the mailbox container), update |
| 107 | + (`mailboxIds` move as whole-property or `mailboxIds/<id>` patch pointer; |
| 108 | + `keywords` likewise), destroy (DELETE). Per-object failures use the spec |
| 109 | + SetError shapes (`invalidProperties`, `notFound`, `forbidden`). |
| 110 | +- **Method-level errors** per RFC 8620 §3.6.2: `unknownMethod`, |
| 111 | + `invalidArguments`, `unsupportedFilter`, `unsupportedSort`, |
| 112 | + `accountNotFound`, `forbidden`, `serverFail`. |
| 113 | +- **Autodiscovery**: `GET /.well-known/jmap` → `301` to the session URL. |
| 114 | + |
| 115 | +## What doesn't (all deliberate, all documented) |
| 116 | + |
| 117 | +- **No push**: `eventSourceUrl` is omitted from the session. |
| 118 | + `Mailbox/changes`, `Email/changes`, `Email/queryChanges` return |
| 119 | + `cannotCalculateChanges` (the spec's own escape hatch). See Findings #2. |
| 120 | +- **No back-references**: `#resultOf` arguments return `serverFail` with an |
| 121 | + explanatory description rather than being silently misread. A JMAP client |
| 122 | + that chains `Email/query → Email/get` in one request must send two. |
| 123 | +- **No blobs/attachments**: `uploadUrl`/`downloadUrl` are omitted; |
| 124 | + `maxSizeUpload: 0` and `maxSizeAttachmentsPerEmail: 0` are advertised |
| 125 | + honestly. Binary upload wants the un-drained raw-body-stream seam (#583) |
| 126 | + — the same wall as micropub's media endpoint. See Findings #5. |
| 127 | +- **No threads** (`Thread/get`), no `Email/import`, no |
| 128 | + `EmailSubmission/*` — this stores and organizes mail; it does not send |
| 129 | + SMTP. |
| 130 | +- **One mailbox per message** (`maxMailboxesPerEmail: 1`, advertised): a |
| 131 | + message is one resource in one container, so JMAP's "email in several |
| 132 | + mailboxes at once" doesn't map to LDP containment. |
| 133 | +- **In-place updates don't move the state string**: the state hash is over |
| 134 | + mailbox *listings*, so a keywords-only update leaves it unchanged. Coarse |
| 135 | + but honest — and advertised as such via `cannotCalculateChanges`. |
| 136 | + |
| 137 | +## Findings |
| 138 | + |
| 139 | +1. **JMAP's stateless slice fits the plugin api exactly — and that sharpens |
| 140 | + matrix/'s line.** matrix/ found that a chat protocol's `/sync` long-poll |
| 141 | + needs server-side cursors and live push, so a stateless bridge can only |
| 142 | + do full-state sync. JMAP is the controlled experiment from the other |
| 143 | + side: it is *email* — the poster child of stateful server-push protocols |
| 144 | + (IMAP IDLE) — redesigned by the IETF as stateless request/response, and |
| 145 | + the entire redesigned core (session, batched method calls, coarse state |
| 146 | + strings, polling) bridged onto loopback pod I/O with **zero** |
| 147 | + approximation. What makes a protocol pluggable is not its domain |
| 148 | + ("email", "chat") but one property: **no server push**. IMAP could never |
| 149 | + be this plugin; JMAP minus its optional push extension can, faithfully. |
| 150 | +2. **Push and delta sync are blocked on `api.events.onResourceChange` — the |
| 151 | + 6th independent consumer** (after notifications/, sparql/, search/, |
| 152 | + matrix/, backup/). Two distinct JMAP features die on the same missing |
| 153 | + seam: (a) `eventSourceUrl` — JMAP push is an EventSource stream of |
| 154 | + `StateChange` objects, which needs a resource-change feed to emit from; |
| 155 | + (b) `*/changes` + `Email/queryChanges` — honest delta responses need |
| 156 | + either an event-fed change log or write-time state counters, neither of |
| 157 | + which a read-time-only plugin can keep. Today's state strings are a hash |
| 158 | + of the mailbox listings recomputed per request — 4 loopback listings per |
| 159 | + state, blind to in-place edits — and the spec's `cannotCalculateChanges` |
| 160 | + is the only honest answer. The same seam also forces Email/query to be |
| 161 | + read-time O(N) (GET every message to sort by `receivedAt`), like |
| 162 | + sparql/ and rss/. |
| 163 | +3. **`/.well-known/jmap` works by core's blanket exemption — another |
| 164 | + by-luck witness for `api.reservePath`,** joining nip05/, webfinger/, the |
| 165 | + DAV trio, and matrix/'s `/.well-known/matrix/client`. RFC 8620 pins |
| 166 | + autodiscovery at that exact path; the plugin registers it outside its |
| 167 | + prefix and it is reachable only because core happens to WAC-exempt |
| 168 | + `/.well-known/*` — coincidence, not contract. The counter-face of the |
| 169 | + same finding: JMAP needs **no** `appPaths` at all (unlike |
| 170 | + mastodon/bluesky/matrix), because the session document makes every other |
| 171 | + URL client-discovered — same family as micropub/. So the reservePath |
| 172 | + seam is really about *protocol-pinned* paths, and JMAP pins exactly one. |
| 173 | +4. **The token bridge, 5th protocol witness** (after mastodon/ OAuth, |
| 174 | + bluesky/ XRPC sessions, matrix/ access_tokens, micropub/ IndieAuth): |
| 175 | + RFC 8620 §8.2's Bearer token and a Solid pod bearer are the same kind of |
| 176 | + thing, so the bridge is the identity function — `api.auth.getAgent` |
| 177 | + resolves it, loopback forwards it, WAC decides. Five protocols in, this |
| 178 | + is a law of the repo, not a trick. |
| 179 | +5. **Blobs want the raw-body-stream seam (#583).** JMAP attachments are |
| 180 | + uploaded as raw binary to `uploadUrl` and referenced by blobId. JSS's |
| 181 | + wildcard parser buffers non-JSON bodies, and a plugin cannot receive an |
| 182 | + un-drained stream — the same wall as micropub's media endpoint and |
| 183 | + gitscratch's pack streams. `uploadUrl` is therefore omitted and |
| 184 | + `maxSizeUpload: 0` advertised, which conforming clients respect. |
| 185 | +6. **JMAP's error model rewards the honest bridge.** Unlike most protocols |
| 186 | + ported here, RFC 8620 gives first-class vocabulary for *declared* |
| 187 | + inability: `cannotCalculateChanges`, capability ceilings |
| 188 | + (`maxSizeUpload: 0`, `maxMailboxesPerEmail: 1`), omittable session urls. |
| 189 | + The gaps in this plugin are advertised in-protocol rather than |
| 190 | + documented beside it — a spec designed for partial servers is a spec a |
| 191 | + pod bridge can implement without lying. |
0 commit comments