Repository navigation
Client Development
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.
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.
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.
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.
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.
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/ |
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. |
logoMotif |
enum, read-only | one of logoMotifs
|
The icon the logo is: the pl mark (pl) or one of nine motifs. |
logoPaint |
enum, read-only |
original or one of logoStyles
|
What that icon is painted in: its own design, or a colourway. Always a colourway for pl. |
Appearance::toArray() is the export format (versioned, version: 1), and applyArray() the
import. The web UI lets users export/import this as a file.
The same capability carries one boolean that is not an appearance property: inboxTabs. A user
can switch the inbox's category tabs off, and then wants one list. Thread.category is served
either way, because mail is still filed in the background, so the category cannot tell you this. If
you draw tabs from it, draw none when inboxTabs is false. It is read-only here and, like the
compact appearance beside it, a hint that can be stale in a cached Session.
The tabs as the user arranged them: which built-in categories are switched on, the tabs they made themselves, and the order of all of them. Advertised at the top of the Session and on every account; every account answers with the same list.
-
InboxTab/get(accountIdrequired) lists every tab, switched off or not. An InboxTab is{ id, name, builtIn, isEnabled, sortOrder }.idis the tab key — a built-in category (primary,social,promotions,updates,forums) orcand a number for one of the user's own.nameis null for a built-in, which you name in your own language.isEnabled: falseis a built-in the user switched off: leave it out of the strip.sortOrderis the strip's order; Primary is always 0. -
InboxTab/changesis the standard /changes, and pushStateChangecarries anInboxTabstate. A reorder reports every tab as updated. -
Thread.inboxTabis the tab the web shows the conversation in: one of the user's own, the built-in category, orprimarywhen that category is switched off. Draw from this, not fromThread.category, when you know the capability. -
Email/querythreadInboxTab: "<key>"lists exactly what the web lists in that tab. A switched-off tab matches nothing; a malformed key isinvalidArguments. -
Thread/set{ "inboxTab": "<key>" }moves the conversation into a tab, pinned, as a drag on the web does. A tab the user does not have comes back innotUpdatedwithinvalidProperties.
Creating, renaming, switching and ordering tabs is done on the web today. Thread.category and
threadCategory are unchanged, and inboxTabs above still says whether to draw tabs at all.
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,logoMotifs,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.
logoMotifandlogoPaintare the logo as the user sees it, and both are read-only. On the web the logo is two choices: an icon, then its paint. The icon is the pl mark or one of nine motifs, from the Session'slogoMotifs—"pl","blue-horn","at-horn","love-letter","airmail","happy-mail","snail-mail","mailbox","pl-stamp","wax-seal". The paint is"original", the motif's own design, or one oflogoStyles. For"pl"the paint is always a colourway, and always the valuelogoStyleholds;"original"only ever comes with one of the nine motifs.logoStylekeeps its meaning beside them — the pl mark's colourway, whatever the icon — so a client that knows only the mark goes on drawing the mark. Treat an unrecognised motif as"pl"and an unrecognised paint as"original"; both sets grow.What an icon looks like in a paint is not on the wire, and should not be transcribed. Every icon × every paint → the colour of each of its parts is one table, printed by
php bin/console app:branding:export-paintsin the schema oflogo-paints.json: a part is a#rrggbb, anullfor a part not drawn, or seven gradient stops. Generate your assets from that output and regenerate them when it changes. For a picture rather than a table,/branding/icon/{motif}/{paint}.svgserves the icon as its rounded tile, with no session needed.Neither can be set: both are refused a different value with
invalidPropertiesand accept an echo, exactly likelogoStyle. The icon is chosen in the web UI.
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.
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).
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.
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.
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 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.
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.
- 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.
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.
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." }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. |
fcmEncryption |
true when an FCM subscription may carry keys and the server seals payloads with content in them — see Sealed payloads over FCM. Absent on a server that predates it, which refuses keys on an FCM create. |
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.
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 ntfyThat 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.
On a LAN or Tailscale address the server also has to be told the endpoint is meant: push endpoints
on a private address are refused unless their host is in PUSH_ALLOWED_HOSTS, because the server
POSTs to them on every change. Set it to the same host as SERVER_NAME.
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.
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.
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 four plMail extensions: snoozedUntil, category, inboxTab, isNew. |
Thread/set |
plMail extension. snoozedUntil, isNew, inboxTab, and the instruction moveTo — see Moving and §4. |
InboxTab/get / InboxTab/changes
|
urn:plmail:params:jmap:inboxtabs. The user's tabs, the same on every account. See Inbox tabs. |
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
|
|
Template/get / Template/set / Template/render
|
urn:plmail:params:jmap:templates. No accountId argument on get and set; per-user. See Templates. |
TemplateFolder/get / TemplateFolder/set
|
Same capability. The folders a user made; the per-account folders of the web tree are not objects. |
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/queryreturnstotalunless you sendcalculateTotal: false;Mailbox/queryonly withcalculateTotal: true. Counting is a second query, so a pager that does not show a count should sendfalse. -
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.
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.
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.
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. |
fromAddress |
plMail extension. The sender's whole address, case-insensitive. Not a substring, and not the display name. |
fromDomain |
plMail extension. The part of the sender's address after the @, whole and case-insensitive; given without the @. A subdomain does not match. |
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.
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.
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.
"Move to" — file a conversation under a label and take it out of the list the user was looking at —
is one instruction on Thread/set, not a mailboxIds patch you compose yourself:
["Thread/set", {
"accountId": "7",
"update": { "812": { "moveTo": { "mailboxId": "17", "fromMailboxId": "42" } } }
}, "m0"]-
mailboxIdis where the conversation goes: one of the user's own labels, or the Inbox, Spam or Trash mailbox. Anything else — Sent, Drafts, Archive — is refused withinvalidProperties. -
fromMailboxIdis the list the user was in. Send the mailbox the list is showing; sendnullor leave it out for a list with no mailbox of its own (a search, Starred, a category tab). You name the view; the server decides what that takes off. From the Inbox that is "label and archive", from a label it swaps that label for the target, from Trash or Spam it takes the mail out of the bin without putting it back in the Inbox, and from anywhere else the only thing that comes off is the Inbox. See Mail → Move to for the whole table. - A
fromMailboxIdthat does not resolve is refused, not read as "no view" — the two differ in whether the Inbox comes off. - It is not a property: nothing reads it back. The result is the changed
mailboxIds, reported throughEmailandThreadstate like any other label change. - Undo is a move the other way round: target and view swapped. That is exact for a single conversation moved between two places; it does not restore labels the way the web's Undo does.
Do not rebuild this from Email/set. Attaching the target and detaching the view looks
equivalent and is not: leaving the Inbox for a tag is an archive on a plain IMAP account, leaving
the bin is a provider restore, and only the messages that carried the view's label lose it. The
Android app did it that way for one release and filed mail in two places on plain IMAP.
A server older than this answers notUpdated[id].type = "invalidProperties" naming moveTo as
not settable. That is the one case where falling back to an Email/set patch is right.
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).
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.
Which identity answers a mail is the server's to say, and it says it in replyFrom — a plMail
property of Email/get. Ask for it by name when you fetch the message a reply or a forward is built
from; it is left out of a get with no properties, because it carries a signature:
["Email/get", {"accountId": "1", "ids": ["42"], "properties": ["subject", "from", "to", "replyFrom"]}, "g"]"replyFrom": {
"id": "addr-eW9Ac2hvcC5leGFtcGxl", "name": "John Smith", "email": "yo@shop.example",
"replyTo": null, "bcc": null, "textSignature": "", "htmlSignature": "", "mayDelete": false
}It is a whole Identity, or null. Open the reply on it — sender, name and signature — and pass its
id as the submission's identityId; on null, or against a server that does not know the
property and leaves it out, stay on the primary identity. It is the address the mail was sent to:
the same answer the web composer opens a reply on, so do not work it out from To and Cc yourself.
- For mail that came in for an alias it is that alias's identity, with the
idit has inIdentity/get. - For mail that came in for an address at one of the account's catch-all domains it is an
identity
Identity/getdoes not list — there is one per address anybody ever writes to. Itsidis good inEmailSubmission/setlike any other, for as long as the account still has the domain; after that it isforbiddenFrom. Show it in your From picker beside the listed ones for this one message, and do not store it as something to offer on the next. - On a draft it is the address the draft is already written from, which is what to reopen it on.
Say which identity a draft is written from when you save it. RFC 8621 names the identity on the
submission only, so a draft would otherwise have no sender until it is sent, and one written from an
alias would come back from Drafts on the primary address. plMail takes identityId on Email/set
too, on a create and in an update patch:
["Email/set", {"accountId": "1", "update": {"87": {"identityId": "addr-eW9Ac2hvcC5leGFtcGxl", "subject": "Re: Order"}}}, "s"]The draft is then stored from that address, from and replyFrom report it, and it is what the
draft reopens on, on any device. Send the id with every save, and include it in whatever decides
that a draft has unsaved changes — a From menu that changes nothing else must still save.
- The ids are the ones
EmailSubmission/settakes, an address identity included. - An id the account may not send as is ignored, and the rest of the save goes through: this is
an autosave, and losing the text over the sender would be the worse failure. The submission still
names its identity and is where
forbiddenFromis answered. -
Check the session first. A server that knows the property says so with
"draftIdentity": truein the account'surn:ietf:params:jmap:mailcapability. One that does not refuses the whole update patch asinvalidPatchfor naming a property it cannot change, so leave the key out there.
Reusable messages: the user writes one in Settings → Templates (or in your app) and inserts it
while composing. The user-facing behaviour is in Templates; this is the
wire. Declare urn:plmail:params:jmap:templates in using.
Two objects, both per user. Neither method pair takes an accountId argument — send one and
you get invalidArguments, as with Appearance/get. accountId is instead a property: the mail
account an object is filed under, or null for the top level.
Template |
|
|---|---|
id |
|
name |
Required. Shown in lists, never sent. |
subject |
String or null. May contain variables. |
htmlBody |
Sanitised HTML with the variables in it as {{tokens}}. |
accountId |
A Session account id, or null. |
folderId |
A TemplateFolder id, or null. |
TemplateFolder |
|
|---|---|
id |
|
name |
Required. |
accountId |
A Session account id, or null. Create-only.
|
parentId |
Another folder's id, or null. Create-only — a folder cannot be moved. |
Draw the tree yourself. The web shows one folder per mail account at the top of the tree. Those
are not TemplateFolder objects and have no id: draw one node per account in the Session and put
into it every template and folder whose accountId is that account and whose folderId /
parentId is null. Objects with all of them null sit at the top level. Offer every template
whichever account the user is writing from, and list the current account's first — filing says
where a template is usually wanted, not where it is allowed.
A folder decides its contents' account. On a write you may send folderId (or parentId)
alone and the server fills in accountId, echoing it in created / updated. Sending both is
accepted when they agree and invalidProperties when they do not. Updating only a template's
accountId moves it to that account's top level and clears folderId.
/get with ids: null returns everything; there is no /query and no /changes. A user has
a few dozen of these. state is a hash of the list, not a counter: when it differs from the one
you hold, fetch the list again. Nothing is pushed for templates, so re-read when your picker opens.
Bodies are sanitised on the way in, with the same allow-list as inbound mail, because a
template written in your app is inserted into the web compose window too. When the stored
htmlBody differs from what you sent, the response carries the stored one.
Destroying a folder destroys no template. Folders inside it go with it; the templates in any of
them move up to where the folder was, and the Template state changes.
A variable is the text {{name}} or {{name|key=value|key=value}} inside subject or
htmlBody. The Session's capability object lists the vocabulary, so offer exactly these:
"urn:plmail:params:jmap:templates": {
"variables": [
{ "name": "recipient.first_name", "group": "recipient", "filledBy": "recipient" },
{ "name": "recipient.name", "group": "recipient", "filledBy": "recipient" },
{ "name": "recipient.email", "group": "recipient", "filledBy": "recipient" },
{ "name": "sender.name", "group": "sender", "filledBy": "server" },
{ "name": "sender.email", "group": "sender", "filledBy": "server" },
{ "name": "signature", "group": "sender", "filledBy": "server" },
{ "name": "date", "group": "date", "filledBy": "server" }
],
"dateFormats": ["short", "medium", "long", "full", "weekday", "iso"],
"dateOffsetUnits": ["d", "w", "m"],
"maxNameLength": 255
}Two variables take arguments:
-
{{date|offset=+7d|format=long}}—offsetis a sign, a number and one ofdateOffsetUnits;formatis one ofdateFormatsorpattern:followed by an ICU pattern (pattern:dd.MM.yyyy). Both are optional; the default is today,long. No{,}or|inside an argument. -
{{signature}}follows the address in From.{{signature|account=<accountId>}}or{{signature|alias=<identityId>}}names one that is used whatever address is in From.
Braces around anything that is not one of the listed names are ordinary text and are left alone. Show variables as chips in an editor if you can; store them as the tokens.
Never insert htmlBody from Template/get. Ask the server to render it:
["Template/render", {
"id": "12",
"accountId": "3",
"identityId": "7",
"recipient": { "name": "Dana Whitfield", "email": "dana@example.org" }
}, "r1"]accountId is required here — it is the account the message is being written from.
identityId is the Identity in From when that is not the account's own address; it decides which
signature {{signature}} means. recipient is the first To address, and optional.
["Template/render", {
"accountId": "3",
"id": "12",
"subject": "Reminder: invoice from September 25, 2026",
"htmlBody": "<p>Hi Dana,</p><p>…</p><div class=\"pl-signature\" data-pl-signature>…</div>",
"textBody": "Hi Dana,\n…",
"openVariables": []
}, "r1"]What to do with the answer:
-
subjectfills an empty subject line only. Never overwrite one the user typed, or a reply's. -
Insert
htmlBodyat the caret (ortextBodyin a plain-text composer). From then on it is the user's text. -
One signature. If
htmlBodycontains adata-pl-signatureblock and the message already has one, replace the existing block instead of adding a second. A block that also carriesdata-pl-signature-pinnedwas named by the template: do not swap it when From changes. -
openVariablesis not empty when a recipient variable could not be answered — norecipientwas sent, or it has an address and no name. plMail does not guess a first name from an address. Those variables are still in the bodies: inhtmlBodyas<span data-pl-var="recipient.first_name">First name</span>, insubjectandtextBodyas{{recipient.first_name}}. Either render again once the recipient is known (if the user has not edited the inserted text yet), or replace the markers yourself when To gains an address. Ask before sending while any remain — a message sent with one open reads "Hi First name,".
Dates are computed in the user's time zone and written in the language the user has set in plMail, as are the labels inside open markers.
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.
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.
The two shapes are exclusive. A create carrying both fcmToken and url is refused with
invalidProperties naming the conflict, rather than one being picked for you. keys is required
with url and optional with fcmToken — send it, see
Sealed payloads over FCM. 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 — the two types that carry no content. 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.
A state change and a verification are tokens and ids, and travel as readable JSON in payload.
Anything that carries content — today CalendarAlert, a reminder with the event's title, and
UpdateAvailable — is never sent through Firebase readable. It is sealed to a key your app
registers, or it is not sent to that device at all.
Register the key with the subscription, exactly as a browser does for Web Push (RFC 8291): generate a P-256 key pair and a 16-byte secret on the device, keep the private key there, and send
["PushSubscription/set", { "create": { "s1": {
"deviceClientId": "phone-42",
"fcmToken": "cX9…:APA91b…",
"keys": { "p256dh": "<base64url, 65-byte uncompressed point>", "auth": "<base64url, 16 bytes>" },
"types": ["Email", "Mailbox"]
}}}, "0"]An existing FCM subscription can be given keys, or new ones, with an update carrying keys. It
redoes no handshake. Check fcmEncryption in the session first: a server without it refuses the
property.
A sealed message has no payload key. It has encrypted instead:
{ "message": { "token": "…", "data": { "encrypted": "<base64url>" }, "android": { "priority": "HIGH", "ttl": "86400s" } } }The value is, base64url-encoded, byte for byte the body a Web Push POST would have carried with
Content-Encoding: aes128gcm: the RFC 8188 header (16-byte salt, 4-byte record size, 1-byte key
length, the server's 65-byte public key) followed by one encrypted record. Decrypt it as RFC 8291
§3.4 describes — ECDH between your private key and the key in the header, HKDF with your auth
secret — strip the trailing 0x02 delimiter, and you have the JSON object, to be handled like any
other payload. RFC 8291 Appendix A is a complete test vector.
{ "@type": "CalendarAlert", "title": "Dentist", "body": "in 15 minutes",
"url": "/calendar/day/2026-10-16", "tag": "412/display-15m/2026-10-16T09:00:00Z" }tag identifies one reminder for one occurrence: use it so a redelivery replaces the notification
instead of adding a second. A sealed message carries no collapse key, so two reminders about two
appointments never replace one another.
A device that registered no key is not sent these at all. The delivery log records the skip as
no-encryption-key. Reminders on that device then depend on whatever the app schedules locally.
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.
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.
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
- Move to —
Thread/setmoveTo, see Moving. - 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, a plMail extension accepting it,isNewandmoveToand 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,tabUnreadCount, the three per-surface densities and the read-onlylogoStyle,logoMotifandlogoPaint(see §2). Build the token system regardless; read the server's values into it rather than inventing your own defaults. - Account list and order.
- Templates — list, edit and insert them over
Template/*andTemplateFolder/*; see Templates. Insert throughTemplate/render, and ask before sending with a recipient variable still open. - Notification preferences.
- App password management is web-only today; link out to the web UI rather than reimplementing.
- 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.
| 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. |
- 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.
docker compose up --buildNothing 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:routerdocker compose exec php bin/console app:mail:syncA 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.
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.
This page is generated from docs/CLIENT_DEVELOPMENT.md. Edit it there — changes made here are overwritten on the next push to main.
Using plMail
- Accounts and aliases
- Account health
- Templates
- Filters
- Calendar
- Invitations and events from mail
- Reminders
- Connected calendars
- Sharing and booking
- Files and integrations
- Security
- Other clients
- Appearance
- Administration
Installing and running
- Docker Compose
- Platform notes
- Behind a reverse proxy
- Configuration reference
- Backup and restore
- Configuration backup
- Upgrading
- Demo mode
- Troubleshooting
Providers
How it works