Building a client for plMail
Everything an engineer (or an agent) needs to write a new plMail client — a native iOS or Android app, a desktop app, a CLI, another web front end — without reading the whole Symfony codebase first.
It covers three things, in this order:
- What plMail is, and the design philosophy a client is expected to inherit.
- How it should look and feel — the visual system, the layout rules, the motion, the copy.
- How it behaves, and the API it behaves through — JMAP, auth, push, blobs, search.
For installing and running the server see README.md. For developing the server see CONTRIBUTING.md. This document assumes the server already runs somewhere.
0. Read this first: the server is under active development
plMail is being actively developed, and the server side is fully in scope for your client's needs. This document describes what exists today. It is a snapshot, not a fixed contract, and several things it lists as absent are absent only because nothing has needed them yet.
So, when you hit a wall — a missing JMAP method, an endpoint that doesn't expose a field you need, a concept that lives in the database but not in the API, a limit that's wrong for mobile:
Ask. Do not work around it.
Adding the endpoint, the method, the field or the vendor extension is a completely normal outcome and usually the right one. A client-side workaround that reconstructs missing server behaviour is almost always the wrong answer: it duplicates logic that belongs in one place, it drifts from the web UI, and it quietly becomes load-bearing.
Concretely, stop and ask before you:
- reimplement server logic client-side because the API doesn't expose it;
- scrape or drive the HTML / Turbo Stream routes because JMAP lacks something;
- poll aggressively to compensate for a missing change-tracking method;
- invent local state (custom keywords, shadow flags, local-only labels) that can't round-trip;
- denormalise or cache something in a way that would break if the server later did it properly.
Things this document flags as not implemented — Email/queryChanges, anchor paging, JWT
issuance, a cross-account unified query — are all candidates for
being built, not permanent constraints. Raise the need with the maintainer and decide together
whether it belongs in the server or the client.
The calendar was the example of exactly this, and it is now the example of it working. This
section used to say there was no JMAP calendar API and that a using containing a calendar URN was
rejected outright. There is one: Calendar/get, CalendarEvent/get, CalendarEvent/query and
CalendarEvent/set, under urn:plmail:params:jmap:calendars, advertised in Capability::SUPPORTED
and served from exactly one account. See JMAP for the id spaces and the two
things that surprise people — a CalendarEvent id is the series rather than a dated occurrence,
and CalendarEvent/query requires a date window.
If you are building something that wants events, say so — the storage is already JSCalendar
precisely so the API can be JSCalendar, and the shape of the methods (Calendar/get,
CalendarEvent/query, /changes against the existing StateManager) is settled. Two things to
know before you ask:
- JMAP for Calendars is still an IETF draft, so when this ships it will advertise a vendor URN
(
urn:plmail:params:jmap:calendars), following the precedent already set byurn:plmail:params:jmap:push. Do not hard-code the draft's URN. - Do not read
calendar_event.jscalendarthrough some side channel, and do not reconstruct recurrence client-side. Occurrences are materialised server-side to a bounded horizon for good reasons (an unboundedFREQ=DAILYhas no last instance), and a client that expands rules itself will disagree with the web UI at DST boundaries and on overridden instances.
And the second half of that promise now exists. "Do not expand recurrence rules yourself" was
an instruction with no way to obey it cheaply: a collapsed CalendarEvent/query names a series
without saying which days it lands on, so drawing a month meant one query per day. Send
expandRecurrences: true and the same query answers one entry per occurrence, in start order,
with position/limit/total counting occurrences — one query for the month. Each occurrence id
is <eventId>_<recurrenceId>, e.g. 42_20260304T090000Z; treat it as opaque, hand it straight
to CalendarEvent/get (the usual #ids pairing works), and read start and recurrenceId off the
object rather than out of the id. One-off events keep their plain series id, and with the argument
absent or false nothing about the response changed. CalendarEvent/set does not accept an
occurrence id and says so; the object's seriesId is the id you write through. Full shape,
including what an expanded query refuses and why, in JMAP.
The corollary: when you read something surprising here, check it against the code before designing
around it. src/Jmap/ is the authority, and it moves.
1. The product
What plMail is
plMail is a self-hosted mail client. It runs on a machine the user owns — a NAS, a home server, a small VPS — connects to the mailboxes they already have (IMAP, Gmail, Outlook/Microsoft 365), and syncs every message into a local PostgreSQL database.
It is emphatically not a mail server. It does not receive mail from the outside world, host a domain, or run MX. It is the client layer: one interface, one search box, one set of labels across however many providers the user has.
What that implies for your app
Three consequences drive almost every client-side decision:
The server is the source of truth, and it is fast. Mail is already in Postgres, indexed, threaded and full-text searchable. Your app should not build a competing sync engine against Gmail or IMAP — it talks to plMail, and plMail talks to the providers. A client that reaches around the server breaks the single-database promise the whole product is built on.
The server is the user's server. It may be on a home LAN, behind Tailscale, or on a slow ADSL uplink. It may be briefly unreachable when the NAS reboots. Assume: variable and sometimes high latency, occasional self-signed or private-CA certificates, no CDN, no global anycast, and a single PHP worker pool that a badly-behaved client can genuinely exhaust. Cache aggressively, poll rarely, degrade gracefully, and never busy-loop.
Multiple accounts are the normal case, not the edge case. A user with a work Gmail, a personal IMAP and an Outlook account is the target user. The unified inbox is the default view. Every screen in your app should be designed multi-account first and single-account second.
Design philosophy
The server codebase is unusually opinionated, and the opinions are worth inheriting because they are what make the product feel coherent.
- Gmail is the vocabulary, not the aesthetic. Labels rather than folders. Conversations rather
than messages. A single search box with
from:/is:/has:operators. Users arriving from Gmail should not have to learn new nouns. But the look is plMail's own — softer, calmer, more configurable. - Everything is themable, and the theme is the user's. Colour, density, corner radius, translucency, background — all user-set, all synced server-side. A client that hardcodes its palette is wrong. See §2.
- Labels are the user-facing concept; mailboxes are plumbing. IMAP folders exist as sync infrastructure. The user sees labels, and a message can carry several. Do not surface folders.
- Nothing is ever hard-deleted. "Delete" means "move to Trash". The server has no hard-delete path at all — deleting a row would discard the local copy of mail the provider still holds. Your destructive UI should say Trash, and should be undoable.
- Comments explain why, and so should your UI. The server code is full of "this looks odd because X bit us". Carry that spirit into empty states and error messages: say what happened and what the user can do, never just "Error".
- Local first, provider second. Actions apply locally and then propagate outward to Gmail/IMAP/Graph. Your UI can and should be optimistic.
Prior art in the repo
Before you invent a screen, look at how the web UI does it. The most instructive files:
| What | Where |
|---|---|
| Design tokens, utilities, theme blocks | assets/styles/app.css |
| The app shell (viewport, PWA, theme bootstrapping) | templates/_layout/app.html.twig |
| The list row anatomy | templates/_partials/_thread_row.html.twig |
| Mobile list ⇄ reading-pane behaviour | assets/controllers/mail/mail_pane_controller.js |
| Drawer / icon-rail sidebar | assets/controllers/ui/sidebar_drawer_controller.js |
| Screenshots of the real thing | docs/screenshots/ |
2. Look and feel
The two-axis appearance model
plMail separates Theme (the palette) from Layout (the treatment). Every theme composes with every layout. On top of both sit numeric knobs the user can override individually.
Theme — App\Domain\Enum\Theme\Theme
| Theme | Surface | Ink | Accent | Dark? |
|---|---|---|---|---|
system |
follows OS | — | #2563eb |
follows OS |
light |
#ffffff |
#27272a |
#2563eb |
no |
dark |
#111827 |
#f4f4f5 |
#3b82f6 |
yes |
nord |
#2e3440 |
#eceff4 |
#88c0d0 |
yes |
dusk |
#1e1b2e |
#ede9fe |
#a78bfa |
yes |
solar |
#fdf6e3 |
#586e75 |
#b58900 |
no |
Layout — App\Domain\Enum\Theme\Layout
| Layout | Radius | Pane blur | Pane alpha | Character |
|---|---|---|---|---|
flat (default) |
0.75rem | 0 | 1.0 | Chrome sits straight on the background; one opaque content card. |
boxed |
1.0rem | 24px | 0.7 | Everything is a floating translucent card over the background. |
Selecting a layout seeds the knobs below; the user can then override each one.
Density — App\Domain\Enum\Theme\Density
| Density | Row padding (block) | Gap |
|---|---|---|
comfortable (default) |
0.875rem | 0.75rem |
cosy |
0.625rem | 0.5rem |
compact |
0.375rem | 0.375rem |
User knobs — see Appearance for the authoritative
list and clamps:
| Field | Type | Range | Meaning |
|---|---|---|---|
accent |
hex | #rrggbb |
Accent colour. Default #2563eb. |
paneAlpha |
float | 0.15 – 1.0 | Opacity of the structural surfaces: sidebar, top bar, main pane, calendar. |
popoverAlpha |
float | 0.5 – 1.0 | Opacity of the surfaces that float over those: compose window, modals, menus, toasts. Its floor is higher on purpose — the two translucencies multiply where they overlap. Default 1.0. |
paneBlur |
int | 0 – 60 | Backdrop blur in px. |
radius |
float | 0.0 – 2.0 | Corner radius in rem, for panes only. |
scrimAlpha |
float | 0.0 – 0.7 | Black scrim over a custom background image. |
inkColor / inkMuted / inkFaint |
hex|null | — | Text colour overrides. |
mainTint / mainAlpha |
hex|null / float|null | — | Tint and opacity of the main content pane specifically. |
backgroundKind |
enum | theme | preset | solid | custom |
Where the app background comes from. |
backgroundPreset / backgroundSolid / backgroundFile |
— | — | The chosen background. |
logoStyle |
enum, read-only | one of logoStyles |
The colourway the "pl" mark wears. |
Appearance::toArray() is the export format (versioned, version: 1), and applyArray() the
import. The web UI lets users export/import this as a file.
This IS reachable over JMAP.
Appearance/getandAppearance/setserve the singleton object (id"singleton", noaccountId— it hangs off theUser), and the Session's appearance capability publishes the vocabularies and ranges:themes,logoStyles,layouts,densities,backgroundKinds,backgroundPresets,unreadEmphases,fontFamilies,ranges.previewLines,ranges.fontScale,ranges.popoverAlpha. Model the same two-axis Theme×Layout shape with the same semantic tokens and read the server's values into it. Two things to know before writing: booleans are validated strictly, so"1"and"0"are refused rather than coerced; and the three per-surface densities take an explicit JSONnullto mean "follow the global density", which is a different instruction from leaving the key out. The rule being enforced is "don't hardcode a palette". The export format already exists, so it is a small addition.
logoStyleis read-only, and it is the mark the user is actually looking at. The value is one of the fixed set the Session publishes aslogoStyles—"berry"(the product default),"product-blue","petrol-copper"and twenty-nine more — so a client that maps them onto assets of its own can check the list at discovery time and know when it is holding one it has nothing for. Treat an unrecognised value as the default rather than as an error; the set grows.It cannot be set. Not an oversight and not a permission: on the server it is derived, from the theme, from whether the user has unlinked the mark from the theme, and only then from a stored colourway. Every one of the thirty-two is also a theme name, and by default picking a theme dresses the mark to match — which is why what you read back is the mark as the web draws it in the topbar and the favicon, not a column. Sending
logoStylewith a different value is refused withinvalidProperties; sending it with the value you just read is accepted and ignored, so get → change one field → set works as it does for every other property. To move the mark, settheme— the new colourway comes back in that call'supdatedmap. Unlinking the mark from the theme is a web-only setting today.
Radius applies to panes, not controls. Modals, the compose window, dropdowns, menus and toasts take
--app-radius. Buttons, inputs, chips and list rows keep a fixed small radius — they must not grow to 2rem corners. This distinction is deliberate and easy to get wrong.
Semantic colour tokens
Never reference raw palette values. Build your client against the same semantic token set the CSS
uses, so a theme change re-resolves everything at once. The canonical list lives in the
@theme inline block of app.css:
| Token | Use |
|---|---|
surface |
Card / pane background. |
line |
Hairline dividers (very low alpha). |
raised / hover |
Subtle raised fills and hover states. |
ink / ink-soft / ink-muted / ink-faint |
Text, in four decreasing weights of emphasis. |
accent / accent-strong / accent-soft / accent-ink |
The accent and its variants. |
sunken |
Recessed wells (inputs backgrounds, code blocks). |
field / field-border |
Form controls. |
danger / warning / success / info |
Status. Each has a -soft background variant. |
inverse / inverse-ink |
Tooltips and inverted chips. |
Composed surfaces: pane (card with border + shadow), pane-flat (no shadow), popover (fully
opaque — a translucent dropdown over a photo grid is illegible), main-pane (the content card,
respects mainTint/mainAlpha), and app-bg (the gradient/image background plus scrim).
The mail sheet — where the theme stops, and how it stops
Rendered mail bodies do not take the app's palette. Mail arrives authored for a white background, so
handing it a dark surface produces black text on black. The web UI's mail-sheet utility redeclares
the palette channels locally so everything inside — including your own chrome, if you nest any —
resolves to light values.
On the web that means a permanently light sheet. On a phone it cannot: a mail app whose reading pane is the one screen that stays white at night is not acceptable, and users will say so.
So a native client should render dark — but not by inverting everything, which is the approach that reliably looks broken. Photographs come out as negatives, logos come out in the wrong brand colours, and a message that already ships its own dark styles double-inverts into something worse than either extreme.
Choose a strategy per message, from what its HTML declares about itself:
| The message | What to do |
|---|---|
| Brings no colours of its own — a typed reply, most personal mail | Restyle it in your dark palette. Nothing is inverted, so nothing can look like a negative. This is the best available result. |
| Has a palette of its own — newsletters, anything designed | Invert with hue-rotate(180deg), then invert img, picture, video, svg and background-image elements back. That second rule is the one everyone forgets, and skipping it is what gives inversion its reputation. |
Already declares prefers-color-scheme |
Tell it the scheme is dark and leave it alone. The sender did the work. |
| Any of the above, in light appearance | Render exactly as sent. |
Two things follow. Offer a way back to the original wherever you transformed a message —
inversion gets some mail wrong, and being told a mangled message is fine is worse than seeing that it
was mangled. And note invert+hue-rotate is a matrix approximation rather than a true HSL
rotation, so round-tripped colours come back slightly desaturated; that is the cost of the technique.
What has not changed: never pass the user's theme into the message renderer. The message gets one of the treatments above, not the accent colour, the pane alpha or the background image.
Layout and navigation
Desktop / tablet (≥768px) — three regions:
┌──────────────────────────────────────────────┐
│ topbar: search, sync, account, settings │
├────────────┬─────────────────────────────────┤
│ sidebar │ list │ reading pane │
│ Compose ▸ │ (threads) │ (thread) │
│ Inbox 12 │ │ │
│ Starred │ │ │
│ Sent │ │ │
│ Labels… │ │ │
└────────────┴─────────────────────────────────┘
The sidebar collapses to a 56px icon rail (state persisted; on web it is applied before first paint so the wide sidebar never flashes). Active and hovered nav rows use a Gmail-style pill that runs off the left edge and caps with a full radius on the right.
Mobile (<768px) — the sidebar becomes a slide-in drawer over a backdrop, and list/reading
become two stacked panes: tapping a row replaces the list with the thread, and Back returns to
the list. On the web this is done with history.pushState, so the hardware/browser Back button works
naturally — a native client should map this to a standard navigation stack push.
Compose on mobile is fullscreen; on desktop it is a docked window in the bottom-right
(fixed bottom-4 right-6), and several can be open at once.
The list row
From _thread_row.html.twig, one row shows:
- Participants — everyone who has written in the conversation, oldest first. Not the newest sender; that made every thread you had answered look like it came from you.
- Subject, falling back to a translated "(no subject)".
- Snippet — first ~100 chars of the latest message's plain-text body, tags stripped.
- Message count when > 1.
- Date — last message time.
- State affordances: unread (weight/indicator), starred, attachment paperclip.
- Hover actions (desktop): archive, trash, snooze, mark read/unread, label.
Unread and starred are exposed as data-unread / data-starred on the row, so styling keys off
state rather than duplicated classes. Mirror that: one row component, state-driven.
Draft rule (subtle, get it right): a row opens the compose surface instead of the reading pane only when the row is the draft — i.e. a thread holding a single draft message, or a bare draft message row. A real conversation carrying an unsent reply still opens the thread, and that draft is edited from inside the reading pane. In the Drafts list this is overridden: every row there opens its draft.
Motion, gesture and touch
- Motion is functional, not decorative. Drawer slide, pane transition, toast in/out. No spring physics, no parallax, no hero animations on mail rows.
- Honour reduced-transparency. The CSS forces
paneAlpha: 1,popoverAlpha: 1,paneBlur: 0,scrimAlpha: 0underprefers-reduced-transparency: reduce. Do the same, and honour reduced-motion too. - Safe areas. The web app runs
viewport-fit=coverand pads byenv(safe-area-inset-*). Native clients get this for free but must not let the compose dock or toolbar sit under the home indicator. - The web app has no swipe gestures on rows — actions are buttons. A native client should add swipe-to-archive / swipe-to-trash, because that is the platform idiom; just make sure whatever a swipe does is also reachable as an explicit control, and is undoable.
- Nothing scrolls the page sideways. Panes scroll their own overflow. Toolbars that don't fit scroll horizontally with the scrollbar hidden.
Copy and tone
Read translations/ for the actual strings. The register throughout is calm, concrete and lowercase-ish — plain sentences, no exclamation marks, no "Oops!". Errors name the cause. The UI ships in English and German; if you add strings, add both, and design for German being ~30% longer.
Accessibility baseline
- Text inputs render at ≥16px on small screens (below that iOS zooms on focus regardless of viewport settings). Match this.
- Everything clickable must look and behave clickable, and everything reachable by pointer must be reachable by keyboard/screen-reader.
- Icon-only controls carry
aria-labels in the web UI. Carry accessibility labels natively. - Contrast must hold in all six themes, which is precisely why the semantic tokens exist.
3. The API
Which API to use
plMail exposes JMAP (RFC 8620 / RFC 8621) at /jmap. This is the API for third-party and
native clients. It is the only stable, documented, versioned surface.
The web UI's own routes (/mail/*, /compose/*, /settings/*) return HTML and Turbo Streams,
not JSON. They are internal, unversioned, CSRF-protected and will change without notice. Do not build
against them.
Known JMAP clients that already work against this server: ltt.rs (Bearer) and Sterna (Basic). Testing against one of them is the fastest way to sanity-check your own implementation.
Authentication
The JMAP firewall is stateless and accepts two credential types.
App passwords — available today, and what you should use.
The user creates one in Settings → App passwords. The secret is shown exactly once and looks like:
plmail_<64 hex chars>
Only a SHA-256 digest is stored server-side, plus a 6-character hint so the listing can show which is
which. Tokens are user-scoped, not account-scoped: one credential enumerates every connected mail
account. They can be revoked individually. lastUsedAt is updated at most every 5 minutes, so it is
a coarse "recently active" signal, not an audit log.
Send it either way:
Authorization: Bearer plmail_abc123…
Authorization: Basic base64(user@example.com:plmail_abc123…)
If you send Basic, the username is verified against the token's owner — a wrong address is rejected with a clear message rather than silently operating as whoever owns the token.
JWT — wired, but not yet issuable. The firewall accepts JWTs (for a future first-party app) and
the server generates a keypair on first start, but there is currently no endpoint that issues
one. A Bearer token that starts with plmail_ is routed to the app-password authenticator;
anything else falls through to JWT.
Today, build against app passwords. But if you're writing the first-party app, a proper login endpoint issuing short-lived JWTs is exactly the kind of thing to request — most of the plumbing is already there. Don't fake a session layer on top of app passwords to get around it; ask.
Failure shape — 401 with application/problem+json and a WWW-Authenticate: Basic realm="plMail JMAP" challenge:
{ "type": "urn:ietf:params:jmap:error:unauthorized", "status": 401, "detail": "Invalid or revoked app password." }
Session discovery
GET /.well-known/jmap (or GET /jmap/session)
Authorization: Bearer plmail_…
Returns the Session object. Everything else is discovered from it — never hardcode the other
paths, and always re-read apiUrl etc. from here:
{
"capabilities": {
"urn:ietf:params:jmap:core": {
"maxSizeUpload": 50000000,
"maxConcurrentUpload": 4,
"maxSizeRequestObject": 10000000,
"maxConcurrentRequests": 4,
"maxCallsInRequest": 32,
"maxObjectsInGet": 500,
"maxObjectsInSet": 500,
"collationAlgorithms": ["i;ascii-numeric", "i;ascii-casemap", "i;unicode-casemap"]
},
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:submission": {},
"urn:plmail:params:jmap:push": {
"vapidPublicKey": "BN…",
"fcm": true,
"fcmConfig": {
"projectId": "plmail-abc123",
"applicationId": "1:1234567890:android:0123456789abcdef",
"apiKey": "AIza…",
"senderId": "1234567890"
}
}
},
"accounts": {
"7": {
"name": "me@example.com",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {
"urn:ietf:params:jmap:mail": {
"maxMailboxesPerEmail": null,
"maxMailboxDepth": null,
"maxSizeMailboxName": 255,
"maxSizeAttachmentsPerEmail": 50000000,
"emailQuerySortOptions": ["receivedAt", "from", "to", "subject", "size"],
"mayCreateTopLevelMailbox": true
},
"urn:ietf:params:jmap:submission": {
"maxDelayedSend": 2592000,
"submissionExtensions": { "FUTURERELEASE": ["HOLDFOR", "HOLDUNTIL"] }
}
}
}
},
"primaryAccounts": { "urn:ietf:params:jmap:mail": "7" },
"username": "me@example.com",
"apiUrl": "https://mail.example.com/jmap/api",
"downloadUrl": "https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "https://mail.example.com/jmap/upload/{accountId}",
"eventSourceUrl": "https://mail.example.com/jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}",
"state": "…"
}
Critical modelling detail: one JMAP account is exposed per connected mail account. A user with
three mailboxes sees three JMAP accounts under one login. The unified inbox is a client-side
concern — you run one Email/query per account and merge the results yourself, ordering by
receivedAt. There is no server-side cross-account query.
urn:plmail:params:jmap:push is a vendor extension describing which push transports this
instance can actually deliver over; RFC 8620 defines no standard place for any of it.
| Key | Meaning |
|---|---|
vapidPublicKey |
Your applicationServerKey for a Web Push subscription. Empty means Web Push is unconfigured — don't offer it. |
fcm |
Whether Firebase is configured and switched on. Always present, true or false. |
fcmConfig |
The inputs to Android's FirebaseOptions.Builder. Absent entirely when fcm is false — not null. |
fcm is always present so you can tell "this server does not do FCM" from "this server predates
FCM"; the right reaction to each is the opposite one. fcmConfig takes the opposite rule for the
opposite reason: a null object invites you to read .projectId off it and get null, and an absent
key cannot be dereferenced. Check fcm first.
Note capabilities advertises the push URN but the supported using list is Core, Mail and
Submission only. Do not put the push URN in using.
Push on Android
Web Push assumes a push service: something that owns the endpoint URL, holds the connection to
the device and receives the server's encrypted POST. Browsers ship one. A native Android app does
not, and Android's own service is FCM, which speaks its own protocol — WebPushSender cannot POST
to it.
So an Android client has three options:
- UnifiedPush. The user installs a distributor app; it supplies an RFC 8030 endpoint and
decrypts the RFC 8291
aes128gcmpayload this server already sends. No server configuration at all. - Firebase. Nothing for the user to install, and what most Android users expect. Supported
since the server gained
FcmSender, and the admin has to paste a Firebase project's credentials before it works — Google then learns that a message arrived and when. - An embedded distributor, where the app holds the socket itself. Costs a foreground service and a permanent notification, per app.
Initialising Firebase against a self-hosted instance. The normal Android arrangement — a
google-services.json processed at build time — cannot work here: one APK serves every installation
and every installation has its own Firebase project. So the server publishes the four public values
instead, as fcmConfig above, and you build FirebaseOptions from them at runtime after fetching
the session:
val options = FirebaseOptions.Builder()
.setProjectId(config.projectId)
.setApplicationId(config.applicationId)
.setApiKey(config.apiKey)
.setGcmSenderId(config.senderId)
.build()
All four ship inside every Firebase app's APK and are public by nature; the service-account key that
can actually send never leaves the server. If the instance registered several Android packages,
the one published is de.plmail.google where present and the first registered client otherwise.
For (1) this repository can supply the push service too, so a self-hoster does not have to find one:
docker compose --profile push up -d ntfy
That is the whole setup. It is off by default, adds no plMail code, and needs no configuration of
its own: the endpoint URL is derived from the SERVER_NAME set at first boot, because the host
phones already reach is the only thing it has to be. Override NTFY_BASE_URL if push should live
somewhere else.
The derived URL is http://$SERVER_NAME:8090. Two consequences worth knowing. It cannot be folded
behind the app's own Caddy at a path the way the Mercure hub is at /.well-known/mercure — ntfy
refuses a base-url with a path at startup — so it takes a port of its own. And the endpoint URL is
itself the secret, so facing the open internet you want TLS in front of it and NTFY_BASE_URL set to
the https address; over a LAN or Tailscale the default is fine as it stands.
It is baked into every endpoint issued, so changing it later forces every device to re-register.
Payloads are encrypted to the device's own key before they reach it, so the push service cannot read mail whichever one you use. It does learn when mail arrives, which is the argument for running your own rather than a public one.
The API endpoint
POST /jmap/api
Content-Type: application/json
Authorization: Bearer plmail_…
{
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["Email/query", { "accountId": "7", "filter": { "inMailbox": "42" }, "sort": [{ "property": "receivedAt", "isAscending": false }], "limit": 50 }, "q0"],
["Email/get", { "accountId": "7", "#ids": { "resultOf": "q0", "name": "Email/query", "path": "/ids" }, "properties": ["id","threadId","subject","from","receivedAt","preview","keywords","hasAttachment","mailboxIds"] }, "g0"]
]
}
Back-references (#ids) are supported and are the intended way to pair query with get in one round
trip — important over a slow home uplink.
Two argument details that cost more to debug than to read:
accountIdmust be a JSON string. An integer is rejected withinvalidArguments, not coerced.Email/getreturnslistin repository order, not the order you asked for, and computesnotFoundby difference. If you paired it withEmail/queryyou must re-order the result against the query'sidsyourself, or your list arrives sorted by database id.
Request-level errors come back as application/problem+json with status 400 and a type of
urn:ietf:params:jmap:error:notJSON / notRequest / unknownCapability.
Implemented methods
Everything registered in src/Jmap/Method/:
| Method | Notes |
|---|---|
Core/echo |
|
PushSubscription/get / PushSubscription/set |
No accountId; per-user. |
Mailbox/get / Mailbox/query / Mailbox/changes / Mailbox/set |
|
Email/get / Email/query / Email/changes / Email/set |
|
Thread/get / Thread/changes |
/get carries three plMail extensions: snoozedUntil, category, isNew. |
Thread/set |
plMail extension. Two properties, snoozedUntil and isNew — see §4. |
SearchSnippet/get |
|
Calendar/get |
urn:plmail:params:jmap:calendars. One account serves calendars. |
CalendarEvent/get / CalendarEvent/query / CalendarEvent/set |
An id is the series, not an occurrence; /query requires a date window, and expandRecurrences: true makes it answer per occurrence. |
EmailSubmission/get / EmailSubmission/set / EmailSubmission/changes |
|
Identity/get / Identity/set |
Not implemented today. None of these is a deliberate exclusion — they haven't been needed yet. If your client wants one, ask for it rather than engineering around it (see §0):
Email/queryChangesandMailbox/queryChangesdo not exist.Email/queryreturnscanCalculateChanges: false. To refresh a list you re-run the query. UseEmail/changesfor the object-level delta and re-query for ordering.- Anchor-based paging is not supported.
anchorraisesunsupportedFilter; useposition+limit. Negative positions (anchoring from the end) are rejected byEmail/query;Mailbox/querydoes accept them and anchors from the end. Email/queryalways returnstotal;Mailbox/queryonly withcalculateTotal: true.VacationResponse/*andBlob/copyare absent.SearchSnippet/getis not — it was listed here as missing whileSearchSnippetGetMethodwas in the tree.- Contacts are autocomplete only.
Contact/autocomplete, underurn:plmail:params:jmap:contacts, returns ranked recipient suggestions; there is noContact/get,/queryor/set— the address book is harvested from mail headers, not writable. Calendars are served, underurn:plmail:params:jmap:calendars; there is noCalendar/setand no/changeson either type, because calendars cannot calculate changes — see JMAP.
Object mapping — the four things that surprise people
1. A JMAP Mailbox is a plMail label binding, not an IMAP folder.
Labels are user-scoped and span accounts; a LabelBinding is the per-account instance of a label,
and that is what has a stable identity inside one JMAP account. So:
Mailbox.id= binding id.Mailbox.labelId= the user-scoped label id this binding materialises. A plMail extension, not RFC 8621. Binding ids are per-account by necessity, so one label reachable from three accounts is three Mailboxes with three unrelated ids and nothing tying them together. This is what lets a client collapse them into a single sidebar row — matching onnameinstead breaks the moment the label is renamed in one account. It is not an id you can pass toinMailboxorEmail/set; those take binding ids, always.Mailbox.name= the leaf name (JMAP models hierarchy viaparentId, so"Invoices", not"Work/Invoices").Mailbox.parentId= the parent's binding id, ornullif the parent has no binding in this account (so a child never points at an unresolvable id).- Roles map from plMail's
LabelRole:inbox,sent,drafts,trash,junk(plMail'sSpam),archive, plusflagged,importantandall. An unmapped role degrades tonull— the mailbox still appears. myRights: system labels (role !== null) are not renamable or deletable; custom labels are fully mutable. Everything else is permitted.isSubscribedmirrors the label's visibility toggle. Note Archive is created hidden by default and only appears once the user switches it visible.
Sidebar order for system labels is fixed: Inbox 0, Sent 10, Drafts 20, Spam 30, Trash 40, Archive 50. Custom labels sort after, alphabetically.
2. Email.mailboxIds comes from the per-message label join, translated into the binding id space.
Not from the thread-level union — reading that would report a mailbox for every message in the
thread. Standard JMAP map shape ({"42": true}), and {} when empty (never []).
The join stores user-scoped label ids, but the ids published here are binding ids, so they
match Mailbox.id and can be passed straight back to inMailbox and Email/set. One id space
throughout — there is no case where you need to translate. A label the account has no binding for
is omitted rather than published as an id you could not resolve.
Until mid-2026 this property emitted untranslated label ids. Because both are autoincrement ints from different tables, the wrong ids usually looked valid and named some unrelated mailbox, so the symptom was a plausible wrong answer rather than an error — and it was invisible on a single-account install, where the two sequences tend to line up. If you are reading this against an older server, that is what you are seeing.
3. Bodies are synthetic parts.
plMail stores a flattened body (bodyText / bodyHtmlSafe), not a MIME tree. So every Email
publishes at most two body parts with the fixed partIds "text" and "html". They are
stable per message, which is all fetchTextBodyValues / fetchHTMLBodyValues need. Treat partId
as opaque anyway, as the spec requires.
Note the capitalisation: fetchHTMLBodyValues, not fetchHtmlBodyValues. That is the RFC 8621
spelling and what the server reads. An unrecognised argument is simply absent, so getting it wrong
returns empty bodyValues with no error at all.
The HTML published is always the sanitised version, never the raw column — this body is handed straight to third-party clients that render it.
preview is the plain-text body, whitespace-collapsed, capped at 256 characters.
4. Keywords are partly columns, partly flags.
| Keyword | Backed by |
|---|---|
$seen |
seen_at timestamp column |
$flagged |
starred_at timestamp column |
$draft |
the IMAP flags JSON array |
$answered |
the IMAP flags JSON array |
Any other keyword is rejected with unsupportedFilter when filtered on. Do not invent custom
keywords for your own state; they will not round-trip.
Address shape is translated at the boundary: plMail stores {name, address}, JMAP emits
{name, email}. messageId / inReplyTo / references are emitted as bare ids with angle brackets
stripped.
The New marker: Thread.isNew
plMail marks a conversation new until its row has actually been put in front of the user, and then for no longer than 24 hours whatever happens. That is what the web's "New" badges, its category tabs and its sidebar dots are drawn from, and it is deliberately not the same axis as unread:
new = never displayed to this user AND arrived inside
MessageThread::NEW_WINDOW(PT24H)
A conversation you read on your laptop is still new to a client that has never drawn its row, and
retiring the marker does not mark anything read. The two are allowed to disagree — that is the
feature, not a bug in it. See MessageThread::isNewAt().
Reading it. Thread/get returns isNew (boolean) on every thread, always present. The window
is applied server-side against one clock reading per response, so two threads in one answer cannot
straddle the boundary. Do not re-implement the 24 hours client-side: it would be a second copy of
NEW_WINDOW that drifts the day somebody changes it.
Retiring it. Thread/set accepts isNew: false, and nothing else — true is refused with
invalidProperties. Send it for the rows you have actually shown the user, after you have shown
them. It is idempotent: a repeat does not move the recorded timestamp, so the record keeps saying
when the row was first displayed, and you may safely send it for every row on every draw.
Why this matters more than it looks. Before this existed, a mailbox triaged entirely on a phone opened in the browser with every conversation from the last day still badged and every category tab still dotted, because only the web could retire a marker. If your client draws mail lists, report the displays — otherwise you are leaving the user's other clients wrong.
Retirement is deliberately not reported as a Thread state change. A client drawing a page of
mail would otherwise push dozens of state changes to every other device the user owns, for a column
none of them needs told about urgently; the next ordinary Thread/get carries the new value.
Email/query filters
Compiled by EmailFilterCompiler. Anything not
understood raises unsupportedFilter rather than being silently ignored — a quietly-dropped filter
returns too many emails and the client cannot tell.
| Condition | Behaviour |
|---|---|
inMailbox |
Mailbox (binding) id. |
inMailboxOtherThan |
Non-empty array of binding ids. |
before / after |
UTCDate against received_at (< and >=). |
minSize / maxSize |
>= / < on byte size. |
hasKeyword / notKeyword |
Only the four keywords above. |
hasAttachment |
Boolean. |
text |
Real full-text search — Postgres tsvector + websearch_to_tsquery('english'). Stemmed, ranked, not a substring scan. |
body / subject / from |
ILIKE substring. from covers both address and display name. |
to / cc / bcc |
Substring over the serialised JSON address array (matches name or address). |
filename |
Substring over attachment filenames. Inline parts have null filenames and never match. |
listId |
Substring over the canonicalised list-id header. |
AND / OR / NOT FilterOperators nest freely. Note NOT is implemented as NOT (a OR b …).
EmailFilterCompiler also understands hasLabel / notLabel, which take user-scoped Label ids
rather than Mailbox (binding) ids. These exist for mail rules, which have no reason to know about
the JMAP id space. They are not part of the client-facing filter vocabulary — use inMailbox.
Sort: receivedAt, from, to, subject, size. Limit: capped at 500 (null or larger
becomes 500). collapseThreads is supported.
The full-text config string ('english') must match how the column was generated — a mismatch
silently returns nothing, because the stemmed tokens never line up. Just don't try to route around it.
The web UI's search syntax
Your search box should accept the same Gmail-style operators the web UI does, and translate them into
JMAP filter conditions. From SearchQueryParser:
| Typed | Means |
|---|---|
from:alice |
from |
to:bob |
to |
subject:invoice |
subject |
has:attachment |
hasAttachment: true |
is:unread / is:read |
notKeyword: "$seen" / hasKeyword: "$seen" |
is:starred |
hasKeyword: "$flagged" |
in:inbox|sent|drafts|trash|archive|junk |
inMailbox of the role's mailbox |
after:2024-01-01 / before:2024-12-31 |
after / before |
| anything else | free text → text |
Quoted strings are kept together. Unknown operators fall through to free text rather than erroring — match that leniency.
Writing: Email/set
Creates drafts, updates keywords and mailboxIds, and "destroys".
destroyis a move to Trash, not a row delete. There is no hard-delete path anywhere in the product. Present it as Trash in your UI.- Every mailbox/keyword change goes through the same propagator the web UI uses, so a change made by your client reaches Gmail / IMAP / Graph exactly as one made in the browser. Archiving from your app archives in Gmail.
ifInStateis honoured — use it for conflict detection on batch mutations.- Draft creation writes through the same draft writer the composer uses.
attachmentsworks onupdateas well ascreate, and is a whole value. Attach a file to a draft that already exists by sending the complete list you want it to end up with — thep-blobIdsEmail/getgave you for the parts you are keeping, plus theu-blobId of whatever you just uploaded. Parts you leave out are removed, and their bytes are deleted. A part re-listed by itsp-blobId keeps its id and is not re-uploaded; supply a differentnameon it and the file is renamed. There is no need to recreate the draft.- A blobId that will not resolve fails the whole update. You get
notUpdated[id].type = "invalidProperties"and nothing in that patch is applied — not the attachments, not the subject beside them. Retry the whole patch after re-uploading.
Semantic reminder: "archived" in plMail's domain model means carries no Inbox label. To archive, remove the Inbox mailbox id. The Archive label itself is IMAP location bookkeeping for plain-IMAP accounts, and is hidden by default.
Sending: EmailSubmission/set
Sending is queued on the same message bus the web composer uses. That pipeline performs the whole
draft→sent transition itself (adds Sent, removes Drafts, clears \Draft, sets sentAt, re-points the
mailbox), so a client that omits onSuccessUpdateEmail still ends up correct.
["EmailSubmission/set", {
"accountId": "7",
"create": { "s1": { "emailId": "#draft1", "identityId": "3" } },
"onSuccessUpdateEmail": { "#s1": { "mailboxIds/42": null, "mailboxIds/17": true } }
}, "c0"]
Things to know:
-
A submission has no table of its own — its id is the Email id. plMail sends each draft at most once, so the mapping stays one-to-one. It is still fully gettable: see what a submission looks like afterwards below.
-
undoStatusis reported as"pending": the send is genuinely queued and has not happened yet when the call returns. -
The web UI's undo-send grace period is deliberately NOT applied to JMAP submissions. A JMAP client asked to send now. If you want an undo window in your app, implement it client-side by delaying the submission call.
-
Scheduled send is supported, in the spec's spelling: RFC 8621 §7 carries the SMTP FUTURERELEASE extension (RFC 4865) as envelope parameters rather than defining a property. Put
HOLDFOR(seconds) orHOLDUNTIL(a date-time) — not both — inenvelope.mailFrom.parameters, and read the real release time back offsendAt:["EmailSubmission/set", { "accountId": "7", "create": { "s1": { "emailId": "#draft1", "envelope": { "mailFrom": { "parameters": { "HOLDFOR": "3600" } } } } } }, "c0"]maxDelayedSendin the account's submission capabilities is the ceiling — 30 days — and it is enforced, not clamped: a longer hold is refused rather than shortened. A hold that has already elapsed sends immediately. The rest of the envelope is checked rather than applied: plMail sends the From and the recipients stored on the Email, so amailFrom.emailorrcptTonaming anything else is refused instead of silently ignored. -
Cancelling before release: update the submission's
undoStatusto"canceled". That is reliable while the mail is held, a race once it has been dispatched with no hold, and refused withcannotUnsendonce it has been sent. A cancel on an Email you never submitted is refused withnotFound— there is no submission to call off, and accepting it used to leave a flag behind that swallowed the user's next send. -
Errors:
invalidProperties(missing/unknownemailId, malformed envelope, hold too long),forbiddenFrom(anidentityIdthis account may not send as),invalidRecipients,alreadyExists(already sent),noRecipients,cannotUnsend,notFound(nothing to cancel).
Reading a submission back
EmailSubmission/get answers for a submission from the moment it is accepted, in all three of the
spec's states:
| State | undoStatus |
sendAt |
|---|---|---|
| Queued or held, not gone yet | "pending" |
when it is due — the real release time |
| Cancelled before it left | "canceled" |
when it would have left |
| Sent | "final" |
when it actually left |
An Email that was never submitted is notFound. That is the only notFound case: it is the absence
of a submission rather than a state of one.
This changed, and if you wrote against the old behaviour you can now delete code. A held
submission used to answer notFound for the entire hold and then appear as "final", which meant
the release time you were told in the create response was the only copy in existence — lose the
response, and the schedule was unknowable. Clients had to keep their own device-local list of
scheduled sends, and a phone and a laptop signed into the same account could not agree about when a
mail was going out. Don't do that any more: sendAt off EmailSubmission/get is authoritative and
shared by every device.
Practically:
- Poll the submission, not the Email, for anything about sending.
EmailSubmission/changesreports all three transitions — the submit ascreated, an accepted cancel asupdated, and the mail actually leaving asupdated— so a scheduled-send list can be kept current from the change log alone. sendAton a"pending"submission is a promise about the queue, not a guarantee to the second. It is when the worker is allowed to start, and a busy install starts late. Show it as a time, not as a countdown to zero.- A submission that was held when this feature was deployed keeps its old behaviour —
notFounduntil it sends — because its release time only ever existed in a queue entry. There is at most one hold's worth of those.
Identities come from the same list the web composer's From dropdown shows — the account's
sendable aliases, primary first. An account with no alias rows yet yields one synthetic identity for
the account address itself. Always let the user pick, and default to the primary: the identityId
on a submission decides the From address the mail really goes out with, and one that is not an
identity of that account is refused with forbiddenFrom rather than falling back to the account
address.
Blobs: upload and download
Upload — POST {uploadUrl} with raw bytes and a Content-Type:
{ "accountId": "7", "blobId": "u-91", "type": "image/png", "size": 40213 }
Max 50 MB (matches maxSizeUpload); larger gets tooLarge / 413. The declared type is stored as
metadata and echoed back, exactly as the spec requires — nothing is parsed or trusted. Uploads are
staged: unused ones are swept by a scheduled app:prune:blobs job, so upload close to when you
reference the blob.
Download — GET {downloadUrl} with {accountId}, {blobId}, {name} filled in.
blobId is namespaced and opaque: m-<id> (a whole message's RFC822 source), p-<id> (an
attachment part), u-<id> (a staged upload). Do not parse it — the namespacing exists precisely
because the underlying tables have independent autoincrement ids.
Security behaviour you must design around: the accept query parameter is ignored (honouring
it would let a caller relabel HTML as an image). X-Content-Type-Options: nosniff is always set, and
only image/* is served inline — everything else comes back as an attachment disposition. This
matters more here than in the web UI because a JMAP client may hand the URL straight to a webview.
Don't build a viewer that assumes inline rendering of arbitrary types.
The {name} segment is used only for the download filename and is never trusted for lookup.
Staying current: push
Three mechanisms, in descending order of what you should prefer.
1. PushSubscription — the right answer for background delivery.
Two transports behind one object. A create carrying url and keys is a Web Push subscription;
a create carrying fcmToken is a Firebase one. fcmToken is a plMail extension of RFC 8620's
object; everything else — deviceClientId, types, expires, the handshake — is identical.
// Web Push
["PushSubscription/set", { "create": { "s1": {
"deviceClientId": "phone-42",
"url": "https://ntfy.example.com/…",
"keys": { "p256dh": "…", "auth": "…" },
"types": ["Email", "Mailbox"]
}}}, "0"]
// Firebase — only when the session says "fcm": true
["PushSubscription/set", { "create": { "s1": {
"deviceClientId": "phone-42",
"fcmToken": "cX9…:APA91b…",
"types": ["Email", "Mailbox"]
}}}, "0"]
The two shapes are exclusive. A create carrying both fcmToken and url (or keys) is refused
with invalidProperties naming the conflict, rather than one being picked for you. A create
carrying fcmToken on an instance where FCM is unconfigured or switched off is refused with
forbidden — check the capability first; this is only a backstop.
PushSubscription/get reports which kind you got back, as a read-only transport of "webpush" or
"fcm". You need it because deviceClientId is stable per device and re-registering replaces the
row: a phone that moved from a UnifiedPush distributor to Firebase has one subscription, not two.
Neither keys nor fcmToken is ever returned — both are the address of a device, and echoing them
would let anyone who can read one response push to it. url is null on an FCM subscription.
Rotating an FCM token is PushSubscription/set update with fcmToken on an existing FCM
subscription — the one address property an update may change, because Android reissues tokens on its
own schedule. It re-arms the handshake: verified goes back to false and a fresh PushVerification
is sent to the new token, so handle that the same way you handled the first one. url and keys
remain create-only; changing where an encrypted payload goes means a new create.
There is a mandatory verification handshake, and it is the whole point. On create, the server
immediately sends a PushVerification object to the address you gave — POSTed to the endpoint for
Web Push, delivered as an ordinary FCM data message for Firebase, identical JSON either way. You read
the code out of it and echo it back via a PushSubscription/set update. Until you do, the
subscription receives nothing. This is what stops the endpoint being an open relay — without it
anyone with an account could register a stranger's address. Budget for this round trip in your
onboarding.
Every attempt to reach your device is logged server-side, and you can tell the user where to
look. The verification you are waiting for, and every StateChange after it, writes a row visible
to that user in Settings → Notifications (per device: transport, verified state, and the last
delivery with its outcome) and to an admin at /admin/push. So "the app registered and never got
its code" is answerable without anyone reading a container log: the row says whether the server tried
and what the transport answered — UNREGISTERED, a 410, or "skipped, this install has no keys". The
log records the payload's @type and nothing else of it, so it never becomes a record of the user's
mail activity; do not build a feature that assumes more is kept.
2. EventSource (SSE) — for a foreground session, briefly.
GET {eventSourceUrl}, text/event-stream. Emits a state event immediately on connect (so you know
where you stand without an extra round trip), then further state events on change, plus ping
events (default 30s, minimum 5s).
Read this before you use it: each connection holds a PHP worker for its entire life. Under FrankenPHP that is a hard capacity limit — N connected clients means N occupied workers, and once they're all taken the server stops answering ordinary requests. On a home NAS, N is small. Consequently the server hard-closes every connection after 300 seconds and expects you to reconnect. Reconnect with backoff, and disconnect as soon as your app backgrounds. Background delivery belongs on Web Push, not here.
?closeafter=state gives you one StateChange and an immediate close — the cheap way to resync
without holding a connection.
3. Polling — the fallback. Keep it infrequent; this is someone's Raspberry Pi.
What a push actually contains:
{ "@type": "StateChange", "changed": { "7": { "Email": "9", "Mailbox": "3" } } }
Deliberately tiny. JMAP never pushes mail content, only the news that a state token moved. You
then call Email/changes to find out what. Tracked types: Mailbox, Email, Thread,
EmailSubmission. Identity is excluded — it changes only when the user edits their own addresses,
which they just did in your app.
Over FCM the same JSON arrives as a data message, never a notification payload — the system
tray must not draw anything before your app has seen it, because only you know whether the user is
already looking at that mailbox. The object above is the string value of one data key:
{
"message": {
"token": "cX9…:APA91b…",
"data": { "payload": "{\"@type\":\"StateChange\",\"changed\":{\"7\":{\"Email\":\"9\"}}}" },
"android": { "priority": "HIGH", "ttl": "86400s", "collapse_key": "plmail-state-change" }
}
}
So RemoteMessage.getData()["payload"] is a JSON string, and its @type is either StateChange or
PushVerification. Collapse keys are per type — plmail-state-change and plmail-push-verification
— so a backlog of state changes collapses to the newest without ever discarding an undelivered
verification. Messages live 24 hours.
A token the server is told is UNREGISTERED or NOT_FOUND destroys the subscription, exactly as
a 404/410 does for Web Push. Quota rejections and Firebase outages do not.
Every token comes from the same state manager the /get and /changes methods use, so a push and a
subsequent /changes can never disagree.
Paging changes: /changes returns at most 256 rows per call and sets hasMoreChanges. The
limit is deliberately modest for mobile — loop until it clears.
Sync model, end to end
Understanding where mail comes from helps you set the right expectations in your UI:
| Account type | Ingest | Instant delivery |
|---|---|---|
| IMAP | webklex/php-imap, one IDLE connection per mailbox, supervised |
IMAP IDLE — works on a LAN, no public URL needed |
| Gmail | Gmail REST + Batch API over OAuth2 | Google Cloud Pub/Sub watch → /gmail/push (requires public HTTPS + one-time instance setup) |
| Outlook / M365 | Microsoft Graph over OAuth2 (not IMAP — Exchange Online blocks it under Security Defaults) | Graph subscriptions → /webhook/graph (requires public HTTPS) |
A scheduled polling sync (every 15 minutes) backs all of them up whenever push isn't available. So: your app should never claim mail is "up to date" on the basis of push alone, and should offer a manual refresh. Equally, don't hammer a sync endpoint — the server is already trying.
The server fetches an account's whole history — there is no retention setting to widen. What
there is is a backfill that takes a while on a large mailbox: the newest mail lands first and
the rest follows over later runs, so old mail can be missing temporarily. The Session's
urn:plmail:params:jmap:sync account capability reports backfillPending for exactly this. If a
dated search finds nothing and that flag is set, the honest message is "older mail is still
arriving" — not "no results", and never "widen the sync window".
Threading is currently RFC Message-ID based, not Gmail-native threadId. Expect occasional
divergence from what the Gmail web UI groups together.
4. Behaviour: what your app must do
Feature parity checklist
Ordered roughly by how much users will miss them.
Reading
- Unified inbox across accounts (client-side merge; see §3).
- Conversation threading, newest expanded, older collapsed.
- Unread / starred / attachment state at a glance.
- Full-text search with the operator syntax above.
- Attachments and inline images; original (raw) message available on request via the
m-<id>blob.
Writing
- Compose, reply, reply-all, forward. Rich text.
- Contact autocomplete (harvested from synced mail server-side; ask
Contact/autocompleteunderurn:plmail:params:jmap:contacts— the server ranks over the whole address book, so it beats a local cache. The OS address book remains a sensible supplement for people the user has never mailed). - Send from any account and any sendable alias — always show the From picker.
- Draft autosave.
- Undo send (client-side for JMAP; see above).
- Scheduled send, driven by the server rather than by your own timer. The hold goes on the envelope
(
HOLDFOR/HOLDUNTIL) andEmailSubmission/getreports the release time and"pending"back for as long as it is held, so a "Scheduled" list can be built from the server's own answer. This entry used to say the opposite, because a held submission answerednotFound— see reading a submission back.
Organising
- Labels: apply, remove, create, delete. Nested labels exist in the data model; nested label UI is still on the server roadmap, so flat-with-paths is acceptable.
- Archive = remove Inbox label. Trash =
destroy. Both undoable. - Snooze — bring a conversation back later. A thread-level property (
MessageThread.snoozedUntil), exposed asThread/set, which is a plMail extension accepting it andisNewand nothing else. It goes through the sameThreadSnoozeServicethe web UI does, so a snooze set from a client and one set in the browser mean the same thing — which is the point, and why a locally-tracked snooze is still the wrong idea: it would disagree with the web UI and break on reinstall. This section used to say snooze was not exposed at all, and cited itself as the canonical "ask, don't work around" case; somebody asked. - Mark read/unread, star.
Settings
- Appearance —
Appearance/getandAppearance/setserve the whole object, includingfontFamily,fontScale,previewLines,unreadEmphasis,accountCorner,listAvatars, the three per-surface densities and the read-onlylogoStyle(see §2). Build the token system regardless; read the server's values into it rather than inventing your own defaults. - Account list and order.
- Notification preferences.
- App password management is web-only today; link out to the web UI rather than reimplementing.
Interaction rules
- Be optimistic, then reconcile. Star, read, archive and label changes should apply instantly in the UI and reconcile against the returned state. The server propagates outward asynchronously anyway.
- Every destructive action is undoable, and the undo lives in a toast at the bottom of the screen for a few seconds. Trash, archive, and send all follow this pattern in the web UI.
- Reading a thread marks it read, but only after it has actually been shown — not on prefetch.
- Back always means back. The mobile list⇄thread transition is a real navigation step.
- Offline is a first-class state, not an error. A home server is unreachable more often than a cloud one. Show cached mail, queue mutations, say plainly that you're offline, and retry.
- Never poll aggressively. No 5-second refresh loops, no keeping SSE open in the background, no re-querying the full list on every foreground.
Error handling
| Situation | What to show |
|---|---|
| 401 | "Your app password was revoked or is invalid" → re-auth flow. Don't retry silently. |
unsupportedFilter |
A bug in your query builder. Log it; don't surface raw JMAP errors. |
tooLarge on upload |
Name the 50 MB limit. |
| Server unreachable | "Can't reach your server" — with the hostname. Users self-host; the hostname is genuinely useful to them. |
| Empty search results | If the query had a date/before: component and backfillPending is set, say older mail is still arriving. |
| No accounts connected | Deep-link to the web UI's account setup; account creation involves OAuth flows that belong in a browser. |
Things not to do
- Don't build against the HTML/Turbo routes. They will change.
- Don't hardcode
apiUrl,uploadUrl,downloadUrloreventSourceUrl. Read them from the session object every time. - Don't parse
blobId. It's namespaced server-side and the spec forbids it. - Don't assume one account. Ever.
- Don't hold an SSE connection in the background. You will take down someone's mail server.
- Don't render message HTML on a dark background naively — pick a strategy per message, and invert imagery back if you invert (see §2). And don't render non-image blobs inline.
- Don't invent keywords — anything beyond
$seen,$flagged,$draft,$answeredis rejected. - Don't implement hard delete. It doesn't exist and shouldn't.
- Don't build a workaround for a missing server feature without asking first. The server is actively developed and adding to it is a normal, available option — see §0. A workaround that duplicates server logic in the client is worse than a one-line request.
5. Getting a dev environment
docker compose up --build
Nothing to fill in first — secrets generate on first start, and the one setting with no sensible default (the address plMail is reached at) is asked for on the setup screen. Open the app, create the first administrator, add a mailbox.
Then, for your client: Settings → App passwords → create one, and point your client at
https://localhost/.well-known/jmap.
Useful during development:
docker compose exec php bin/console debug:router
docker compose exec php bin/console app:mail:sync
A test stack with its own Postgres (so you never touch real mail) is available via
npm run test:env:up, serving at http://127.0.0.1:8001. See
CONTRIBUTING.md for the full console command reference and the test suites.
6. Quick reference
Endpoints
| Path | Method | Purpose |
|---|---|---|
/.well-known/jmap, /jmap/session |
GET | Session discovery |
/jmap/api |
POST | All reads and writes |
/jmap/upload/{accountId} |
POST | Blob upload |
/jmap/download/{accountId}/{blobId}/{name} |
GET | Blob download |
/jmap/eventsource |
GET | SSE state changes |
Limits
| Limit | Value |
|---|---|
| Upload size | 50 MB |
| Concurrent uploads | 4 |
| Request object size | 10 MB |
| Concurrent requests | 4 |
| Calls per request | 32 |
Objects per /get |
500 |
Objects per /set |
500 |
Email/query limit |
500 (hard cap) |
/changes rows |
256 per call |
| SSE connection lifetime | 300 s |
App-password lastUsedAt write throttle |
300 s |
Stack, for context
Symfony 8 / PHP 8.4 · PostgreSQL 18 · Doctrine ORM · Symfony Messenger (Doctrine transport) ·
Mercure (web UI live updates) · FrankenPHP · AssetMapper + Tailwind v4 + Hotwire Turbo/Stimulus ·
libsodium-encrypted credentials · AGPL-3.0 · linux/amd64 and linux/arm64.
Server roadmap items that will affect clients
These are already planned. If your client needs one sooner, say so — priorities are negotiable, and a concrete client requirement is the best reason to move something up.
- Completing the label-based refactor (Label as the user-facing concept; Mailbox demoted fully to IMAP sync infrastructure).
- Gmail-native
threadIdthreading, replacing Message-ID threading. - Incoming IMAP flag sync over the IDLE stream.
- Nested label UI.
- A JWT issuance endpoint for a first-party app.