plMail

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:

  1. What plMail is, and the design philosophy a client is expected to inherit.
  2. How it should look and feel — the visual system, the layout rules, the motion, the copy.
  3. 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:

Things this document flags as not implementedEmail/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:

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.

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.

ThemeApp\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

LayoutApp\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.

DensityApp\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/get and Appearance/set serve the singleton object (id "singleton", no accountId — it hangs off the User), 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 JSON null to 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.

logoStyle is read-only, and it is the mark the user is actually looking at. The value is one of the fixed set the Session publishes as logoStyles"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 logoStyle with a different value is refused with invalidProperties; 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, set theme — the new colourway comes back in that call's updated map. 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:

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

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


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 shape401 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:

  1. UnifiedPush. The user installs a distributor app; it supplies an RFC 8030 endpoint and decrypts the RFC 8291 aes128gcm payload this server already sends. No server configuration at all.
  2. 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.
  3. 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:

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):

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:

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".

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:

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:

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

UploadPOST {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.

DownloadGET {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

Writing

Organising

Settings

Interaction rules

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


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.