Skip to content

Commit a469ef7

Browse files
jmap plugin: minimal JMAP mail server over pod messages
RFC 8620 session + api endpoint with an RFC 8621 slice: Mailbox/get, Email/get/query/set — messages are JSON resources under <pod>/private/mail/, every operation a loopback call with the caller's own bearer. Headline finding: the stateless/push line sharpened — JMAP, email redesigned stateless by the IETF, bridges with zero approximation (IMAP never could); what makes a protocol pluggable is not its domain but 'no server push'. Push + delta sync are the 6th independent consumer of api.events; blobs want the raw-body-stream seam (#583, maxSizeUpload: 0 advertised honestly); /.well-known/jmap rides core's blanket exemption (another by-luck reservePath witness); token bridge 5th protocol witness.
1 parent 5b09448 commit a469ef7

3 files changed

Lines changed: 1118 additions & 0 deletions

File tree

jmap/README.md

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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

Comments
 (0)