app:prune:blobs looked under a hardcoded var/, so installs with another APP_STORAGE_DIR reclaimed nothing; it and the thumbnail cache now use APP_STORAGE_DIR. A third pass deletes attachment thumbnails whose part no longer exists. Closes #232, closes #233. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
439 KiB
Changelog
Notable changes per release. plMail runs its migrations automatically on boot, so anything that changes the schema irreversibly is called out explicitly.
The published image tags: latest follows the most recent release below,
main follows the tip of the default branch, and sha-… pins one commit.
Unreleased
The weekly cleanup works on installs that moved their storage, and clears old thumbnails.
app:prune:blobs looked for leftover files under a fixed var/, so on an install that sets
APP_STORAGE_DIR to another directory it found nothing and reclaimed nothing. It now looks where
the files are written. The preview thumbnails of attachments are kept there too now, beside the
attachments, instead of under a fixed var/; on such an install the old var/attachments/thumbs
directory can be deleted, and the thumbnails are drawn again when they are next shown. The cleanup
also deletes the thumbnails of attachments that no longer exist, which nothing removed before. No
schema change. #232,
#233.
The installed app has the icon you picked, on iPhone and Android too. The tab icon followed Settings → Appearance → Logo, but an app installed from the browser or added to a home screen kept the wax seal, because its icons came from fixed files. The install manifest and the iOS home-screen icon now name your choice. Your browser draws them as PNGs when you pick a logo and uploads them for your account, so the image needs nothing new to draw them; a logo picked before this is drawn by the next page you open. An app that is already installed keeps its old icon until the browser refreshes it or you reinstall. No migration. #141, building on #92 by @Flouss.
The weekly cleanup no longer deletes profile pictures, signature images and backgrounds. Every
Sunday at 05:00 app:prune:blobs removed week-old files it found no record for anywhere under
var/uploads, var/attachments and var/raw. Profile pictures, signature images and appearance
background images are kept under var/uploads without such a record, so they were deleted along
with the real leftovers. The cleanup now only looks in the per-account directories that mail
attachments, original messages and staged uploads are written to, and leaves everything else on
those volumes alone, including directories added later. Files it already deleted cannot be brought
back by plMail: restore them from a backup of the volume, or upload them again. No schema change.
#214.
Reconnecting a service in the setup wizard no longer fails. Opening the Integrations step for a service that was already connected and saving it again answered with an error page, because the step built a second connection with the same name instead of editing the first. It now edits the existing one, and a blank secret still keeps the stored one. The settings page had the same error when a second connection to a service (a work Nextcloud next to the home one) kept the default name; it now asks for another name instead. No schema change. #216, by @phdelodder.
Inline images in a message show up again. Pictures that a mail carries inside itself, such as
a logo or the icons in a booking confirmation, rendered as empty boxes: the message body is shown in
a sandbox that sends no session cookie, so each image was answered with a redirect to the login
page. They now load through a signed link (/mail/inline/{id}) that only answers for an image of
the message being read, and nothing that is not a picture. The link stops working after a day or
two, so one that leaks through a forward or a log does not open the image for good; the message
signs a fresh one each time it is shown. Images labelled application/octet-stream or
image/octet-stream load the same way when their bytes are a picture. Messages already in the
mailbox are fixed too, no backfill needed. No schema change.
Single sign-on can be the only way in. A new setting under Admin → SSO, Single sign-on only
(or OIDC_DISABLE_PASSWORD_LOGIN=true in the environment), hides the password form and makes the
server refuse a password sign-in, so everybody comes in through the provider. It is in force only
while a provider is set up, so a provider that was removed cannot leave an install with no way in,
and the box can be ticked only by an administrator who has signed in through the provider
themselves. If the provider is down, php bin/console app:sso:allow-password-login brings the form
back. App passwords for mail clients and the phone apps, and sessions that are already open, are not
affected. Off by default. Schema change: one boolean column on oidc_config, false for every
existing install. #169.
A deleted label no longer comes back after the next sync. On a plain IMAP account, and for a folder on Outlook, deleting a label left its folder on the mail server, and the next sync made the label again. Deleting one now deletes the folder there too. When there is mail in it, the delete dialog first asks where that mail goes — Inbox, Archive, Trash or one of your other folders — moves it there, deletes the folder once the server says it is empty, and only then removes the label. This runs in the background and shows in the activity indicator, which names the folder it is waiting for; a folder that still holds mail on the server is never deleted, and a server that refuses the delete ends it at once with its reason. A folder still being imported is deleted only once its import has finished. A folder plMail creates on an IMAP server (an Archive or Trash it did not have yet) no longer shows as "Importing" in the activity indicator. Deleting an Outlook category never reached Outlook at all, and now does; labels nested under a Gmail label are deleted in Gmail with it. No schema change. #40.
Inline images labelled application/octet-stream show up. Many mailers do not say what an
inline image is — they label it application/octet-stream, or image/octet-stream, which is not
a registered type at all and is what Vaultwarden's notification mails put on their logo — and the
attachment route served every such part as a download, so the image never appeared in the message. For those labels only, the file's own
bytes now decide, held to the same allow-list as everything else: a PNG, JPEG, GIF or WebP is
served inline, while SVG, HTML and anything unrecognised are still downloads. The image proxy
already did this for unlabelled remote images and now shares the same check, so a remote image
labelled image/octet-stream is now also identified from its bytes and served under its real
type, rather than passed through under the label it came with. No schema change.
Part of #213.
A mail can go to Bcc recipients only. Sending with nobody in To was refused with "Add at least one recipient before sending", even with addresses in Cc or Bcc: the window counted all three, but the server only counted To. It now counts all three as well. A mail with no recipient at all is still refused, and a draft may still be saved with none. No schema change.
Outlook's <![endif]--> no longer shows up in a message. Mail written for Outlook hides
parts of itself behind conditional comments, and one form of them ends in a marker that is not
inside a comment. A mail that also had a comment containing -- anywhere in it was read by an older
parser in one step of the rendering, which turned that marker into text, so a parcel notification
could show <![endif]--> in the middle of a sentence. The same mail could also lose a link around a
table. Every comment is now removed before that step, and the marker with it. Mail already in the
mailbox is repaired on request: php bin/console app:backfill safe-html-conditional-comments writes
the shown copy of every message that has the marker in it again from the sender's original, and
safe-html-table-links does the same for the links. No schema change.
#175, by @phdelodder.
One X on a What's new screenshot. A screenshot opened full size brought its own X into the corner of the dialog, just below the dialog's X. Now there is only the dialog's: pressed once it puts the screenshot back, pressed again it closes What's new, the same as Escape. #187.
An event that ends when it starts no longer runs into the next day. A calendar feed or CalDAV event whose end is its start, such as a reminder at 23:59, was given an hour, as if no end had been stated. Other clients show it as 23:59–23:59, and here it ran past midnight and showed again on the next day. It now gets one minute, which keeps it on its own day. An event with no end at all still gets an hour, but the hour stops at midnight. Events already synced are corrected when the publisher next changes them, or right away by removing the calendar and adding it again. #170.
Some IMAP messages no longer show their own headers above the content. A Stalwart server
answers some messages' body with the whole message, headers included, and plMail stored that as the
body: the message opened with Delivered-To:, Received: and the rest of its header block printed
as text, and its original held the headers twice. An exact repeat of the header block at the top of
the body is now dropped before the message is read. Mail already stored that way is repaired by
app:backfill repeated-headers, from the stored original and without asking the server; it reads
the original of every IMAP message once. No migration.
#173, by @phdelodder.
v0.3.6 — 2026-10-16
Several Gmail accounts import without failing on a duplicate key. With more than one Gmail
account connected, mail one of them forwarded to another (or that both received) was merged into
the first account's copy, and that copy's Gmail id was replaced with the second mailbox's. The
first account then stored the message again, and the next import batch to reach it failed with
duplicate key value violates unique constraint "uniq_message_gmail_id_account", was fetched again
on every retry and used up the Gmail quota, so 429 rateLimitExceeded followed. A message another
Gmail account holds is now left with that account and written down as such
(app:gmail:skipped lists it as held by another Gmail account). Messages this already happened to
stay listed twice.
#196, by @karatektus.
A label a rule puts on new mail shows on its conversation. A rule with Apply label labelled the arriving message, but the conversation lost the label again in the same step, so it was missing from that label's list and from a tab filled by it. A message joining a conversation plMail already had was affected unless it was the first one that label was ever applied to on the account. Mail labelled before this update gets its conversation's label back the next time any label in that conversation changes. #185, by @karatektus.
A conversation no longer splits when its replies are synced first. When replies arrived before
the message they answer, which is the usual case right after connecting an IMAP account (the Inbox
is synced before Sent Items), each reply that found nothing to hang on started a conversation of
its own, and the message that arrived later started another: a four-message exchange could be
listed as three conversations, until app:backfill rethread was run. A message now also brings in
the mail that already answers it, so a conversation ends up as one whatever order its folders are
synced in. The conversation that was already in your list is the one kept, with its star, its tab
and its labels. Conversations split before this update stay split until the next
app:backfill rethread. Migration: one index on message, built on boot; on a large mailbox
the first start after the update takes longer while it is built, and new mail waits for it.
#176, by @phdelodder.
IMAP folders that never synced now do. On some servers, Dovecot among them, a folder's
UIDVALIDITY (the number that says whether its message numbers still mean what they did) is above
2147483647, and plMail could not store a number that large: every sync of such a folder stopped
with "value … is out of range for type integer" in the worker log, and its mail never arrived. Other
folders of the same account were not affected. Migration: mailbox.uid_validity is widened
from INT to BIGINT, which rewrites the small mailbox table once on boot; no stored value
changes. Reversible: rolling back forgets a folder's UIDVALIDITY that only the wider column can
hold, and the next sync records it again.
#174, by @phdelodder.
Release notes after an update, and a feedback form. The first time you open plMail
after it has been updated, a dialog lists what changed since the version you last saw: the
highlights first, then every other change as a headline you can open. The ? in the top bar opens
it again at any time, with a dot on it while there is something you have not seen, and the whole
changelog is under Full changelog. It never opens on a fresh install, only after an update; if
you would rather not be shown it, tick Don't show this after updates, and Settings → General
→ What's new turns it back on. Send feedback, in the dialog and under the ?, sends a mail
from your own account, with your plMail version and, if you tick it, a screenshot of the page you
were on; a copy stays in your Sent folder. It goes to feedback@pluetzner.de, or to
APP_FEEDBACK_TO where that is set. Migration: everybody who already has an account is recorded
as having seen 0.3.1, so their first look is every release since; nothing else changes in the
schema.
#166, by @karatektus.
Drafts from Outlook and Gmail open in the editor. A draft that arrived by sync (from Outlook or Gmail) was listed in its conversation, and clicking it gave a "Not found" page: it had been marked as already sent, which the editor refuses to open. It is now kept as the unsent draft it is, like one written in plMail. The visible side effect is that a conversation holding such a draft is no longer moved up the list by the draft's date, the same as for a draft written here. A draft you then send from Outlook or Gmail is recognised as sent, and no longer offered for editing. Migration: drafts already stored that the provider still files as drafts are made openable too; one that was sent elsewhere before the upgrade is left as it was. #164, by @Flouss.
Pull to sync on phones. Reaching for the button at the top of the screen is no longer the only way: pulling the mail list down from its top now syncs it, through the same sync as that button. Drag past the Release to sync indicator and let go, and the sync icon spins while the run lasts. It is for a finger on a phone-width screen only. A mouse, a trackpad and wider windows behave as before, a list scrolled down scrolls as it always did, and a pull while the sidebar is open does nothing. Reduced motion removes the indicator's slide. No schema change, no deployment change. #163, by @Flouss.
Notifications only for newly arrived mail. The notification on a phone
or browser was sent for every change to a message — opening a conversation, marking it read,
starring, archiving, a spam move, a copy of something you sent — and again a few minutes later when
Outlook reported your own reading back, and it always read "New mail". A device switched on under
Settings → Notifications is now told when mail arrives in the Inbox of a mailbox that has finished
importing, and about nothing else. Spam, drafts, sent copies and an import in progress no longer
notify, and neither does a message whose arrival time plMail cannot work out from its headers.
Reminders from Calendar alerts and the update notice for administrators are separate pushes and
unchanged. Apps that register through JMAP, and an Android phone through
Firebase, are still told about every change: they ask what moved. Migration: one boolean column
on jmap_push_subscription, false by default; devices registered through the in-app toggle (an id
starting web-) are switched to the new behaviour once, so nobody has to turn notifications off and
on. Reversible.
#162, by @Flouss.
Outlook no longer reports moves that did not happen. Microsoft sends a message again whenever anything about it changes — plMail marking it read is enough — and each of those was written to the change log as a move into the folder the message was already in. Only a message whose folder actually changed is recorded now. The effect runs one way: a JMAP client, and an Android phone through Firebase, are told about fewer Email and Thread changes from an Outlook mailbox, and none that describe nothing. No migration. #162, by @Flouss.
Category tabs no longer add browser history entries. A tab switch, in the inbox or on an account's page, replaces the current entry, as Gmail does, so the browser's Back leaves the inbox instead of stepping back through every tab you looked at. The address still follows the tab. No migration. #161, by @Flouss.
Read and star changes made in Outlook sync to plMail. An Outlook account follows each folder with a change feed, and the feed was begun asking for a message's id, folder and Message-ID only. Exchange reports a change in the properties a feed asks for, so a message whose only change was being read, unread, flagged or unflagged was not reported at all: mail handled in Outlook, on a phone or by a rule stayed as plMail had last seen it. (The other direction, plMail to Outlook, was not affected.) The feed now asks for both. A feed already running keeps what it was begun with, so after the upgrade every Outlook folder is listed once more and followed from a new feed; the account shows as importing until that pass is done, which costs its listing pages and fetches nothing it already has. Each message listed is also compared with the server's read state and star, so the few that drifted while nothing was listening move to match the server: a message plMail has as read that Outlook still has unread goes back to unread, and the other way round, so a badge can go up as well as down. Migration: clears the stored change feeds of Outlook accounts; nothing else changes in the schema. #114, by @Flouss.
Fixed saving attachments to Paperless-ngx. The "Save to…" chooser listed your Paperless tags as folders to open, but a tag has nothing inside it, so clicking one only reloaded the same list and the dialog seemed to do nothing. A tag is now the choice itself: click it and the attachment is filed under it, or pick No tag to file it under none. The same goes for the folder a filter saves to. The upload itself was also refused ("No file was submitted") because it was sent in a form Paperless could not read; it now goes through. Paperless still turns down file types it cannot consume, a ZIP for one, and says so. Nextcloud, Google Drive, OneDrive, Dropbox, Immich and Google Photos choose exactly as before. No migration. #78, by @Flouss.
Mail read in plMail is marked read in Outlook. On Microsoft accounts, marking mail read (or starring it) is sent to Outlook by a background job, and that job used to work out the new state by reading the message back from plMail's database. When the job started a moment before the change had been saved, it read the old state and told Outlook the message was still unread: Outlook was touched, nothing changed, and an hour later the next sync brought the "unread" back into plMail. In a bulk mark-read several messages were affected. The job now carries what you chose. Archive, trash and label changes are unchanged, and a retried push still sends the current state rather than the original choice. Messages already stuck this way are not repaired; mark them read again. Gmail and IMAP jobs already carried the change themselves. No migration. #111, by @Flouss.
Fixed the message header in narrow panes. On a phone, or with the reading pane on the right and dragged narrow, the sender's address was cut mid-word across three lines while the date and the buttons took half the width. Below 36rem of pane width the date, the menu and the arrow now sit on a row of their own under the header, so the sender gets the full width. It follows the width of the pane, not of the window. Wider panes are unchanged. No migration.
Outlook mail moved in another app leaves its old folder here too. A message marked as spam (or archived, or deleted) in the phone's mail app was taken out of its old folder and put in the new one, but its conversation kept the old folder as well. The message sat in Spam while the inbox list went on showing it. A conversation now sits in exactly the folders its messages do, so the move shows in the list at the next sync. Conversations already left that way are put right the next time one of their messages changes folder. #121, by @Flouss.
Optional All Mail view (#57). Archived mail had no list of its own to be browsed in, only search. All Mail holds it, together with what arrived, what you sent and your drafts, as Gmail's All Mail does, leaving out only Spam and the bin. It is off until you switch it on with its eye in Settings → Mail → Labels; then it is a row beside Drafts for every account together, and a row at the top of each account's folders for that account alone. Archiving or snoozing from it leaves the conversation in the list. A conversation with several labels is listed once. No migration.
Outlook changes appear without a reload. Marking a message unread in Outlook on the web moved the unread counts in the sidebar and left the list showing the old row until the page was reloaded; moving or deleting one there did the same. A sync that only changed such a message ingested no mail, and "an account changed" was announced only after a sync had ingested something, so the open list was never told. It is told now, once per sync, and only when a row really changed: a server that agrees with what plMail already has says nothing. #122, by @Flouss.
Profile pictures use the full file picker. Settings → Profile showed a small grid of its own: the first couple of dozen images at the top of the service, with no albums, no people, no search, no way to scroll on. Each connection's button now opens the full picker, with "Use as profile picture" where a mail has "Attach", and one picture can be chosen at a time. A PDF, or a file the service says is over 4 MB, is shown greyed out rather than hidden; Immich and Google Photos do not report sizes, so a large photo from them is still refused after it is chosen, with the reason under the picture field. A scanned photograph from Paperless-ngx is now taken as the original — it used to arrive as the archived PDF and be refused. The setup wizard's profile step still has the small grid, because the picker would replace the wizard it is inside. No migration.
Option to always show remote images (#124). Settings → Mail → Images has one switch, Always show remote images, off until you turn it on. With it on, no message asks before loading its pictures; they still come through plMail's image proxy, so a sender learns that the message was opened but not your address. Mail in Spam stays blocked as before, and the senders you trusted one by one are kept for when it is switched off again. The choice lives in your user settings and a config backup carries it. No migration.
Category tabs in an account's own inbox (#55). Clicking an account in the sidebar opened a plain list, so on an Outlook account — or any account — the tabs were nowhere to be found unless you went back to the top-level Inbox. The categories were being worked out all along; that page just never drew them. Settings → Mail → Categories now has Tabs on each account, off until you turn it on: with it on, an account's page shows Primary, Social, Promotions, Updates, Forums and your own tabs for that account's mail; off, it stays the account's whole inbox in one list, as before. It is offered only while Inbox tabs is on, and a config backup carries it. The tabs on this page are plainer than the top-level Inbox's: no "new" pills, sender hints or unread tint (those add up every account), and no dropping a conversation onto a tab. No migration.
Single sign-on keeps you signed in. A single sign-on sign-in left nothing but the PHP session holding you in: a cookie that ends with the browser, kept in a file that is cleaned up after 24 minutes without a request and that goes with the container whenever the server is updated. Which is why it signed people out every few hours. It now sets the same 60-day remember-me cookie, stored in the database, so a lapsed session is picked up again without a visit to the provider. Accounts with two-factor authentication are still asked for their code when the cookie signs them back in. Everyone signed in through the provider before the upgrade signs in once more to get the cookie.
Gmail import includes all mail in the mailbox.
plMail kept a Gmail message only when its headers named the account, and on a real mailbox
(app:gmail:skipped) what it dropped was the account owner's own mail: 202 messages sent from
"send mail as" addresses, 89 forwarded in from an address not connected here, 43 drafts and 4
addressed to the googlemail.com spelling of the address. A message in a Gmail mailbox is now that
account's unless another connected account has a better claim to it; drafts arrive as drafts, and
googlemail.com is read as gmail.com. Only chats are still left out. The import also fetches
Spam and Trash now, which it said it did and never asked Google for — the reason an import could
not reach Gmail's own total. Every Gmail account lists its mailbox once more after the upgrade to
take these in, and shows as importing until that pass is done. A message that names nobody and that
Gmail collected from another connected account is merged into that account's copy rather than
listed twice. Migration: clears the record of
skipped Gmail messages except chats, and the backfill markers of Gmail
accounts. #90, reported by @karamanliev. The progress line also counts mail the import
stored on another connected account — Gmail collecting a mailbox that is connected here too — so it
no longer stops short of Gmail's total by that mailbox's size. Once everything is in, the import confirms it within
about ten minutes instead of showing "Importing · 100%" for up to an hour, and the count no longer
runs past a total that new mail has outgrown.
Spam and Trash are no longer processed. Mail arriving in either — from any provider, and now from a Gmail import — is no longer categorised by the language model, held back from the inbox tabs, read for parcels, invoices or meetings, or offered as a calendar event, and senders of spam no longer become contacts. A forged invitation in Spam stays out of the calendar. Read receipts filtered into Spam are still matched to the mail they are about. No migration.
French translation. Choose Français under Settings → General → Language, or when creating the first account. The whole interface is translated, including the admin panel, the validation and sign-in messages and the emoji picker's search; French also defaults to the 24-hour clock, as German does. Mail written in French is read as German and English mail already was, whatever language the interface is in: an appointment like "mardi prochain à 14h30" is offered for the calendar, a "facture" with its "net à payer" becomes an invoice card, and a "code de vérification" a login-code card. A slashed date such as 04/08/2026 is read day first for a French reader. No migration.
Customisable inbox tabs. The new Settings → Categories, in the Mail group, holds everything about the tabs: what
sorts mail into them (the card that used to sit under General), which of the five built-in tabs are
on, the tabs you make yourself, and the order of all of them — dragged, or moved with buttons from a
keyboard or a phone. Primary is always first and always on. A tab you switch off takes no mail: the
header rules, Google's labels and the assistant all file what would have gone there under Primary,
and switching it back on re-sorts and brings it back. A tab of your own is filled by a filter whose
action is File in category; mail filed there leaves the built-in tab it would otherwise be in,
and the tab shows while it holds mail, takes drag-and-drop, and counts and badges like the others. A
filter files Gmail mail too, whatever you chose about Google's own sorting. Only mail whose sender
passes authentication is filed, so a forged "your parcel is waiting" never lands in a tab named after
the real carrier; on a mailbox where plMail cannot tell which server vouches for the mail such a
filter files nothing, and the filters page says so. Mail stays in a tab when the filter that put it
there is changed or deleted. The Android app and other JMAP clients can draw the same tabs and move
mail between them (urn:plmail:params:jmap:inboxtabs), and a re-sort that moves conversations
between tabs now tells them — a phone used to keep showing a conversation where it had been. Re-sorting
also reads only the mailbox being re-sorted, where it used to sort every user's mail once per account.
Migration: one new table, a nullable column each on messages and conversations and two settings
columns on users, added without rewriting any mail. With tabs on, every inbox page and sidebar-counts
poll makes one more indexed read. #87, by @Flouss.
v0.3.5 — 2026-10-13
Password managers no longer fill client-secret fields. Opening
Admin → SSO, or a Google, Microsoft or integration provider form, could put your mail address in the
client ID and your password in the secret field, one press of Save away from being stored; with
that stopped, a click in the field still had Chrome list every saved password and Bitwarden offer to
generate one, and Bitwarden opened its menu in the text fields below. All of it followed from the
field being a password input, which no attribute talks every browser and extension out of. Client
secrets, API tokens and mail-server passwords are now text fields drawn as dots, and a text field
that asks for nothing says autocomplete="off". The sign-in form and the forms that set a login
password are unchanged.
Sign in with OpenID Connect (Authentik, Keycloak, …). Enter the provider's
issuer, client ID and secret under the new Admin → SSO and the login page offers a "Sign in with
…" button above the password form, which stays; the OIDC_* variables do the same for an install
configured from its compose file, and what is saved in the admin area wins. An account is found by
its address the first time — only when the provider says it verified that address — and by the
provider's subject from then on, so changing the address at the provider later changes nothing here.
A switch creates an account for somebody plMail has never seen; it is off by default. Suspension,
the login throttle and two-factor apply as they do to a password. Setup, with Authentik step by
step, is in Single sign-on. app:user:oidc-unlink forgets a link from the
console, for somebody deleted and re-added at the provider. Migration: two nullable columns and
a unique index on user, and a new one-row table oidc_config; nothing backfilled, reversible.
#106, by @jo2904.
Wider Happening soon panel. The panel opened at the narrowest dialog width, so a fortnight of bookings pushed the undated cards under "Also spotted" far down the scroll. It now opens wide, and on a screen wide enough for it the list and "On your radar" stay on the left while "Also spotted" sits to their right. On a phone it stacks as before. No migration. #83, by @Flouss.
The calendar shows times in your own time zone. A calendar you subscribe to from Google or a CalDAV server arrives with the time zone that account holds for it, and a shared one is often set to UTC there. When such a calendar was your default, the whole calendar page was drawn in UTC, so an appointment at 11:30 Paris time showed at 09:30 — the stored time was right, only the clock it was printed on was not. The page, and the dot in the top bar that decides what is left of today, now use your own time zone (the Time zone setting, else the install's default) whenever the default calendar is a synced one. A calendar created here keeps the zone it was given. No migration; nothing stored changes.
Inbox unread count in the browser tab on every page. The "(3)" in front of the tab's title appeared on the Inbox and vanished on Settings, the calendar and the admin pages, although the sidebar there still showed the same number. It stays now, and updates on sync like the Inbox's does. Mail folders are unchanged: each shows its own figure or none, so Trash, Sent and Archive do not borrow the Inbox's. No migration. #72, by @Flouss.
Fixed missing attachments on Outlook and Hotmail mail. A message with a file on it arrived in plMail with no attachment at all, no paperclip, nothing to download, for every Outlook.com, Hotmail and Microsoft 365 account. The list of a message's attachments was asked of Microsoft in a form it rejects, and a refused list was read as "none", silently. The request is fixed, and a message that Microsoft says has attachments but whose list does not come back is now logged as a warning instead of passing unnoticed. Mail that has already been synced stays as it was stored, without its attachments, until it is fetched again; mail that arrives from now on is correct. No migration. #77, by @Flouss.
Parcels picked up at a relay point show as delivered. The mail that says "Votre colis a été retiré" or "… récupéré par son destinataire" named no stage the radar knew, so the card stayed "announced" after the parcel was in its recipient's hands. Both now read as Delivered; the same words negated ("n'a pas été retiré", "n'a pas été récupéré par son destinataire") do not, and neither do those words about something other than the parcel. No migration.
Fixed the carrier shown for Pickup relay parcels. The Pickup relay network (network1.pickup.fr, network2.pickup.fr) hands over the parcels of several carriers, and its mail gave every parcel whose number has the international S10 shape the carrier "Deutsche Post", whoever was shipping it. A card now takes its carrier from the sender's name when that starts with Chronopost or Colissimo, with that carrier's tracking link, and is otherwise headed "Pickup" with no carrier and no link. What the number itself says still counts: a number ending in FR is La Poste's, and Colissimo's own shape is Colissimo's. Cards already on the radar correct themselves the next time their mail is read, no duplicates. No migration.
Re-reading old mail no longer resets a parcel's status. A card keeps the
newest statement of its series, but "newest" meant the last mail read, not the latest dated.
app:backfill insights reads by the order mail was stored, which for an Outlook import is
newest-first, so a parcel's "out for delivery" mail was read before its "announced" one and the
older statement overwrote it. A mail older than the one a card already stands on now leaves the
card alone; mail of the same instant, or a card whose mail was deleted, behave as before. No
migration.
Cards from old mail no longer look new. The cards without a date of
their own — GitHub activity, mostly — are listed for the last seven days, measured from when the
card was created. That equals the mail's age only while cards are made as mail arrives:
app:backfill insights, or an import, creates them from old mail now, so a month-old notification
took a place among the newest twenty for a week. The window and the order now follow the date of
the card's mail, and the card's own date only when its mail was deleted. No migration.
One-time codes leave Happening soon once they expire. When a code's mail says how long it lasts, the card drops off when that time passes. When it says nothing — a password manager's, many shops' — the card had no date to drop off by and was listed like any undated card, for seven days, with the code on it. Such a code is now offered for fifteen minutes from its mail; it is still on the mail itself. Codes that state an expiry, and every other kind of card, are unchanged. No migration.
Insights read the whole of an Outlook mail.
For an HTML mail, Microsoft Graph gives the text as a preview cut at 255 characters, and that
preview is what the card extractors (parcels, codes, flights, tickets, invoices) read instead of
the mail. A tracking number further down — in a Pickup or IKEA relay notice, say, or a GLS mail
with the number under a heading — never produced a card. They now read the mail's HTML for an
Outlook account, and the preview only when the HTML has no words in it. Mail already stored is
read again by app:backfill insights --since=…. No migration.
GLS France parcels are recognised. GLS France prints no tracking number of a
recognisable shape: its mails carry an eight-character code under "Numéro de colis" (Q7XK2M4P),
and the sender was not known either. Mail from gls-france.com is now read for that code, behind
that label only, and the card links to GLS France's own tracking page. Eight bare digits, or a
code in mail from anyone else, are still not read. No migration.
Fixed wrong invoice numbers and merged invoice cards. A billing mail whose footer says "Manage invoice notifications" had that line read as an invoice number: "invoice no" matched the start of "notifications" and the rest of the word became the number. Every bill that sender sent got the same number and so the same card, and each overwrote the last. "Invoice no" and "invoice number" now have to be whole words. No migration.
Fixed sign-out not sticking. A page's background requests that were still on their way when you pressed Sign out could sign you straight back in through "Keep me signed in". Its tokens are now kept on the server, one per device, and signing out deletes this device's; other devices stay signed in. Everybody who relied on "Keep me signed in" is asked to sign in once more after the update. One table is added; the migration is reversible.
Sender name per address. Settings → Aliases has a sender name field under each address; left empty, the address sends under the account's name as before. The name was already shown in the From selector for an alias that had one (a phone app could set it), but the mail itself went out under the account's name regardless — the selector and the recipient now see the same one, in the browser and from the apps. No migration. #70, reported by @Node815.
Replies are sent from the address the mail came to. Mail that came in for an alias is
answered from that alias, with that alias's signature, instead of every reply opening on the
primary address; answering your own sent mail keeps the address it was sent from. A domain whose
every address reaches the mailbox can be added under Settings → Aliases → Catch-all domains, and
mail to any address at it is then answered from that address without adding each one as an alias
(not offered for Gmail and Outlook accounts, which send only as addresses the provider knows).
A meeting invitation sent to such an address is yours to answer. Only the address a mail actually
reached you by counts as yours, not the whole domain, so somebody else with a mailbox at the same
domain stays in your reply-all and is never answered as.
Reopening a draft saved from an alias no longer puts it back on the primary address. Typing an
arbitrary From address into a new message is not part of this. The phone apps build their replies
themselves and get the same answer from the server: Email/get has a replyFrom property, the
identity that answers a mail, returned when asked for by name, and EmailSubmission/set accepts
the identity it names for a catch-all address. A draft saved by an app now keeps its sender: Email/set
takes identityId on a draft (announced as draftIdentity in the session), so a reply written
from an alias no longer comes back from Drafts on the primary address. Apps already installed are
unaffected and go on answering from the primary address until they are updated. No
migration. #41, reported by @YoGo9.
HTML signatures with images. The signature editor
has two views, Rendered and HTML. HTML takes the source; Rendered shows it as it will be stored,
cleaned by the server, with a line underneath naming what was removed (usually a <style> block
and class attributes). An image a signature links to is fetched and kept in the signature — up
to 256 KB each — so it shows in the editor, which it could not before, and is sent as an attached
inline image instead of a link recipients have to allow. Existing signatures are unchanged until
they are next saved. No
migration. #32, reported by @eatcodeplay.
Sidebar counts update live. Saving a draft did not raise Drafts, deleting or sending it did not lower it, and a mail read on the phone left the badge where it was until the next sync or a click on another folder. The numbers were only re-read when a button remembered to ask, and the composer, the worker that sends a mail and the phone apps had no way to. The server now says so itself whenever mail changes — on the response to the request that changed it, and over the hub for a change made elsewhere — so every tab hears, once per change. Opening a conversation that was already read no longer writes anything, tells the provider anything or wakes the phone apps. No migration. #86, reported by @pmdevelopment.
Replies from a catch-all address show as "me". It showed the account's name, as though a third person had joined the conversation. Somebody else writing from the same domain keeps their name. No migration.
Meetings on two calendars show both colours in a narrow month. The bar a month cell falls back to when there is no room for the title stacked the two colours on top of each other in a few pixels of height. No migration.
Fixed high CPU use with IMAP servers that drop idle connections. Some servers (Lark, QQ) end an idling connection after a few minutes. plMail read that as a quiet mailbox and kept reading a connection that was gone, as fast as it could, until the 29-minute refresh — a core per folder. It now sees that the connection has ended and reconnects. No migration. #94, reported by @montywei.
Fixed imports stopping one message before the end. A server can list a message it will not hand over (iCloud does, for a draft deleted but not yet purged). Left alone on the last page, it made the import fail before its tries were counted, so it was retried every few minutes for good at 486 of 487 and the account's remaining folders waited behind it. It is given up on after five tries now. The same answer — nothing — from a folder that really is empty was also taken for the server not answering, so mail deleted from an emptied folder elsewhere stayed in plMail; an empty folder is read as empty. No migration. #96, reported by @montywei.
A slow mail server no longer holds up other imports. A server sending a trickle of data never tripped the 30-second read timeout, which is per read, so one fetch could occupy an import worker for as long as the trickle lasted while every other account waited. A worker now gives a message it is handling five minutes; after that its IMAP connections are closed, the message is retried later and the queue moves on. No migration. #95, reported by @montywei.
Fixed Gmail imports waiting a day to finish. plMail fetches
some Gmail messages and then does not store them — a draft, a chat, mail whose headers tie it to no
account connected here. Nothing remembered that, so every hourly listing found them missing and
fetched all of them again: on the mailbox this was reported from, 2,235 messages and about a third
of the hour's quota, every hour, with the account showing "Importing: waiting" until the import
gave up after a day. They are written down now. The listing leaves them alone, the import ends on
the pass where everything outstanding has an answer, and the progress line counts them as done, so
it reaches Google's total. php bin/console app:gmail:skipped shows what was skipped for an
account, grouped by kind. An import already stuck this way finishes within two hourly listings of
the upgrade. Whether some of these messages should be stored after all is not changed here.
Migration: adds the empty table gmail_skipped_message; nothing existing is
altered. #90, reported by @karamanliev.
v0.3.4 — 2026-10-13
Fixed Gmail imports stuck on Google's quota. Google lowered the
per-user limit to 6,000 units a minute for projects created since May 2026 and raised the cost
of fetching a message with its body from 5 units to 60 (its table says 20, which is the
cost without the body); plMail paced imports for 15,000 at the old cost, so on
a new project most of what an import asked for was refused, the log filled with rate-limit
warnings and an import could stop without resuming. plMail now reads the limit out of Google's
first refusal and keeps to it — nothing to configure — counts a message at its real cost,
fetches five to a request so a minute's worth is never asked for in one second, and shares one
allowance between its workers. An account Google still refuses at the right limit is slowed
down and speeds up again by itself. An import longer than an hour no longer fetches again what
an earlier pass already brought in. A refusal is no longer an
error: whatever met it — a batch, an import page, a sync, a label change being pushed — waits
out the minute and carries on. The
import line in the background-work indicator now shows the quota, roughly how much of it is in
use, how many messages a minute are coming in, how long the rest of the import takes, and says
so when Google has paused it. No
migration. #90, reported by @karamanliev.
Your own tabs on an account's page. Clicking an
account in the sidebar offered the five built-in tabs and none of the ones you made. Mail filed in
one of yours is in that tab only — not in Primary or any other — so on an account's page it was in
no tab at all. The page now has the same tabs as the top-level Inbox, yours included, each shown
while it holds mail in that account, and ?tab= accepts yours. Counts are per account; a link to a
tab that is not yours, or was deleted, opens Primary as it does in the Inbox. No migration.
Fixed the browser tab icon in Firefox. Settings → Appearance → Logo changed the icon in the app but not in the tab, which kept showing the wax seal. Every page also linked a fixed 192px wax-seal PNG as a second tab icon, and Firefox prefers that to the SVG for a tab. The PNG is gone from the tab icons; the home-screen and install icons are unchanged. No migration. #74, by @Flouss.
Fixed messages with invalid bytes not being stored. Over IMAP, a mail
whose text was labelled us-ascii but written in Latin-1 — "Grüße" from a mailer nobody configured
— was refused by the database ("invalid byte sequence for encoding UTF8: 0xfc"), asked for again
on each of the next syncs and then given up on, so it never appeared. The same happened with such
a byte in a Message-ID, In-Reply-To or References header, and in an attachment's content type or
content ID. All of them are now read the way a subject or a file name with that byte already was,
and the message arrives with its umlauts. A message that was given up on before this is not
fetched again by itself. No migration.
#81
Clearer read/unread button. On an unread row, "Mark as read" wore the closed envelope, while the same action above a selection wears the open one — which looked like a Promotions problem only because that is where mail is unread. A row's button now matches the toolbar: open envelope to mark read, closed envelope to mark unread. No migration. #61, by @Flouss.
New Gmail and Microsoft accounts get their own colour. Accounts added with a password were already given the first colour no other account was wearing; accounts connected through Google or Microsoft skipped that step and all kept the first colour, so their dots in the sidebar and the corner mark on every message row looked the same. They now take the first free colour like any other. Accounts you connected before this keep the colour they have; the swatch on the account's row in Settings → Mail accounts changes it. No migration. #60, by @Flouss.
Readable mail list beside an open message. A row went to a single line once the list was about 575px wide, which left the subject around 120px and its preview wrapped next to it ("Facture pour Co…") whenever the reading pane was open, or on a large phone. A row now goes to one line only once the list is 75rem (about 1200px) wide. Below that it is two lines — sender and subject, then the preview across the whole row — and on a list narrower than about 575px, a phone, the three it always was. Not less, because a one-line row in a 65rem list (the list beside the reading pane on a 2560px screen) still cut real subjects after about 30 characters. Archive, delete, snooze and the other row actions appear on hover in two- and three-line rows too, instead of only in one-line rows. A two-line row has no unread dot; like a one-line row it says unread with a bold sender and subject. Also fixed: with the two-line preview setting on, a one-line row wrapped its preview anyway. No setting; no migration. #62, by @Flouss. The calendar button can skip a view. The button in the top bar has always been a three-way switch: mail, then the calendar beside the mail, then the calendar full width. Settings → Calendar → Behaviour has one choice, Calendar button, that can make it a two-way one. Full width only goes between the mail and the calendar full width, one press each way, and clicking a mail link while the calendar is full width goes back to the mail instead of dropping into a split. Split only goes between the mail and the calendar beside it. All three is the default, so the button behaves as before until you change it. Nothing is added to the database. #80, by @Flouss.
Unread counts on inbox tabs. The number is the sidebar's Inbox badge split by category, so the tabs add up to it, and it appears on every tab, the one you are on included, and updates in place on sync. Before, an unread conversation in Promotions made the Inbox badge read 1 while Primary, the tab that opens, showed nothing unread, and the only hint on the strip was a colour on the Promotions icon. The "3 new" pill is unchanged: it says what arrived that you have not been shown, the unread count says what is unread. On for everyone; Settings → Appearance → Message list switches it off per person. One column is added to the user table; the migration is reversible. #54, by @Flouss.
Change an account's colour. The coloured dot beside each account, and the mark on its messages in a mixed list, was picked for you when the account was added and could never be changed. Settings → Mail accounts now has a colour swatch on every row — Gmail and Outlook accounts included, which the Edit form is closed to — that opens the palette of eight. Two accounts may share a colour if you want them to read as a pair. No migration. #69, by @Flouss.
An open message stays open when the list changes. With the reading pane on the right, choosing another folder, switching a category tab, turning the page, sorting or searching closed the message you were reading, although it had never covered the list. It stays now, and the back arrow closes it and leaves you on the folder you moved to. The open row is marked again when you return to the folder it is in. With one pane at a time nothing changes: there the message has covered the list, so choosing a folder still closes it. No migration. #63, by @Flouss.
Sidebar "+" buttons appear on hover. Both were on screen all the time. They also appear when reached with the keyboard, and stay visible on a touch screen, where there is no pointer to reveal them with. #66, by @Flouss.
Separate model host for semantic search. Admin → AI has a tick box, Search uses a different model host, with its own provider, address, token and extra headers. Off by default. It is for running the two models on different machines: a hosted model for writing and a small one at home for search, so the mailbox is indexed on a machine you own and nothing is billed per message, or the reverse.
- The index is kept when the search model has the same name on the new host. A vector belongs to its model, not to the address it was made at.
- Each host gets only its own token and headers. A search host without a token is sent none.
- Test asks both hosts, each for the model that lives on it.
Five columns are added to ai_settings, unused until the box is ticked; the migration is
reversible.
Custom headers for the model host. For a reverse proxy with basic auth, a zero-trust
gateway, a routing gateway with its own key, or a provider that takes a project or tenant header.
Admin → AI has an Extra headers box under the token: one Name: value per line, sent with every
request for either provider, stored encrypted. The page lists the names on file and never shows a
value again. They are dropped when the address or the provider changes, like the token. One column
is added to ai_settings; the migration is reversible.
Any OpenAI-compatible language model. Admin → AI has a Provider field:
Ollama, as before, or OpenAI-compatible, which covers llama.cpp, vLLM, LM Studio, LiteLLM, hosted
providers and Ollama under /v1. Writing help, summaries, sorting and search by meaning all work
with either. The address is the API root including its version, such as
https://api.example.com/v1, and the token is the API key.
- Ollama is still the default and nothing changes for an existing installation.
- Not for the other provider: the keep-alive fields, Warm up now and the "in memory" half of the model host card. The OpenAI format cannot ask for any of them, so they are put away while it is chosen.
- A hosted provider receives your mail. Sorting sends every arriving message no rule recognised, and search sends every message once to index it. plMail sends only to the address you enter.
- Changing the provider re-uses the index when the search model has the same name. A different model still needs a new pass from Admin → AI, as it always has.
One column is added to ai_settings; the migration is reversible. Built on
#58 by @shady2k.
Fixed: the Admin → AI token is sent to the model host. It was stored and never used, so an Ollama behind a reverse proxy that asks for one answered 401 to every request while the page said a token was on file. It goes as a bearer token with every request, including the Test button and the panel on Admin → Performance. The token belongs to the address it was typed for: changing the model host without typing a token drops the stored one. No migration.
Admin → AI shows the model host's errors. The message kept the percent signs of its own placeholder, so a refusal read "HTTP %403%" and the host's reason sat between two stray percent signs.
Permanent delete is off until you turn it on. Settings → Mail →
Disposal has one switch, Delete-now buttons. It decides whether the Delete forever
buttons exist at all: the flame on a row in Spam and the bin, which was always there, and now the
same button above a ticked selection. This changes things for anyone who used the row button: it
is gone until the switch is on. With it on, a selection in Spam or the bin is deleted for good
from the toolbar, instead of a trip to the bin and a second deletion there. It asks first, says how
many, and removes the mail from the mail server too; there is no Undo. Select all N followed by
Delete forever empties the whole of Spam or the bin in the background. # does nothing above a
selection there, since the Delete it presses is replaced. The server refuses to delete for good
while the switch is off, whatever a page sends. It is per person and a config backup carries it. No
migration.
#71, by @Flouss.
Fixed the count after deleting a selection in the bin. Ticking mail in
the bin and pressing Delete took the rows off the list, and they were back on the next load:
the mail was already in the bin, so there was nothing to trash, and the list reported a change
anyway. The bin has no Delete above a selection now. With the Disposal switch on it has
Delete forever in its place, with the same question and the same count; with it off, Move to
inbox is the way out. # does nothing there. No migration.
v0.3.3 — 2026-10-11
Fixed the emoji picker in Safari and on iPhone. Nearly every cell of the grid was blank, with a handful of emoji scattered through it, and an emoji typed into a message from the iOS keyboard could not be seen in the message. plMail ships its own emoji font so the picker looks the same everywhere, in a format Safari does not draw; the browser found each emoji in the font and painted nothing. The font is now only used where the browser can draw it. On an iPhone, iPad and in Safari on a Mac the picker and the message show Apple's own emoji instead. #39.
New accounts import their newest mail first. Adding an account used to bring its whole history in through the sync itself: every IMAP folder from its oldest message to its newest, or every Gmail message or Microsoft folder listed before one message was fetched, in one job on the queue all new mail arrives on. A large account showed an empty Inbox for most of an hour, new mail on every other account waited behind it, and the worker doing it was shown as dead. #42.
- Newest first, Inbox first. The most recent mail of the Inbox arrives within about a minute, then the rest of it, then Sent, Drafts and your own folders, the archive, and Spam and the bin last. Several accounts added together take turns, so each has a usable Inbox early.
- Its own queue and worker. History is imported on
ingest_backlogby a newworker-backlogprocess, a page at a time. Mail arriving now stays oningestand is never behind it. The worker starts by itself; there is nothing to configure. - You can see it. A chip beside the search box says Importing and how far along it is while an import runs. It opens a list of each importing account with how much is in and which folder it is on, and says so when an import is waiting.
- Repeated sync requests collapse. The quarter-hour poll, a push and the Sync button used to queue a full sync each, one behind the other. A request a later sync has already covered is skipped.
- One message that will not import costs itself. It is retried with its page five times and then passed over, with an error in the log, instead of stopping its folder.
This covers IMAP, Gmail and Microsoft accounts alike. Folders that had already synced are untouched; a folder that was in the middle of its first sync when you upgrade is picked up again from the top, and what it had stored is not fetched twice. One migration adds four columns to the folder table.
Two things change for Microsoft accounts beyond the import. A folder whose sync state expired on Microsoft's side used to be read again from end to end inside the sync; it is handed to the import instead. And a message deleted in Outlook while another folder's sync was failing is now removed here once that folder answers, where it used to stay behind: in no list, and counted in every total.
The Background work button updates live. It never did on a mail page: the controller that refreshes it was not loaded there, so a bulk action's progress showed whatever it had reached when the page was drawn. Where it was loaded, it refreshed once and then not again, and a refresh shut the open list. All three are fixed.
Separate password for sending. The account form has one password field, as before, and under the SMTP settings a new Use a different password for sending, which reveals a second. Zoho issues an app password per protocol, and an account set up with one of them synced and then failed every send on authentication, with nowhere to enter the other. The username stays shared. The connection test uses both, and a config backup carries both. One migration adds a column to the account table; it runs by itself and rewrites nothing. #42.
Fixed one message stopping an IMAP folder's sync. A multipart message whose
header puts spaces around the equals sign — boundary = "…", which the standard allows and some
mailers write — could not be read, and because that failed while a page of mail was being fetched,
the whole folder's sync failed with no content found on every poll until the message was deleted
on the server. Nothing newer in that folder arrived, and nothing in plMail said so. Such a message
is read now. And a message that cannot be read for any other reason no longer takes its folder with
it: the rest of the page is stored, the message is asked for again on the next five syncs, and then
it is skipped with an error in the log naming the folder and the UID. A folder that was stuck this
way catches up by itself on the next poll after upgrading. No migration.
#45.
Reading pane. Opening a message used to replace the list; Settings → Reading pane now offers Right, which puts the message beside the list on a screen that has room for both. The open row is marked, the back arrow closes the message and leaves the list where it was, and the divider between the two can be dragged (or moved with the arrow keys; double-click resets it). Its position is stored as a percentage of the mail card, 25 to 75, rather than in pixels, so it keeps its meaning when the calendar pane opens or is resized. It is drawn only while the card is at least 52rem wide — a narrow window, a phone, or a docked calendar that leaves the mail too little room fall back to one pane at a time. Off by default, so nothing changes until someone chooses it. Per person, not part of an exported theme, and carried by a config backup. No migration.
Settings has a Features group, with a page each for the reading pane and the keyboard shortcuts. The shortcuts switch moved there from General.
Simpler selection toolbar. Archive, Delete and Move to stay; star, mark as read or unread, snooze, label and spam are under More. Nine buttons in a row were wider than the list whenever it shared its card — with a docked calendar, or now with a message open beside it — and the row was cut off at the edge. The keys still reach all of them directly.
Fixed shortcuts after ticking a row. A click left the focus on the row's checkbox,
which counted as typing, so e after ticking three rows did nothing; rows ticked with x were
unaffected.
A message opened by its own address (a reload, a bookmark) has no list behind it, so with the reading pane on the right it is shown beside the Inbox, whichever folder it was opened from.
Category tabs fit on phones. On a narrow screen the tabs (Primary, Social, Promotions, …) ran into each other, labels underneath the next tab's icon. They now keep their size and the strip scrolls sideways inside itself, opening on the tab you are on. No migration.
The Immich picker hides Live Photo videos. Immich keeps it as an asset of
its own, hidden from its timeline and without a preview, so the picker showed it as a grey tile that
could only be attached as a three-second clip — on a library full of iPhone photos, a large share of
the grid. Library, search, album and person views now leave those clips out. Archived photos stay,
and so does anything a server that predates the visibility field sends. No migration.
#46.
Gmail-style keyboard shortcuts. j and k move through a list, o opens, u goes
back, x selects, e archives, # deletes, s stars, r a f reply, reply to all and
forward, c writes, / searches, g then a letter goes to a list, z undoes. ? shows all of
them, and so does the keyboard button above a mail list. A key acts on the selection, otherwise on the open conversation, otherwise on the highlighted
row, and does exactly what the button it stands for does. Nothing happens while you are typing or
while a dialog or menu is open. On for everyone; Settings → Keyboard shortcuts switches
them off per person. No migration.
#36.
a replies on a message that has only one recipient, where there is no Reply to all to press. The
toolbar above a selection has a Star button, and s presses it; before, s did nothing with
rows ticked. An open conversation has a Snooze button in its toolbar, which is what b presses
there.
Config backups include sorting settings. Settings → Categories — rules or the assistant, whether that overrules the provider's own categories, and whether the inbox has tabs at all — was never written into the file, so a restore put everyone back on rules, the provider's categories and a tabbed inbox. All three now travel with the user. Backups made before this still restore, onto those same defaults. No migration.
French parcel tracking. Colissimo, Chronopost, Mondial Relay, Colis Privé, DPD France
and Cainiao mail now yields a parcel card, with their number shapes, French wording for the stage
and for the day ("sera livré après-demain", "prévue le 14/11"), and Amazon.fr alongside the other
Amazon shops — its button now opens amazon.fr, not the German site. A parcel waiting at a relay
point or locker is a new stage, Ready for pickup. The satisfaction survey a carrier sends after
delivery is ignored, where it would otherwise have turned a delivered card back. A French S10
number from a shop of unknown carrier reads as La Poste instead of Deutsche Post. Not read yet: GLS
France and the Pickup relay network (no number to recognise), and "returned" and "attempt failed".
Existing mail is not re-read on its own; app:backfill insights picks it up. No migration.
Choose which IMAP folders to sync. Settings → Accounts has a new Choose which
folders to sync button on every IMAP account's row. It opens the account's folders with a switch
beside each: switch one off and plMail stops fetching it, the mail already stored stays where it is,
and switching it back on resumes from where it stopped. The inbox has no switch. Nested folders are
indented under their parent, and a folder that only groups others — Gmail's [Gmail] — is greyed
out with no switch to press, since the server will not open it. Gmail and Microsoft accounts, which
sync through their provider's API, do not get the button. A message you move in another mail client
into a folder that is switched off disappears from plMail, because the check for mail deleted
elsewhere only looks in the folders that are on; switching the folder back on fetches it again.
One migration adds a column to the folder table; it runs by itself and rewrites nothing.
Fixed errors on Gmail's [Gmail] folder. Over IMAP, Gmail lists [Gmail] as a
folder, but it is only a placeholder that groups All Mail, Sent Mail and the rest: the server marks
it \Noselect and refuses to open it. plMail tried anyway, logged an error each time, and — because
the folder could never be swept — withheld the check that lets mail deleted elsewhere disappear from
the account. A folder marked \Noselect is now created with sync off, and one that already exists
is switched off on the next folder sync, with nothing to do on your part. It held no mail, so
nothing is lost. No migration.
v0.3.2 — 2026-10-09
Security
Security: outsiders can no longer change your calendar. Reported by @jhuber in #34, who found everything in this section by reading the code. Two holes, neither of which exposed data or ran code, both of which let an outsider put misleading entries on a calendar unnoticed.
- A meeting could be moved or cancelled by anyone who knew its identifier. Everyone invited to a meeting has that identifier, and so does anyone a copy was forwarded to. A cancellation or an update from any of them was applied, and the changed meeting still showed as accepted. A change is now applied only when it comes from the address that sent the invitation, or from the organiser the event names. From anyone else nothing changes, and a card above the message says so and offers Apply the change. The same now protects meetings mirrored from a connected calendar.
- A booking in a mail's markup was put on the calendar on the strength of an unchecked From line. A stranger could add a "flight", and a forged airline address with a real booking number could cancel the real one. A booking is now added unasked only when your mail server vouches for the sender; otherwise it is offered on a card with Add to calendar. For IMAP accounts the server's verdict is believed when its name is on the same domain as the IMAP host.
- Mail in Spam or the bin is no longer read for events. A message moved back out is read then.
Mail styles no longer leak into replies and prints. Inline styles
are kept because in mail they are the design, and in the reading pane that is harmless: the message
is in a sandboxed frame. The quote in the reply composer and the print page are not framed, and a
mail using position: fixed could cover the composer with content of its own. Such declarations are
now removed, and both places clip what is inside them. Also from #34.
The default TRUSTED_PROXIES is documented for what it is. It trusts every private address so a
reverse proxy works unconfigured, which also lets another machine on the LAN forge its address and
step around the per-address limits on login and booking. The per-account login limit is unaffected.
The default is unchanged — narrowing it blindly would break sign-in behind a proxy — and
Reverse proxy now says how and when to narrow it. Also from #34.
Reminders no longer reach Google readable. A calendar reminder pushed to an Android device through Firebase carried the event's title as plain JSON, although the code said it was encrypted — that was only true of browser push. Anything with content in it is now sealed to a key the app registers, the same way a browser's push is, and a device that has registered none is not sent it at all. State changes carry no content and are unchanged. The Android app needs the version that registers a key to show these reminders; older versions never showed them. Also from #34.
Nothing already on a calendar is removed or changed by upgrading. One new nullable column,
event_source_link.hold_reason. See
Invitations and events from mail.
Fixed: a fresh install could only be opened on the machine it runs on. The stock compose.yaml
served https://localhost and nothing else. Opened from another machine by address or hostname, the
browser reported an SSL protocol error, which unlike a certificate warning cannot be clicked
through, so a headless server could not be set up at all without first setting SERVER_NAME. The
default now answers on every name and address with the same self-signed certificate, and the
browser shows its usual warning. Live updates follow as well: the hub address is taken from the
public address chosen during setup, where the stock file used to pin it to localhost. An install
that sets SERVER_NAME or MERCURE_PUBLIC_URL itself is unchanged. See
Docker → Starting it.
Mark as spam button. It sits beside Delete: among a row's buttons, in an open conversation's toolbar and above a selection. On one conversation it opens a short menu — move it to Spam, or move it and also create a filter that sends everything from this sender, or from the sender's whole domain, to Spam from now on. The domain filter asks once more before it acts, and for a mail provider's domain such as gmail.com it is behind More options first. The filter is an ordinary one under Settings → Filters. Undo takes back the move and a filter the same click created. Until now the only ways to say "this is spam" were Move to and dragging onto the Spam row. See Mail → Mark as spam.
Filters can match a sender exactly. Two new conditions sit beside "From contains": From is
exactly, which matches the sender's whole address, and Sender's domain is, which matches
everything after the @. "From contains" looks for the text anywhere in the address or the display
name, so a filter for a@x.de also caught ba@x.de, and anybody could match it by putting that
address in their display name. The spam button's filters use the new conditions. Existing filters
are unchanged. JMAP clients get the same two as fromAddress and fromDomain in Email/query.
Message templates. Settings → Templates is a new section: write
a message once, file it in a folder, and insert it from a new button in the compose window. The
folder tree opens with one folder per mail account, and the account you are writing from has its
templates listed first. A template can hold variables — the recipient's first name, full name and
address, your own name and address, a signature, and dates with an offset and a format of their own
("14 days ago", written out long or as dd.MM.yyyy). Dates, sender and signature are filled in when
the template is inserted. Recipient variables wait for a recipient: they stay in the message as
placeholders until someone is in To, and Send asks first if one is still open. The compose window
can also save the message on screen as a new template. Two new tables, mail_template and
template_folder; nothing existing changes. Removing a mail account keeps its templates and moves
them to the top level. See Templates.
Templates are available to JMAP clients. A new capability, urn:plmail:params:jmap:templates,
with Template/get, Template/set, TemplateFolder/get, TemplateFolder/set and
Template/render. The first four read and write the same templates and folders as Settings →
Templates, through the same sanitiser and the same ownership rules. Template/render returns a
template ready to insert for the account being written from: dates, sender and signature filled in,
the recipient too when the client supplies one, and a list of the recipient variables that are still
open. The Session lists the variables and date formats an editor may offer. The apps do not use any
of this yet. See Client development → Templates.
A demo mailbox starts with templates. A visitor to a public demo gets six templates along with
the seeded mail and calendar: a quote follow-up, an invoice reminder, an out-of-office and others,
filed at the top level, under the demo account and in a folder inside it. They use every variable
between them. app:test:seed-demo seeds the same six. Nothing changes on a normal install.
Fixed: an emoji picked in a reply you had not clicked into yet did not appear. A reply opens with the cursor on its empty first line, and whatever a toolbar button inserted there — an emoji, an inline image, and now a template — went somewhere that is never drawn: the button seemed to do nothing, and the reply stayed empty. Typing was never affected, and neither was a message you had clicked into first. The insertion now lands on the line.
Usage figures for public demos. In demo mode the admin area has a new section, Demo visitors: how many demo sessions were started in the last hour, 24 hours, 7 days and 30 days, how many different visitors started them, the total so far and how many have a mailbox right now. No address is stored. What is kept per session is a keyed hash of the visitor's address with its last part cut off, and that hash is deleted after 30 days. The demo's privacy notice says so. None of this exists on a normal install: the section is not in the navigation, its address answers 404 and nothing is recorded. See Demo mode.
Fixed messages growing without end. Some mail sizes its outer block as "one screen tall". Inside plMail a message sits in a frame that is made as tall as its content, so the block grew with the frame, the frame grew to fit the block, and the message kept getting longer, pushing everything below it away. Sizes given in screen heights are now ignored inside a message, and a message whose content merely follows the frame is no longer resized again.
Inbox as one list, without tabs. Under Settings → General → Categories, Inbox tabs can
be switched to One list: no Primary, Social, Promotions and the rest, just the inbox, newest
first. Per person, and tabs stay on unless you change it. Mail is still filed in the background, so
switching back shows sorted tabs at once; if the assistant is what sorts your mail it keeps doing
so, and the card says that when the tabs are off. "Select all in this view" then means the whole
inbox. Apps can read the choice from the JMAP session as inboxTabs.
#29.
A round of first-impression fixes. All reported by @pmdevelopment in #37.
- A selection survives the pager. Conversations ticked on one page of a list stay selected on the next, the count and the actions follow, and one action covers all of them. Going to a different list starts again with nothing selected, and None in the select menu clears every page.
- A whole folder can be selected from the select menu. "All 195 in this view" was only offered after every row on the page had been ticked. Unticking a row afterwards now narrows the selection back to what is ticked; before, the action still ran on the whole view.
- Fixed: moving mail into a folder could fill the log with "duplicate key" errors. A sync that was running while mail was moved tried to store the moved messages a second time, and each one was logged as a message that could not be built. Nothing was lost or duplicated. The sync now notices that the folder already holds them.
- A paired device and a mail app are given the public address. The pairing code and the JMAP address under Settings → App passwords carried whatever address the browser was on, so a phone paired at home got a LAN address. Both now use the address from Admin → Public address, and fall back to the browser's only when none is set.
- Hiding quoted text gives the space back. The message kept the height it had with the quote shown.
- Your own sent mail shows its images without asking. Only mail in Sent: a message in the inbox that merely claims to be from you is still blocked, and so is anything in Spam.
- Placeholder text is lighter than what you type. In several themes an empty field and a filled one looked the same.
- The actions on a connected integration are buttons, lined up, where they were four small words on different baselines.
- Save signature, and four buttons in the composer, stay readable under the pointer. They went white on white in light themes.
- The Spam badge is muted. Still a count of unread, no longer in the accent colour.
- German: "vor 12s" in the admin area, where it said "12s her".
Fixed: copying an error's reference showed an empty box instead of a tick. The copy itself worked. Reported by @pmdevelopment in #33.
Before you upgrade
- One migration, applied on boot. It adds an empty table,
demo_visit, which only a demo instance ever writes to. - And one column,
user.category_tabs, true for everybody, so no inbox changes by upgrading.
v0.3.1 — 2026-10-08
Mail with no plain-text part is read for insights. Parcels, invoices, codes and the rest were looked for in a message's plain text only, and a shop's shipping notice is very often HTML and nothing else: the tracking number was on the page and plMail was reading an empty string. Such mail is now read as the text its HTML shows — without stylesheets, hidden Outlook blocks or the addresses behind links. A report of a missed insight carries that text too; for these mails it used to arrive empty.
- A date announced as the day the shop hands the parcel to the carrier ("Voraussichtliche Übergabe an den Versanddienstleister") is no longer taken for the delivery date.
- Mail already stored is not re-read on its own;
app:backfill insightsreads it again (the last 24 hours unless--sincesays otherwise).
A calendar file somebody mails you is offered, not added. Two changes that belong together:
- A file attached as an ordinary file is read. An appointment sent as
termin.icswas only recognised when the sender also declared it as calendar data. Recruiting and booking systems often attach it as a plain file, and then the mail offered a download and nothing else. It is now recognised by its name too. - Calendar data that does not invite you waits for a yes. A file with no organiser and no guests, or an invitation addressed to somebody else, used to go straight onto the calendar — which let anybody put things there by sending mail. The card above the message now asks: Add to calendar or No thanks. No reply is sent to anyone. Invitations addressed to you, bookings and your own events are unchanged, and events of this kind already on your calendar stay there.
app:backfill events picks up mail that arrived before this (the last 24 hours unless --since
says otherwise).
An Amazon order is on the radar from the moment it is placed. The parcel card appeared with the dispatch mail; the order confirmation before it ("Bestellt: …", "Ordered: …") was not read at all, although it already names the order and the day it is promised for. It now makes the card, with a new first stage, Ordered, and the later mails about the same order move that card on. German mails that promise a weekday ("Zustellung: Freitag") get their date as well.
Importing a large Gmail mailbox no longer runs the account out of quota. Google allows each Gmail account a fixed amount of work per minute, and since 0.3.0 an import fetched old mail fast enough to use all of it. The import itself recovered quietly; what failed was the regular check for new mail on the same account, with "Quota exceeded … Units per minute per user" in the log and new mail arriving late until the import was through. Fetching is now paced to stay well inside the allowance, so an import of a very large mailbox takes somewhat longer and new mail keeps arriving while it runs.
Admin → Performance no longer blames plMail for mail that was late before it got here. "How late mail arrives" measured from the date a message carries, which is the sender's clock: a newsletter dated half an hour before it was sent, or a first message held back by greylisting, was shown as plMail taking that long. It now measures from the moment your provider accepted the message. On the installation this was found on, an account showing "95% within 15 min" had in fact stored all of its inbox mail within a minute.
- Inbox mail and the rest are counted apart. Providers only announce mail that lands in the inbox; spam and filtered mail wait for the quarter-hourly check by design, and used to put a fifteen-minute tail on accounts whose push was working. They now have a column of their own.
- The slowest messages are listed, in a section you click open, one to a row: where each was filed, what brought it in (push, IDLE, the schedule, the sync button) and how long it had already been under way before your provider had it. No subject and no sender, like the rest of the page.
- What brought a message in is recorded from this version on; older mail shows a dash. The last week of mail is given its accept time in the background shortly after the update, so the panel may be sparse for a few minutes.
- This adds three columns and an index to the message table. On a very large mailbox, building the index makes this start-up take a little longer than usual.
A label name that differs from an existing one only in capitals is refused. "work" could be created beside "Work". Gmail treats the two as one label and rejected the second, but only later, in the background: the new label existed in plMail, could be put on mail, and never reached Gmail, with an error in the log and nothing on screen. plMail now refuses it when you create or rename the label, in the browser and from the apps, and says which label is in the way. Renaming a label in the browser had no check at all and could take another label's exact name; it has the same one now.
- If you already have such a pair, nothing is changed for you. Delete or rename the newer one; until you do, putting it on Gmail mail keeps failing in the background.
"Move to" is available to apps, not just the browser. The Android and iOS apps talk to plMail
over JMAP, which had no way to ask for a move: an app could only add one label and remove another
itself, which files mail in two places on a plain IMAP account and cannot take mail out of Trash or
Spam properly. Thread/set now accepts a moveTo instruction that runs the same code the browser's
button does. Nothing changes in the web interface. For client authors:
Building a client → Moving.
Admin → Performance: how long things take, in one place. A new section directly below System. System says whether plMail is working; this says how long it took.
- How late mail arrives. Per account, the time between the provider receiving a message and plMail storing it. Seconds means push is working; a typical delay of five minutes or more is highlighted, because that account is living on the quarter-hourly poll — which "works", so nothing else would tell you.
- What each worker has waiting. The queue numbers arranged by the process that handles them: how many jobs wait, how long the oldest has waited, how long the job in hand has run.
- New mail waiting for the assistant. The line under the hold switch in Admin → AI says "typically 6 s, 95% within 13 s" — which shows that one message took twice as long as the rest and nothing about why. Here each held message is a row: how long it was kept back, how long it queued before the assistant was asked, how long the model call took, and how much of that call the host spent loading the model. A message that was slow because the model had been unloaded says so.
- Model host. Moved here from Admin → AI, unchanged. The search index backfill stayed under AI, where its Start and Pause belong.
Rows name accounts; nothing about any mail's content is shown. See Administration → Performance.
Before you upgrade
- One migration, applied on boot. Three nullable columns on
message; no row is rewritten, so it is instant on a mailbox of any size. - Mail sorted before this release has no durations and shows a dash in the new table.
v0.3.0 — 2026-10-07
Adding an account no longer puts new mail behind hours of follow-up work. Everything plMail does to a message after fetching it — sorting by the assistant, event extraction, insight cards, read receipts — was queued beside the fetching, on the one worker that fetches mail. A first import of a large mailbox therefore queued that work for every message it brought in, oldest first, and new mail waited behind it; with the assistant sorting mail that was a model call per message and could be hours. Fetching mail is now the only thing that worker does.
- Follow-up work has its own queues and workers.
worker-livehandles mail that has just arrived on an account whose import is over, and nothing else, so new mail is never behind an import.worker-enrichhandles an import's recent mail and then, only when that is done, old mail.worker-releaseonly ends the wait described below. See Mail ingest → What follows a message. - Only mail from the last 30 days gets follow-up work as it is imported. Older mail is stored,
threaded, filed by the ordinary rules and run through your own rules, as before — but no events,
insight cards or receipt handling are derived from it.
app:backfill insightsandapp:backfill event-extractionread history if you want it. - Old mail is sorted by the assistant after the import, not during it — newest first, in small batches, in the background, and only once every account of yours has finished importing.
- New mail waits for the assistant before it appears in a tab. If you let the assistant sort your mail, a message used to appear where the rules put it and move a few seconds later when the assistant answered. It is now kept out of the inbox tabs for those seconds and appears once, in the right tab. The wait is capped — 60 seconds by default — and past the cap the mail is shown where the rules put it, and moved if the assistant answers later. Mail from a first import is never held.
- Admin → AI has a switch for that wait, and shows what it costs. Wait for the assistant before showing new mail in a tab turns it off, Wait at most sets the cap (2–300 seconds), and the line underneath reports how long mail was actually held over the last 7 days and how often the wait ran out. See Administration.
"Move to" files a conversation under a label in one step. There was a label button, which adds a label and leaves the mail where it is, and dragging onto a folder, which only works with a pointer and a sidebar in reach. Filing mail the way Gmail does — label it and get it out of the list you are in — took two actions. A folder button beside the label button now does it in one, for a selection in the list and for an open conversation: the label you pick goes on, and the label of the list you were looking at comes off. From the inbox that is "label and archive"; from a label it swaps one label for the other; from Trash or Spam it takes the mail out of the bin without putting it back in the inbox. Everything else the conversation wears stays.
- The picker offers the Inbox, your labels, Spam and Trash, and applies the one you click at once. The list you are already in, a label the conversation already has, and the bin for mail already in it are left off. See Mail → Move to.
- Archive, label changes and moves can be undone. Each shows an Undo for eight seconds, and it puts back exactly the labels every conversation had, rather than reversing the action — so an already-archived conversation that was ticked along with the rest stays archived. Dragging onto a folder gets the same Undo. An action over a whole view runs in the background and has none.
- Labelling a selection is one request, not one per conversation. The toolbar's label menu posted once for every ticked row; it posts once now, which is also why there is one toast and one Undo for the lot.
Fixed
- Signing in with a two-factor code could leave you on the code form, its button reading "Checking…" for good. The code had been accepted — reloading showed the inbox. While the code form is on screen the page behind it still fetches things, and each of those requests, turned away because the sign-in was not finished, was remembered as "where you were going". After a correct code you were sent to the last of them. When that was the request that refreshes the live-update cookie, which has no page to show, the browser stayed where it was. It happened on installations whose live updates were failing, because that is when the page keeps making that request. Only a page you were actually opening is remembered now, and a part of a page loaded in the background is not one.
- Starting plMail took minutes on a NAS with spinning disks. After "Statement statistics are enabled" the log went quiet for two minutes and more before the web server came up. At every start each container set access-control entries on its cache directory, which is part of the image — and changing anything about an image file makes Docker copy the whole file into the container and flush it to disk. Two thousand files, one synchronous write each, in every container at once. Nothing needed those entries: the image already makes the directory writable. They are no longer set. Only installs running as root were affected, which is why the TrueNAS catalogue app was not.
- And thirty seconds before that, on the compose files in this repository. The web server and
workers wait for the database to report healthy, Docker's first health probe comes one interval
after the container starts, and the interval was never set — so it was the default thirty seconds,
and sixty when Postgres was still replaying its log at the first look. It is five seconds now. If
you keep your own copy of
compose.yamlortruenas.compose.yaml, addinterval: 5sandretries: 30to thedatabasehealth check. - A busy Gmail or Outlook quota no longer raises an alarm, and no longer costs mail. When a provider was still rate-limiting after five retries, the job was logged at the highest level with a full stack trace and parked on the failed queue — whatever the job was. For a sync that only looks for what is new, that was noise: the next sync resumes from the same point. For a job fetching particular messages it was worse than it looked: nothing asks for those messages again, so they stayed missing until somebody retried the parked job by hand. A throttled sync is now dropped with one warning, and a throttled fetch is put back to try again a quarter of an hour later, up to ten times, before it is allowed to fail.
- A fresh install had no live updates until the container was next restarted. The inbox did not
refresh by itself and the browser console showed a blocked connection to
https://localhost. The address typed into the install form was saved, but the web server had started before it existed and kept telling pages to reach the live-update hub at the built-in default. The install form now ends by restarting the web server, on a page that waits until it is back and then opens the inbox. - The "plMail is restarting" page waits for the server instead of guessing. It used to reload after six seconds whether or not anything was listening yet, and on a slower machine that showed the browser's own connection error.
Before you upgrade
- One migration, applied on boot. It adds two columns to
messageand two toai_settings, and changes no existing row. - Holding new mail for the assistant is on by default, and applies only to people who sort by assistant. Switch it off under Admin → AI if your model host is slow.
- The worker container runs three more processes,
worker-live,worker-releaseandworker-enrich. Nothing to configure on the standard images. If you run your own consumers instead ofapp:work, addmessenger:consume enrich_live,messenger:consume releaseandmessenger:consume enrich enrich_backlog— without them nothing sorts mail, extracts events or indexes for search. - Jobs already queued at upgrade are worked through where they are, on
ingest, once.
v0.2.58 — 2026-10-04
A link wrapped around a table is a link again. Newsletters and job alerts build each card as one link around a table, so the whole card can be clicked. In plMail those cards went nowhere: the link was stored empty, with the card after it instead of inside it, and the same mail worked in every other client. The step that applies a mail's stylesheet read the body with an HTML 4 parser, which ends a link where a table begins. It reads it the way a browser does now.
- Mail already in the mailbox is repaired on request.
php bin/console app:backfill safe-html-table-linksgoes through stored mail with a table after a link and writes the body again from the sender's original. Nothing is fetched from a mail server. - Two smaller things follow from the same change. Bold and italic closed in the wrong order are
put right the way a browser would, and Outlook's
<o:p>tags no longer leave empty paragraphs in a message.
Before you upgrade
- Nothing has to change. There is no migration. Run the command above once if older mail should get its links back; new mail is right from the start.
v0.2.57 — 2026-10-04
The image runs as a user that is not root. Nothing in plMail needs root while it runs, but the image assumed it: started as another user with capabilities dropped, the web server refused to execute, PHP could not write its cache, and the hub could not open its database. All three are fixed in the image, so a platform that picks the user itself can run it. The TrueNAS catalogue app does, as uid 568 by default with no capabilities at all.
Before you upgrade
- Nothing has to change. plMail's own compose files still run it as root, and behave as before.
- To run it as another user yourself, set
user:on the plMail services and make the mounted directories belong to that user. Volumes created while it ran as root stay root's; they are not re-owned for you.
v0.2.56 — 2026-10-03
"Restart now" restarts. The button in Admin → System, and the one Admin → Address offers after a change, stopped PHP and then hung: the web server waits for every open connection before it exits, and the live-update stream it proxies never closes by itself. With one plMail tab open anywhere the container neither served nor exited, so nothing could bring it back, and on TrueNAS the app sat in "deploying" until it was stopped by hand. It closes what is still open after five seconds now, as the hub already did.
- Live updates work on plain HTTP without being told. The cookie that lets a browser subscribe
was named
__Secure-…unlessMERCURE_COOKIE_NAMEsaid otherwise, and a browser drops such a cookie over HTTP: an install opened onhttp://ip:porthad a red stream indicator for good. The name follows the public address now, prefixed when it is https and bare when it is not. SettingMERCURE_COOKIE_NAMEstill wins. - A database password with special characters no longer stops plMail starting. A
DATABASE_URLcarrying percent-encoded characters crashed every process at boot, and one withoutserverVersiondid too. Both are read as written now, with PostgreSQL 18 assumed when the URL does not say. APP_STORAGE_DIRmay be an absolute path inside the project, such as/app/var/data. It means the same asvar/data, and files already stored are found either way.MERCURE_UPSTREAMnames the container the web server proxies the hub to, for a stack that does not call itmercure.- No database password is generated for a database plMail did not set up. With a
DATABASE_URLthat carries its own password, the secrets directory no longer gains aPOSTGRES_PASSWORDand apostgres_passwordfile that nothing reads.
Before you upgrade
- Nothing has to change. There is no migration and the compose files stay as they are.
- If you set
MERCURE_COOKIE_NAMEto get live updates over HTTP, you can remove it. Leaving it is harmless.
v0.2.55 — 2026-10-02
plMail's icon is the wax seal. The pl mark pressed into red wax is what a new account starts on,
what the sign-in page and every page without a user wear, and what the installed web app and the
files under /icons/ show. The mark is still there, as "Classic" under Settings → Appearance → Logo,
with all its colourways.
- Accounts that never chose a logo move to the seal. That is read off the account: the pl mark, still in berry, still following the theme. Anyone who picked another icon or colourway, or unlinked the mark from the theme, keeps exactly what they have. An account that looked at the icons and deliberately stayed on the default mark cannot be told apart from one that never looked, so it moves too; Classic is one click away.
- The seal arrives in its own colours. A new account's icon no longer takes the theme's colourway unless you ask it to, under Colour → Follow the theme.
Before you upgrade
- One migration, applied on start. It changes two column defaults and the accounts described above. It can be reverted, but reverting restores the defaults only: accounts already moved to the seal stay on it.
- The Android app still shows its own icon. Nothing changes there with this release.
v0.2.54 — 2026-10-02
The public address can be changed after setup. The setup screen asked for it once and nothing
could answer it again. An install restored from another machine's backup therefore kept that
machine's address: browsers were sent there for live updates, the stream indicator stayed red, and
the only remedy was APP_PUBLIC_URL in the compose file. Admin → Address changes it now. The new
address is saved at once and takes effect when the containers start again; the page offers the web
container's restart, which is enough for live updates, and says to restart the whole stack so the
workers register push at the new address too. When the saved address and the one in use still differ
after a restart, APP_PUBLIC_URL is set in the compose file, and the page says so.
- A hub address that only mirrored the old public address goes with it. A restored backup can
carry
MERCURE_PUBLIC_URLas the old address with the hub's path on it. Saving a new public address drops that entry, so the hub address is derived from the new one; a hub address pointing anywhere else is kept. - The TrueNAS compose file names the right Postgres user.
postgres:18-alpineruns as uid 70, and 999 is the Debian image's.truenas.compose.yamlchowned the database directory to 999 and the platform notes said the same. Installs worked regardless, because Postgres takes ownership of its directory itself on start.
Before you upgrade
- Nothing has to change. There is no migration. An existing TrueNAS install can keep its compose file; the corrected uid matters only to a copy made from it.
v0.2.53 — 2026-09-29
Answering an invitation puts the meeting in the calendar where you can see it. Yes or Maybe used to redraw the card above the message and put a toast in the far corner, while the calendar beside it — the one place the answer changed anything — went on showing the week without the meeting until the page was reloaded. The calendar is redrawn in place as you answer now, and the meeting is ringed for a moment where it landed. Pointing at Yes or Maybe first outlines the slot it will take, dashed, in its calendar's colour. With motion at Full the answer flies there and turns into the meeting on the way, or onto the calendar button in the top bar when the calendar is closed; at Minimal and None, and whenever your system asks for reduced motion, nothing travels and the outline is the whole of it.
- A meeting you answered Maybe is striped. It was drawn exactly like a Yes, so a week of tentative meetings looked settled. It is striped in its calendar's colour with a dashed edge now, in every view, and a screen reader hears "Maybe" in its name.
- Every theme's text holds 4.5:1, on the page as well as on a pane: the faintest ink, the status colours in badges and alerts, the accent where it is text, and the links and status colours on the message sheet. Most themes fell short somewhere; the worst was Paper's warning, at 2.70:1 inside its own badge.
- Checkboxes, icons and accent buttons follow the theme. Checkboxes were ticked in the browser's own blue whatever the accent, some icons were fixed greys, and accent buttons wrote white on accents too light to carry it. The strip explaining why a PDF cannot be signed gets the warning tint it was meant to have.
- The sidebar and the calendar switch no longer fade in on every load. The open row faded in from black on every page, and the calendar switch in the top bar flickered from the mail icon to the one you had chosen. Both are drawn right the first time.
- Mail about your own application or account is an update, not a promotion. The categoriser had started filing replies to job applications, and account-activation mail, under Promotions.
Before you upgrade
- Nothing has to change. There is no migration, and the compose files stay as they are.
v0.2.52 — 2026-09-28
plMail starts in seconds instead of a minute. Every plMail container set file permissions on
everything under var/ as it started — which on TrueNAS is the whole data dataset: the database
files, one file per stored message and every attachment. On a real mailbox that took most of a minute
before the web server even began, 58 of 61 seconds on one measured restart, and every worker walked
the same files at the same moment. The permissions were for a user nothing in plMail runs as, and the
TrueNAS dataset refuses them anyway. They are set on the cache and log directories alone now.
- The JWT keypair is kept with the other generated secrets. On TrueNAS it was written to each
container's own throwaway filesystem, so every service made a new one on every start — "Generated
the JWT keypair." in the log. Nothing signs in with these tokens yet (the Android app uses an app
password), but a token one service signed would have been refused by the others. It is created
once now, in a
jwt/directory next to the secrets file, and a data reset that rotates the secrets removes it from there.
Before you upgrade
- Nothing has to change. There is no migration, and the compose files stay as they are. On TrueNAS the keypair is generated one last time on the first start, where it then stays.
v0.2.51 — 2026-09-28
Hiding a system label in settings takes it out of the sidebar. The eye beside Inbox, Sent, Drafts, Trash or Archive in Settings → Labels saved the change and greyed the label out in the list, and the sidebar went on showing the row — only Spam ever asked. Every system row answers to its eye now, and the sidebar beside the list changes as you click instead of on the next page load. The "More" entry goes away when everything in it is switched off.
- Unread, one list of every unread conversation. A new sidebar entry, off until you switch it on under Settings → Labels. It gathers unread mail from every folder — archived and labelled conversations included, without the inbox tabs — and leaves out the bin, spam and anything snoozed. The web interface only; the Android app does not list it.
- Starred and Unread take drops. Dragging a conversation onto Starred stars it, onto Unread marks it unread, and either way it stays where it was — several at once if they are ticked. One that is already starred stays starred.
- Archiving a Gmail conversation on the phone no longer logs an error. The app puts plMail's Archive label on a conversation that was only in the inbox, and the push asked Gmail to create a label called Archive, which Gmail refuses as "Invalid label name". The archive itself always reached Gmail. Snoozing a Gmail conversation went the same way; plMail's own system labels are never created at Gmail now.
Before you upgrade
- Nothing has to change. There is no migration, and the compose files stay as they are.
- Archive may leave your sidebar. An Archive label that already exists was created hidden, which the sidebar ignored until now. If you use it, switch it on under Settings → Labels; Archive labels created from now on start visible.
v0.2.50 — 2026-09-25
An event put on Google or Outlook no longer comes back as a second one. Ticking a Google or Microsoft calendar on an event, or creating one there from plMail, pushed it to the provider, and the same sync read it straight back. Both give an event plMail creates an id and a UID of their own, and the sync looked for it before it had saved that id — so it found nothing and stored the event again. The calendar then showed it twice: a merged chip in two colours, and a second chip beside it that nothing merged. The push is saved before the sync reads anything now. This applied to every way an event is created, from the editor to the Android app and booking pages; CalDAV calendars keep plMail's UID and were not affected.
- A pair that already exists folds back into one the next time the provider reports that event, for instance after an edit on either side. The copy under the provider's UID is kept and the plMail original follows it, so the two show as one again. Left as a pair, a later edit at the provider could have stopped that calendar syncing altogether.
- An event of a day or more is one bar. A trip from Friday evening to Sunday was a block down each of its three days, and pointing at one lifted that day alone — Saturday's titled at a midnight nobody had scrolled to. It is one bar in the all-day row now, "17:00 Bodensee … until Sun 21:00", and the hours it takes are shaded in the grid underneath, with the day's other events on top at full width. Pointing at the bar or at any of its days lights all of them. An event that crosses midnight but lasts less than a day stays in the grid, and its two halves now light together too.
- An all-day event across several days is one bar as well, instead of a chip on each day, and the all-day row's entries are tinted in their calendar's colour like the events below them.
- A short meeting's title no longer runs over its time in a narrow week, such as the calendar docked beside the mail.
Before you upgrade
- Nothing has to change. There is no migration, and the compose files stay as they are.
- Don't delete either chip of a doubled event. Both point at the same event at Google or Microsoft, so deleting either one removes it there too. After the upgrade the pair folds into one by itself when the event next changes.
v0.2.49 — 2026-09-25
A summary no longer says the connection was lost. Every summary that took more than ten seconds to start writing ended with "The connection to plMail was lost", ten seconds in, while the server went on to finish and store it: reload the thread and there it was. The connection was fine. While the model thinks, the server sends a heartbeat, and the first one made the page's own script fail, which the page took for a lost connection. The heartbeat is read properly now, and anything from the server the page cannot handle is skipped and reported in the admin panel's browser errors instead of ending the summary.
- Stop stops. A summary whose connection drops is finished and stored anyway, and to the server Stop looked exactly like a dropped connection: the model ran to the end, and the summary was stored under a card that read "Stopped. Nothing was saved." Stop now tells the server, which ends the call to the model host and keeps nothing. Opening another thread or closing the tab still lets a summary finish.
- Deleting mail works on GMX. Moving mail into a folder whose name is not plain ASCII, like
GMX's trash, "Gelöscht", failed every time with
moveMessage(): Argument #1 ($folder) must be of type string, null given, and emptying such a folder never removed anything on the server. plMail finds those folders by the name the server lists them under now. - The calendar's week view starts today, and is called "7 days". A Monday-to-Sunday week spent more of its columns on days that were over as the week went on, and in the docked pane, where seven columns do not fit, it pushed today out towards the edge. It now shows seven days from today, or from the day you moved to, and Previous and Next still step a week. Bookmarks and remembered views keep working. Month still starts on Monday, and a shared calendar's public page still shows a real week.
- Replies to GitHub notifications reach GitHub. GitHub's reply addresses run to about ninety characters before the @, and plMail refused anything over sixty-four, so a reply went to notifications@github.com, which accepts nothing, or opened with nobody to send to. Long addresses also stay inside the message details panel now instead of running past its edge, and Reply-To there has a copy button.
- A database that starts a few seconds late no longer shows up as an error. Every container
waits for the database when it starts, trying once a second for a minute. Each miss was logged as
CRITICAL, and one that fell just before the database answered could reach the admin log and
outline the admin's user menu:
Error thrown while running command "dbal:run-sql -q 'SELECT 1'". A miss is retried quietly now, and only a database that never answers is logged.
Before you upgrade
- Nothing has to change. There is no migration, and the compose files stay as they are.
- Mail you deleted on GMX before this is still there. Those moves, and any emptying of a folder with an umlaut in its name, never happened on the server, and plMail does not repeat them. Delete or empty them again.
v0.2.48 — 2026-09-24
One unreadable mail no longer stops a folder. plMail saves new mail fifty messages at a time,
and the database refuses text that is not valid UTF-8. Two kinds of mail that older German mail
systems still send got through unconverted: an attachment named in the old Windows character set,
"Erklärung.pdf" without any encoding, and a message that says it is UTF-8 while carrying a Windows
"€". Either one got its whole batch of fifty refused, and because the next sync fetched the same
fifty, the folder stopped there for good. The log showed invalid byte sequence for encoding "UTF8". Both are converted now. And when the database refuses a batch for any other reason, plMail
saves its messages one at a time: everything it takes is stored, and only the message it refuses is
held back, tried again on the next syncs, and let go after five.
- Umlauts survive a stray character. A UTF-8 mail with one Windows "€" in it was read entirely in the Windows character set to rescue that one character, and every "ü" in it came out as "ü". Only the character that needs converting is converted now.
- An invisible NUL character no longer gets a mail refused. The database will not store one in text, so it is left out.
Before you upgrade
- Nothing has to change. There is no migration, and the compose files stay as they are.
- A stalled folder catches up by itself. A refused batch was never saved, so the first sync after the upgrade fetches it again and stores it. Nothing needs re-syncing by hand.
v0.2.47 — 2026-09-24
A first sync no longer stops on your parcels. plMail reads new mail fifty messages at a time and turns what it recognises (a parcel, an invoice, a login code) into a card on the radar. When two mails in the same fifty were about the same thing, say the shipping and the delivery notice for one parcel, each made a card of its own, and saving them broke the rule of one card per thing. That failed save took the rest of the sync with it: every message in the next fifty was logged as "Failed to build message; it is asked for again next sync", with "The EntityManager is closed" as the reason, and the folder stopped until the next sync. A fresh install reads thousands of messages in its first sync, so it ran into this within minutes. The two mails now share one card, which follows the newer mail, and a card that cannot be saved no longer stops the mail behind it.
- One folder's failure stays in that folder. When a folder failed to sync, the folders after it in the same account failed too, and each of their messages was blamed for it. They sync as usual now.
- Every folder remembers its last check. When plMail checks a folder against the server, it notes when it did and the folder's UIDVALIDITY, the number a server changes when it rebuilds a folder. In a sync of a whole account, every folder after the first one with new mail lost those notes, so a folder rebuilt on the server was not recognised as rebuilt. Every folder keeps them now.
- A fresh install stops logging a warning every second. Until someone pressed Restart workers
in the admin panel, the worker logged
Failed to fetch key "workers.restart_requested_timestamp"about once a second, because the table that signal is kept in did not exist yet. It is created when the containers start now, and an existing install gets it on its next start.
Before you upgrade
- Nothing has to change. There is no migration, and the compose files stay as they are.
- No mail went missing. A batch that failed was fetched again on the next sync, and no message
was given up on. The cards of the failed batches were lost, though. If your worker log shows "The
EntityManager is closed", run
docker compose exec php php bin/console app:backfill insightsonce after upgrading: it reads your stored mail again and brings them back.
v0.2.46 — 2026-09-24
Three containers instead of nine. Everything plMail runs besides the web server now runs in one
worker container: the IMAP supervisor, the four queue workers, the scheduler and the Mercure hub.
Each is still its own process, kept running by app:work, so a send still never waits behind a
sync. The stack is php, worker and database, plus secrets-init, which runs once at startup
and exits. Every process keeps the name its container had, so the admin dashboard, /healthz and
the log filters read as before.
- The hub is part of plMail's image now. The official Mercure 1.x hub ships inside the image and
runs in the worker, which answers to
mercureon the network, so nothing that talks to the hub changes. It is not FrankenPHP's built-in hub: that one is still a 0.x release, and cannot read the tokens plMail has used since v0.2.31. - A plain-HTTP install sets
MERCURE_COOKIE_NAMEonce, for the app and the hub both. - Stopping no longer waits on open browser tabs. The hub gives live-update connections five seconds to close, then closes them, and browsers reconnect by themselves.
Before you upgrade
- Nothing has to change. The new image still runs the older compose files, with one container
per process and the separate
dunglas/mercurehub, exactly as before. - To move to three containers, replace your compose file with the current
compose.yaml. On TrueNAS, paste the currenttruenas.compose.yamlover the app's YAML and put your values back into itsx-configblock. The hub keeps its history, because the worker mounts the samemercure_datavolume. - If you maintain your own compose, the worker needs
init: true,stop_grace_period: 30sand the network aliasmercure. Seecompose.yaml.
v0.2.45 — 2026-09-24
plMail tells you when there is a newer build. Admin → Updates checks every hour whether the channel you follow has published one: Releases, every tagged release, or Main, every build of the main branch. The administrators hear about each new build once, with a notification on every device they have registered for push and a note at the top of the admin area. The page lists what changed since the build you run and how to update. Until an administrator picks a channel, an installation follows the one its build came from, and Off sends nothing at all.
- Only what a pull would bring. A check reads the channel's newest image from the registry, so a commit that builds no image is never announced, and it asks GitHub how that image relates to yours: a main build following releases is not told to go back to an older release.
- Forks check their own. Images now carry where they are published (
APP_IMAGE), so a fork that publishes its own image checks that one.
Before you upgrade
- One migration runs on boot and adds a table for the update channel and its last check. Additive; nothing else changes. The first check runs within the hour after the upgrade.
v0.2.44 — 2026-09-24
The calendar opens beside your mail. On a desktop the calendar now starts open next to the message list, so mail and diary are side by side from the first visit, demo visitors included. If you never moved the calendar switch, you will see it open after this update. Close it and it stays closed; a phone still opens on the mail.
- Instant delivery stops raising false alarms. "Instant delivery is registered but not arriving" kept appearing for Gmail accounts whose push was working. plMail asks Gmail to announce changes to the inbox only, but counted every change in the mailbox against it: the copy of a message you sent, an archived thread, mail your filters keep out of the inbox. Only inbox changes count now. "Turn instant delivery back on" also clears the card at once, instead of leaving it up until the next mail arrived.
- A phone no longer opens on an empty page when the calendar was left open on a desktop. The mail is there from the first frame.
- The docked calendar's date fits. In the agenda and day views it read "Sep 24, 20…"; it drops the year while it is this one.
v0.2.43 — 2026-09-24
The tab icon shows the icon you picked. Picking an icon under Settings → Appearance → Logo pointed the tab at it straight away, but the tab icon was drawn from your saved setting, and the save lands a moment later. So the tab got the icon you had before, and the browser kept it for a week: every icon except Classic showed up as the one picked before it. The tab icon is now drawn from its own address, so there is nothing for the save to race. A browser that kept a wrong one needs no clearing either, because the icons live at new addresses now.
- The top bar's icon stands on its own. The rounded tile around it is the icon as a phone's home screen shows it, and in the corner of a web page it read as an app icon. The @-Horn keeps its tile, because its cream @ needs the coloured field behind it. Every icon in the top bar is a little larger now, the pl mark included, so switching between them does not change the size.
- Dark themes keep it in sight. On a dark top bar the horn switches to its colourway's colours for dark themes, as the pl mark always has, and the mailbox post turns grey.
v0.2.42 — 2026-09-23
Pick an icon, then its colours. Settings → Appearance → Logo has two steps now. First the icon: the pl mark, or one of nine new ones — the blue horn (yes, that one), an @ that turns into a posthorn, a love letter, airmail, a happy envelope, snail mail, a mailbox with its flag up, a postage stamp and a wax seal. Then its colours: every new icon starts in its own, and any of the thirty-two colourways can repaint it. Each icon decides which of its parts take a colourway, so the love letter's heart stays red, the airmail stripes stay red and blue and the snail stays green.
- The top bar, the tab and the phone all wear it. The web draws every icon in every colourway when it is asked for — nothing is stored per combination — and the Android app (0.0.25) builds its launcher icons when it is compiled.
- For JMAP clients:
AppearancereportslogoMotifandlogoPaint, both read-only, and the Session listslogoMotifs.logoStylemeans what it always did, for clients that predate this.
Before you upgrade
- One migration runs on boot and adds two columns to the appearance settings. Nothing changes for anybody until they pick a new icon: everyone keeps the pl mark they had.
v0.2.41 — 2026-09-23
A release made mostly of a code review's findings: seven reviewers over the whole codebase, every finding traced in the code before it was fixed.
Before you upgrade
- Web Push to a LAN or Tailscale ntfy needs
PUSH_ALLOWED_HOSTS. Push endpoints on private addresses are refused now (they were a way to make the server POST into your network). If your phones get notifications through the bundled ntfy on a private address, list its host there — otherwise notifications stop after the upgrade. Integrations on the LAN keep usingINTEGRATIONS_ALLOWED_HOSTS, which is now checked on every redirect hop too. - Signing out is a POST. A page or script that linked to
/logouthas to submit the form in the user menu instead; a plain link no longer signs anyone out. - Eleven migrations run on boot: indexes for the mail list, sidebar counts and JMAP queries, a few bookkeeping columns, and a trigger that orders JMAP change states. None rewrites existing data.
Fixed — mail
- Mail could be sent up to six times when saving the Sent copy failed after the server had accepted it. The send is recorded the moment it is accepted.
- Bcc recipients received nothing from Gmail and Microsoft accounts.
- A folder missing from one folder listing deleted all of its local mail. An empty or much-shrunk listing is refused, a folder must be gone for three syncs and a day before it goes, and a rename keeps its mail. Folders with the same name in different places ("Archive/Invoices", "Projects/Invoices") were opened as the same folder; they are opened by their full path now.
- Cancelling a scheduled send and scheduling it again sent it at the old time.
- Gmail sync skipped a new message whenever an older one was deleted in the same batch, and label changes on Gmail mail filed under a Gmailify account never reached Gmail.
- A message that failed to import — a broken
Date:header was enough — was lost for good. - Sent mail was never filed on servers whose Sent folder is called "Gesendet" or "Sent Items"; the server's own folder flags are read first now.
- Replies ignore Reply-To no longer, and replying from Sent goes to the recipients, not to you.
- Bulk snooze from the toolbar never worked.
Fixed — calendar
- Editing or dragging a recurring event flattened its rule ("every other Monday and Wednesday, ten times" became "weekly, forever") and pushed that to Google, Microsoft or CalDAV.
- An invitation for one occurrence changed the whole series; a cancelled occurrence cancelled every one.
- A conflicting edit stopped a calendar from syncing; moving a series brought back its cancelled occurrences; reminders and exceptions removed on the phone stayed here; long-lead alerts were skipped on busy installs; booking pages dropped their last day; all-day exceptions were exported as times; shared feeds changed every event's UID nightly; exports now define their time zones.
Security
- Live updates were readable by any signed-in user on the same install; they are private now.
- A crafted mail could get script run in the app through the compose window; only drafts can be opened there and their HTML is cleaned on the way in, and attachments are never served as something a browser would run.
- The two-factor code limit was per session, so a stolen password gave unlimited guesses.
- Integration, calendar and push URLs could reach internal addresses, including through redirects.
- CSRF checks on the POSTs that lacked them, a per-account login throttle, and safer mail-server host handling (connection-test errors no longer echo the server's replies).
Changed
- A request that fails says so in a toast and leaves the page as it was, instead of drawing the error page into it, with a reference to find it by in Admin → Logs → Reference, and a copy button for it. Clicks that used to fail silently — star, archive, labels, drag and drop, reorders, appearance — now say so and leave things as they were.
- The mail list and the thread view make far fewer and lighter queries; JMAP
Email/querypages in the database instead of in PHP. - Pirate English is complete again (205 strings), and a test keeps the three languages in step.
v0.2.40 — 2026-09-23
Added
-
A date-and-time picker of plMail's own. The event editor used the browser's date-and-time fields, which write the time in the browser's language whatever plMail is set to: "03:00 PM" for someone who chose the 24-hour clock. In their place, two cards say when an event starts and ends, and one panel under them sets whichever is chosen — six days (a month with arrows behind the calendar button), then the hour, then the minute, with every five minutes and an exact field behind "···". Picking the start's quarter hour moves on to the end, which offers the usual lengths and greys out anything before the start; a later day makes it a multi-day event. The 12-hour clock gets 12, 1 … 11 and am/pm, and all day hides the times.
The same picker sets a custom send later time, with what is outside the hold greyed out, and a booking page's daily hours.
Fixed
- Picking a PDF as a profile picture no longer ends on an error page. A picture picked from a connected service was judged by the label the service gave the download rather than by what it was, and the refusal was a server error. The file's own contents decide now, only the four kinds of picture plMail keeps are offered, and a refusal is said under the picture field.
- A profile save that is refused keeps the app around it. It used to come back as the bare profile card, with no sidebar and no way on but the browser's back button.
- The clock placement survives a config backup. Added in v0.2.39 and left out of the backup, so a restore put it back to the default.
- Clicking under the event editor's times no longer ticks "All day". Its label spanned the whole row.
v0.2.39 — 2026-09-23
Added
-
The time, while you read your mail. A new choice under Settings → General → Clock, Show the time, puts a clock that moves on by itself somewhere you can see it:
- On the Happening Soon button, the default. The button now shows the time on every page rather than only when something is coming up — the next thing's icon when there is one, a clock when there is not — and the panel it opens starts with the time and today's date.
- Under the plMail name, as a quiet second line: weekday and time.
- At the bottom of the sidebar: a larger time, the date and the next thing coming up, which opens Happening Soon.
- Nowhere, which is the topbar exactly as it was.
It follows your timezone and 12- or 24-hour choice, is right on the first paint rather than filled in afterwards, and stays right in a tab that has been in the background. On a phone the topbar leaves the time off, since the phone already shows it.
v0.2.38 — 2026-09-23
Fixed
-
Hovering a month day enlarges it where it is, and nothing inside it moves. In the top rows a hovered day widened in place; in the bottom two it grew upwards, and every chip in it slid up under the pointer. Days by the right-hand edge could also grow off the grid, and pointing at one chip unwrapped its title and pushed the chips below it down.
The whole cell is now what opens — date, add button and chips together, on an opaque card over the neighbouring days. Its top edge never moves: it grows down to the bottom of the grid and scrolls from there, so the last row only widens. It grows to the right wherever there is room and to the left where there is not, which at a normal window size means Sunday alone. Titles stay one line; the wider card is what makes them readable.
Changed
- No tooltips on calendar events. Every view already shows an event's full title on hover — the month cell enlarges, a week or day block opens to its content, an agenda row unwraps — so the browser's tooltip only repeated it in a second box under the pointer. Screen readers still hear the same text, including the calendars a merged event is on.
v0.2.37 — 2026-09-23
Fixed
-
The highlight in a search result is now where you can see it. Reported as highlighting that did not work on LinkedIn mail. It did work: the mark was four hundred and sixty-eight characters past the right-hand edge of the row.
A snippet is a fragment Postgres cuts around the match, and it was asked for twenty-four words on the understanding that this bounded its length. It counts tokens, and one tracking link —
trk=…&otpToken=…— is a single token of some four hundred characters. Measured against the mail in question, ten job listings each carrying one: the fragment came back 1148 characters long with the match at 568, while a row shows about a hundred. The snippet on screen began at the fifth listing, which is why it read as an ordinary preview rather than a broken one.The fragment is now cut by characters around its match, the same way the other half of this feature always has been — the two produced visibly different things in one list, and only one of them was bounded.
v0.2.36 — 2026-09-22
Changed
-
A JMAP client gets the same marks the web page does. v0.2.35 taught the web search to mark a term buried inside a link or a hostname — Postgres reads each of those as one indivisible word, so the highlighter could not see the term inside it, while the search had found the row anyway through the index that keeps the pieces. Every other client got a blank where the explanation should be: told a message matched, and handed nothing to say why.
SearchSnippet/getnow marks those too, through the same one piece of code and the same one place that turns a mark into HTML after escaping it. Nothing new produces a<mark>, which was the condition on doing this at all.It was left undone in v0.2.35 because the web page has the message text in hand already and this request does not, and fetching up to five hundred bodies a second time is a bill worth reading before signing. Read: on a request for five hundred snippets the fetch adds 1.5ms to about 197ms — under one percent — because the field that usually needs a second look is the SUBJECT, and five hundred subjects weigh thirty kilobytes. Bodies are fetched only for the rows whose body actually needs one, and a request where nothing needs a second look makes no second query at all. Even the worst case, every one of the five hundred needing its whole body, is four percent on top of the work the request was already doing to read those same bodies.
v0.2.35 — 2026-09-22
Changed
-
A search result marks the term even where it is buried inside something bigger. Searching a company name highlighted one row and left a dozen equally-good ones plain, which reads as the highlighting being broken rather than as the rule it was. Postgres makes a whole URL one lexeme and
chargecloud.deanother, so neither matches the word inside it — while the search itself had found those rows anyway, through the index that keeps the pieces of compound tokens and hostnames. The row matched on a piece, and a piece is exactly what the highlighter could not see.Where the highlighter comes back with nothing and the term is in the text regardless, the fragment is now built from the first occurrence, with every occurrence inside the window marked rather than only the first. It costs no query — the text is already in hand — and it is escaped by the same one place that escapes the rest.
-
The preview starts where the message starts. Bulk mail opens with "View in browser", a logo, a nav bar, an unsubscribe line and a greeting, and the preview spent its two hundred characters on them — so a list of newsletters previewed as a column of the one phrase every row had in common.
It now begins at the first block long enough to be prose and containing a space. Nothing is recognised and there is no list of known-bad sentences, which is deliberate: such a list is wrong in every language nobody thought of. A logo's alt text and a nav item fail on length in any language, a tracking URL on its own line fails for want of a space, and a message too short to clear the bar at all — "Sounds good, see you then" — is shown whole, exactly as before.
Fixed
-
Opening a folder from Admin, Calendar or Settings gives you the whole app back again. Since v0.2.34, clicking Inbox in the sidebar of one of those three pages could land you on a bare message list: the right address, and nothing else on screen — no sidebar, no topbar, no toolbar.
The sidebar is one file shared by four pages, and only the mail layout contains the frame the message list lives in. v0.2.34 taught the folder rows to swap just that frame instead of fetching a whole page, which is a real saving — 21–24 database queries a navigation down to 9–13 — and wrote the instruction into the shared file, where it was true for the mail page and false for the other three. Those rows went on asking for a frame that was not there.
What made it slip through is that asking for a missing frame looks harmless, and usually is. Turbo notices the frame is absent and loads the whole page instead — unless you HOVERED the row on the way to clicking it. A hover starts a prefetch, the prefetch asks for the frame without checking that one exists, the server answers with the stripped-down list, and the click then uses that waiting answer rather than asking again. So the same row worked or broke depending on how the mouse arrived at it, which is why it survived a release: click it directly and it is fine.
The rows no longer name the frame at all. They carry a marker, and the page points them at the list only after finding the list on itself — so a row can no longer claim something its page does not have, and nothing about the arrangement has to be remembered by whoever adds the next row. Folder clicks on a mail page still swap the frame alone, and the saving is unchanged.
Two smaller things came out of the same investigation. An account's folder rows had the same defect and could not have been fixed by reading the template: they are fetched separately and never learn which page they are on. And mail list URLs now declare which request headers choose between their several forms, which they should always have done — without it a browser is free to keep one form and hand it back in place of another, producing this same stripped page by a route that has nothing to do with hovering.
-
A label renamed in one tab now reaches the others while you are looking at them. Creating, renaming, deleting or hiding a label refreshes three regions — the sidebar, the mobile drawer and the settings list — and until now that refresh went back to exactly one tab: the one that had pressed the button. A second tab, or the same mailbox open on a laptop, went on showing the old name.
It used to be covered by accident. Before v0.2.34 every folder click fetched a whole document, so the sidebar was rebuilt on each one and a rename made elsewhere arrived on the next click. That rebuild cost about twelve database queries per navigation and is what v0.2.34 removed, naming the price at the time: the rename now waits for the next sync. On an idle mailbox that is minutes, and on an installation with no accounts configured there is no sync to wait for and it was never.
The lists now go out on the user's live-updates topic as well, and every tab applies them at once. They travel already rendered, because the tab that made the change had just built them to answer itself — a "go and look" message would instead have every other tab re-ask the server for a label tree that was already in hand, which is the same dozen queries per tab that navigation had just stopped paying. Nothing extra is read to send them: measured at 20 database statements for a rename, with and without.
The toast stays behind. "Label updated." is for whoever pressed the button, and the copy on the wire is the lists alone — so no other tab announces something that did not happen there, and the one that did is not told twice.
Unchanged for anyone running without a Mercure hub, which is supported: a hub that refuses is logged and the rename commits regardless. Still waiting for the next sync: a label renamed through JMAP by a connected client rather than in a browser.
v0.2.34 — 2026-09-22
Added
-
A search result now shows why it matched. Every occurrence of the search term is marked in the subject and in the preview, and the preview is a window of the message around the match instead of its opening hundred characters — which, for most hits, did not contain the term at all. The row asserted a match and then showed something else, so the only way to find out why a conversation was in the list was to open it.
The fragment comes from the same Postgres
ts_headlinethe JMAP snippet has always used, over the same query that found the mail, so the marking is stemmed exactly as the search is and cannot highlight something the search did not match on. One batched query per account per page; a row whose match is not showable — the sender's name, a meaning match, an operator-only query — keeps the preview it has always had.
Changed
-
Every mail list page costs five fewer database round trips. Inbox, archive, starred, a label, an account and search each render the whole document, sidebar and topbar included, and five of the statements that went out were the same question asked more than once by services with no way to see each other: the sidebar's system block fetched Inbox, Archive and Trash one row at a time, the topbar's calendar dot and its "happening soon" trigger each read the visible calendars, and the health indicator read every account and every calendar a second time.
The roles now arrive in a single
IN (…), and the calendar and account reads are answered from one per-request read that the narrower questions filter in memory. Nothing is cached beyond the request, and the write paths still go to the database directly — a list of calendars held past the moment one is created is worth more than the query it saves. Measured on a fifty-row page: 26–29 statements down to 21–24. -
Opening a folder no longer re-renders the sidebar it was clicked in. Choosing a folder, a category tab, the next page, a different sort or a search is a navigation of the message list and of nothing else — and every one of them fetched a whole document. The sidebar the click started in, the topbar above it, the calendar pane beside it and the reading pane behind it were all rendered and sent, and then discarded by the browser, which takes the one frame it asked for out of the answer and drops the rest of the body.
Those navigations now ask for the list and are answered with the list. Measured on a fifty-row page: 21–24 database statements down to 9–13. The sidebar keeps its scroll position and its open sections instead of being rebuilt into the same shape, and the calendar pane beside the list holds perfectly still.
What the answer still carries is the document's head, in full, because the browser merges that into the open page and removes whatever the answer leaves out — the tab's title is in there, and so are the csrf token and the cache-control meta that keeps the back button from restoring a stale list. The one thing that now waits: a label renamed in another tab or on another device reaches this tab's sidebar on the next sync rather than on the next click.
Fixed
-
Search buried what it found under what it guessed. Searching one company name returned 63 results where Gmail, over substantially the same mail, returned 16. Sixteen were matches on words. The other forty-seven were rows the vector liked enough to clear its similarity threshold — job alerts, a product newsletter, an invoice, a piece of outright spam — every one correctly badged "meaning", every one useless, and together enough to fill the first page completely. The best match in the mailbox, a reply whose subject and sender both carried the term, was on page two.
The threshold was not mistuned. It was measured against a twelve-message corpus, where a false-positive rate of a couple of percent is invisible, and then applied to the two thousand most recent messages, where the same rate is forty-seven rows. No stricter fraction fixes that, so the arm is now capped as well as thresholded: the ten nearest, and no more.
Ordering carried the other half. A semantic hit can only be drawn from recent mail, so under "most recent" every one of them was structurally newer than the mail it displaced — they did not merely tend to win, they could not lose. Results the words found now come first under both sort orders, and the meaning matches follow as a short badged tail. Nothing is removed; sorting by relevance is fixed by the same change, because
rankhad been mixingts_rankwith cosine similarity, two scales with no common unit, and is now only ever compared within one of them. -
"Show original" showed a reconstruction and called it the original. The page served a flattening of the parsed header map plus the decoded text body: headers lowercased and re-serialised, a DKIM signature spread over a column of
v:,a:,d:,bh:,b:lines, php-imap's syntheticpriorityandspoofedpresented as though they had been headers, and underneath it no MIME structure and no HTML part at all. The original bytes were on disk the whole time, and the JMAP blob download had been serving them correctly for as long as it has existed.The page now reads them in the same order that download does, and says so plainly when it genuinely has to fall back — a message synced before raw storage existed has no bytes to show, and a reconstruction that admits to being one is worth more than one that does not.
-
The "copy to clipboard" button on that page did nothing. Its script was inline and carried no nonce, and the policy this application enforces names a nonce and does not allow
unsafe-inline— so the browser refused to run it, the button never got its listener, and a click produced no clipboard, no error and no visible change. It failed in production only: under debug the same policy rides along as report-only and the button works, which is why it survived. -
A JMAP
SearchSnippet/getcould hand a client the sender's own markup.ts_headlineis a highlighter and not an escaper: it inserts the delimiters it is given and returns the rest of the document byte for byte. Asking it forStartSel=<mark>therefore produced a string in which the sender's markup and ours were the same kind of thing, and the check that stood in for escaping — "does it contain<mark>" — could not tell them apart. A subject or text part containing<img src=x onerror=…>shipped it as snippet HTML, and per RFC 8621 a client is right to render that. The mail did not have to be opened.The delimiters are now two control characters, the whole string is escaped, and only then are they swapped for
<mark>. The conversion and the delimiters it converts live in one class, so they cannot be changed apart.
v0.2.33 — 2026-09-21
Fixed
-
The blank-message repair had been looking for the wrong thing, and said it had succeeded. The bodies it was meant to recover are not stored as text parts at all: webklex hands over the whole
multipart/relatedblock around a body when that block holds exactly one part, so the row is a MIME container, not HTML. The repair matched onlytext/plainandtext/html, so on a real mailbox it ran, found nothing, and recorded itself as done — which is worse than failing, because it stops asking.A
relatedholding one part is the whole trigger, and it is why this hid so well: add a single inline image beside the HTML and the same message parses correctly. Almost every HTML mail has an image in it. Transactional mail that does not — a rejection from a recruiting system — lands in the broken case every time.Both the ingest path and the repair now open the container instead of trying to decode it. The boundary is not in the database, but a multipart body opens with its own delimiter, so it can be rebuilt and handed back to the parser — which also settles the charset, since inside a message of its own the HTML is a body part again. A container that also carries files is deliberately left closed: taking its body while dropping them would make a blank message worse.
The repair runs again by itself. It is a new task as far as the ledger is concerned, precisely because installs that ran the old one have a row saying it is done. By hand it is now
app:backfill misfiled-bodies-v2— the name changed for the same reason.
v0.2.32 — 2026-09-21
Fixed
-
v0.2.31 broke live updates on every TrueNAS install, and could not be fixed by configuring anything. That release bound the Mercure token's issuer and audience to
MERCURE_PUBLIC_URL.truenas.compose.yamlleaves that blank on purpose — the setup screen asks for the public URL on first boot and stores it in the database, which is long after the hub container has to have been told what to trust. An empty issuer is not a weak issuer; the token factory refuses it outright, so the install minted no token at all. No subscriber cookie, every publish throwing, and a red dot that looked exactly like a hub that was down.The issuer and the audience are now a fixed identifier that no installation has to know or set. An identifier does not have to resolve —
.invalidis reserved for precisely this — and what matters is only that the application and the hub agree on the same string. They are the same on every install, which costs nothing: the token is signed with a secret generated per installation, so one install's token has never been usable against another's hub whatever it claims. -
truenas.compose.yamlhad been left behind twice. It still carried the/healthzcheck that v0.2.30 replaced everywhere else, so its hub would have reported itself unhealthy the moment the image updated; and it had nopull_policy, so its hub never updated at all. Both are fixed, and the hub now pulls on everyup— an unpinned tag is not a moving image unless something makes it one.
v0.2.31 — 2026-09-21
Fixed
-
Live updates stopped arriving, and the inbox said so. The Mercure hub moved to the 1.0 protocol, which wants RFC 9068 access tokens — an issuer it has been told to trust, an audience naming the hub, and the grants carried differently. plMail was still minting the older shape, so every subscription came back 401 and the indicator sat on "Live updates unavailable" while the hub itself was perfectly healthy. New mail still arrived; it just needed the page reloaded to show up, which is most of the point of the thing gone.
plMail now speaks the 1.0 protocol. The hub is deliberately not pinned to a version — it is meant to keep itself up to date — so the answer is to follow it rather than to hold it still.
PULL THE HUB IMAGE WITH THIS RELEASE. This is a protocol change and both halves have to move together: an install running the old 0.x hub gets the mirror image of the bug above — plMail speaking 1.0 to a hub that does not — and the symptom is identical, a red dot and no live updates.
dunglas/mercureis deliberately unpinned, but an unpinned tag is not a moving image:docker compose up -dreuses whatever is already on the box. So pull it:docker compose pull mercure && docker compose up -d mercureThe shipped
compose.yamlnow setspull_policy: alwayson the hub so this cannot happen again — but that only helps installs that take the new compose file.If you maintain your own compose, the hub needs two settings it did not before, and no image pull supplies them:
MERCURE_TRUSTED_ISSUERS, andresource_identifierinsideMERCURE_EXTRA_DIRECTIVES. See the release above for the value — this entry originally said to use the hub's public URL, which is wrong and breaks any install whose public URL is stored rather than configured. Both must be the fixed identifier that release introduced.An install serving plain HTTP must also set
MERCURE_COOKIE_NAMEto a name without the__Secure-prefix: 1.0 names the cookie__Secure-mercure_access_token, and a browser drops a__Secure-cookie that did not arrive over TLS. Installs on HTTPS need no change there.
v0.2.30 — 2026-09-21
Fixed
-
The Mercure hub reported itself unhealthy for ever while working perfectly. Its readiness probe moved:
/healthzanswers 404 now,curl -freads that as a failure, and the container never goes green again. On a normal install nothing waits on that, so live updates carried on and the only symptom was(unhealthy)indocker psand in the TrueNAS view — alarming, and about nothing. The test stack does wait on it, so CI stopped being able to start at all.plMail no longer describes the hub's health itself: the image's own check is used, which moves when the image moves. The hub is deliberately not pinned to a version so that it keeps itself up to date, and that is exactly why nothing about its internals should be written down here — anything hard-coded about an image that updates itself is a thing waiting to rot.
v0.2.29 — 2026-09-21
Fixed
-
Some mail arrived with no body at all and a single attachment named something like
09704cea. That name is not a name: it is a checksum the IMAP library writes when a part has no filename of its own, and a part with no filename is not a file anybody attached — it was the message. A sender that marks its bodyContent-Disposition: inlineand gives it a Content-ID, which Workday and a good deal of the transactional-mail estate do, falls past the library's rule for what counts as a body, so the text went into the attachment list and the message rendered blank. Other clients showed the same mail without comment, which made it look like plMail losing it.A text part with no name of its own is now recognised as the body and put back where it belongs. A file that does have a name —
Rechnung.html— is still an attachment, and a message that already parsed a body keeps it: the repair only fills a gap, it never overwrites. The recovered text is converted out of the charset it declared on the way in, which the attachment path never did, so a German body labelled ISO-8859-1 arrives as words rather than as a rejected write.Mail already in the mailbox is repaired automatically, within ten minutes of updating, from the copy that was stored beside it all along — no re-sync, and nothing to type.
app:backfill misfiled-bodiesdoes it immediately for anyone who would rather not wait. -
A blink from Microsoft cost a folder fifteen minutes of its mail. Graph answers
502with an empty message often enough that it is weather rather than news, and nothing retried it: the folder was skipped, an error with a full stack trace went into the log, and the next attempt was the following sync. Reads are now retried twice, a second and then three seconds later, so the blink is invisible when it passes — the response never reaches the syncer and nothing is logged at all.Only reads. A
502means the gateway gave up on the answer, not that Exchange never acted, so sending mail, moving it and writing categories are left exactly as they were rather than risked twice. Throttling is untouched too — that is answered with the wait Microsoft asks for, which is a better answer than guessing.
Added
- Somewhere for one-time repairs to live. Work that has to happen once per installation after
an update had nowhere to record that it had, so it was a choice between a migration doing things
migrations should not and a nightly sweep for a job finished after the first night. There is now
a ledger, in the same shape as the migration ledger and readable the same way: a task claims its
row before it runs, so a container restarted mid-repair is tried again rather than forgotten, and
a task that fails three times stops rather than taking the worker down with it.
app:upgrade:run --statusshows what this installation has done.
v0.2.28 — 2026-09-15
Fixed
-
A drag that outlived the list it started in could leave the whole tab unable to answer the mouse. Dragging a conversation while the list refreshed underneath it — new mail arriving over Mercure is the ordinary way — removed the row being dragged, and a removed drag source never fires the event that says the drag is over. The browser went on believing one was in progress and ate every click, mouseover and keypress before the page could see them; the page itself was fine, which is what made it look like a freeze rather than a bug.
The list refresh is now held for the length of a drag and taken the moment it ends, so the row cannot be pulled out from under the gesture in the first place, and mail that arrived meanwhile appears as soon as the drag is over rather than being dropped. Belt and braces behind that: the end of a drag is now heard on the row itself rather than only on its way past, so it is still heard from a row that has left the page; and a render that arrives mid-drag clears the drop-target highlighting rather than leaving it lit.
v0.2.27 — 2026-09-15
Changed
-
A week narrower than its own columns pans instead of squeezing. Seven columns used to divide whatever width there was, so a phone — or a narrow docked pane — gave each of them about fifty pixels, and a block showed its colour and little else. A column has a floor now: below the width where seven of them fit, the grid slides sideways under an hour gutter that stays put, a day at a time, and four days are legible where none were. Day view is still one tap away in the toolbar, as a choice rather than as the only way to read a narrow week.
-
A block writes its time on one line and its title under it, with the calendar's colour as a bar down its left edge. One line of "10:00 Design review" in a 96px column was four characters of each. Day view keeps the single line, where the column is the whole grid and there is room for both. Where a title still cannot finish — the all-day band above the grid is one line tall by definition — resting the pointer on it opens it to whatever it had to say, over its neighbours, and puts it back when you leave.
-
The agenda groups its entries under a heading per day, which stays at the top of the list while that day's events are on screen. The start and end time, or "All day", sit in a lane of their own down the left, so the titles line up down the list instead of starting wherever the previous time happened to end. The lane is as wide as the widest time in the view and no wider — it was a fixed guess before, generous for a clock time and still too narrow for a translated "all day".
-
A month cell draws as many of its day's entries as fit and fades the last one where it runs out. It used to say "N more", and the count could never be right: a cell's height is the window's divided by six, and an entry is one line tall or two depending on whether it has a time. The date is a link now, which opens the day the rest of them are on. Resting the pointer on a day floats that whole day out — wide enough for the full titles, long enough for every entry, and nothing inside it moves as it opens. Where a cell is too narrow for words at all, which seven columns of a phone are, an entry is drawn as a bar in its calendar's colour instead of as an ellipsis.
-
The grid opens on now rather than on 07:00. A fixed hour cannot be right at both ends of the day: at 22:00 the working day is two screens above the only row that matters, and at 09:00 a grid opened at 07:00 spends the top of the viewport on hours that are already over.
Fixed
-
An all-day entry heads its day in the agenda and the month. It sometimes did and sometimes did not, and the time zone was the sometimes: an all-day event's start is a floating midnight in UTC while a timed one's is an instant, so east of UTC every morning meeting was stored earlier than the midnight of the day it falls on, and sorted above the holiday. The time grid never showed it, because it puts all-day entries in a band of their own and never asks.
-
A grid block is opaque. It never had a background at all: an inline colour was overriding the surface class written beside it, so the only paint a block carried was a tenth of a calendar colour over whatever happened to be behind it. Invisible while a block sits in its own column, and obvious the moment one opens across its neighbour.
v0.2.26 — 2026-09-14
Added
-
Paperless-ngx, as somewhere to file an attachment and somewhere to attach from. Connect it under Settings → Connections with its address and an API token, and it appears beside Nextcloud and the rest.
Attaching is the half worth describing. Paperless replaced folders with tags and expects everything else to be found by searching, so the picker uses tags as its containers — the root lists them with the newest documents beneath, and opening one lists that tag's documents. The search box is Paperless's own full-text index, which reads the OCR layer as well as the metadata, so a scanned invoice is findable by a line item nobody ever typed. The point is that there is no longer any reason to open Paperless, find a document and download it before writing the mail.
Saving out files the attachment under a tag you pick, or none, which is Paperless's inbox. Uploads there are asynchronous — Paperless answers with a task and consumes the file afterwards — so what comes back is marked as a task rather than a document, and trying to attach it before it has been filed says so instead of failing obscurely.
No share links: minting one in Paperless is a permissioned feature with its own expiry, not something to do silently on a user's behalf, so the picker offers "attach a copy" only.
Fixed
- The account menu is no longer painted over by the calendar. Its
z-[200]read as "above everything" and was nothing of the sort — it ordered the menu inside the header's own stacking context and said nothing about the header against the rest of the page, so it stayed on top only while nothing outside the header made a context of its own. The calendar keeps making them, especially once a background image turns the panes' glass on. It goes to the browser's top layer now, which is above every stacking context there is, so the question cannot be asked again.
v0.2.25 — 2026-09-14
Added
-
A filter that saves attachments can be told WHERE. "Save attachments to Nextcloud" had no folder beside it, so every filter-driven upload landed in the root of the connection — while the same save done by hand from a message offers a browsable chooser. The rule editor now opens that same chooser.
It is the same one, not a second one that looks like it. The picker grew a part-less mode: a save from the reading pane has an attachment in hand and browses to a folder to put it in, while a rule is naming a folder for mail that has not arrived yet, so with nothing in hand the choice is handed back to the editor instead of being uploaded to on the spot. Navigation, breadcrumbs and the albums-versus-folders split are the ones that were already there.
Nothing behind the editor needed building: the rule action, the queue message, the handler and the driver have all carried a folder the whole time — there was simply no way to set one. Choosing the top level puts a rule back on the connection's default, and changing the connection clears the folder rather than carrying one service's id to another.
A rule shows where it saves in its own summary now, too: "Save attachments to Nextcloud into Mail/Invoices". Two rules pointing into one connection used to read identically.
Changed
-
A whole-view mark-read or archive finally happens outside the request — which is what it has always claimed to do. The handler's own notes say it exists to escape
Maximum execution time of 30 seconds exceeded, and the controller answers with a progress indicator precisely so nobody waits for the work. Neither was true: the message was never listed in the routing map, and Messenger runs an unrouted message inline in whatever process dispatched it. Every "mark all read" over a large mailbox therefore ran inside the web request, under the limit it was written to escape.Self-hosting: this needs one new container.
worker-bulkconsumes the newbulkqueue. Pull the compose file and bring it up; without it a whole-view action is queued and never runs, and the indicator sits at nothing. Page-sized selections — the rows on screen — are untouched and still apply inline.It gets a queue of its own rather than joining
maintenancebecause of who is waiting. A backfill is nobody's foreground; a bulk action is somebody watching an indicator, and queued behind a ten-minute embedding run it would have been slower than the inline version it replaces.
Fixed
-
A deadlock no longer kills a bulk action. Two transactions took row locks on the same messages in opposite orders and PostgreSQL broke the cycle by killing one of them:
SQLSTATE[40P01]: Deadlock detected … while updating tuple (496,11) in relation "message"Nothing was corrupt — the loser rolled back whole and would very likely have won a moment later — but the job was marked Failed, so a collision that clears itself became an error in front of whoever pressed the button. Each chunk is retried three times now, and a collision that outlasts those hands the job back to the queue for redelivery rather than ending it.
The same trace showed a second fault underneath. Doctrine closes the EntityManager on any failed flush, so the code that records the failure on the job could not run at all: it threw
EntityManagerClosedfrom inside its own catch block, which replaced the real error with one about Doctrine and left the indicator spinning — exactly the outcome that block exists to prevent. It resets the manager first now, and says so plainly if even that will not write. -
The list catches up with a bulk action while it runs. Nothing ever refreshed it on the strength of the job itself; what re-read the list was the toolbar's own "the write landed" event, which fired when the request returned. Inline that was the same instant the work finished. Handed to a worker it is the instant the work was queued, so the screen would have gone on showing seventy-one conversations that were already archived. The list now re-reads on every chunk the job reports, and immediately when it finishes.
-
Four tests that were lying, three of them for weeks. Two E2E specs had gone stale against deliberate changes — the appearance preview grew from three sample rows to eight on purpose, and the calendar pane's month grid gained an add button per cell, which made
getByLabel("New event")match forty-three of them. Two PHPUnit tests were a time bomb: their fixtures are dated 2026-08-10, the threader's subject fallback only looks back thirty days, and on 2026-09-09 at 10:00 the window closed behind them — no commit involved. And one demo test picked "the bounced message" out of the whole shared table with no ordering, so which one it got was undecided; it failed once in a full run and passed on the identical one after it. Each is fixed at its cause rather than by moving a number: the arriving message now threads on References like the sync it imitates, and the demo test asks for its own user's mail.
v0.2.24 — 2026-09-11
Fixed
-
The focus highlight on a joined control is now actually the shape of the control. v0.2.23 fixed half of this and shipped the other half: the ring was no longer clipped out of existence, but it was still a SQUARE ring on a square half, and the group's rounded clip bit 3.39px out of it at each outer corner — a highlight arriving with its corners missing, which is what "this does not look fixed" was. Measured: a 7px inner radius against a 2px band.
The halves carry the group's own corners now —
rounded-l-lgon the face,rounded-r-lgon the save button, the same at both ends of the Send pill and of the calendar's view switcher — so the ring follows a curve the clip has nothing left to cut. Measured again on both chips, both pill halves and all four segments: zero erased.
v0.2.23 — 2026-09-11
Fixed
-
The focus highlight on a joined control is the shape of the control again. Reported on an attachment chip, where it read as a second, wrongly-cornered border around the filename. The cause is not the radius on the button: those buttons have no radius at all. A chip, a send pill and a view switcher are each one rounded box with square-cornered halves inside it, rounded only by the group's own
overflow-hidden— so the browser's default outline traced a hard rectangle around a half and the clip sliced its corners off where the group curves.The ring is drawn inside the control now, which is what the settings pages have always done with their segmented controls, and the clip rounds it instead of eating it. Six places carried the fault: the attachment chip, the Send pill and its chevron, the calendar's Day/Week/Month/Agenda switcher, the select and account menus, and an event on the time grid — that last one worse than cosmetic, since a block clips its chip completely and keyboard focus landed on it with no visible ring at all. The switcher's ring also takes its colour from the segment, an accent ring on the accent-filled one being no ring.
v0.2.22 — 2026-09-11
Fixed
-
A narrow week no longer writes each day over the next one. Seven columns divide whatever width the pane has, so at 400px each is about 45 — and the weekday, the date and the hover + wanted 89 of them on one line. A flex row does not wrap or clip on its own: it shrinks what it can and spills the rest. The label crossed its own border, and the + button for Monday was painted over Tuesday's heading, where it still added an event to Monday.
Below 5rem of column the weekday now stacks over the date instead, and the + is positioned rather than pushed, so nothing a heading draws can leave the day it names. The decision is a container query on each heading and not a viewport breakpoint, because the width that matters is the column's: the same 400px window is one roomy column in Day view and seven cramped ones in Week, and the docked pane is resized by its own handle inside a window whose width never changes at all.
-
Today's date is drawn in a circle again. Two faults, one shape. A flex item shrinks to its own min-content before its container overflows, so in a column too narrow for the heading the ring was squeezed to the width of the digits inside it — 20px tall and 13.7 wide, which is an ellipse. And the negative margin that kept it from stretching the baseline row put its box 2px above the top of the scroller, which clips, flattening what was left. The heading aligns on centres now, and the ring is excused from shrinking.
-
Numbers typed into a message are drawn by the message's typeface. Reported as "too much spacing", and it was neither spacing nor the numbers: the composer body names the vendored emoji family first, on the stated grounds that only emoji code points would resolve there. They did not — Noto Color Emoji covers
#,*and 0-9 as well, because those are the bases of the keycap emoji 1️⃣ 2️⃣ #️⃣ — so every digit came back at emoji metrics while the letters beside them fell through untouched. A ten-digit phone number measured 170px in a composer that draws the same ten in 84, which is why it read as letter-spacing rather than as a font.Those code points are cut out of the slice that carried them, and the emoji picker — the one surface that shows keycaps whatever the reader typed — gets a keycap-only family of its own so its grid still shapes them as single glyphs. Emoji in the body are untouched and still come from the vendored font.
v0.2.21 — 2026-09-09
Added
-
A Gmail sign-in that dies every week now says why, and warns before the next one. Google expires refresh tokens issued by an OAuth app whose consent screen is still in Testing after about a week — which is the state nearly every self-hosted install is in, because leaving it there is the documented way to avoid Google's verification review. The account works, stops seven days later, gets reconnected, and stops again, for ever.
Google gives no clue which case you are in. It returns
invalid_grant, and that is the same answer it gives for a revoked consent, a changed password, and a token that aged out. The only thing separating them is how long the sign-in lasted, and nothing was recording that. Now two columns do: when a grant was issued, and how many hours the previous one survived — kept across the reconnect, because that is the one moment somebody is looking at the account and could act on the cause.A sign-in that dies after about a week gets a card naming that cause and the actual fix (set the consent screen's publishing status to In production — which is not the same as submitting for verification, a distinction the handbook previously blurred). Once the pattern has been seen once, Account health warns a day or two before the next one runs out and offers to sign in again while nothing is broken. Both cards show when the current sign-in was issued, because the claim is an inference and the reasoning should be checkable.
Nothing is claimed about an account connected before this was recorded, and nothing is claimed from one long-lived grant that was simply revoked — the window is deliberately narrow enough to exclude it.
-
Browser errors reach the admin panel. Uncaught exceptions and rejected promises from plMail's own scripts now land in their own card under Admin → Logs, alongside the things the Content-Security-Policy blocked. Until now a JavaScript fault was visible only to whoever happened to have the console open, which on a self-hosted mailbox means nobody.
It is a card of its own, not rows in the log, because it holds a different kind of thing. A server error is one event in one request; a broken line in a script runs on every page load for every user. So a row here is a distinct fault — message, file and line — with a count and a first- and last-seen time. Merged into the log, one bug would be four hundred rows and the log would be unreadable. The count is how many page loads hit the fault rather than how many times it fired: the browser reports each fault once per page, which is what stops a broken line inside an animation frame from posting a thousand requests about itself, and it is the more useful number anyway — it says how many people are running into it.
Most of what a browser reports is not plMail, and that is the hard part. Extensions inject analytics and form scrapers into the page and those scripts throw; a panel that accepted all of it would fill with other people's bugs and stop being read. A report is kept only when it can be attributed to a script this server sent — an injected script has no URL at all, and a cross-origin one is reported as the bare string
Script error.with nothing in it. The filter runs in the browser and again on the server, because the browser half is part of the code being reported on.CSP violations arrive at the same card. The full policy is enforced in production rather than report-only, so these are things this installation actually refused to load in somebody's real browser — which nothing else can tell you.
Fixed
-
A dead sign-in no longer writes a CRITICAL every fifteen minutes. A revoked Google grant produced 517 log entries on one install — one per sync cycle, each with a three-deep nested stack, each also leaving a dead job on the failure transport. None of it was a fault in this application: the grant was gone, the account was already flagged, and the interface already had a card with a Reconnect button on it.
Both exceptions involved were already marked unrecoverable, which stops the retry ladder but not the logging — Messenger logs and files an escaped handler exception whether or not it will retry it. So neither handler lets one escape any more. The calendar half is the sharper case: it already had careful "say it once, and again when it changes" throttling, and then rethrew, so an unthrottled CRITICAL sat underneath saying the same thing every quarter of an hour.
-
Log entries read as sentences instead of templates. PSR-3 messages are stored with their placeholders filled in now. Raw, every occurrence of Messenger's own message was byte-identical — five hundred rows of
Error thrown while handling message {class}…, with the class, the retry count and the error each a click away in the context column and the list itself saying nothing.
v0.2.20 — 2026-09-04
Added
-
You choose what sorts your mail into tabs. Under Settings → General → What sorts your mail, Sorted by picks what decides: Rules reads headers — a mailing-list header, an unsubscribe link, a sender you have written to before — and answers the same way every time for the same message without asking a model anything; The assistant has the model read each message and decide what it is for, which is better at mail that does not announce itself and occasionally confidently wrong. Anything the assistant has not reached yet falls back to the rules, so a tab fills in as it works rather than being wrong until it does.
On Gmail accounts decides whether any of that is allowed to disagree with Google. The default keeps Google's categories, because they are already there and two systems sorting one mailbox is worse than either — but if Google has never sorted your mail the way you want, this is the switch that lets plMail answer for itself.
It is per person, not per installation, and changing it re-sorts your mailbox in the background rather than applying only to mail that arrives afterwards. Nothing is asked of a model to do that: every verdict is already stored, so a re-sort is a scan and a column write, which is why it is a job your settings page starts rather than a command to ask an administrator for. It shows up in the jobs indicator like any other.
-
Summarise the whole conversation, however long it is. A summary has always been written from as much of the thread as fits the model's context, and now it says so — and offers to do the other thing. Summarise everything runs as a background job with a context window sized to the conversation, so a thread forwarded eleven times gets read whole instead of from its last few screens. It runs detached: close the tab and it finishes and stores the summary anyway.
-
Tell your administrator when mail lands in the wrong tab. A Report button in a message's Details panel says where the mail should have gone. What it records is the decision rather than the message — what each of Gmail, the header rules and the model said, which was deciding, the bulk headers the mail carried, and whether there was any plain text for the model to read at all. That is what a rule or a prompt gets changed on the strength of. Sender, subject and the decision travel; the message does not.
-
Ask the model again about the mail you already have. Sort recent mail again in the AI settings hands the newest few hundred messages back to the classifier, forced, so a better prompt or a different model can be tried on real mail instead of only on whatever arrives next. It runs as a tracked job with a count, because a button whose only feedback is the page reloading is a button you press twice.
-
The appearance preview is a working miniature. Picking a theme used to show a few coloured swatches. It now shows a real list you can click through, a real message, a real header dropdown and a real compose window — static content, but the actual templates, so what you are choosing between is what you will get.
-
The calendar opens on the view you left it in. Day, week, month or agenda — whichever you last chose is what you get next time, instead of the page always starting on Week and the docked pane always on Agenda.
The page and the pane remember separately, which is the one part worth explaining. They have always opened on different views on purpose: seven columns in a 380px strip are seven slivers, and picking Month in the pane makes the pane widen itself to fit. One shared setting would carry a choice made on a full-width page into that strip and widen it on open — something happening to you rather than something you did. So each shape keeps its own answer, and the defaults are exactly what each opened on before, so nobody's calendar moves until they move it.
It rides in the user settings alongside the pane's width and mode, so it travels in a config backup, and a value the settings bag cannot make sense of opens on the default rather than failing to draw the calendar.
Changed
-
Reported mail is one section, and the export is a selection. Missed insights and wrongly-sorted mail were two piles behind two panels with two export buttons; they are now one list, because the question an administrator brings to that page — what is still mine to do — does not have two answers. Each row says which kind it is, both can be marked handled, and the chips above the list filter by state and by the problem itself, one chip per disagreement this installation has actually produced.
The export used to offer a fixed scope: everything, or only the unhandled part. That answers the question you have on your first visit and none of the ones you have afterwards — the one that comes up is these six, the ones about the shop. So the export now takes whatever is ticked, in full, with every verdict and the headers behind it; filtering never unticks anything, so a selection built across two chips survives. The document is
plmail.reported-mailversion 2, a new name rather than a bumped version alone, so anything written against the old one fails cleanly instead of half-reading a file it only partly understands. -
Semantic search is tuned per model. The embedding model is a dropdown of measured presets with their trade-offs written out, and picking one sets the model, its query instruction and its similarity threshold together — the three are one decision, and a threshold carried over from a different model is what makes good results look like a broken feature. It stays a free-text field underneath, so an unlisted model is still yours to configure. qwen3-embedding is the default, being the one of them that is genuinely multilingual.
-
The categorisation prompt was rewritten. The old one could be talked out of its answer by a message that simply said what it wanted to be, and it never saw the headers that make a mailing a mailing — so it now gets told which bulk headers a message carries, and it is asked the bulk question before it is asked what the mail is about.
Fixed
-
Semantic search stopped blaming the model for being slow. Every search reported "The model took too long, so this search used words only." Two faults, one message: the candidate-window subquery filtered on the wrong table alias, which turned it into a correlated subquery that re-sorted the whole table for every row and duly hit the statement timeout; and every database error, whatever it was, was reported as that timeout. A cancelled statement and a failed one now say different things and both are logged. Measured on the same query: 5000 ms and cancelled, then 188 ms.
-
A summary that cannot be written says why. "The summary could not be written." covered a model that was not there, a connection that failed, a request that timed out and a reply that came back empty — four different problems, one sentence, nothing in the log. Each says which it was now, and the timeout says which timeout: the model's own, or plMail's wait for it.
-
A mailing beats having written to the sender. The rule that pulls mail from people you correspond with back into Primary was doing something else as well: one enquiry to a shop's
info@about a broken part pinned every sale that shop ever sent into Primary, for ever. A person you correspond with does not send you mail with an unsubscribe link in it — their marketing platform does, from the same address. Mail that is plainly a mailing is now sorted as one whoever it is from; anything weaker than that still loses to somebody you have written to. -
Feedback-IDno longer means "marketing". It marks mail sent by a large sending platform, which a recruiter's request for documents and a shop's sale both are. It was filing the first under Promotions. -
The Details panel named the wrong category. It recomputed the answer with the shipped defaults instead of reading it, so it announced "Updates — Gmail said so" about a message sitting in Primary for somebody who had asked for the model and for Gmail to be overruled. It now reads the row, names all three verdicts separately, and marks the one in force. It then read the wrong row — the message's category rather than the conversation's, which is the one the tabs filter on — so it also now says when a conversation is being carried by a newer message than the one you are looking at.
-
The maintenance worker stopped looking dead during long jobs. Messenger reports a worker as alive between messages, so a handler that runs for twenty minutes emits no heartbeat and the admin dashboard calls it stale after two. Long handlers now beat while they work.
v0.2.19 — 2026-09-02
Changed
-
A drop on a label now adds it; a drop on a folder still moves. Dragging a conversation onto "Receipts" took it out of the inbox, because every drop was a move. That is right for the rows above LABELS in the sidebar — Inbox, Archive, Spam, Trash, and each account's own folders are places, and dropping into one means the mail is there and nowhere else. It is wrong for a label, which is a thing a conversation wears: dropping on one attaches it and leaves the mail exactly where it was, the same as the "Label as" menu has always done.
Which meaning you get is decided by the row you drop on, not by the label — and it has to be, because the same custom label is in the sidebar twice. Under LABELS it means "everywhere" and tags; under an account it names that account's folder and files. One label, two rows, two meanings.
Fixed
-
A move into a folder the server already had was abandoned instead of made. When plMail needs a destination folder it does not know about, it creates it — and if the server answers
NO [ALREADYEXISTS] Mailbox already exists, it used to treat that like any other refusal, give up with "destination not resolvable" in the worker log, and leave the mail where it was. The folder was sitting right there the whole time.It happens whenever the server has a mailbox plMail has not recorded: one made in another client since the last folder listing, one differing only in case, one under a namespace the sync did not walk. The only sign was a warning in the log, so a drag would simply not take on the server while looking as though it had.
A folder that already exists now counts as success, and — the half that actually matters — gets recorded locally, since the folder existing on the server and not in plMail's database is exactly why the destination could not be resolved. Any other refusal is still a refusal.
v0.2.18 — 2026-09-02
Adds one column to the message table; the migration runs on boot and backfills the rows it can answer for without guessing. Nothing is removed.
Changed
-
The details panel now shows every answer about which tab a message belongs in. Opening a message's details used to say where it landed and, on Gmail mail only, what plMail's own rules would have said instead. That was enough to report the decision and not enough to judge it. It now always shows three lines: category (where it is, and what put it there — or "you moved it here" if you dragged it), the rules (plMail's own cascade with neither Gmail's labels nor the model in the way), and the model.
The rows disagreeing is the useful part rather than a fault. A header rule and a language model reaching different conclusions about the same mail is the thing worth looking at, and until now there was never a message carrying both answers to compare.
-
The model is asked about every message, not only the ones no rule recognised. It used to be asked exactly where the deterministic rules had already given up — which is where its verdict is actually used, and which made the classifier impossible to assess: there was no mail with both a rule's answer and a model's answer side by side.
Nothing about where mail lands changes, and that is why this is safe. The verdict is stored beside the decision and read only at the tie-break, so a message a rule matched keeps that rule's answer however loudly the model disagrees. What you get is a second opinion on record, not a second decider.
The cost is real: one model call per message rather than per unrecognised message, on the same host the composer and the search index already queue behind. It stays bounded by the three checks that were always there — the installation switch in Admin → AI, each person's own switch, and the fact that no message is ever asked about twice.
-
A drag now carries a small pill instead of the whole row. Dragging one conversation used to drag a picture of its row — which the browser gives you for free and which is the most literal answer to "what am I holding", and which is also a thousand pixels wide, because that is how wide a row in a mail list is. It travelled under the pointer to a sidebar a third of that, so the folder being aimed at spent the whole gesture hidden behind the thing aiming at it. That is the one part of a drag you cannot work around by moving the mouse, since the image goes where the pointer goes.
Dragging several has always carried a pill saying how many. Dragging one now carries the same pill saying the subject, cut with an ellipsis if it is long — a pill wide enough for any subject would be the row again under another name.
Fixed
-
Gmailified mail never reached Gmail. If Google fetches another mailbox into Gmail and you have both connected here, every message arrives twice — once through the Gmail account, once straight from IMAP. plMail already merges the pair rather than showing you two rows: the IMAP copy keeps its location and its flags and gains what the Gmail copy knows, its id and its labels. That part was right.
What was wrong is what happened afterwards. Before telling Gmail about a change, plMail asked the account the row belongs to whether it was a Gmail account — and for a merged row that is the IMAP account, so the answer was always no. The message had a perfectly good Gmail id and a Gmail account sitting right beside it, and every push was dropped: archive, trash, restore, star, mark as read, and every label change alike. All of them applied locally and on IMAP and nowhere Google could see.
Reported as a mail dragged out of spam that came straight back — the IMAP server obeyed, Gmail never heard, and the next sync put it where Gmail still thought it belonged.
The row now records which Gmail account carried it, and the push asks that one. Existing mail is backfilled where the answer is not a guess: if you have exactly one Google account there is only one account the id could have come from. With two or more it is left alone and filled in the next time the Gmail sync sees the message, which is the same path that stamped it in the first place.
-
The
mainimage stopped being published, and the reason was a two-line ordering mistake. The Dockerfile stamps the build's version and commit into the image so the admin panel can say which build it is. Those twoENVs sat abovecomposer install— and every layer after anENVis keyed on its value, so a commit hash that is different on every commit meant the vendor layer was never once reused. The line directly above it promises the opposite: "prevent the reinstallation of vendors at every changes in the source code".So every build of every branch and every tag re-downloaded 111 packages, and the per-architecture layer cache the workflow is careful to scope could only ever hit the layers above that point. It was a build-time cost and, more importantly, an exposure: a step that runs on every build needs the network on every build, and a package host having a bad minute failed the build outright. That is what happened — twice on
main, while the release tag of the identical commit went through on a retry.Moved below the install, the vendors are keyed on
composer.lockalone: they change when the dependencies change, which is what was always meant. If you self-host from themaintag, that image had been stale since the failure; it publishes again from this commit.Worth being straight about the limit of this: it removes the exposure rather than explaining the upstream failure. The build no longer needs a network round trip on a commit that changed nothing about the dependencies, so a bad minute at a package host can no longer reach it.
v0.2.17 — 2026-09-01
Nothing in the application changed. This is the test suite and the workflow that runs it.
Changed
-
The E2E workflow runs in parallel instead of in a queue. It was one job on one two-core runner doing PHPStan, then PHPUnit, then ~690 browser tests, in that order — the browser suite waiting on two static checks that say nothing about whether a page renders. It is now six jobs: one builds the image so the rest start from a warm layer cache, one runs the static checks beside the others rather than in front of them, four run the browser suite in quarters through Playwright's own
--shard, and a last one merges their reports into the single HTML report you download.Each job gets its own stack, and that is what makes it work rather than being incidental to it. A single app container was already the ceiling on how many browsers were worth pointing at it. It also frees the four specs that own install-wide state — the AI settings singleton, the integration provider rows, the Mercure hub one of them restarts — which used to run last, alone, in one worker, behind everything else. They were never slow; they were serialised, because there was one installation to collide over. On a runner of their own there is nothing to collide with.
The cost, stated because it is a real one: preparation is per job, so six jobs pay for it six times. That is more machine minutes for less waiting, which is the trade sharding always is, and it only pays because nearly all of that preparation is cache. A cold cache makes this slower than what it replaced.
A pull request also stops paying for superseded runs now: pushing a second commit cancels the first one's. Tags and manual runs are never cancelled — a release is the run whose answer gets kept.
Fixed
- An admin layout test had been failing since v0.2.15, and taking two minutes to do it. It waits for each admin section to finish loading by checking that no spinner is left in the frame, and counts elements rather than visible ones — so when the AI card grew a "Warm up now" button whose spinner sits beside its icon, rendered and hidden, waiting for a click, the wait began looking for something that is never removed. Every run spent the timeout on a section that had loaded perfectly. It asks for visible spinners now, which is what it always meant.
v0.2.16 — 2026-09-01
Adds one column to the conversations table; the migration runs on boot. Nothing is removed and nothing is rewritten — the column starts empty on every existing conversation and only fills in when somebody drags one onto a category tab.
Added
-
Drag a conversation where you want it. Pick a row up and drop it on a folder in the sidebar, or on one of the inbox's category tabs. Both are moves in the ordinary sense: the conversation leaves the list it was dragged from and is in the one it was dropped on, and any labels it wears — "Receipts", "Family" — come with it, because a label is a thing a conversation IS and a folder is a place it lives.
While a drag is in flight, every folder that will take it grows a dashed edge and the one under the pointer fills in. Only the ones that will actually accept it light up: not the folder you are already looking at, not Sent or Drafts, and not another account's folders — a conversation belongs to one mailbox, and the folder behind a label is per account even though the label itself spans them all. A row that offered to take the drop and then declined would read as a failed drag.
Because the drop ends with the row gone from the list, it says where it went: "Moved to Receipts". That is the whole of the feedback for a gesture aimed by pointer at a target a couple of dozen pixels tall.
Dragging a row that is ticked takes every ticked row with it. Dragging any other row takes that one alone and leaves your selection alone with it.
A category you have chosen stays chosen. The tabs are worked out from each message's headers, most-recent-wins, so before this a conversation moved out of Promotions would have been put back by the next issue of the newsletter — days later, with nothing on screen connecting the two. A category set by hand is now marked as such, and neither an arriving message nor a run of
app:backfill categoryoverwrites it.The tab you are dropping onto does not have to exist yet. The strip only shows categories that hold mail, which is right for reading and would make an empty Forums permanently unreachable — so while a drag is in progress the missing ones appear as dashed placeholders beside the real tabs.
Two limitations worth knowing. It is a mouse gesture: HTML5 drag does not fire from a finger, and on a phone the sidebar is a closed drawer while the list is on screen, so there is nothing to drag onto — the label menu and the row's own buttons are still the way there. And on an inbox where every message is in Primary there is no tab strip at all, so there is nowhere to drop for a category; the placeholders join a strip that is already on screen and cannot conjure one, because a strip that appeared mid-gesture would push the list down under the pointer that had just picked a row up.
-
Change what the writing assistant has been told, without leaving the message. The assistant's panel has an Instructions disclosure now, holding your own standing note — the one from Settings → AI — editable in place, with "Try again with this" beside it.
Tuning a prompt is a loop: change a clause, generate, read it, change it back. Doing that through the settings page means abandoning a half-written mail, saving, coming back and starting the draft again, so in practice nobody did and the note stayed whatever it was on the day it was written.
Nothing is saved. The box lives as long as the composer does, and Settings → AI is still where a lasting instruction is written — trying a sentence out and adopting it should take different gestures, or you are left with whichever experiment you happened to stop on. Clearing the box is a real answer too: that attempt goes out with no standing instruction at all, which is the one experiment you could not run before.
It replaces your note and reaches nothing above it. Answer in the language of the mail, plain text, no preamble and the administrator's own prompts are the application's, and a box in the composer that could switch those off would be doing it where nobody would think to look.
v0.2.15 — 2026-09-01
Adds two columns to the AI settings table; the migrations run on boot. Nothing is removed and nothing is rewritten, and the values it fills in are the ones your model host was already using — so this release changes how the AI feels, not what it does.
Added
-
How long a model stays loaded is yours to set. The first "Help me write" after a quiet spell used to take a small age and show nothing at all while it did. That is not the model thinking: it is your Ollama host reading twenty gigabytes back off disk, because it had dropped the model out of memory a few minutes after the last request. How long it waits before doing that is
OLLAMA_KEEP_ALIVE, an environment variable on the host — a machine plMail can talk to but cannot configure, so until now the only way to change it was to go and edit the host and restart it.Admin → AI now has a box under each model. Write a duration —
5m,30m,4h— or a plain number of seconds;-1keeps the model loaded for good and0unloads it after every request. Longer means a faster first answer, and the host keeps that memory reserved for the whole time either way. Every request plMail makes carries the setting, so it covers drafting, summaries, sorting mail into tabs and the search index alike.Both boxes start at
5m, which is exactly what Ollama does on its own — so nothing about your host changes until you decide it should. The boxes simply say out loud what was already happening.-1is worth considering for the search model. It is small, and it is asked for constantly — every search by meaning, and every message being indexed — so pinning it usually costs a rounding error of memory and saves a wait every single time. It is not the shipped setting only because plMail cannot see how much memory your host has, and that is not a decision to make on your behalf.Leaving a box empty is a real answer too, and it means "send nothing, and let the host decide". If you have set
OLLAMA_KEEP_ALIVEdeliberately, that is how you keep it. -
Warm up now. A button beside the writing model that loads it straight away and tells you how long it took. "Model loaded in 41 s" means it came off disk; "loaded in 230 ms — it was already in memory" means the click bought nothing. Useful after a restart, and the only way to find out what a cold load actually costs on your hardware before deciding how long to hold it. A host that cannot be reached and a model that is not on the host are told apart, because they send you to two different places.
Fixed
-
An idle mailbox could talk to itself, forever, at ten megabits a second. When another client changed a message's flags — a phone marking mail read — the server told plMail, and plMail answered by re-reading every message in the folder to find out what had changed. On a 1832-message inbox that is one full listing per flag. Worse, the listing itself prompted the server to send another notification, which prompted another listing: once started it never stopped, roughly twice a second, with nine identical sync jobs sitting in the queue at any moment. Nothing was lost or corrupted — it was pure waste, and it looked exactly like a busy mailbox.
Three things changed. The notification is now read rather than merely noticed: it already says which message and what its flags now are, so when the server includes the message's UID — most do — the change is applied directly and nothing is asked of anybody. Where the message cannot be identified, the fallback listing is debounced, so a screenful of mail marked read on a phone costs one listing instead of thirty. And the sync itself now refuses a duplicate that another job performed seconds ago, so no future caller can rebuild the pile-up.
If your instance has been unusually busy, this is very likely why.
-
Six admin sections had no space between their cards. Language model, Search index, Users, Integrations, Push and Backup all rendered their panels stacked flush against one another. The cause was invisible in the obvious place: Turbo puts the contents of a response frame into the frame already on the page and throws the response's own frame away, attributes and all — so the
classthat laid those cards out was written on an element that never reached the browser, in a template that looked entirely correct. The spacing now lives on the element that persists, and a test refuses any frame that declares layout it cannot keep. -
A search test asserted how fast your machine was. The guard that stops an expensive last-resort search pass from running away is real and unchanged; the test proving it fired had a one-millisecond budget and one row to scan, so on quick hardware the pass simply finished in time and the test failed. It now provokes a genuine cancellation instead of hoping for one.
v0.2.14 — 2026-08-31
Adds one table; the migrations run on boot. Nothing is removed and nothing is rewritten. Your calendars can now be opened in the calendar app you already use — and the calendar finally knows what changed, which is what makes that fast.
Added
-
Your calendars, in Thunderbird, DAVx5, Evolution or Apple Calendar. plMail speaks CalDAV now. Point a client at your plMail address, sign in with an app password — the same kind that syncs mail — and every calendar on the account appears, each as its own entry carrying the name, colour and order it has here. Events can be created, edited and deleted from the client and land in plMail. A calendar that mirrors Google or another server is offered read-only, because it is: an edit that appeared to succeed and never left plMail would be worse than no edit at all.
The address is the only thing to type. Autodiscovery does the rest.
Two limits, stated plainly. A client can manage events but not calendars — creating, renaming and recolouring a calendar stays in plMail's own settings. And invitations are still handled the way plMail already handles them rather than over CalDAV, so scheduling and free/busy are deliberately not offered: two systems sending invitations would send every RSVP twice.
-
The calendar knows what changed, so syncing it is quick. Until now every calendar sync was a full re-read. The state a client is given never moved, and there was no way to ask what had happened since — because an event changes from four different places, and a record that caught only some of them would have been a number no client could trust. There is a complete one now, written from inside the change itself rather than by anything that has to remember to call it. A sync costs one request and the entries that actually moved.
This makes the phone app's calendar quicker too, whether or not you ever connect anything else.
Changed
- Two clients can no longer overwrite each other in silence. Editing the same appointment from two places used to mean the second write won and the first was gone, with nothing said. Both the app and a CalDAV client now write against the calendar's state, and a change made on top of a version that has since moved on is refused rather than applied.
v0.2.13 — 2026-08-31
Adds one column to the user table. Nothing is removed and nothing is rewritten. If you hold configuration backups, this release changes what new ones carry — old files still import.
Fixed
-
A configuration restore could bring back a user you had removed. Live, and signable-in, with their old password hash, two-factor secret, recovery codes and every mailbox credential — listed on the review page as an ordinary new account, indistinguishable from a colleague you had just added. It happened because removing someone frees their email address on purpose, and freeing it was also what the restore relied on to recognise them.
plMail now remembers the address a removed account held, so a restore can tell the difference and leaves them removed. One limit, stated plainly: accounts removed before this release have no address on record and cannot be recognised — for those, the old behaviour stands. There is nothing to recover it from, and guessing would be worse.
-
Four things you had configured were missing from every backup taken since they shipped. They were absent rather than wrong, so a restore reported success and simply came back without them:
- the whole assistant configuration — the model host and its access token, both model names, the feature switches, and every prompt you had rewritten. Each person's own assistant preferences had always been carried, so a restore returned those pointing at a host that was gone;
- account signatures and read-receipt defaults, per account and per alias. Composed by hand and stored nowhere else, so on a move to a new host they were unrecoverable;
- the log level chosen in the admin panel, which looked covered because the environment variable beside it was restored;
- five interface choices — insight extractors, the insight strip, forward-quote and send behaviour, preview width — and the scopes an OAuth provider had granted.
-
A restore that collided with a leftover app password failed with a blank error page. It now says what happened and why, and still changes nothing.
-
Backups now keep the date an app password was created, instead of stamping the restore.
-
A malformed backup file could produce a blank error page instead of "that is not a plMail backup", including on the restore step of a fresh install.
-
The admin handbook said the backup file contains "no user accounts". It contains every one, with their secrets. Corrected in both languages, along with the lists of what the file carries.
v0.2.12 — 2026-08-31
Everything in v0.2.11, which never produced an image. Same schema change as below: one added column, nothing removed or rewritten.
Fixed
-
v0.2.11 could not be built. One translation in the pirate locale opened with its placeholder rather than a word, which YAML reads as structure instead of a sentence, and the container build compiles every locale. Nothing shipped under that number — if you are on v0.2.10, this is the upgrade.
The catalogues are now parsed by the test suite, which had never opened one.
v0.2.11 — 2026-08-31
Adds one column to the user table. Nothing is removed and nothing is rewritten, so an upgrade costs you nothing and a downgrade only forgets which accounts were switched off.
Added
-
You can change your own password. Settings → Security, at the top. Until now nobody could: not from the app, not by mail — an administrator set one for you and that was the only way it ever changed. It asks for your current password before it will take a new one.
-
An account can be switched off instead of deleted. There was nothing between leaving a user alone and destroying them, so "this person has left, keep their mail" meant deletion. An administrator can now suspend an account from Admin → Users: everything they own stays exactly where it is, and they cannot sign in until somebody switches it back on.
It takes effect everywhere at once, including sessions that are already open and mail clients that are already connected — an app password outlives the browser it was created in, and stopping the login form alone would have left every connected client syncing.
-
An inbox tab says in colour when it is holding unread mail. The category icon wears its own colour — blue for Social, green for Promotions — while there is something unread behind it. No second number: the tab still shows only the "3 new" pill.
This is most useful under Unread only, where the tab strip used to say nothing at all. Every row on screen is unread there, so bold text separates nothing, and the Inbox badge in the sidebar is one total that cannot tell you which tab the mail is in — a full tab looked exactly like an empty one, and finding your mail meant clicking each tab in turn.
-
A forgotten password can be reset from the console, with
app:user:password. The interface said this was possible for a while before it was.
Fixed
-
The inbox tabs and the page count included conversations you had moved to the bin. Binning a conversation adds a Trash label rather than removing the Inbox one, and the lists have always known that while the numbers beside them did not. A tab could point at mail that was not there, and the pager could offer you a page that did not exist.
-
Searching users in the admin panel ignored anything shorter than three characters and quietly handed back the full list instead of saying so.
-
Declining to remove your own administrator rights reported success, having changed nothing.
-
A password field's help text showed
at least %count% charactersinstead of the number.
v0.2.10 — 2026-08-28
No schema changes. Summaries get to the point.
Changed
-
A conversation summary is at most three short lines, and reads as a list. It used to open with a paragraph, because that is what we asked the model for — and most of that paragraph was greetings, thanks and sign-offs, which are what a mail thread is made of and never what it is about. It now says what happened and what is still open, and nothing else. For the rest, the messages are right underneath.
Every stored summary is rewritten on next open; there is nothing to clear. If you have replaced the summary prompt yourself in Admin → AI, yours still wins — press "Back to the shipped wording" on that card if you would rather have this one.
v0.2.9 — 2026-08-28
No schema changes. Writing on a phone, and a search result that opened nothing.
Fixed
-
A search suggestion opened a blank page. Picking one of the conversations that appear under the search box changed the address bar and then showed an empty mailbox — on every device, and for longer than we would like. Choosing the same row with the arrow keys and Enter always worked, which is why it went unnoticed.
-
On an iPhone, a strip of the mail list showed between the composer and the keyboard. The window now covers everything behind it, whatever the browser has done with the keyboard, and keeps what you are writing above the keys.
-
The search bar on a phone reaches both edges of the screen, instead of leaving the list showing down either side of it.
v0.2.8 — 2026-08-28
No schema changes. The search box on a phone, and the zoom that came with tapping anything.
Changed
- Search is a button on a phone now, not a sliver of a field. It had about 120px of the topbar to live in, which was too narrow to read back what you had typed and wide enough to be the first thing a thumb landed on reaching for something else. Tapping the magnifying glass drops the full width of the box out from under the topbar with the cursor already in it. Nothing changes on a desktop.
Fixed
-
Tapping a text field on an iPhone zoomed the whole page in, and it did not zoom back out. The search box, the login form, the composer — anything you could type into. iOS does that to any field whose text is smaller than 16px, and everything here was 12 or 14. Fields are at least 16px on a phone now, and they still grow with the text size set in Settings → Appearance.
A message written on a phone is composed very slightly larger than one written at a desk as a result. That is the trade for a composer you do not have to pinch your way out of after every tap.
v0.2.7 — 2026-08-28
No schema changes. Writing a message on an iPhone or an iPad, with the keyboard up.
Fixed
-
On an iPad the compose window stayed put when the keyboard came up, so its bottom row — the formatting bar, the paperclip, Send — sat behind the keyboard with no gesture that brought it back. The window moves up now, and shrinks instead of climbing off the top of the screen when there is not room to do both.
-
On an iPhone there was a band of empty window between what you were writing and the keyboard. The space was being held clear of the home indicator, which the keyboard was already covering.
Both are the same misunderstanding: while the keyboard is open, iOS goes on telling a page the screen is its full height, and everything anchored to the bottom of that screen believes it. Android says otherwise and was never affected — which is why this only ever looked wrong on one of them.
v0.2.6 — 2026-08-27
No schema changes. Six things that were wrong in ways that looked right.
Fixed
-
Asking for a summary could die part-way through. The request said it wanted no time limit at all, and the limit it got was zero seconds. It names a real ceiling now, above the one the model host is already held to. Drafting was written the same way and would have hit it on any request that took longer than half a minute.
-
A cancelled send could reopen a composer you had just closed — and a cancel pressed in the moment before the send became cancellable could be dropped entirely, sending the message anyway. Both are gone.
-
An event running over several days showed as an hour. The details card named the day it started, the time it started and the time it ended — three true things that together said something false. It names both days now. The heading above it said "Title".
-
Two cards on the profile page were touching, so they read as one card with a line through it.
-
A message's "···" menu was drawn underneath the message below it.
-
Account health's heading and its cards started at different places on any window wide enough.
v0.2.5 — 2026-08-27
Adds seven columns and changes one; the migrations run on boot. The prompts behind the assistant are yours to edit, and a handful of things that were quietly wrong are not any more.
Added
-
The system prompts are in Admin → AI. All seven — the four writing tasks, the summary, the tab sorting, and the sentence that makes the assistant answer in the language of the mail. Leave a box empty to keep the wording plMail ships, so a later update can still improve it; what you type replaces it completely.
Editing the summary prompt marks summaries already written as out of date. Nothing is deleted — they are rewritten the next time somebody asks.
Fixed
-
A cancelled send could reopen a composer you had just closed. With the send behaviour that keeps the window on screen, calling a send off and then closing the window meant the response — whose job is to put the composer back — arrived after you had shut it, and it came back on its own.
-
A message's "···" menu was drawn underneath the message below it. It was never clipped; it was covered.
-
Account health's heading and cards started at different places on any window wide enough — and only there, which is why it survived three previous sweeps.
-
The summary card no longer appears before you ask for it. A conversation nobody has summarised now shows only the control, beside the subject.
-
The calendar could turn one event into six. Ticking a second calendar that mirrors the same remote calendar as the first made two copies, and once the provider gave the event its own identity the two stopped being recognised as the same meeting and were drawn separately. plMail now keeps the copies together, marks which calendar actually syncs, and refuses to offer the same remote calendar twice. A multi-day event also stops printing its start time on every day it covers — it does not start again on Saturday.
v0.2.4 — 2026-08-27
Adds a table and two columns; the migrations run on boot. Conversations can be summarised, when you ask.
Added
-
Summarise a conversation. A control at the top of an opened thread writes a short summary of it. Nothing is summarised on its own — a summary is about a minute of the writing model on a cold start, and spending that on every conversation that arrives would make everything else wait.
The summary is kept and shown again when you reopen the thread, so it is paid for once. If the conversation changes afterwards, the old summary is still shown but marked as out of date, with the offer to write a new one — a summary of a thread that gained one reply is usually still true, and hiding it would waste the minute you already spent.
It writes in the language of the conversation, like the rest of the writing help. Off by default, switchable by an administrator for the installation and by each person for themselves.
Fixed
- Asking the assistant to write made the rest of the page stop responding. A request holds a lock on your session while it runs, so everything else you clicked queued behind a draft being written — which on a cold model is most of a minute. Search was fixed for this in v0.2.3 and the composer was not, which was the worse half.
v0.2.3 — 2026-08-27
Adds one migration and an optional database image. Search by meaning was taking thirteen seconds and taking the rest of the page down with it. Both halves are fixed.
Fixed
-
Searching by meaning was slow, and got slower the more mail was indexed. Thirteen seconds on average, and up to forty-seven. The comparison every semantic search runs was being performed on every message the search matched rather than only on the candidates it was meant to consider — and then performed a second time on each of them. Measured on a test mailbox, one search went from 12,616 comparisons to 2,000, and from 3.8 seconds to 0.6.
A search that still takes too long now gives up the meaning half and returns the keyword matches rather than the page hanging.
-
A slow search made the whole tab stop responding. That was not the search: a request holds a lock on your session while it runs, so everything else you clicked queued behind it and then died. Search lets go of that lock before it starts working, so a slow search is now just a slow search.
-
Relevance ordering was wrong for keyword results. Because the comparison above ran on messages the meaning search had never matched, four in five keyword results were being ranked by a similarity score that did not apply to them. They are ranked by their keyword score now, which is what "Relevance" always claimed to mean.
Added
-
An optional database image with pgvector, for installations that want it. It is opt-in and nothing requires it:
compose.pgvector.yaml, layered on like the demo overlay.It is built from the same Alpine base plMail already ships rather than swapping to a different one, which matters more than it sounds — changing the C library underneath an existing database makes some indexes silently return wrong rows, and that was measured on this stack rather than taken on trust.
Worth knowing before you switch: it makes the comparison about 1.75× faster, not the order of magnitude the internal notes previously claimed. The handbook now says so, along with what reaching a genuinely indexed search would take.
v0.2.2 — 2026-08-27
Adds six columns to user; the migration runs on boot. Everyone can now configure the assistant
for themselves, and the admin pages line up.
Added
-
Settings → Assistant. Your administrator decides what the installation offers; this decides what you use of it. Three switches for the three features, two notes the assistant is given before it writes for you — who you are, and how you like to be written for — and one setting for how much of a conversation it reads.
That last one can now be set to the whole conversation, which is new: the assistant stops answering a thread it has only seen the last message of. It costs time rather than anything else — the model runs on your own machine — and a long thread drops its oldest turns first, never the message you are replying to.
Nothing here can switch on something an administrator has switched off.
Fixed
-
The collapsed sidebar's icons were not in a straight line. Inbox and Starred sat ten pixels left of the rest, because they are the two rows that carry an unread badge and are built differently because of it. The new-mail dot now sits on the corner of the icon rather than beside it, so a collapsed rail still says where mail arrived.
-
The admin pages had never agreed on an inset. Cards, the labels above them and the prose between them each chose their own, so headings and the text beneath them started at different places. Twenty-eight cards now take the same measurement from one place, and two tests keep it that way — one that fails when a card sets its own, and one that measures the rendered page and names every card that disagrees.
-
The writing assistant answered in the wrong language. A German message came back with an English reply, and proofreading a German draft quietly translated it. It now writes in the language of the message it was given.
-
"Draft a reply" was offered on new messages, where there is nothing to reply to and it could never work.
v0.2.1 — 2026-08-27
No schema changes. Fixes to the language-model work released earlier today, all of them things that only show up once it is actually being used — and a change to when new mail gets indexed.
Fixed
-
Asking for a draft died after thirty seconds, mid-sentence. PHP's own execution limit is well below what the request legitimately takes: a writing model that has fallen out of memory needs around thirteen seconds to be read off disk before the first word exists, and a reply worth reading is another half-minute after that. The limit fired inside the wait, so the text stopped half-written, the reader was shown an error, and the model carried on generating for nobody.
-
Generated text landed underneath the quoted message. A reply opens with the original already in the body, so "the end of the draft" is below it — the answer arrived somewhere nobody scrolls to, under the message it was answering. It now goes where your cursor already is when you start writing a reply: above the quote. Plain-text drafts too.
-
The wait for a model to load looked like a hung window. There is nothing honest to put in a progress bar — the model host reports no progress at all while it loads — so the sentence saying what is happening now has three dots moving beside it. They stop the moment the first word arrives, because text appearing says it better.
-
Admin → AI was misaligned. Each card's heading and its contents started four pixels apart.
Changed
-
The panels in Admin → AI fold away, and the settings card starts folded once a host and models are on file. Settings are a thing you finish; after that the card is a wall of filled-in fields standing between you and the two panels worth coming back to. It opens itself on a fresh install, straight after a save, and when something you typed was refused.
-
New mail is no longer indexed the moment it arrives. On a machine with one GPU that is the wrong trade: it is rare to search for mail that has just landed, and paying to index every arrival competes with the things somebody is actually waiting on. Two triggers replace it — shortly after a search, because a search has just loaded the model that indexing uses and nobody is waiting on it any more, and once a night as the backstop (
app:ai:index-new-mail, 03:20).Indexing also stops standing aside after a search. It always should have: the composer spends the 20 GiB writing model and is worth keeping out of the way of, but search spends the small embedding model — the same one indexing uses — so a finished search is the best moment to index, not a reason to wait. It still stands aside for the composer.
v0.2.0 — 2026-08-27
Adds two tables and two columns; the migrations run on boot. The language model can now be watched while it works — and the composer button that was reported as dead had never run at all.
Fixed
-
"Help me write" did nothing, in every installation that had it switched on. The composer looks up its editor by one attribute name and renders it under another, so the very first guard returned and no request was ever made — no pending state, no error, nothing in a log. The subject was read the same wrong way and failed more quietly still: every prompt that did reach a model went out with an empty subject. Reported as "I click the AI button and nothing happens", which was exactly right and had nothing to do with the model.
-
Background work could not be opened. The topbar indicator pulsed away saying work was running, which was true, and clicking it did nothing whatsoever — the panel was hidden by a CSS class while the control that opens it clears an attribute. Worse, because the attribute was absent, the first click was read as a close.
-
Finished background work never left the list. Three "Marking as read" jobs stuck at partial progress had stopped weeks earlier. A job that fails properly says so and clears after five minutes; these were killed mid-flight — a restart, a worker that went away — and the query behind the indicator had no notion of time at all, so "still going" and "abandoned" were the same row forever. Jobs now report progress as they make it, and
app:jobs:reapsweeps the stragglers. -
Chrome filled saved mail credentials into API-token fields. Observed in Admin → AI: focusing "Model host" pulled in the account address and the token field took the account password. Nothing was submitted, but a login was one press away from being stored as a token and sent to whatever host that field names. Every credential input now says it is not the site login.
-
The image proxy refused pictures without saying so anywhere. The reader still sees the same blank placeholder — telling a stranger's server apart from a refused one is what its rules exist to prevent — but the operator now gets a line saying which of the two happened.
Added
-
A performance panel for the model host, in Admin → AI. What is loaded right now, how long until it is unloaded, whether the models you named are actually installed, and what recent calls have cost — median and 95th-percentile tokens a second, per workload and per model, over an hour, a day or a week.
The state worth the whole panel is "partly on the CPU". When a model does not fit, some of its layers spill out of GPU memory and throughput collapses — while the answers stay perfectly correct. Nothing else in plMail can see that, so without this the symptom is that everything is simply slow, for as long as it takes somebody to think of checking.
What is recorded is counts and durations. No prompt, no completion, no subject, no address, and failures are recorded as a category rather than a message, because an HTTP error routinely quotes the request back and the request is your mail.
app:ai:prune-metricskeeps a month. -
The composer shows the model working. Text arrives as it is written, into a panel of its own that you accept or throw away — never straight into the message, so one undo removes it and an arriving word cannot land in the middle of a sentence you are typing. Stopping stops the model, rather than only the watching.
The wait before the first word is now a sentence rather than a silence. A model that has gone idle takes around fifteen seconds to load, and saying so is the difference between waiting and giving up.
-
Search says whether it searched by meaning, and how much of the mailbox it could. A search that quietly fell back to words alone now says why — the host did not answer, the model is not installed, the query was too short. Results found by meaning are marked as such, so an apparently unrelated message can explain itself. And while indexing is still running it says how far it has got: meaning-search over a mailbox that is 8% indexed is not a feature, it is a trap.
-
Indexing existing mail has a progress display and controls. Start, pause and resume in Admin → AI, with a rate and an estimate that stays hidden until it means something. It counts what is actually indexed rather than what it thinks it did, so a worker that dies resumes instead of starting over — and it stands aside while anyone is using the AI and comes back on its own, because both want the same GPU and a batch in flight makes the composer feel dead again.
-
A second opacity slider, for windows and menus. Translucency multiplies: a composer at 70% over a pane at 70% left about a tenth of the wallpaper in the middle of the text. Windows, menus, modals and toasts now have their own setting, and it cannot be taken as low as the panes'.
Changed
- Vectors are matched to the model that made them. Changing the search model in settings used to leave every existing vector silently in place, comparing numbers that mean nothing to each other. Search now only compares vectors from the model in use and says that the rest are waiting to be indexed again.
v0.1.45 — 2026-08-26
Adds three tables; the migrations run on boot. plMail can use a language model you run yourself — and thirteen settings controls start working again.
Added
-
Optional AI features, off by default and off in a way that means something. plMail is a complete mail client with all of this switched off, which is the state every existing installation is in and where most will stay. Nothing is enabled until an administrator enables it, and nothing leaves your network: the model is an Ollama container on a machine you own.
Three separate switches, because they have very different costs. Search by meaning adds results matching what you meant rather than only what you typed. Sort mail into tabs fills in a category — but only where no rule recognised the message at all, so it can never overrule a header, a Gmail label, or the fact that you have written to somebody. Help me write offers drafts and rewrites in the composer, only when asked, and always by appending: it never replaces what you wrote.
Configured in Admin → AI, with a Test button that tells you the difference between "nothing answered at that address" and "that host answered but is not holding the model you named". It is also a step in first-time setup, where "no thank you" is a complete answer that is remembered.
Semantic search needs one pass over an existing mailbox —
app:ai:embed-mailbox— because switching it on only embeds mail that arrives from then on. It runs on the maintenance worker, is safe to interrupt, and safe to repeat.See Admin → AI in the handbook, and
docs/internals/ai-assist.mdfor why the vector design avoids pgvector.
Fixed
-
Thirteen settings controls rendered correctly and did nothing when you used them. The log browser's two filters, the clock and timezone pickers, two compose defaults, two compose-behaviour radios, three push delivery filters, a copy field, the attachment thumbnails' error fallback, and the log level toggle added in v0.1.44 were all inline event handlers — and the enforced Content Security Policy refuses to run those, because neither a nonce nor a hash authorises an inline handler.
It stayed hidden because an inline handler violates nothing when the page loads: the pages were clean, the controls looked right, and the failure was silent unless a console was open. A control that does nothing reads as a broken setting rather than a refused script.
v0.1.44 — 2026-08-26
Adds a table; the migration runs on boot. The log level is a setting on the page, not a restart.
Added
-
Admin → Logs can set how much is kept. A second row under the filters — Keep entries at — decides what reaches the log table at all. It is deliberately not another control in the filter row: that row narrows what is shown out of what was stored, this one decides what gets stored, and the two side by side would invite changing the wrong one and concluding the log was broken.
This used to mean editing
APP_DB_LOG_LEVELon the host and restarting the stack — at the exact moment that is least convenient, because the reason to change it is that something is wrong and the answer is one level further down. That is how installs end up running oninfofor months.The change applies to the next request immediately and to background workers within about ten seconds; each process holds the answer briefly rather than asking the database on every line, and a lookup that fails for any reason falls back to the environment rather than throwing — this code runs inside the logger, and a logger that throws while deciding whether to log is worse than one that is briefly out of date.
APP_DB_LOG_LEVELremains the default, and an install that never touches the control keeps following it, including later changes to it. Choosing from the environment hands it back after a level has been set here — which is not the same as picking today's value out of the list, because that would freeze it.
v0.1.43 — 2026-08-26
No migration. A list you have already seen stops animating itself in again.
Fixed
-
The list entrance replayed on every navigation back to it. Reported as the loading animation being interrupted part-way and starting over. The rows region decided whether to play its staggered entrance by asking whether it had been seen before — and it is an element with no id, which that check answers "new" to unconditionally. So the cascade played every time the element was inserted, and Turbo replaces the whole page body on every visit: coming back out of a conversation built a fresh list holding the same conversations and cascaded all of them in again, over whatever was still playing.
The rows are the things with identity, so they are what gets asked now. A conversation that has been on screen stays still; one that has not drops in. A folder change is all new rows and still plays in full, a single new mail among fifty still announces itself, and returning to a list you were just looking at does nothing at all.
v0.1.42 — 2026-08-26
No migration. Back from a conversation no longer shows the list as it was before you read it.
Fixed
-
Opening a mail and pressing the browser's Back button showed the conversation as unread again. Opening a conversation is a Turbo visit, so the list is snapshotted on the way out — and it leaves before the read happens, because the read is on a two-second timer and the update it renders back targets a row that is no longer on screen. Back restored a picture of the list taken before the read: the row bold, the unread count still counting it, and no request made that could have known better.
A poll repaired it a few seconds later, which is why it happened "sometimes" and why a reload always fixed it. The mail lists are no longer snapshotted, so Back asks the server, which knows. That costs one request on a screen whose only purpose is to show current state.
v0.1.41 — 2026-08-26
No migration. A hub that is down no longer fails the work it was announcing.
Added
- The details panel shows what plMail's own rules would have said about a category, on messages
where Gmail decided it. Gmail's
CATEGORY_*labels are authoritative while they arrive, so the local rules never run on that mailbox — and take over the instant the labels stop, having never been checked against real mail. Both answers are now side by side, so the difference is something to tune before that day rather than discover after it. The extra line is suppressed when Gmail is not deciding, where it would only repeat the line above it.
Changed
- The blocked-images bar sits above the message now, under the attachments rather than beneath the body. On anything longer than a screen it was past the fold: the reader met a message full of gaps, scrolled to the end to find out why, then scrolled back. It is the control for reading the message, not a footnote about it. Still outside the sandboxed frame, which is the part that is not negotiable — a "Show images" button the message could draw for itself is worse than no button.
Fixed
-
A Mercure hub that was briefly unreachable failed a whole account sync. Mail was fetched, threaded and committed, and then the handler was sent back for retry because the update saying "your mailbox changed" could not be published. The doorbell failing does not un-deliver the parcel.
Five of the seven publishers had no guard at all, and two of those were worse than the one that was reported: the background-job notifier is called from a job's own failure handler, where an exception would replace the real error with this one, and the send-outcome notifier announces the result of sending mail. Every publisher now records the failure and carries on. A sixth swallowed its errors silently, which hid bugs as effectively as outages; it logs now too.
The visible cost of a hub outage is what it always should have been: a screen that waits for its next refresh.
v0.1.40 — 2026-08-26
No migration. Search stops paying for label joins it never reads.
Fixed
-
Every search joined the label tables whether or not it asked about a label.
thread_labelandlabelwere part of the statement unconditionally, and only two filters ever read them —label:and the mailbox behindin:. Both joins are to-many, so every matching row was multiplied by the number of labels its conversation carries and then collapsed again by theGROUP BY. A mailbox where conversations sit in an inbox, a category and a user label did three times the work on every free-text search to build a set it immediately threw away.The answers were always correct, which is why it went unnoticed:
GROUP BYhides the duplication perfectly and only the clock shows it. The joins are now added when something asks for them.There is a test for the shape of the statement rather than for its results, which is unusual and deliberate — a result-based assertion could not fail if the joins came back.
Note
If search is still slow after this, check the trigram indexes are present:
SELECT indexname FROM pg_indexes WHERE tablename = 'message' AND indexname LIKE '%trgm%';
Four rows are expected — subject, body_text, from_name, from_address — plus
idx_message_search_vector. They are created by migrations and deliberately absent from the ORM
mapping, so doctrine:schema:update --force drops all of them and turns every search into a
sequential scan of the whole table. Never run that against a plMail database; migrations are the
only supported path.
v0.1.39 — 2026-08-26
No migration. A deploy script for demo instances that cannot quietly do nothing.
Added
-
bin/deploy-demo.sh— pull, bring up, prune, in that order and with the failure that matters actually checked. Every deploy leaves its predecessor behind and nothing collects them, so on a host with a modest disk they accumulate until a pull runs out of space part-way through a layer.up -d --waitthen brings the stack up on the image that is already there and reports every container healthy — a deploy that changed nothing looks exactly like one that worked.So the script checks for room first, prunes if there is not enough, refuses to pull rather than half-pull, and clears the replaced images afterwards. It pipes the pull nowhere, because through a pipe the exit status belongs to whatever came after it and
pull | tailreports success no matter what happened. The prune is-a(a replaced release is fully tagged, so dangling-only leaves it) and never--volumes(the demo database is in one).DEMO_MIN_FREE_MBsets the floor,DEMO_COMPOSE_FILESoverrides the overlays. Documented under Demo mode → Deploying an update.
v0.1.38 — 2026-08-26
No migration. Errors in the log now carry a stack trace, and a bug can no longer disguise itself as a network problem.
Fixed
-
Ninety-four log entries recorded an exception's message and threw the rest away. Sites all over the application logged
'error' => $e->getMessage(), which is a sentence with no class, no file, no line and no cause behind it — and forty-eight of those were at error or critical, where a trace is the whole point. Every one now carries the exception itself alongside the message, so an entry in the dashboard says where it came from and what was underneath it. -
The image proxy caught every possible failure and called all of them routine. One
catch (\Throwable)covered both "the host did not answer" and "this code is broken", and logged the pair atinfo— below the level that gets stored. Found the honest way: a typo introduced while fixing the unlabelled-image bug threw a fatal, was caught as though the network had hiccuped, and then did not appear in the log at all.A network failure is still
info, because an unreachable host is not an application fault and any mailbox with newsletters in it produces a steady trickle of them. Anything else is nowerror, which is stored without anyone lowering a threshold first. -
The same trap, in five more places. Listing an IMAP folder, checking whether a UID is still on the server, purging a remote message, issuing a Mercure cookie and running a calendar-data mapper all catch everything and continue — correctly, because those failures are ordinary weather rather than faults. But
\Throwablealso covers\Error, and an\Error— an undefined method, a wrong type, a wrong argument count — can only ever be a bug in this codebase. Reported at the same level as a network hiccup, and below the stored threshold, one of those is invisible.Those sites now choose their level from what was actually caught: ordinary failures stay where they were, a programming error is reported as one. Nothing else changes — the same failures are still tolerated, still swallowed, still return what they returned before.
Where a site can name the exceptions it expects, naming them is sharper and is preferred; the image proxy does exactly that with the HTTP client's own interface. This is the floor for the many places that legitimately cannot.
v0.1.37 — 2026-08-26
No migration. Images that arrive without a label are images again.
Fixed
-
An image served without a content type was refused, and rendered as a blank space. The image proxy required the response to declare
image/…. S3 answersapplication/octet-streamfor any object uploaded without a type, and a great deal of the web's images live in exactly that state — Amazon's return-label QR codes among them. A perfectly good PNG was refused, the reader got an empty box where their barcode should have been, and nothing anywhere said why.An unlabelled response is now identified from its own magic bytes and served as whatever it actually is. This is less trust in the sender rather than more: the declared type is discarded and the content decides, held to the same allow-list attachments are held to. That list excludes SVG deliberately — it is the one image format that can carry script, and "the sender did not label it" must not become the way to get script served from plMail's origin. A response that is not an image is still refused, and now says so.
v0.1.36 — 2026-08-26
No migration. Search stops timing out, and the log finally says where a fault happened.
Fixed
-
A search could spend the whole request on one query and return a 500. Free-text search runs a cheap indexed pass first and, when that comes back with less than a screenful, falls back to scanning message bodies for the raw substring. That fallback cannot be indexed away — it exists for a needle full-text cannot see, like a word inside a domain name — and on a large mailbox it ran long enough to hit PHP's thirty-second limit:
MaxExecutionTimeError: Maximum execution time of 30 seconds exceeded.It now runs under a five-second database timeout, and when it expires the results the cheap pass already found are returned instead. What is lost is the occasional infix match; what is no longer lost is the search. The timeout is enforced by Postgres rather than PHP on purpose — PHP's limit kills the process and leaves the database still working on a query nobody is waiting for any more.
-
A remote image that failed to load said nothing anywhere. The image proxy has four ways to give up, and the two most common — the host answering with a refusal, and an error page served as HTML with a 200 — recorded nothing at all. The image became a transparent placeholder, indistinguishable from a spacer, and an expired link, a host blocking an unfamiliar user agent and a mistyped URL all looked identical with no evidence to tell them apart. Both now log the reason, with the status code or the content type attached.
At
info, like the proxy's other diagnostics: setAPP_DB_LOG_LEVEL=infoto see them. Kept below the default deliberately — every expired tracking pixel in a newsletter takes this path, and a dashboard that is mostly those stops being read. -
Every logged exception said
"exception": []. The log handler wrote the raw context to the database withjson_encode, and a PHP exception has no public properties — so its class, file, line, stack trace and underlying cause were all encoded as an empty object. The dashboard showed the level and the message and threw away the one field that says where.Faults now record class, message, code, file, line, a stack trace, and the full chain of previous exceptions — which is usually where the real cause is. Log entries also carry the request that produced them: method, path and route name.
Stack frames deliberately carry no argument values, and the request's query-string values are not recorded either — only the parameter names. A trace's arguments are the plaintext password on a sign-in path and the message body on a mail one, and a search term is private to whoever typed it. What is kept is what identifies the code path; what is dropped is what would turn an admin page into a place secrets accumulate.
v0.1.35 — 2026-08-26
Adds columns to message and account; the migrations run on boot. Mail that bounces now says so.
Added
-
A message that was refused now shows it. When a delivery status notification comes back, the message it is about gains a red Not delivered panel naming the recipient that failed, the reporting server's own diagnostic, and the status code. Until now the only trace of a failed send was a machine-generated mail in the Inbox whose body is an SMTP transcript, while the message itself sat in Sent looking like it had worked.
Deliberately narrow. A delay notice is not a bounce — mail servers send one after a few hours and keep trying for days, and marking a message in flight as failed would be wrong at exactly the moment it mattered. A delivery confirmation stamps nothing. A bounce naming a message this mailbox never sent — forged mail bouncing back at the address it was forged from, which arrives constantly — is ignored. Nothing is retried, and no address is ever disabled: plMail records what one server said about one attempt and leaves the decision to you.
The bounce itself stays unread in the Inbox rather than being filed away the way a read receipt is. Its body is often the only readable account of what went wrong.
-
The health page says how often an account has had to re-list its whole mailbox. Both providers hand out a sync cursor that expires, and the answer to either is a full re-enumeration. Once in a while that is housekeeping; repeatedly it means the account is not being synced inside the window the provider keeps, or a worker is dying mid-batch. Nothing recorded it, so the two were indistinguishable — and the symptom of the second is a mailbox that is quietly, intermittently behind. It appears as a detail on the existing sync card rather than as a warning of its own, because on its own it is not a problem.
Fixed
-
A bulk action over more than 100 threads applied only the first 100 — and reported success. The handler clears the EntityManager after each chunk, but resolved the whole thread list once up front, so from the second chunk on it was writing through detached objects. On a real mailbox that surfaced as
Multiple non-persisted new entities were found through the given association graph; under other conditions it did something quieter and worse — the job finished, the counter read 250 of 250, and 150 of those messages were never touched. It now carries thread ids across the chunk boundary and re-reads each chunk, because scalars survive a clear and managed objects do not.A failing job was also left looking like a running one: the failure was written to the same detached job row and flushed nothing, so the indicator span for ever. It re-reads before recording the failure now.
-
A bounce could be mistaken for a read receipt, which put Read at … on a message that was never delivered. Both are
multipart/reportand both carryOriginal-Message-ID, so a bounce satisfied the receipt matcher's fallback. The two field blocks are now told apart by the fields only one of them has. This was reachable before this release and is the reason the fix is listed separately from the feature.
v0.1.34 — 2026-08-25
No migration. The sidebar badge keeps up, and the list stops flashing.
Fixed
-
A single arriving mail did not move the sidebar badge. Counts refresh at most once every fifteen seconds, and anything arriving inside that window was discarded rather than deferred — so one delivery, with nothing following it, left the badge on its old number indefinitely. It is now fetched at the end of the window instead. Bursts are still collapsed into one request.
There are two sidebars on a page — the rail and the drawer — and only one of them was being brought up to date. Both are now.
-
The message list flashed when it refreshed. The rows animate, but the category tabs and the pager were rebuilt from scratch each time, so everything around the rows blinked. They are updated in place now, which is what makes new mail look like it arrived rather than like the page restarted.
v0.1.33 — 2026-08-25
Adds a table; the migration runs on boot. Acting on a whole view no longer times out.
Added
-
Acting on a whole view now runs in the background, with an indicator that says so. Selecting every conversation in a view and marking it read — 5,455 of them, in the report this came from — hit the thirty-second request limit and left a broken page with no way to tell how much had happened. It becomes a job now: the page answers straight away, and a control appears in the topbar showing what is running and how far along it is.
The indicator is not there when nothing is happening, and does not linger after a job succeeds — the list already shows the result. A job that fails stays for five minutes, because nothing else would tell you.
Selecting rows on the page still acts immediately. That is bounded by what is on screen, finishes in milliseconds, and would only feel slower for being deferred.
Fixed
- "New event on today" opened in the past, for the last hour of every day. After 23:00 the calendar's new-event dialog offered 00:00 that morning rather than the next full hour — the whole day behind the clock, on the field you are most likely to accept unchanged.
v0.1.32 — 2026-08-25
Adds three columns; the migrations run on boot. Two silent mail-loss bugs on Gmail accounts, and three things the app knew and never told you.
Fixed
-
A Gmail sync could abandon mail silently. When Google refuses a sync cursor as too old, plMail is supposed to re-list the mailbox. On any account past its first sync it fetched a fresh cursor and stopped, so everything that happened in between — mail that arrived, was read, was deleted or was refiled — stayed missing or stale for good, with the app reporting full health.
-
A Gmail message id made entirely of digits crashed its whole batch. PHP turns a numeric-looking array key into a number, so those ids came back out as integers and the fetch died. Five retries, then the batch was dropped: every refiled message in it, read state included, was never re-read. The deletion path had the same fault with no error at all — the message simply was not deleted.
-
A rate limit from Google Drive or Photos was reported as a missing permission, sending you to re-grant something you already had on a connection that would work again in a minute.
Added
-
Connected services now say when they were given less than plMail asked for — the same check the mail accounts got in v0.1.28. Google Photos in particular is often refused permission to browse a library while being allowed to save to it, which until now looked like an empty library and nothing else.
-
"N background jobs were given up on" now names the account and the kind of work. Fifty failures that are all one mailbox is one problem, and the card could not say so.
-
Your mail server can finally tell you something. IMAP has a channel for exactly this and the standard says a client must show the text; plMail read those lines and dropped them. The most common one by far is a mailbox over quota — which otherwise just stops receiving mail, with everything else looking healthy.
v0.1.31 — 2026-08-25
No migration. The "not given full access" card can now be cleared by granting access.
Fixed
-
The permissions card would not go away, however many times you reconnected. Two reasons, and either alone was enough. The comparison counted
openid,email,profileandoffline_access— sign-in scopes that providers do not report back — so a complete grant looked four permissions short, on every account, permanently. Microsoft accounts were affected outright, since Graph omits all four. And the older evidence outlived the fix: a calendar keeps the error it failed with until it next syncs, so even once the grant was right the stale error raised the card again. A recorded grant with nothing missing now ends the question. -
The Composing card on Settings → General had no inset, so its labels and toggles sat flush against the card's edges.
Added
-
Microsoft and IMAP accounts now say when a change never reached the server. Marking read, archiving or starring can be applied here and refused there — the change then survives on screen until the next sync quietly undoes it. Gmail gained this in v0.1.28; the other two were silent.
Nothing is written off as permanent. A refusal can be a missing permission or a mailbox that is briefly unavailable, so the record simply says what the provider said, and the next change that goes through clears it.
v0.1.30 — 2026-08-25
No migration. Marking a large selection read now actually reaches the provider, and a send is confirmed after it happens rather than before.
Fixed
-
Marking more than 1,000 conversations read did nothing. Gmail refuses a batch of over a thousand ids outright. plMail sent them all in one call, so the refusal came back, the rows had already changed locally, and the next sync quietly put them all back — a button that appeared to work, twice, with the reason only in a log. Batches are split now, for marking and for deleting.
-
"Message sent." appeared two seconds before the send was attempted. The confirmation was tied to the end of the undo window (eight seconds), not to the send (ten). It comes from the worker now, once there is an outcome — and a send that fails says so, which it never did before: every sender turned a failure into a silent no-op with no retry and no record.
-
A single network blip could mark an account as needing reconnection permanently. Any failed token refresh raised the red "sign in again" card, and the line that cleared it only ran when the provider returned a new refresh token — which Google does once, at first connection. The card never went away on its own.
-
Gmail's own malformed-request errors are no longer retried. A 400 is refused identically every time; the retry ladder just produced three more copies of the line explaining what was wrong.
v0.1.29 — 2026-08-25
Adds two columns to account; the migration runs on boot. A detected fault now offers the repair
it tells you to make.
Fixed
-
Calendars refused for missing permissions now name the account and offer the reconnect. They said "has stopped syncing", explained in their technical detail that the account needed reconnecting with calendar access allowed, and offered a Try syncing now button that could only fail. If you upgraded to v0.1.28 and saw no change, this is why: recording what a provider granted only helps accounts connected afterwards, and the evidence for everyone else was already sitting on the calendars themselves.
-
An OAuth account can be reconnected whenever you want. Settings → Mail accounts has a Reconnect button on Google and Microsoft accounts now. Previously the only way to reach the consent screen again was a health card, which appears only after plMail has decided something is wrong — so "I would like to grant the permission I declined" was not something you could ask for.
Added
-
A mailbox can report that it has stopped syncing. It could not before: a calendar has recorded its sync failures for as long as it has synced, while the mail account carried a
last_synced_atcolumn that nothing wrote and nothing read. A server refusing connections for a week looked exactly like one that synced a minute ago.The card waits for three failures in a row, so a dropped connection stays quiet, and one success takes it away.
v0.1.28 — 2026-08-25
Adds two columns to account; the migrations run on boot. plMail now checks what a provider
actually granted, instead of assuming it got what it asked for.
Fixed
-
A Google or Microsoft account could connect without the permissions plMail asked for, and nothing said so. Google's consent screen offers sensitive scopes as separate tick boxes and a declined one does not fail the sign-in; a Microsoft tenant can withhold the same by policy. The account connected, the app said so, and the missing permission surfaced days later as something that simply did not work.
Two things did not work, and one of them had no explanation available anywhere. Calendars never synced. And a grant that could not write meant marking read, archiving and starring were applied on screen, refused by Gmail with
insufficientPermissions, and undone by the next sync — so marking five thousand conversations read left every one of them unread.Account health now carries a card naming the account, with a button to connect again and grant the rest. Existing accounts fill in on their next sign-in refresh, or the first time a change is refused — you do not have to do anything to get the card.
-
A permanently refused export was a log line and nothing else. The change stayed applied here and never reached the provider, which is a divergence the person it happened to could not see. It is recorded on the account and reported, and cleared by the next export that works.
-
Four cards for one problem. Calendars stopped by an account-level cause collapse into a single card that names them, instead of one card each offering a retry that could only fail. A single blocked calendar still gets its own card, because its name says more than a count.
-
The select-all banner covered the category tabs and its button read "Select all %count%".
v0.1.27 — 2026-08-25
No migration. A send no longer announces itself finished while still offering to stop.
Fixed
- "Message sent." appeared beside "Sending… 8 · Undo". Two toasts an inch apart, one saying the message had gone and the other offering to call it back. The send response suppresses the first one deliberately — and that suppression had never worked once.
- Compose attachment chips replayed their entrance animation every time the window re-rendered.
- Time-grid event chips printed a start time the column's own axis was already showing.
- The per-account mail list carried the account disambiguation meant only for the merged lists.
- The setup wizard showed the appearance export/import block, which is not something to put in front of someone ninety seconds into the app.
All five are one mistake. Twig's default filter substitutes when a value is undefined or empty,
and false is empty — so false|default(true) is true, and every caller passing false to turn
something off was ignored. A test now refuses that form anywhere in the templates.
v0.1.26 — 2026-08-25
Adds a column to user; the migration runs on boot. Save a signature once instead of drawing it
on every document.
Added
- A saved handwritten signature. Settings → Profile has a pad beside your picture. Draw your signature there and the PDF reader gains Use saved signature, which places it without asking you to draw again. Remove it from the same place.
It is stored as an image that belongs to you and is served only to you, in the same directory scheme as avatars. It is not a credential and there is nothing to verify against it — it is the same picture of a name that drawing one by hand produces, and it means exactly as much.
Deliberately not the Signature section in the sidebar: that one is the block of text appended to outgoing mail, and the two have nothing to do with each other.
v0.1.25 — 2026-08-25
No migration. Sign a PDF by drawing on it, and reply with the signed copy.
Added
-
Signing a PDF in the reader. Draw a signature with a mouse, trackpad or finger, place it on the page, drag it where it belongs, and either reply with the signed copy attached or download it. The demo mailbox carries a document that asks to be signed.
This is a visual signature — a picture of a name on a page, worth what a printed-signed-scanned copy is worth. There is no certificate and it proves nothing about the document being unaltered. Neither the interface nor this entry will pretend otherwise.
The stamping happens in the browser. plMail's server sees the result only if you choose to reply with it, and there is no signing service anywhere in the path. A password-protected PDF is refused with a reason, because signing one would mean stripping the protection it was given.
Fixed
- The details panel was painted over by the message below it. Opening "to me ▾" on any message
but the last one in a conversation put the next message's header on top of it — over the middle,
so the edges stayed visible and it read as a rendering glitch. The panel's
z-30was measured inside its ownsticky z-10header rather than against the page; the header is raised now. - A revoked Google or Microsoft sign-in is no longer retried. A dead grant answers identically every time, and it was producing five CRITICAL log lines per calendar and three per mailbox, all the same sentence — which is how the one line that matters stops being read. It is classified from the provider's own OAuth error code, so a timeout against the token endpoint is still retried. Nothing changes about how you are told: the account already grows a Reconnect button in Settings.
- A calendar was written off for a slow token endpoint. The Microsoft driver treated every failed refresh as permanent, including a network timeout that one more attempt would have survived.
Fixed in this release rather than the last
v0.1.23's German handbook was left marked as translated from an older English one, which failed the documentation check on the v0.1.24 tag. The image was published regardless — the two run in parallel by design — so the release itself was sound; the tag is simply red in the history.
v0.1.24 — 2026-08-25
No migration. PDF attachments open in a reader instead of downloading.
Added
- Read a PDF without downloading it. A PDF chip opens a reader in the modal: pages, zoom, a page-position readout, and the download link it always had. The demo mailbox carries an invoice to try it on.
Nothing about how attachments are served changes. The reader fetches the same route the download
link uses, so PDFs are still delivered as attachment and the inline allow-list still admits only
raster images — mail-controlled bytes are never handed to the browser's own PDF plugin.
The rendering is plMail's own, in the browser, under the same enforced Content-Security-Policy as every other page: a same-origin module worker and no WebAssembly. Nothing is uploaded anywhere to make it work, and a test asserts the reader raises no policy violation, so an upgrade cannot quietly change either of those.
Signing a PDF in place — scribbling a name on it and replying with the signed copy — is the next step and is not in this release.
v0.1.23 — 2026-08-25
No migration. A second scrollbar on phones, a label menu cut off inside a thread, and a demo mailbox with a conversation in it.
Fixed
- A second scrollbar on mobile.
htmlandbodyare100dvhbecause mobile browsers measure100vhagainst the viewport with the URL bar hidden — but the mailbox, settings and admin shells asked forh-screen, so each was taller than the body holding it and the page scrolled to make room. - The Label-as menu was sliced off when opened inside a thread. The reading pane hides its overflow and the menu hung below the toolbar, with no way to scroll to the rest.
- The demo bar covered the last row of the list, which on a phone is the row you were reaching for.
Demo mode
The seeded mailbox gains a four-message conversation, so replies collapsing into one thread has something to demonstrate on, and a formatted HTML mail that stays in Primary — the newsletter is correctly filed under Promotions, which left the only formatted mail behind a tab nobody clicks.
Under the floor
- CODESTYLE 9.5: an assertion must survive the fixture growing. A test that hard-codes how many threads the seeder makes is asserting that number rather than the behaviour, and it fails in a spec whose subject has nothing to do with the change. Three specs brought in line, found by seeding an extra thread and seeing what broke.
v0.1.22 — 2026-08-25
No migration. A demo instance can now serve a privacy notice, and two things that made the demo look broken are fixed.
A privacy notice
/datenschutz, its own page with its own link beside the Impressum. It is
required even on an instance that asks for nothing: a web server writes the
caller's IP address into a log, and an IP address is personal data.
Written from what plMail does rather than from a template — it names the three
cookies it actually sets, and the no-third-parties claim is its content security
policy, which permits connections to this server only. The retention period is
read from APP_DEMO_TTL rather than written into the text, so tuning the one
cannot leave the other claiming something the reaper does not do.
APP_DEMO_PRIVACY_HOST names whoever runs the machine, who is a processor.
Unset, the page says so rather than rendering a blank. See
Demo mode, which also lists what neither the
software nor this notice can check for you.
Fixed
- Pressing Receive a second time appeared to do nothing for up to thirteen seconds. The list refresh coalesces bursts through a fifteen-second floor measured from the last refresh — right for a background sync, wrong for somebody who has just pressed a button and is watching for the result.
- The user menu still wore its own tooltip, until the pointer left the button. A click was meant to take the bubble down, but the menu stops the click before it gets there.
v0.1.21 — 2026-08-25
No migration. Clicking an event now opens it to read rather than to edit — including the description, who else is coming, and the mail it was read out of, none of which the editor could show.
Events open to be read
The only way to look at an event was the editor. Answering "when is this, and why is it on my calendar?" meant opening a form that asks you to change it, and a field with no answer appeared as an empty input rather than being absent.
A click now opens a read-only panel — on the calendar grid and in Happening Soon alike — with Edit one step in. It shows three things the form never could: the description and participants, which live in the JSCalendar overlay the form does not edit, and the message the event came from. Delete stays on the editor, being the one irreversible action here.
Happening Soon rows open whether or not there is mail behind them. Previously the only thing you could act on was the provenance link, so a hand-typed appointment — which by definition has no mail — was the one kind of row with nothing clickable on it at all.
v0.1.20 — 2026-08-25
No migration. New mail on a Gmail or Outlook account never appeared in the list until you navigated — the badge moved and nothing else did.
Fixed
- New mail did not reach the message list on Gmail, Outlook or demo
accounts. plMail publishes two kinds of sync announcement:
mailbox.syncedfrom IMAP, which names the folder that changed, andaccount.syncedfrom the provider APIs, which cannot. The layout routed only the first to the list. Nothing errored — the sidebar listens for both, so the badge read "Inbox 4" beside a list holding three, and stayed that way until the next navigation. Whether it looked broken depended on whether you happened to click away. - A tooltip still covered the user menu it had just opened. The fix in v0.1.19 covered one of the two ways a menu is hidden here, and the reported case was the other one.
v0.1.19 — 2026-08-25
No migration. Demo mode grows a mailbox the product has actually happened to, an install page a demo visitor cannot close, and eleven flash messages that reached the page as raw translation keys.
Fixed
- A demo visitor could lock an install out of
/install. That page closes as soon as a user exists, and demo mode mints one per arrival — so the first passer-by took it away permanently, before the operator had made themselves an administrator. The page simply 404s, which is what it does on a correctly installed instance, so the symptom was indistinguishable from working. - Flash messages showed their translation key. A rejected two-factor code
produced a toast reading
two_factor.flash.code_rejected. The strings existed and were translated in all three locales; nothing looked them up. Eleven call sites, now covered by a test that scans for it. - The eight recovery codes ran together on one line. They were joined with
real newlines and
<code>collapses those — nothing was rendering what was already there. - Confirming a two-factor code gave no sign anything was happening, and the reply carries codes shown exactly once. The button now says so and stops accepting presses.
- The snooze menu's two distant options were taller than the near ones — anything past two days carries the date, and it wrapped.
- Opening a menu painted its own tooltip across it. Clicking a dropdown trigger focuses it, and focus shows a hint.
- Solar and paper left the search box with no visible edge under the boxed layout: a white field on a warm off-white surface, seen through a translucent topbar.
/settingsopened on its fourth entry. It opens on the first now.- Read receipts have a tab of their own. The card held nothing else, and signatures left the alias tab before it for the same reasons.
- Layout is a segmented control, like every other short choice on the appearance panel. It was the last dropdown there.
Demo mode
The seeded mailbox now goes through the same ingest pipeline a real sync does, so it arrives categorised, with insights extracted and any filters applied — before, it was written as finished rows and none of that had ever happened to it. It comes with a week in the calendar and an HTML newsletter, and the scripted mail says things an extractor can actually read.
Sending from the demo no longer reloads the page. Mail arrives over Mercure and stays where it lands, rather than the list blinking and replaying its entrance on top of a message that was already there.
Attaching a real mailbox now explains itself instead of spinning: the form opens in a turbo-frame, and a redirect into one has nowhere to land.
A demo instance can serve an Impressum at /impressum, linked from the login
page — blank by default and saying so, because § 5 TMG wants a real operator and
a notice naming nobody looks compliant without being one. See
Demo mode.
v0.1.18 — 2026-08-25
No migration. A demo mode: one environment variable turns an install into a public demo where every visitor gets a throwaway mailbox, sending is answered by a script, and a button makes mail arrive.
Demo mode
APP_DEMO_MODE=1 is the whole of it. It is instance-wide rather than per-user,
and that is deliberate: the promise is that nothing reaches the network, and a
per-account flag would move that promise into every outbound path individually,
where it would hold until the first one that forgot to ask.
Do not set it on an install holding real mail. With it on, mail sync stops,
sending goes nowhere, and /demo hands anyone who asks a working session. It
defaults to off, and an install that has never heard of the variable is a normal
install — the accident worth designing against is a real deployment quietly
becoming a demo.
What a visitor gets: their own throwaway user (not a shared account — two people
arriving at once would be marking each other's mail read), the ten seeded
threads the README screenshots are taken against, a bar at the foot of the page
with a Receive mail button, and two hours before app:demo:reap deletes
them. Nothing is asked of them: no address, no sign-up.
The button walks a scripted queue — a reply, an invoice with a real attachment, a delayed train, an HTML newsletter — and wraps at the end. Delivery goes through the same ingest pipeline a real IMAP sync uses, so the mail threads, gets categorised, runs the visitor's own filters and appears without a reload. Compose works too, and answers itself a few seconds later from whoever was addressed.
Attaching a real mailbox is refused — account forms, OAuth and CalDAV connect, calendar subscribe. Everything else stays clickable, themes and filter builder included.
compose.demo.yaml is the overlay, and it exists rather than being a documented
command line because compose reads Symfony's committed .env, which ships the
variable as 0. Written as ${APP_DEMO_MODE:-1} it would have resolved to 0
for everyone who cloned the repo, and the stack would have come up healthy and
simply not been a demo. See Demo mode.
Under the floor
- The demo mailbox is now shared.
app:test:seed-demoand the demo provisioner build it from one place, so the README screenshots and the hosted demo cannot drift into being pictures of different things. - Deleting a user is now possible. There was no such path — a self-hosted
install's users leave by having their password changed — and the reaper needed
one. It finds owned rows from Doctrine's mapping rather than a hand-written
list, because twenty entities point at
Usertoday under two different property names, and a list would be right only until the next one was added. .idea/is no longer tracked. It has been in.gitignorefor a long time, but gitignore does not apply to files already tracked, so nine of them kept turning up in diffs.
v0.1.17 — 2026-08-24
No migration. Live updates move to Mercure 0.8, the test suites stop sharing a database, and a correction to what v0.1.16 said about Playwright.
Correction to v0.1.16
Those notes said Playwright was pinned to 1.61 because 1.62 reports painted, on-screen elements as hidden. That was wrong. The failing tests were clicking a screen-reader-only radio — a one-pixel clipped box — and the click was not taking. plMail's own suite had already documented that exact failure as hitting "roughly one full run in three", so the test was broken before 1.62 and only intermittently so; the new version made it reliable, which is a tool exposing a bad test rather than breaking a good one.
The tests click the visible label now, the way a person does, and the pin was lifted the same day. Nothing about the application was involved either way.
Under the floor
- symfony/mercure-bundle 0.5.0 (mercure 0.8). Held back from the previous
release's dependency sweep on purpose — it carries the live-update transport —
and it did turn out to change its
HubInterface. Verified against the specs that stop and restart the hub mid-test to prove the stream comes back on its own. - The unit and browser suites no longer share a database. They both ran as
testand so both landed on the same one, while a test in the unit suite truncates tables by design. Run at the same time, that could block the browser suite's queries and produce timeouts in specs with no connection to it. Each has its own now. - A test fixture that collided with itself. UIDs were drawn at random from a few hundred values against a uniqueness constraint, which is a coin-flip that eventually comes up twice — it did, in CI. They are handed out in sequence now.
v0.1.16 — 2026-08-24
No migration. Message bodies size themselves again — they had stopped, and on a self-hosted instance the only sign was a console error. Plus a dependency sweep: Symfony 8.1.5 and eighteen friends.
The reading pane got its height back
Every message was an 80px letterbox with a scrollbar inside a scrollbar. The body is rendered in a sandboxed frame with no access to the page around it, so the only way it can be sized is for a small script inside it to measure itself and say so. That script was blocked.
A srcdoc frame is governed by the embedding page's Content-Security-Policy as
well as its own, and the script was authorised by a nonce — which cannot work
here. A page's nonce belongs to the request that rendered the page, and a
conversation is normally rendered by a later request, fetched into a frame as
you click it. The two only ever coincide on a full page load, which is why it
looked so strange in use: broken every time you opened a mail, fixed every time
you reloaded.
It is authorised by the script's hash now, which is the same in every request and needs no agreement between them. That is also stricter than what it replaces: a nonce authorises anything carrying it, a hash authorises one exact script.
Only enforced policies block, and plMail sends the full policy as Report-Only when debug is on — so this only ever happened on real installs, and never in development or in the test suite.
Two console deprecations went with it: the confirm-dialog registration Turbo had renamed, and the home-screen meta tag Chrome now wants under its standardised name.
Dependencies
Symfony 8.1.0/8.1.1 → 8.1.5 across eighteen packages, tailwind-bundle 1.0, and the CI actions a generation forward — which also retires the Node 20 deprecation warnings on every workflow run.
Development tooling moved too (TypeScript 7, @types/node 26), and the type check that comes with it passes clean for the first time. Playwright is held at 1.61 — see the correction under v0.1.17: that diagnosis was wrong, and the pin was lifted the same day.
v0.1.15 — 2026-08-24
No migration. The radar panel's undated cards stop pretending everything without a date came from a repository, and every card now offers the mail it was read from.
"From your repos" was only ever true by accident
That section was written when GitHub notifications were the only insight without a date, so three separate places quietly encoded undated means repo: the heading, the card, and the link out.
It stopped being true the moment anything else failed to state a date. A courier mail that never says when still tells you about a parcel — so those parcels landed under "From your repos", stripped of everything the dated card would have given them: no status, no tracking link, and no way back to the mail.
- Undated cards say what they are. The kind was already on every card, but only for a screen reader; sighted readers got a coloured tile and an icon, which says "parcel" to whoever already knew.
- Every card links to its mail, dated or not. An insight is a claim about a mail, and the undated ones were both the ones missing the link and the ones where it is most wanted — there being no date to explain what you are looking at.
- Status and tracking appear on undated cards too, because a parcel with no ETA is still a parcel in transit.
- The link out is labelled by what it is. GitHub's mark was printed for any payload link at all; it now comes from the kind, and anything else names the host it points at.
- The section is called Also spotted, which stays true whatever lands there.
Elsewhere
- The live-updates dot says it is connecting while it is connecting. It reported only success or failure, so a stream still opening — which is what a hub coming back up produces — left the dot with no colour and no tooltip at all.
- Same-day parcels stop shuffling. A parcel's ETA is a day rather than an hour, so several due on one day all sat at that day's midnight with nothing deciding their order; under a capped list that also decided which one was dropped.
v0.1.14 — 2026-08-24
No migration. An Outlook push bug that had been quietly handing Microsoft subscriptions plMail then forgot about, a way to cancel the ones already out there, and category tabs that stop throwing away the filter you are looking through.
Outlook push stopped orphaning its own subscriptions
A renewal that failed for a transient reason left Microsoft holding a live
registration plMail had just erased. Renewal cleared the subscription id and
then asked subscribe() to rebuild — but the teardown subscribe() starts with
reads that id to know what to delete, and it had been set to null one line
earlier. So the DELETE never went out, a second subscription was created, and
Microsoft went on delivering notifications for the first one.
Nothing matched them, so they were logged as GraphNotification: unknown subscription — hourly, for up to the three days it takes one to lapse, about a
registration that could not be cancelled because the only record of it was gone.
The two failures are told apart now. A 404 means Microsoft has already let go: nothing to hand back, and it is logged at info rather than as a warning, because that is the ordinary end of a subscription's life. Anything else — throttling, a 5xx, a dropped connection — has to assume the registration is still live and hands it back before building a replacement.
No mail was ever lost to this: push is a hint that brings a sync forward, and scheduled polling has always been the backstop. What it cost was latency whenever push was down, and a log nobody could act on.
app:push:cancel-subscription, for the orphans already out there:
php bin/console app:push:cancel-subscription --account=10 \
--subscription=701dc0d3-b4ed-4ad8-94f2-a263a90a30bb
The webhook that reports one has an id and no account, and therefore no token to cancel it with. Naming an account on the command line supplies the missing half; any account in the same mailbox will do.
The inbox tabs keep your filter
Clicking an unread badge opens that view narrowed to unread, and the toolbar says so with a Show all beside it that keeps every other narrowing intact. The category tabs did not do the same in reverse: each linked to its category and nothing else, so reaching Social from an unread inbox answered with all of Social — silently, since the chip went with it, and the only way back was the badge again. Filtering and changing tab were mutually exclusive.
Which tabs exist is deliberately unchanged: it still comes from what each category holds in total, so the strip does not reshuffle as the filter goes on and off, and a tab with nothing unread shows its empty state rather than vanishing under the pointer.
Under the floor
The browser suite went from one-to-three failures per run to clean, and the two that mattered are already in v0.1.13. What is new here is that it now runs under the same clock a CI runner does — times are read in the zone the page is rendered in rather than the one the test process happens to be in, which is why a suite that passed eighteen times locally still failed on a tag.
v0.1.13 — 2026-08-23
No migration. Corrections to what the New marker means, all of them server-side — so the phone gets them from this release without an app update. Behind them, a day of chasing E2E flakes that turned up two real bugs and needed no raised timeouts at all.
New means new
Mail you had already read still arrived badged as New. The two are separate
statements on purpose — New means this has never been put in front of you, and
a conversation you scrolled past has stopped being new while still being unread
— but nothing said an already-read message cannot also be new. Mail read in
another client arrives carrying \Seen with no plMail surface having ever drawn
its row, so it came in announcing itself as news you had already had.
Returning to the app re-reads the list. Nothing refreshed on resume and the live stream has no replay, so a phone that had been in a pocket showed whatever was on screen when it went there — badges included.
A Reply badge, escalating New for an answer to something you sent. Same behaviour as New in every other respect: it is the same marker wearing a different colour, so counts, dots and retirement are unchanged.
That one needed a fix underneath it first. listedAt was written only when a
conversation was CREATED, so a reply landing on a thread you had already looked
at arrived permanently un-new — its marker retired by a render that happened
before the reply existed. The case the marker is most obviously for was the one
case it could never announce.
Two bugs found by running the tests, not by reading them
A dismissed search dropdown reopened itself. The list is drawn twice for one query — the operator matches come from the browser, the server's results drop in underneath — and both finished by opening it, unconditionally. So the results for a query you had just dismissed brought the list back: after Escape, and after clicking away, where it springs back over whatever you clicked onto. On a fast connection the response beats the keypress and nothing is seen; the slower the connection, the more reliable the bug.
Which conversation a reply joined was up to the query planner. The lookup that threads a message by subject takes one row and had no tiebreaker, so two equally recent conversations left the choice undefined — and it was picking the older one. A batch landing with a single timestamp is ordinary rather than exotic: any import or bulk sync does it. The same mailbox could thread the same message differently on two runs, which makes a report of "this went to the wrong conversation" unreproducible by construction.
Known, and not fixed here
The label dropdown in a conversation is still painted under the navbar,
unchanged from v0.1.12 — the cause is understood (the panes use
backdrop-filter, which traps anything positioned inside them) and the attempted
fix broke clicking in that menu and the snooze menu, so it was reverted rather
than shipped. It is recorded in the test suite rather than forgotten.
v0.1.12 — 2026-08-22
No migration. A round of fixes from two write-throughs of a running instance — the ones where an action could be taken and not taken back.
Getting mail back
A label put on from the list could never be taken off. The bulk label menu is shared by every row, so it rendered with nothing ticked — and it decides attach-or-detach from the tick. A label that WAS attached showed as unticked, so the next click attached it again. Clicking looked like a toggle and was an idempotent attach.
Archived mail had no way back. Not from the row, the thread toolbar, the overflow menu or the label menu. The one plausible candidate, Archive, was offered ON already-archived mail, where it does nothing and says nothing about having done nothing. There is a Move to inbox anywhere that is not the inbox now, and Archive, Delete and Snooze are gone from the places they meant nothing.
Delete forever, in the bin and in spam only — it removes the message, its attachments and its raw source here and at your provider. Not offered from the archive: that is ordinary filing, and a delete-forever one click away from it has no undo to catch the mistake.
Select all really means all
Selecting the page and then asking for the rest now works: a whole folder, a whole label, a whole inbox tab, or every starred mail. It is one request rather than one per conversation — the old shape would have opened two hundred connections on a bin that size.
That also fixes the pager. Bulk actions used to take rows off the list while it went on saying "1–5 of 5", and an emptied list never showed its empty state.
Reading a list
- The preview line was the sender's own plain-text part, which is whatever
they chose to put in the half nobody reads. Most rows in one inbox previewed
as "Email is only available as html" — a real sentence, written by a bulk
sender — and one showed raw
<p style="…">. The preview is made from the rendered body now. - Sent lists who the mail went to, not you. It said your own name on every row, and not even consistently — the same person appeared under two spellings in one list.
- The Snoozed list says when each conversation comes back. It showed the arrival time, which every other list shows and which is the one thing that list is not about.
- The snooze menu is in chronological order and names its dates. On a Saturday "this weekend" is the following Saturday, correctly — but it was listed above "next week" and labelled only "Sa., 08:00", which reads as this morning.
Elsewhere
- A compose URL opened directly — a bookmark, a middle-click, the back button after a session expired — no longer renders as unstyled HTML.
- The suggestion panel stops moving the compose window. Typing a recipient made the dock grow and climb the screen.
- The 404 page is in your language and your theme. It was English in beige for everybody, because a 404 is thrown before anything authenticates.
- The German interface duzt everywhere. Seven strings addressed the reader as Sie.
Known, and not fixed here
The label dropdown in a conversation is still painted under the navbar. The
cause is understood — the panes use backdrop-filter, which traps anything
positioned inside them — and the fix attempted for it broke clicking in that
menu and in the snooze menu, so it was reverted rather than shipped. It is
recorded in the test suite rather than forgotten.
v0.1.11 — 2026-08-22
A security release with a lot of interface work behind it. One critical fix, one high, and a class of E2E flake that turned out not to be a flake at all. Nothing to do before upgrading; no migration.
The one that matters
Answering a mail ran what the sender put in it. Message carries two body
fields, and the difference between them is the whole security boundary:
bodyHtml is the sender's raw HTML, bodyHtmlSafe is what the sanitiser made
of it. The reading path used the safe one and rendered it in a sandboxed frame.
The reply path reached for the raw one — and the draft it builds is written
into plMail's own document, with |raw, into a contenteditable, outside every
protection the reading path has.
So a mail containing <img src=x onerror=…> was inert while you read it and ran
the moment you pressed Reply, in a session that can read the mailbox, mint an
app password that survives a password change, and add a forwarding rule. Reply,
reply-all and forward were all affected.
Fixed at the quote, and again where drafts are saved — which covers paste, a hand-written POST, and every non-browser client. If you run plMail on the open internet, this is the reason to upgrade today.
An SVG attachment was served inline. The rule was "does the type start with
image/", and image/svg+xml passes it while executing script as a top-level
document. The type comes from the sender's own MIME headers, and the path was
reachable from the mail itself: the sanitiser rewrites every cid: reference,
links included, so <a href="cid:logo">Rechnung ansehen</a> had the attachment
id filled in by plMail and opened without the ?download=1 the interface adds.
Now an allow-list, with a sandbox policy on the response as well.
The application now sends a Content-Security-Policy, and enforces it. The
reading frame had one and the image proxy had one; the page holding your session
had none, which is what turned both findings above from "script in a text box"
into "take the account". Getting there meant removing the violations rather than
allowing them — the last of which was plMail's own stylesheets, which AssetMapper
was loading as data: JavaScript.
A stock install now sends security headers of its own (HSTS,
X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy). Before this they
came from the operator's reverse proxy, if they had one, while the README's own
instructions are docker compose up -d and "open https://localhost".
Sending mail
Send gives you the screen back. The composer used to stay open for the whole undo window and act as its own cancel. It closes immediately now, the mail is already where it belongs, and the undo moves to a toast that counts down. The old behaviour is still there under Settings → General → When you press Send, because the trade is real in both directions — but waiting is what people notice, and the undo is what they rarely need.
Deleting mail
Delete forever, and a way back out of the bin. Deleting a mail that was already in the trash took the row off the list and left the mail exactly where it was — because trashing is "add the Trash label", and adding one twice does nothing. In the bin and in spam you now get Delete forever, which removes the message, its attachments and its raw source here and at your provider, and Move to inbox, which the bin has never had.
Archive and Snooze no longer appear on mail in the bin or in spam, where they never meant anything.
Smaller things you will notice
- Menus stop hiding behind the chrome. The label menu in a conversation
disappeared under the navbar. Not a z-index problem — the panes use
backdrop-filter, which traps anything positioned inside them however high you set the number. - Confirmations are part of plMail now, not the browser's grey box with the address at the top. Twenty-eight places used the old one, including "reset the database".
- Signing out is a full page load, so nothing survives it, and a session that ends while you have a window open says so in a bar instead of replacing the app with a login form. If you are mid-way through writing something, it offers a new tab rather than throwing the page away.
- Signing in no longer lands you on your own profile picture. plMail remembered the last thing the browser asked for while signed out — and that is usually an image, not a page.
- Pasting from Word or a web page brings the text, the lists and the links, and leaves that document's fonts, colours and tables behind.
- Hovering Reply no longer rebuilds the whole compose window on the server.
- The sidebar counts are fetched once per change, not twice.
For contributors
The browser suite's "flakes" were one bug. APP_ENV=test brought the Symfony
skeleton's MockFileSessionStorage, which does not lock — and the test stack
serves that environment, so two overlapping requests in one session lost each
other's writes and a CSRF token could vanish between the page render and the
submit. It landed on a different spec every run, which is why it read as several
unrelated problems. Three consecutive full runs are now green, and a green run
reports 563 tests rather than 517: the exclusive project depends on the main
one, so every flake was also skipping 19 specs.
npm run test:unit:docker brings the stack up like the other :docker:
scripts. Against a stopped stack it used to block forever with no output.
An app-less container now says so instead of trying to create a Symfony project
and failing with Invalid stability string "".
v0.1.10 — 2026-08-22
No migration, and nothing to see in the app. git clone && docker compose up -d — the install the README documents — has never worked. Every service
crash-looped, and the log said the database was unreachable.
docker compose reads the .env file next to compose.yaml to resolve
${VAR}, and the .env next to ours is Symfony's: committed, full of
development defaults, and never written with compose in mind. So
APP_ENV: ${APP_ENV:-prod} was not a default at all. .env says APP_ENV=dev,
and that is what won — for everyone who installed the documented way.
The published image is built --no-dev, so it does not contain DebugBundle,
which config/bundles.php loads in dev. Every bin/console call died on the
missing class before it could do anything, including the entrypoint's database
probe — which reported the failure as "The database is not up or not
reachable" while the database container sat there healthy. Six services in a
restart loop, and the one message pointing at the cause was the one nobody
reads past.
APP_ENV is hardcoded to prod in compose.yaml now. Running the stack in dev
is what compose.override.yaml.dist is for, and it still wins where it is used.
Nothing was wrong with any release; the image never had to change. Upgrading
is git pull and docker compose up -d --force-recreate — you need the new
compose.yaml, not a new image.
It survived this long because neither path a maintainer uses touches that file:
truenas.compose.yaml hardcodes prod and is pasted into an appliance with no
.env anywhere near it, and a development checkout has an override that builds
an image which does have the dev dependencies. A test now holds every ${VAR}
in the shipped compose files to the rule the whole thing turned on — interpolate
only what .env leaves alone, or leaves at exactly the value written here — so
the next variable to drift is caught by the build instead of by somebody's
install.
v0.1.9 — 2026-08-21
No migration, and nothing to see in the app. A test was left behind by the layout change two releases ago, and the build has been red since.
When the insight strip became its own pane it moved out of the mail pane entirely — and the test asserting where its frame lives was still looking for it inside the message list. v0.1.7 shipped with that assertion failing and v0.1.8 was tagged on top of it. The selector now describes the layout as it is, and adds the claim that actually matters: the frame is NOT inside the mail card. That is the one that fails if the strip is ever nested back into the pane it is meant to sit beside.
v0.1.8 — 2026-08-21
No migration. Two pieces of polish, both of them a few pixels, both of them the kind you cannot stop seeing once you have.
The mail pane sat slightly too low when the insight strip was gone. The column spaced its children by a gutter, and the strip's frame counts as a child even when it is empty — before it has fetched, on a day with nothing upcoming, and after you dismiss it. So a gap was being paid for something that was not on screen. The gutter belongs to the strip now and exists exactly when the strip does: dismiss it, reload, and the mail pane sits flush with the top of the row.
The panes wore a hard little arc under each bottom corner in the dark themes. The shell clips at the window edge and leaves one gutter of room, so a pane running to the bottom has 12px beneath it — while the shadow reached 22px, and the bottom half of it was cut off by that edge. At a rounded corner the cut ran across the shadow's own curve, which is what made it read as a smudge rather than as a shadow. It only ever showed on the three panes that touch the bottom — the sidebar, the mail pane and the calendar — and never on the insight strip, whose shadow lands on the mail pane and is never clipped.
It had been there a long time and was invisible in the light themes, where shadows are drawn at five percent. The dark themes use thirty to forty-five, where a clipped shadow is not subtle at all. The shadow now stays inside the gutter, and the arithmetic is written down beside it so that changing the gutter without revisiting it does not quietly bring the arc back.
v0.1.7 — 2026-08-21
No migration. The insight strip becomes a pane in its own right, and a dialog opened in a hurry stops showing the last one's form.
Two cards, not a band inside one. The strip above the mail list was drawn inside the mail pane, and no amount of styling could stop it reading as that pane's header — a child of a card looks like part of it. The mail side is now a column: the insight pane, a gutter, then the mail pane. Same width, top edge level with the sidebar, and the strip wears the same surface, border, radius and shadow as every other pane in the app, because it now uses the same rule they do rather than a lookalike of its own. It had been reaching for the MENU shadow, which is why it sat on the page like a dropdown that had lost its way.
It still costs nothing when there is nothing to say. Lazy, and absent entirely on a day with no upcoming insight — the strip does not reserve a gap, and the mail pane simply starts at the top.
A dialog opened in a hurry showed the previous one's form. Save a new calendar, press Edit on it a moment later, and the dialog opened already filled in with the right name — indistinguishable from the edit form having arrived, because for that one case the stale content and the real content say the same thing. Type a new name into those few hundred milliseconds and Turbo replaced the field under the cursor when the real form landed, so the rename posted the OLD name: a save that silently did nothing. The frame is blanked back to its spinner when a dialog OPENS now, not only when one closes — closing does its cleanup behind the exit animation and abandons it, correctly, when a new dialog interrupts the fade, which left exactly this gap. Every dialog in the app is covered by the same change.
v0.1.6 — 2026-08-21
No migration. The Track button on a parcel card reaches the parcel now, and the strip above the mail list looks like something lying on the mail pane rather than part of it.
Track led to an apology. The link a parcel card built for an Amazon
shipment carried the order number and the package index and nothing else — on
the reasoning that everything else Amazon appends to that URL is campaign
tracking a card has no business carrying. That was right about vt and ref_
and wrong about one parameter: without shipmentId the progress tracker
answers "Leider können wir die Informationen zur Sendungsverfolgung gerade
nicht abrufen" and bounces you to the order seven seconds later. The id names
the parcel rather than the reader, so it is kept; the campaign parameters are
still dropped.
A mail that states no shipment id now points at the order instead of at a tracker that cannot resolve. The order page always loads and states the delivery status itself, and a button that lands somewhere useful beats one that lands on an apology.
Cards already on your radar keep the old link until app:backfill insights
re-reads the mail behind them. The sweep overwrites the payload, so running it
once is the whole fix — nothing has to be deleted.
The strip reads as its own surface. It sat flush against the top and sides of the mail pane, which made it look like that pane's header. It now has a gutter of air on every side, the soft surface the app's menus wear, and a lifted shadow — with the gutter beneath it doing most of the work, since that is what breaks it away from the list toolbar.
v0.1.5 — 2026-08-21
No migration. The facts plMail already reads out of your mail now say themselves above the list, instead of waiting in a panel you had to know to open.
A strip of what is coming, over the mail list. Up to three dated insights, soonest first — a parcel out for delivery, a flight tomorrow, a ticket on Thursday — each with what it is, when it is, and the one button worth having: Track package, or the thing on GitHub. It sits directly above the list and narrows with it when you open a mail, and it is the same set the calendar's radar panel holds. The difference is who it is for: the panel answers "what is coming up?" for somebody who went looking, the strip tells you a parcel is out for delivery when you did not.
It keeps itself current. A sync that finds something new refreshes the band where it stands — no reload, and no page you have to be sitting on for it to reach you.
The ✕ means "not now", not "never". Dismissing the band hides it until an
insight it has never shown you is extracted: a parcel that ships tomorrow
brings it back, the one you just waved away stays gone. That is measured
against when a fact was first read and not when it was last touched, so a
carrier revising its estimate — which rewrites a row you already dismissed —
does not drag the strip back for the whole of a parcel's journey. Dismissing a
single card from its ⋮ menu is the permanent one, and when the last card goes
the band goes with it.
And a switch, under Settings → Insights. It hides the band and nothing else: the facts are still read, and still reach the radar panel and the strip inside a conversation. Switching a SOURCE off is the other control, one row further up, and it always was.
It costs nothing on a day with nothing to say. The band takes height and never width, so no pane gives up a pixel for it; it is fetched only after the mail is on screen, so opening a mailbox does not wait for it; and when there is nothing upcoming it is not there at all — no heading, no empty box, no gap. Those were the three objections on record against ever putting this in the mailbox, and they are answered rather than repealed.
v0.1.4 — 2026-08-21
No migration. The unread badge is a link now: click it and you get the mail it was counting. The numbers themselves changed to make that honest — they count conversations, which is what the lists they open are made of.
The unread badge opens the mail it counted. Click one and that same view comes back with everything already read filtered out — the inbox, Starred, Archive, Spam, Snoozed, or a label, under one account or across all of them. While a list is narrowed it says Unread only, with Show all beside it, because a filtered list and a genuinely quiet one look identical otherwise. Everything else about the view survives the filter — the account it was narrowed to, the sort order, the tab — so Show all puts you back where you were rather than somewhere next to it.
The badges now count conversations, so some of your numbers will drop. This is the part worth knowing before you upgrade. They used to count unread messages: a conversation holding three unread replies added three. Every list in plMail draws one row per conversation, so "Inbox 4" could open four rows or two, and there was no way to tell which from the badge. That was tolerable while the number was only ever read. It is not tolerable when the number is a link — so the badge now says how many rows clicking it will give you. Nothing about your mail changed; only what the pill is counting. Starred already worked this way and nothing said so, so this also ends a quiet disagreement between the badges themselves.
Trash and Drafts are not links, and neither is the Labels roll-up. The first two carry a total rather than an unread count — nobody triages a bin — so there is no unread question being asked there. The roll-up over a collapsed Labels heading stands in for several lists at once and has no single one to open.
The badge is a real link. It can be tabbed to, opened with the keyboard and middle-clicked into a new tab, rather than being a decoration that only answers a mouse.
v0.1.3 — 2026-08-21
No migration. Fixes throughout: the unread counters keep up with mail you read, a scheduled send stops quietly refusing itself for one hour a year, mail Gmail re-files reaches the tab it was moved to, and Amazon parcels are read as the parcels they are.
The unread counters keep up with mail you read. Reported as the counter updating unreliably, and it was worse than that — it never corrected itself at all. The badges were told about bulk actions from the list toolbar and about nothing else, so reading a mail the ordinary way, by opening it, left "Inbox 4" sitting beside three unread conversations until you navigated somewhere or reloaded. That is what made it look intermittent rather than broken: moving between folders redraws the badges anyway, so whether it looked wrong depended on whether you happened to click away afterwards. Opening a conversation, the envelope button on a row, and mark-unread in the reading pane all move the numbers now. Re-opening mail you have already read still asks the server for nothing, because nothing can have changed.
A scheduled send no longer refuses itself in silence. Once a year, in the hour a clock goes back, the send-later picker compared two wall clocks that are no longer in order — 02:00 happens twice — accepted a time it should have refused, and then did nothing at all when the server refused it instead. If you have ever had "send later" appear to ignore a click, this was one way it could. The picker's bounds are compared as moments in time now rather than as clock readings, and a time that happens twice is resolved the way the server resolves it.
Mail Gmail re-files reaches the tab it was moved to. Gmail sorts mail after
it has been delivered, and re-sorts it every time you drag a message between its
tabs. plMail re-reads exactly those messages, but it kept the category it worked
out on the day the mail first arrived — so a message you had moved to Promotions
explained itself as a promotion when you opened it while still sitting in
Primary in the list, until the next app:backfill category. The two now agree
without waiting for that.
Amazon parcels are read as parcels. Amazon names no tracking number
anywhere, states its stages in a progress bar that lists every stage in every
mail, and promises delivery in words rather than digits. All three defeated the
reader: dispatched parcels were called delivered, English "Dispatched:" mails
were not recognised at all, and "Arriving Monday" produced no date. A parcel is
now identified by its order number and which package of the order it is, so a
second package no longer overwrites the first. Days named in words resolve
against the mail's own arrival rather than against today, so app:backfill insights lands where it would have landed at the time.
Also. Two browser tests that failed the last two release tags were fixed: both were the suite reading the wall clock rather than anything wrong with the app, and both failed only at particular times of day, which is why they survived a retry and blocked a tag.
v0.1.2 — 2026-08-20
One small migration — a new table, nothing rewritten and nothing locked. The radar can be told what it missed, what you tell it collects somewhere an administrator can download, and it learns three more shapes on its own.
The radar can be told what it missed. Extraction is deterministic, which is
the whole reason it can be trusted — nothing guesses, so nothing invents a
parcel. The cost is that a mail in a shape nobody has written a parser for yields
nothing, and looks exactly like a mail with nothing in it. Report a missed
insight, in a message's ⋮ menu, is how you say which of the two it was: a
short dialog asks what plMail should have spotted, and files it. Reporting the
same mail again corrects your first report rather than filing a second.
Reporting hands over a copy of the mail, and says so first. The report keeps the sender, the subject, when the mail arrived and the first stretch of its text, along with your note — a snapshot rather than a link, because the mail gets archived or deleted and a report that resolves to nothing says only that something was missed once. That copy is readable and downloadable by your administrator, and the dialog opens with that fact rather than with a field to fill in. On an installation you do not run yourself, report the mail whose shape you want recognised, not one whose contents you would rather keep.
Admin gains Reported mail. The pile, with a count of what is still waiting, and one JSON file holding every snapshot in the order the mails arrived — which is what a new extractor actually gets written from. The download is a POST rather than a link, for the reason the config backup export is: what comes back is other people's mail. Unlike the config backup, that file is not encrypted — keep it where you would keep the mailbox itself. Exporting deliberately marks nothing handled; a report is done when you say so, not when it has been downloaded, because otherwise a second export comes back empty and the work is recorded as finished by somebody who had not started.
Three more things the radar reads. One-time login codes, invoices with an amount and a due date, and subscriptions about to renew or trials about to run out. Each has its own switch under Settings → Insights, like every source before them.
A one-time code expires instead of happening. It is the one insight whose time says when the fact stops being true, so the card carries the moment the mail says the code dies — "valid for 10 minutes", "gültig bis 09:30" — and leaves the radar then, rather than sitting there looking usable. When the mail states no lifetime the card carries no time at all: a default ten minutes would either retire a code that still works or keep a dead one looking fresh.
Also. New extractors do not read old mail on their own — app:backfill insights is still what walks what is already in the database, so an established
mailbox shows nothing from the three new sources until it runs.
v0.1.1 — 2026-08-20
One small migration — a column with a default, no rewrite and no lock worth mentioning. The interface stops appearing out of nowhere, the mail list stops being thrown away and rebuilt fifty times a minute, and a blocked image stops being a square.
Things arrive from somewhere now. A new setting under Appearance — Motion, as Full, Minimal or None — decides how much the interface moves when something appears. Full is movement and fade, Minimal is the same cues with nothing travelling and nothing displaced, and None is exactly what plMail did before any of this existed. Every duration and distance in the app comes from one enum, so the whole motion budget is one screenful a reviewer can read end to end. If your system asks for reduced motion, that overrules the setting, in both directions and without asking.
The mail list is brought up to date by difference now, not by replacement. A refresh used to assign over the rows wholesale, which threw away the one thing the response was richest in: the server sends the complete list, so what changed was sitting right there in the two trees. Three things that cost, all fixed by computing the difference instead — an open row menu is no longer destroyed under whoever is reading it, focus inside the list survives a background sync, and the browser can finally tell one new mail from fifty redrawn ones. There is no delta protocol and no sequence number behind this: every refresh is still the whole truth from the server, so a missed live update costs a late redraw and never a wrong list.
New mail arrives. A conversation that has genuinely just landed drops into the list from above and the rows below it move down to let it in — about eight hundred milliseconds, several times anything else in plMail, and deliberate. Nothing in a redraw plays it: a star, an archive, a bulk action or a sync that found nothing leaves the list still. A conversation pushed off the end of the page fades where it stood instead of vanishing between frames.
Opening a folder is the opposite shape: each row lands in about two frames, but one after another, so what you see is a cascade running down the list rather than any single row moving. Searching, turning a page and switching category tabs are the same gesture. The list itself does not animate — a grey rectangle fading tells you a rectangle changed.
A blocked remote image is no longer a square. The placeholder was a 1×1 transparent GIF, and a 1×1 has an intrinsic ratio of 1:1 — which the reading frame then used instead of the height the sender declared. A 600×80 banner was drawn six hundred pixels tall, so a newsletter with images blocked was several screens of hatching. Placeholders now keep the shape the mail asked for, and no placeholder can grow past a screenful.
Also. The handbook gains a Motion section, including the one thing the new gestures cost: while a new mail is landing, the select box and the hover actions at either end of the row are travelling, so for about half a second they are not quite where they will be. Nothing is frozen and no click is thrown away — and Minimal removes every pixel of travel if it bothers you.
v0.1.0 — 2026-08-18
One migration, and it REWRITES the message table — minutes of exclusive lock on a large mailbox, so take the backup first. Search stops being slow, the compose window stops losing replies, and the sidebar stops answering a question nobody asked.
Search. Free text was taking nine to ten seconds on a 300,000-message
mailbox — and taking the same nine seconds whether the word was in a quarter of
a per cent of the mail or eight per cent of it, which is what an index doing
nothing looks like. One OR spanning two index families is a shape Postgres
answers by abandoning both, so it walked all 100,000 threads and filtered them
row by row. Split into a UNION, every branch picks its own index. The pager's
"1–50 of N" was a second query re-running the whole search for a number in the
corner of the toolbar; it rides along with the page now as a window count, 35ms
instead of 450–830ms. Hostnames and addresses are indexed by their parts, so
wirhub finds a mail whose body only says help.wirhub.de — Postgres reads
that as one token, and chasing it with a substring scan over every body was
what the slow branch was for. That branch survives as a last resort, run only
when a page comes back thin. End to end: 12.3s → 1.6s, 13.1s → 2.7s,
11.0s → 0.7s.
Search while you type. Results now appear under the suggestions from about
the third character — the ten most recent matches, in 57–88ms for a rare term.
It is a preview and says so: it runs only the passes fast enough for a
keystroke, and steps aside entirely once your query carries an operator,
because ten unfiltered rows under is:unread look exactly like ten honoured
ones.
A reply could be silently thrown away. The compose form was the only one in the app still using a session-stored CSRF token, which had to survive in the session for the whole life of the window — minutes of autosaves. Any concurrent request that wrote the session first took it away, and the storages differ on whether that can happen: the file handler locks, but Redis and memcached do not, so a scaled deployment lost replies. Worse, a refused form re-renders with HTTP 200, so the composer said "Draft saved" about a draft the server had discarded and cleared the unsaved-changes guard. Both halves fixed.
Sending, and the way out of it. A send used to close the window and put the cancel somewhere else — a toast in the dock, a countdown bar in the thread — so the way back was never where you had just clicked. The Send button is the cancel now: it reads "Sending… click to cancel", a second click calls it off, and it cannot change size at any point. A toast confirms the message went. Forwarding an inline message closed with no feedback at all, for ever; it files itself properly now.
The inline composer gets a reduced header: the To field itself, typable, with everything else behind the chevron. A forward opens needing exactly one thing and used to open with a label where that thing goes. The suggestion panel opens on typing rather than on focus, and overlays the body rather than shoving the editor down the page.
An account in the sidebar opens its inbox. It used to list everything the account held, whatever it was labelled — your sent mail interleaved through what you had received, drafts and spam among it.
The calendar day grid opened at about 11:00 with the morning scrolled off, and an event under the sticky all-day band could not be dragged at all, because a pinned row takes the pointer as well as the view.
Also: the IMAP flag pass no longer loads every body in a mailbox to compare
two booleans — it was the single largest cost in a real install's database, 262
seconds over 1,187 calls. /mail/search no longer 500s for a user with no
account connected. The README is 101 lines instead of 318.
The migration: message.search_vector is dropped and re-added with the
token-splitting pass, and its GIN index rebuilt. Two minutes on 300,000
messages, exclusive — nothing else to do, but do not run it during a working
hour.
v0.0.39 — 2026-08-17
One migration, additive (a table and two defaulted columns); nothing to do on upgrade beyond an optional backfill. plMail grows a radar, every logo colourway becomes a theme worth wearing, and the compose window stops being taciturn.
The radar. plMail now reads facts out of mail as it arrives — parcels with
their tracking numbers and delivery estimates, flights with their times,
event tickets, GitHub issue and pull-request activity — and turns each into a
card: dated ones in the Happening Soon panel under "On your radar", GitHub
under "From your repos", and a strip above any conversation something was
found in. Extraction is deterministic and local: sender domains, headers and
fixed shapes, no cloud, no model — the radar would rather miss a parcel than
invent one. Five carrier follow-ups are one card (upserted by the thing's own
identity), dismissing a card outlives the carrier's next enthusiasm, and
app:backfill insights walks mail that predates the feature. Every source
has its own switch under Settings → Insights, and the catalogue is the
extractor registry itself — an extractor added to a build appears there and
starts working with no further wiring.
Themes with character. The 32 logo colourways became full themes, each with a palette of its own — seven properly dark (ink is a printed page's negative, midnight & gold a night library, aurora a lit night sky), the rest each on their own paper. Every ink tier meets the contrast floors, computationally. The theme picker shows the seven classics and folds the colourways behind More; your current pick stays visible until you choose another. The logo follows the theme by default now, with "choose independently" bringing the full logo grid back; existing non-default logo picks were migrated to independent so nobody's choice moved.
The compose window answers back. "Sending…" pulses — one dot, two dots, three — and carries a Cancel for the in-flight moment that presses the cancel window's own control the instant it renders. A forward opens unsigned, with the address rows unfolded and the caret blinking in a typable To field; its quoted original counts as content, so forwarding without a word above it is no longer questioned as an empty mail. The quote stays folded behind the pill by default, and Settings → General → Composing can open it out instead.
Also: every checkbox in settings became a labelled two-segment control; the favicon URL is versioned so a colourway change reaches the tab without a hard reload; the icon set is transparent now; and a shared-calendar test that failed every evening after Berlin crossed midnight ahead of a UTC container asserts in the page's own timezone.
The migration: mail_insight (new table), user.appearance_logo_linked
(default true, false where a non-default colourway was already chosen). Run
php bin/console app:backfill insights once if you want cards for mail that
is already in the database.
v0.0.38 — 2026-08-17
One migration, additive with a default; nothing to do on upgrade. The inbox tabs learn everything Gmail's know, and the logo stops pretending to be Google's — it is redrawn, recoloured, and handed to the user as a setting.
The category tabs carry the whole Gmail hint now. Each tab wears its category's icon — outline at rest, filled on the tab you are on — and, while a category holds conversations you have never been shown, a "3 new" pill in that category's colour with a second line naming who the new mail is from, newest arrival first. That pill is deliberately the only number on a tab: the unread counter that used to sit beside it said something the sidebar badge and the bold rows already say, and two numbers in one glance was two demands with nothing to tell them apart. The whole strip is live — pill, sender names and tab set update in place on the same sync the sidebar badges ride, without a reload, and a hint retires the moment you look at its tab.
The pl mark is redrawn and de-Googled. The topbar monogram was painted in Google's literal brand hexes; that colourway is gone and cannot be picked back. The mark itself was tuned on the way — the two letters now share a top line and a bottom line, the p's bowl is square-ish rather than tall, the l runs lower before its tail turns so neither letter reads deeper than the other, and the ink is centred in its frame so icon tiles no longer lean.
And its colourway is yours to choose. Thirty-two of them under Settings → Appearance → Logo — single colours, two-tone pairs, ink with one coloured flick, and gradient sweeps — applied to the topbar as you click and saved like every other appearance knob. Dark themes get each colourway's dark-chrome strokes, so an ink-dark mark never vanishes into a dark topbar. New accounts start on Berry, the product's own sweep.
The favicon follows the person. The blue envelope is retired; the tab icon is the mark, served by a route in the viewer's own colourway and revalidated so a change in settings reaches the tab strip on the next page, not whenever a cache expired. Anonymous pages get the product default, the static icon set and PWA tiles are repainted in Berry as the sessionless fallback, and the manifest and theme-colour follow.
The migration: one new column, user.appearance_logo_style, defaulted to
berry — every existing account simply wakes up wearing the new mark.
v0.0.37 — 2026-08-17
No migration and nothing to do on upgrade. A review of the whole codebase, and what it turned up: one endpoint that leaked which mail exists, a listener that outlived the window it belonged to, and a good deal of the same check written out more times than anyone had noticed.
The status routes stop answering questions about other people's mail. Every
action behind /status/{type}/{id} — star, archive, trash, label, snooze, read
— resolved through one helper, and that helper failed open three ways. An id
matching no row died with a TypeError, so a missing id answered 500 while a
real id belonging to somebody else answered 403 — and telling those two apart
is an existence check anybody with an account could have run against every
message and thread id on the server. Both route segments are constrained now,
absence is a 404, and the six actions carry a CSRF token. Nothing in the
browser changed: every caller was already sending that token and the server was
throwing it away.
One answer to "is this yours". The check was a private method on the controller — nineteen of them, in eighteen files, under six different names. All nineteen were correct; the trouble is that each was a fresh derivation of one sentence, so the twentieth was a fresh chance to get it wrong, and a controller that simply never grew one looked no different from outside. There is a voter now, it fails closed on the seven ownership links that are nullable, and it reaches a message's owner by the one route the schema guarantees rather than by a walk that could fatal on a message with no mailbox and no thread.
And one CSRF check. Fifteen copies, two names, already drifting — one of
them had its token prefix baked in, so the call site said one thing and the
token was called another. The session cookie's SameSite is written down as
well: it was already lax and stays lax, because the OAuth callbacks are
cross-site navigations that read state from the session and strict would
break every account connection. Two authenticated endpoints that checked no
token now do.
A phone could be left unable to scroll. The compose window's "no subject?" warning bound an Escape handler to the document and only ever released it from its own two buttons, so closing the window with the question unanswered left the handler behind holding the dead window alive. Alongside it, the one unguarded lookup in a teardown full of guarded ones — which, had it ever fired, would have skipped the line that gives the page its scrollbar back.
Things that were in the wrong place. The topbar's sync button was a
controller called Dev serving /dev/sync, a URL an operator might reasonably
block, with inline SQL inside it. The sidebar counters lived in src/Twig
despite holding no Twig and being injected into a controller. The compose
controller gave up its uploads, its request context — an array whose shape was
written out in eight docblocks — and the decisions that were never about HTTP.
Also: four debug console.logs, one printing label activity on every use;
an autocomplete controller whose body had been commented out and which no page
had mounted in some time; a JavaScript dependency imported by nothing; and an
end-to-end test that only passed if you happened to have an environment
variable set.
v0.0.36 — 2026-08-13
One migration, additive and nullable; no reset. Mostly Appearance, which grew a good deal and then had to be rebuilt twice because the first two answers were the wrong shape. Also two bugs that shipped in v0.0.35, one of them ours from that same release.
Appearance is two cards with a split between them. The controls on one side, a live preview on the other, resizable between 240 and 900 pixels and remembered per user. Both cards are the same height and the controls scroll their own body, the way the mail panes do, so the page does not grow a second scrollbar. On a phone the preview is not dropped: a button in the settings card header reveals it, pinned to the top with the controls still usable underneath. The handle between them is the calendar's, down to the grip and the strain it shows when a drag pushes past the limit.
The mail list can be told what to show. The account corner — the coloured triangle in a row's lower-left saying which account a message arrived on — can be switched off; it stays on by default, since wanting it gone is not the same as nobody wanting it. Preview lines and unread emphasis are settings now too. Sender discs are not, and cannot be: that disc is the row's checkbox, so switching it off would take selection with it. It paints over the identity instead and keeps the target.
Type and density. A font family and a text scale across the interface — not the compose editor, whose size is per-message formatting that ends up in the mail you send. Density can be set once or given separately to the sidebar, the list and the reading pane. Only density is per-surface: the list and the reading pane are one painted surface, so opacity, blur and radius cannot differ between them, and text size resolves against the document root.
Backgrounds apply when you pick them. Choosing a preset, choosing a solid colour, or going back to the theme wrote the setting and changed nothing until the page was reloaded — only an uploaded image ever took effect immediately. A solid colour also had a setting, an enum case and a resolver arm, and no control anywhere on the page.
The sidebar folds. Labels and accounts collapse, remembered per user rather than per browser, and rendered folded by the server so nothing snaps shut after the page paints. A collapsed label section says how many unread threads are inside — counted as threads, not summed across labels, because a thread under two labels would otherwise be promised twice.
Sidebar density now does something. It had a setting, a token and one consumer: the Compose button. Every other row was hardcoded. Rows take their padding from a tier now, and Comfortable is identical to what it was, so nobody's sidebar moves — one stray twelve-pixel growth on the Compose button is undone. Compact stays finger-sized on a touch screen, which Comfortable was not quite either.
Account health only appears when something is wrong. It used to sit in the settings nav permanently to say that everything was fine. It is a page about exceptions, so it now shows up when there is an exception — and while you are standing on it, so the page you are looking at still has an entry.
A reset hands its registrations back. Truncating local data left Google and Microsoft delivering notifications for calendars and mailboxes that no longer existed, for up to a week, with nothing able to match them. Reset and calendar removal now revoke the provider-side watch first, best-effort: a failed revoke is logged and stepped over, because a reset that refuses to run because a provider is unreachable is worse than a stale channel.
A dead mail push is now found by evidence rather than by waiting. The old rule was thirty-six hours of silence, which cannot tell a broken push from a quiet mailbox at any threshold. plMail now records when the mailbox actually changed: mail that arrived and was noticed by a poll, with no push announcing it, is one push demonstrably missed. Health also separates a watch that lapsed — renewal never ran, look at the scheduler — from one that is alive and silent, which is the Cloud Pub/Sub leg, and shows the three dates it judged from. Note: the second verdict needs the new column, so existing accounts are judged on expiry alone until their mailbox next changes.
Settings and admin share one card. Twenty-two settings sections had three different shapes between them; admin had a card macro and six hand-rolled copies beside it. The chrome is defined once now. Nothing about admin's collapsing panels or their summaries changed.
Two bugs from v0.0.35, and the second is ours. Changing any density — or
any tile-shaped control — blanked the whole settings pane. sr-only is
absolutely positioned and a label is not, so those radios were positioned
against the pane rather than the box they appear in; clicking one focused it,
the browser scrolled it into view through every scroll container above it, and
a pane that clips its overflow scrolled its own contents out of sight. One rule
fixes every such control in the app. And a bulk action re-rendered the list
under the next click — the list toolbar lives inside the frame that was being
replaced, so a second bulk action could land on a button that was gone, or on
one whose selection had just been cleared, and do nothing quietly. The refresh
now swaps rows, tabs and pagination without touching the controls you are
using, and leaves your selection alone.
v0.0.35 — 2026-08-13
Two migrations, both additive and defaulted; no reset. Contains a security fix that matters on any instance with more than one user — if you run one, update. Most of the rest answers a manual test pass of the running app, and two reported defects were investigated and deliberately left alone because they turned out not to be defects.
Contact autocomplete was not scoped to its user. ContactAutocompleteField
named its entity class and never said which rows belonged to whom, and
Contact is per-user — so on a multi-user instance the suggestion endpoint
listed other people's correspondents by name and address, no id-guessing
required, and a contact id from another account resolved and could be
addressed on a draft. One user-scoped query builder closes both, and it fails
closed: no signed-in user now means no contacts rather than all of them.
LabelType had always done this; this field was the outlier. Single-user
instances were never exposed.
Undo send could confirm a cancellation over a mail that had already gone out. The handler read the cancelled flag and then sent, and a cancel arriving between those two steps committed too late to matter — HTTP 200, "send cancelled", message delivered, with the row asserting both at once. Reproduced three times out of three against a real SMTP sink at 9.9 seconds of the ten second hold. The cancel and the worker now race in one atomic statement each, so the database decides who won and both sides learn the answer: a cancel that loses says the message had already been sent, instead of handing back an editable copy of delivered mail. The window the interface offers is eight seconds against a ten second hold, so losing is rare rather than routine; a claim expires after fifteen minutes so a killed worker cannot wedge a draft.
Scheduling a send no longer forks a second draft. Autosave rewrites the draft and send URLs from its own response as a new message acquires an id, and the schedule action was never in that list — so the pill posted to the id-less route and built a second message, leaving a duplicate in Drafts forever.
The recipient field stops committing addresses nobody chose. The suggestion panel is absolutely positioned and overhung the Subject row, so a click aimed at Subject landed on the panel instead: it committed whatever was highlighted as a recipient and left the caret in the address box, so the subject you then typed went into the recipient field. The panel now takes real room while it is open.
The sidebar keeps up, and folds. Badges refreshed only when a sync arrived, so a bulk action changed the rows and left the counts behind until a reload. The label and account sections now collapse, remembered per user and rendered folded server-side rather than snapped shut after paint. A collapsed label section summarises its unread as a count of threads, not a sum of per-label numbers that would count one thread twice.
Sent no longer carries an unread badge. Thread labels are the union of their messages', so replying gave the thread a Sent label while the unread mail was the incoming one — and Sent then advertised somebody else's unread count.
Appearance grew a good deal. A mail-list group with the account corner as a toggle (still on by default), preview lines and unread emphasis; a font family and a text scale; a live preview that shares the real row's style hooks rather than imitating them; and density that can differ between the sidebar, the list and the reading pane. Two limits are worth stating plainly rather than pretending otherwise: sender avatars cannot be switched off because that disc is the row's checkbox, so "off" paints over the identity and keeps the target; and only density is per-surface, because the list and the reading pane are one painted surface and text size resolves against the document root.
Also: a compose window that fails to open now says so instead of leaving Turbo's bare "Content missing"; the list preview stopped running a tag-stripper over text that was never markup, which was deleting what people had actually written; labels can be deleted from the sidebar dialog as well as from settings, and the confirmation now admits the label goes from every account; and the message list revalidates after a Back rather than showing a cached snapshot.
Two reported defects were left alone on purpose. An empty label name does refuse to submit, focus the field and fire its invalid event — the validation works, and it was not going to be replaced on a guess. And the same conversation appearing twice across two accounts is two threads in two mailboxes, which is what the data model says; merging them means cross-account thread identity, and that is a decision rather than a patch.
v0.0.34 — 2026-08-13
Four migrations, all additive and all backfilled — no reset is needed, and no existing install should look different on the morning after except where this entry says so. The compose window had six buttons that did nothing; it now has none. Most of the rest answers a second external audit of the running app.
Every button in the compose window does something. Six of them were
placeholders — named, so a screen reader could reach them, and inert. The
emoji button now opens the whole Unicode set with search, skin tones and
recents, served by the app rather than a CDN, and it deliberately does not
interpret what you type: :) and :smile: stay as you wrote them. The image
button, paste and drag-and-drop all insert an inline image that travels as a
cid: part rather than an attachment. The link button opens a small panel with
the address and the text, prefilled from whatever you had selected, and a link
already in the message offers Open, Change and Remove. Signatures are a
settings section of their own, account-wide with per-address overrides, and an
address can inherit, sign with nothing, or carry its own — three states that
stay distinct. The ⋮ menu holds priority, a read-receipt request and a
plain-text mode that warns before it discards formatting. Encrypt is still
disabled, with a tooltip saying so; it needs certificate storage and key
custody, and a button that pretends otherwise would be worse than an honest
one.
Mail can be sent later. The chevron beside Send offers a few presets and a custom date and time, resolved in the timezone and clock format you configured rather than the browser's guess. A scheduled draft says so in the list and can be called off from the row. The hold has a floor of a minute and the ceiling the JMAP session already advertised, so the web composer and the API cannot disagree about how long mail can wait.
Read receipts work in both directions, and the default is to send nothing. Requesting one is a tick in the ⋮ menu. Being asked for one is the half that matters: the default is never send, set per address, with "ask each time" and "always" as opt-ins — and an automatic send is downgraded to asking whenever the address the receipt is requested to disagrees with the sender, because that mismatch is a known trick. A receipt that comes back marks the sent message read instead of landing in the inbox as a cryptic bare message.
New mail is marked as new until you have seen it in a list. A different axis from unread on purpose: a thread you scrolled past but never opened is no longer new, yet is still unread. It shows as a badge on the row and a dot on the inbox tabs and sidebar, and it expires 24 hours after the last message, so it is something you notice rather than a debt to be worked off. Existing threads are backfilled as already seen — nobody's mailbox lights up on deploy.
When an account breaks, the app says so and offers to fix it. A dead sign-in was recorded faithfully in a column no template ever read, so a single refused Google grant could produce thousands of log lines and stop three calendars syncing while the interface said nothing. There is now a health section that names the problem in a sentence and offers repairs that keep the mailbox — chiefly reconnecting an account in place, which writes a fresh grant onto the existing row and keeps mail, labels, rules, threads and calendars, and which refuses outright if the re-consented identity is a different address or provider. A calendar failing permanently now backs off instead of re-reporting the same failure every cycle, and still reports the first occurrence and any change.
Reordering your accounts no longer changes which one sends. sort_order
was quietly doing three jobs — row position, the primary account, and the
identity colour beside each account — so dragging a row to tidy the list
changed your outgoing address and repainted two accounts' marks everywhere.
Display order is now only display order, the primary account is chosen with
its own button, and the colour is stored per account and backfilled from the
old order, so no existing install sees its dots move. That coloured dot was
never a status light; account status lives on the health page.
A page of mail is drawn in about twenty queries instead of a hundred and twenty. Search was the worst at a hundred and sixty-seven. Half the cost was not the obvious N+1 at all: a Twig global re-ran its query every time anything touched it, and the row template touches it once per row. Search had never preloaded labels. A query-count ceiling now guards every list, because a reintroduced lazy load costs exactly one page of rows and cannot hide under it.
The calendar is drawn in your timezone. The current-time line and the new-event defaults were computed in UTC — three places all falling back to the process default, which the container pins to UTC. An event saved to a hidden calendar was also invisible with no error, because the editor lists hidden calendars and the views do not; the save now lands somewhere you can see, and the editor marks the hidden ones.
Fixes found by testing the running app, in the places nobody looks:
confirmation messages were written to the session by five controllers and read
by nothing, so those sentences never appeared at all. Scheduling a send under a
minute away was refused by the server and the refusal was rendered nowhere, so
the composer simply sat there. Clicking the empty space below a signature put
the caret at the end of the sign-off, so the first sentence typed was swallowed
into it and the message then counted as empty. The theme preset tiles each
carried data-theme, which is the selector every theme block uses, so every
tile redeclared the whole palette onto itself and its label was painted in the
theme it named — at 1.22:1 against its background. Compose toolbar labels were
Swiss German (vergrössern) in an otherwise ß interface; the inbox tab
"Updates" and the system folder names in settings were never translated at all.
v0.0.33 — 2026-08-12
No migration. Two features, a fix, and a change to how the image is published.
A message shows what you wrote, not what you quoted. Open a thread and every message repeated the whole conversation beneath its own reply — the same quoted history stacked over and over, so the actual new sentence was a scroll down from the top of each message. Each message now shows only its own text, with the quoted reply-history folded behind a Show quoted text toggle like the composer's. The fold happens as the message renders, over the already-sanitised body inside its sandboxed frame, and it is deliberately cautious: it collapses only when there is genuinely new text above the quote, so it can never hide a word you wrote. Plain-text mail is left as it is.
Saving an attachment asks where it should go. Saving a file to a connected service used to drop it into one preconfigured folder, and every service was offered for every attachment — including a photo library for a PDF it could never accept. Now the menu shows a photo library (Immich, Google Photos) only for images and videos, and file storage for anything; and picking one opens a destination picker — a folder tree for Nextcloud and the other file stores, an album list for the photo libraries, each able to create a new folder or album on the spot. The chosen destination is validated on the server against path traversal and albums that are not yours, and a saved-file rule keeps its own configured folder, so a one-off save can never redirect it.
A sent message in a thread carries its date. A reply this account sent has a send time but no arrival time, and the thread showed only arrival — so a sent message was the one message in a conversation with a blank where every other message had a date. It now shows its send time there, as it already does when opened on its own.
The image is built once, on a release. It used to build on every push to
main and again on the release tag seconds later, and a third time on every
pull request — which already builds the same image to test it — so publishing
one release paid for the build several times over. It now builds only on a v*
tag. latest and the version tags still publish on release; main still
follows the tip of the default branch for anyone who wants it.
v0.0.32 — 2026-08-12
No migration. Two fixes from a live instance.
A conversation reads in the order it happened. A reply this account sent has a send time but no arrival time, and a thread ordered its messages on arrival alone — so every reply sank to the bottom of its own thread, below mail that came in after it, and the reply box quoted the wrong "latest" message. The thread now orders by when each message actually happened — received by arrival, sent by send, a draft by when it was written — computed in the query rather than stored in a column beside the three dates that already hold it.
The message body no longer collapses on a hard load. Opened by clicking, a long message rendered at its full height; opened by F5 or a deep link, its frame shrank to a sliver with the whole message scrolling inside it. The sandboxed frame measures and reports its own height the instant it is parsed, but the app's script is deferred, so on a full load the report arrived before anything was listening and was lost — and the frame never repeated itself. The frame is now asked for its height once the page is ready to hear it.
v0.0.31 — 2026-08-12
One migration, and it is not reversible-sensitive — it installs pg_trgm
and adds trigram indexes so search can match inside a word; the trigram index
on the message body approaches the size of the body column, and every sync now
maintains it, which is the price of substring search. No reset is needed. Most
of this release answers a 40-item external audit of the running app, and the
single largest change moves every rendered email into a sandboxed iframe.
An optional bin/console app:backfill recipients repairs one class of old row
(below); everything else takes effect as-is.
Mail is read on our terms now. The HTML body renders inside a sandboxed, opaque-origin iframe with its own restrictive CSP, so a future sanitiser gap is no longer XSS in the app's own origin with your session cookie, and email CSS can no longer collide with the app's. Remote images are blocked until you ask for them, and when you do they load through a signed, SSRF-hardened proxy — the sender never sees your IP, and a tracking pixel never fires on open. A message sitting in Spam wears a warning, and a sender whose display name impersonates a domain it does not own is called out. (Two bugs met on the way: the proxy sat behind the login wall the sandbox can't send a cookie through, so no proxied image ever loaded; and the fetcher passed one option the HTTP client refuses, so it threw on every fetch. Both fixed, with a test that finally checks an image renders inside the frame.)
Compose can't send by accident. Enter in the recipient field makes a chip; it no longer submits the message — a stray Enter used to send a half-written mail to whatever text was in the box. Addresses are validated at the chip and at the server, an empty subject or body prompts first, and Undo reopens the compose window where the send came from with everything you typed intact, instead of filing a silent draft. Compose also defaults to your primary account and is fully German.
The ghosts are gone and the tab stopped hammering the server. Seven contentless "Jan 1 1970" messages in Spam were unparseable IMAP fetches the syncer persisted anyway; it now refuses them and reaps the ones already stored. Idle polling dropped from four requests a second to at most two every ten, and none at all while the tab is hidden. Counters agree with their lists, a unified list marks each row with the account it came from, and stepping back out of a thread no longer flashes an empty list.
Search finds part of a word. wirhub matches help.wirhub.de, and
Testmai matches Testmail — prefix and substring, on top of the full-text
match that was there. The search box clears when you change folder, a page past
the end clamps to the last one, and a bad ?tab= or a non-numeric id answers
404 rather than 500, on a branded error page.
German mail reads as German. Dates, weekday and month names now render in
the reader's locale, <html lang> is set, mobile zoom is no longer blocked, and
button/link accessible names are filled in. Contrast is fixed structurally:
button and badge ink is derived from the accent's own luminance, so a light
accent gets dark text instead of unreadable white.
The calendar dialog says when it refuses. Saving an event whose end is before its start showed nothing — the server's 422 had no visible outcome; it now renders the error inline and announced. The 00:00 row is labelled, the event dialog traps focus, the agenda shows time ranges, the start time defaults to the next full hour, and the checkbox help text no longer contradicts itself.
Smaller things. The sidebar's "More" section holds Snoozed, Archive, Spam and Trash, its chevron turns, and its rows step in. The reading pane's sender avatar wears the same colour the list gives that sender. A message's account is a small corner mark on its row rather than a dot that read as unread. And the message frame stopped popping a "Message content" tooltip on every hover.
No migration for this next part. One optional command repairs old rows:
bin/console app:backfill recipients. Every message ever synced over IMAP
stored an empty recipient
list. webklex hands To/Cc/Bcc over as an Attribute, which implements
ArrayAccess and nothing else, so the foreach that read it walked the
object's (non-existent) public properties, ran zero times and returned nothing
— no error, no warning, and no recipients. The message header therefore had no
"to" line, reply-all had nobody to reply to, and no contact was ever harvested
from a recipient. Gmail and Graph were unaffected; they parse the header and
the toRecipients array directly.
Nothing was lost with it: the headers themselves reach a different column, so the backfill re-derives the recipients from the row itself — no mail server, no resync, and no reset. It only fills lists that are empty, so a correct capture is never overwritten by a poorer re-parse. Until it is run, the message header reads the same headers on the fly, so a mailbox looks right either way.
The message header names the recipients at a glance, and a message with none says so. The header row now reads "to Alice, Bob +2" instead of "Details", with the full list still in the expander. A message that genuinely names nobody — a bcc delivery, a list that hides its members — states "undisclosed recipients" rather than dropping the line, because an absent line reads as a bug and a stated absence reads as a fact. The expander gained a copy button per address and one for the whole header block. And reply-to, mailed-by and signed-by now appear on IMAP mail: webklex spells stored header names with underscores, and the panel looked only for the hyphen form.
Attachments download again, and "Save to…" is a menu you can see. Two dead
clicks in the message view, neither of which logged anything. The download link
was intercepted by Turbo, which fetched the file itself and discarded it
because it was not HTML — the server answered 200 and the browser saved
nothing. The save-to menu was clipped out of existence by the overflow-hidden
that rounds the chip's corners: it opened below the box it was positioned
against, so it was painted nowhere and could not be pressed.
v0.0.30 — 2026-08-12
One migration adds the columns this release thinks with; nothing is irreversible. Mail read anywhere else is read here too — on every provider. plMail captured an IMAP message's flags once, at ingest, and never looked again: read on the phone stayed unread here forever, a star set in another client never arrived. The deletion sweep's folder listing now carries flags in the same single command, so presence and read-state are learned in one pass and cannot disagree with each other. Locally, seen and starred apply through the same paths the buttons use — thread counts recounted, JMAP clients notified — and a change you just made here is never reverted by a server answer that predates it: an unconfirmed local change parks its row until the provider acknowledges, which is what stops the flap loop instead of merely making it rare.
The idle connection now reports everything it hears. It used to react to new mail and discard the rest of what the server told it. A deletion announced mid-idle sweeps its folder within seconds instead of within two fifteen-minute cycles, and a flag change announced mid-idle refreshes the same way. The idle connection stays a trigger, never an authority — every answer still runs through the sweep's own safety rails.
Gmail and Graph read-state was broken too, each in its own way. A Graph delta carries isRead and the star and was read only for the folder; both apply now, and an absent key preserves rather than resets. Gmail's batch handler dropped every already-known id before fetching anything — which had quietly stopped the label mirroring for relabelled messages as well; with the filter corrected, label changes and read-state flow as v0.0.29 intended.
v0.0.29 — 2026-08-12
Two migrations run on boot: one adds the column the deletion sweep marks
suspicions in, one deletes the retired "labels stay local" setting from the
settings bag — the second is irreversible and the whole feature it configured
is gone. And after updating, a reset is the recommended path (app:reset,
which now clears calendar data too): mail archived in Gmail before this ships,
label trees never pushed under the old toggle, and stragglers from
UIDVALIDITY rebuilds all predate the new mirroring and heal by re-syncing
fresh rather than by machinery.
The lifecycle mirrors, on every provider, in both directions. A message deleted on the server is deleted here — which none of the three providers ever did. IMAP learns it from a periodic full listing per folder, and the sweep is built to be wrong safely: absence only marks; erasure additionally requires every folder listed since the mark and a final probe answering an explicit "not there"; a folder that answers empty after holding thousands is treated as a rebuild, not a purge, and suspends reaping account-wide; a changed UIDVALIDITY strips addresses and re-matches by Message-ID, never wipes. Gmail's feed finally subscribes to deletions; Graph's delta removals distinguish a move from a disappearance. All three delete through one shared path — replacing two hand-rolled ones that each leaked different files.
Labels mirror without asking. The "labels stay local" toggle is gone: labels created here are created there (Gmail labels by API, Graph folders by API, and IMAP folders by CREATE — with the namespace prefix and separator read from the folders the server already has, not from a convention). Labels changed there change here, removals included, scoped so one account's feed can never touch a sibling account's same-named label. Archiving means the same thing everywhere: Gmail's inbox-departure becomes the Archive label here and vice versa, Outlook's archive folder resolves by its well-known name before anything creates a duplicate beside it. Discarding a draft finally discards it on the server too.
The calendar pane and mail navigation stop fighting. A mail-bound click in split view swaps only the list — the calendar holds perfectly still. A fullscreen calendar demotes to split (or to mail where the row cannot hold both) on any mail-bound click, wherever it came from: sidebar, drawer, another page. "Fullscreen" is judged by what covers the mail, not by what the mode is called — one toggle press on a narrow window counts. A reload paints the remembered layout server-side with the pane's body embedded in the page, so mail and calendar arrive together instead of the calendar filling in a beat late.
Smaller honesties. The reading pane's sender avatar wears the same
colour the thread list derives for that sender, instead of one blue gradient
for everybody. Inbox category tabs exist only while they hold mail — Gmail
itself has quietly retired Forums — and a lone Primary tab hides the whole
row. The thread list's refresh button is gone; the topbar's sync button is
the one that answers. app:reset takes calendar data at the same depth it
takes mail, keeps event suppressions at every depth, and says so honestly in
its prompts.
v0.0.28 — 2026-08-10
No migration. German mail still rendered as "über", and the sync was not to
blame this time. "Stop believing a charset label the bytes disprove" fixed
the MIME layer, and it holds: a mislabelled part decodes correctly even inside
a multipart invite. The damage was one layer lower down. A stored body is
UTF-8 whatever it arrived as, but the sender's own <meta charset=iso-8859-1>
came through that conversion unchanged — and the parser behind the HTML
sanitizer follows the specification's encoding sniffing algorithm, which
prefers that tag to any default. So the rendered copy was decoded as latin-1
all over again. The declaration is now corrected to match the bytes before
anything parses the document, which is what the HTML specification asks of
anything that transcodes one.
Mail already showing mojibake can be repaired in place, unlike last time:
only the rendered copy was ever wrong, so app:backfill safe-html-charset
re-derives it from the body as stored. No resync, no mail server, and the
sender's original copy is left exactly as it is. Run it once after updating.
A calendar invite written in latin-1 no longer costs its event. Invites are stored as they arrive and RFC 5545 says UTF-8, so nothing converted them — and one 0xE4 in a SUMMARY was not a mangled title but an INSERT Postgres refused, taking the extraction with it.
Light and Dark read mail on their own light. v0.0.26 made the reading sheet a theme decision and then left Paper's cream in the three blocks without a named selector — which is why the light theme's reading pane looked like Solar had bled through. Light now reads on its own white and zinc; both dark palettes on their palest slate.
v0.0.27 — 2026-08-10
No migration, and nothing in the app itself — this release moves the test suite onto the server the app actually ships on. CI used to serve the E2E suite from PHP's built-in dev server, and everything that server could not do had grown an accommodation: a router script written for the occasion, a php.ini copied in with sudo, and finally a restart loop because it segfaulted mid-suite. All of it is gone. The workflow now builds the repository's own FrankenPHP image and runs PHPStan, PHPUnit and the browser suite against the compose stack — the same commands, the same server, the same stack a developer runs locally, so a green suite finally says something about plMail as shipped.
Getting there flushed out three real defects: two specs talked to a hard-coded compose project and could seed a database the browser was not looking at (one of them had been silently not stopping the Mercure hub it believed it stopped); and a fresh checkout could not boot the test stack at all, because the init path had lost the entrypoint's dependency bootstrap. A test that flaked when a fast runner sent two replies inside the same second now makes its own second boundary.
v0.0.26 — 2026-08-10
No migration. Every theme now defines every variable — all 38 of them, explicitly, in every palette. Three themes were four-line diffs against a light-tuned base, which is how Nord got the light theme's link blue on a near-black surface and an ink ramp that ran backwards. The real remnant bug was worse than CSS: picking Paper seeded its clay accent onto the account itself, where it survived onto every theme chosen afterwards — and picking System on a dark desktop painted the app light until the next navigation. Both fixed. A parser-backed test now fails the build if a theme ever misses a variable, and a browser test switches through every theme without reloading and asserts the result is pixel-for-pixel what a fresh load produces.
The reading pane wears the theme. Two conflicting definitions of the mail sheet had been shipped, and the older plain-white one was silently winning — the warm-paper redesign was dead code. Resolved in the redesign's favour, and the sheet's seven colour channels now come from the theme: Nord reads mail on snow storm, Solarized on its own base, Paper on cream. This is why the thread list looked like the odd one out on the light theme — now the whole reading surface agrees with wherever it is.
Search respects the bin. A deleted conversation no longer turns up in
search results through its other labels — the same rule the list views
learned in v0.0.25, applied to search's own SQL. in:trash and in:bin
still find it on purpose.
The default calendar speaks your language. A user provisioned in German gets "Persönlich", not "Personal". Calendars that already exist keep their names — they are the user's data, and may have been renamed.
The E2E dev server on CI restarts itself if it crashes, so one segfault can no longer disguise itself as forty-five failing tests.
v0.0.25 — 2026-08-10
No migration. A message that moves is still one message. Moving mail — trash, archive, snooze, a filter filing something away — used to leave the row holding its old IMAP address, so the destination folder's next sync met the real message as a stranger and inserted it again: one ghost per move, which is how a mailbox of 35 became 86. Moves now mark the row as relocated and sync reconciles it by Message-ID; an external move is told apart from a genuine copy by asking the server, and an answer of "could not tell" never merges and never deletes. Installs already carrying ghosts heal on their own syncs — each duplicate group collapses onto the copies the server actually confirms, and a message whose every copy is unconfirmed is left strictly alone. The flags-worker no longer warns forever about vanished UIDs, and a half-completed move on a server without native MOVE support can no longer be retried into a second copy.
Archive works now. Archiving removed the Inbox label and added nothing, so the Archive view — which lists by the Archive label — was permanently empty. Archived mail is now filed under Archive, keeps its other labels, and the sidebar's "More" section stays open while you are inside it instead of snapping shut on every navigation. Spam also actually exists: the sidebar visibility toggle had nothing to show, because there was no Spam view at all — there is now, in every locale.
Trash stays in Trash. A trashed thread kept its other labels and went on
listing under them; every list and unread badge now excludes trashed mail
except the Trash views themselves. Search is deliberately unchanged for now —
it has its own query builder and an in:trash operator that deserve their own
change.
The little lies of the UI. The onboarding integrations row wraps instead of forcing the modal to scroll sideways, and provider names no longer break mid-word. Dropdown search results mark exactly what was typed, in bold, without the phantom trailing space (a flex gap painted beside the highlight) — and the search box inside long dropdowns, which the v0.0.22 theming had silently removed from every select in the app, is back. Selecting text in a modal and releasing the mouse outside no longer dismisses the modal — a dismiss now requires the whole gesture, press and release, on the backdrop. Switching density no longer rewrites your background setting as a side effect (that was the "page breaks until reload"). Two-factor copy now protects your "account" rather than your "mailbox", in every locale.
The calendar E2E specs drive dropdowns the way a person does. Ten specs still fired the retired native-select gesture at the themed combobox and died; they now go through a shared helper that operates the widget by its ARIA contract — which doubles as a regression tripwire on the accessibility the theming promised. A new spec pins that contract directly.
v0.0.24 — 2026-08-10
No migration, and no runtime change at all — this release exists to turn the
release checks back on. The E2E workflow had been red on every tag since
v0.0.19: a backup-exporter test hardcoded that APP_ENCRYPTION_KEY must appear
in an exported config, which is true wherever the key is set in the real
process environment (compose does this) and false on the CI runner, where the
key exists only in .env.test — a layer a backup deliberately does not read,
because the shipped .env defaults are placeholders and restoring
mailto:admin@example.com over a working install is the exact harm the
exporter is scoped against. The exporter was right; the test now derives its
expectation from the exporter's own rule and pins both directions, so it can
never again be green on one harness and red on the other. If this tag's E2E
run is green, that is the fix proving itself.
v0.0.23 — 2026-08-10
No migration. A reply you send is one message again. A message sent from the web frontend on an IMAP account used to come back from the Sent folder as a stranger: the composed row carried no Message-ID, and the MIME was left for Symfony to stamp — which mints a fresh id on every serialisation, so the copy that went to the recipient and the copy appended to Sent did not even agree with each other. The next sync inserted the Sent copy as a second message in the same conversation. Sends now mint one Message-ID before anything reads the mail, written to the row and the headers alike, and the Sent-folder sync reconciles on it — a provider's own auto-saved copy is recognised and dropped too. The rows the old path left behind repair themselves: the next sync of the Sent folder pairs each ghost with its imported twin and removes it, so affected conversations heal without anyone touching them.
Answering a conversation moves it back to the top. Thread lists sorted on the last message the thread received, so your own reply left the conversation buried under everything that had arrived since. Sending now records the activity the same way an arrival does — forward only, so a backdated arrival cannot drag a live conversation down, and draft autosaves never reorder anything. The Sent list sorts by your own send dates as a consequence rather than by luck.
Two counters that were only ever right by accident of the duplicate are right on their own now: a reply draft counts toward its conversation's message count, and a sent reply updates the conversation's labels immediately instead of waiting for the duplicate to drag the Sent label in.
v0.0.22 — 2026-08-09
One migration runs, and it is irreversible — but it changes no table. It
deletes the per-account "sync only the last X mails" setting from the settings
bag, because the feature is gone: the cap applied per sync run, not per
mailbox, so an account that outgrew it between runs lost the middle of its
mail — interior holes, not a window. Removed everywhere: the settings control,
the caps in both the IMAP and Gmail syncers, and the capability the app read.
An account that wants its full history re-syncs (app:reset) or is re-added;
previously capped Gmail accounts simply complete their backfill on the next
sync.
Dropdowns finally wear the theme. Every user-facing select is a themed widget now — the same one compose's recipient fields always had — because a native select's popup obeys the operating system, not the stylesheet, and looked pasted-on in every dark theme. The native selects remain underneath as the source of truth: forms submit identically, and without JavaScript everything degrades to working controls. Includes the five selects the filter rule builder creates in JavaScript, which no earlier styling pass could reach.
Finishing a login twice is not forbidden. Submitting the two-factor code twice — a double click, a second Enter, the browser's "resend?" after Back — answered the second attempt with a bare 403 at the exact moment the login had succeeded. The resubmit now lands where the first submit went, and the form guards itself against double clicks.
The backup sheds three more machine-local values. MERCURE_JWT_SECRET
pairs the app with the hub container beside it — restoring another
machine's copy re-keys half of a running pair and kills every live update
until the whole stack restarts. APP_SECRET signs cookies and nothing
durable — the only thing another machine's copy can do is keep another
machine's cookies alive on the new install. TRUSTED_PROXIES describes the
machine's own reverse-proxy chain. Old backups carrying any of them import
fine; the values are classified and left alone.
v0.0.21 — 2026-08-09
No schema changes. The backup grew a format version, not a table.
A config backup knows its own operator now. Backups carry every user and everything they configured: password and app-password hashes, two-factor secrets and recovery codes, mail accounts with their IMAP/SMTP credentials and OAuth tokens, aliases, integrations, filters, labels, calendars, and share and booking links — whose published URLs keep working after the move. Everything sensitive travels only inside the encrypted envelope, re-encrypted under the target install's key on arrival. Restoring onto a fresh install ends at sign-in instead of "create the administrator"; importing onto a live install never overwrites an existing user — found means kept, and the review says so. Older plMail versions refuse the new format whole rather than applying half of it. Mail, calendar entries, sync state and per-browser grants stay out, deliberately.
The restore page stops listing non-tasks. Values already identical to the
environment are a footnote, not a chore; MAILER_DSN and
MESSENGER_TRANSPORT_DSN left the export entirely, joining DATABASE_URL as
machine-local deployment choices the compose file owns; and the
old-encryption-key note folds behind one line for the only reader it
concerns — someone also restoring their old database.
One browser, one remembered device. With "remember this device", every sign-in quietly added another row to Settings → Security: the 2FA library re-registers a trusted browser on each login to extend its lifetime, and the database-backed manager read that as "insert a sibling", orphaning the old row and its cookie each time. The manager now renews the grant the presented cookie actually resolves to — same row, same secret, expiry pushed out. Matching is on the cookie alone, never user-agent and address, so two identical laptops behind one router stay two rows; revoked grants are never resurrected. Existing duplicates age out or clear with "Revoke every remembered device".
Settings looks finished. The sidebar is grouped — Personal, Mail, Calendar, Access — instead of thirteen equal links; file pickers look like the buttons they are (Tailwind's preflight had stripped them bare); and every native select shares one theme-aware look instead of six accidental ones, including three that ignored the accent colour.
v0.0.20 — 2026-08-08
No schema changes. Templates, one controller, and the calendar's layout plumbing; nothing touches a table.
A shared calendar is a calendar now. The public share page grows the same four views the owner has — month, week, day and agenda — with a switcher whose every view is a plain link, because the page has no session to keep a choice in and must not acquire one. The day-by-day list that used to trail under the grid is gone; it became the agenda view. Days the link does not publish stay visibly "not shared" in every view, paging still stops at the window's edges, and busy/free links get all four views with nothing more to leak: the grid's layout engine asks entries only for a start, an end and whether they run all day, so it structurally cannot place a title anywhere.
Both calendars now render through the same shells. The toolbar, time grid and agenda were extracted out of the authenticated calendar and both pages draw through them — a restyle of one is a restyle of both, and the shared page can no longer drift out of looking like plMail.
Three bugs fell out of building it. A shared page shown in the owner's zone captioned a Berlin August as "July 2026" (the owner's own calendar was shielded by middleware the public page rightly lacks); the shared agenda's back-step was built from a phrase PHP accepts and does not mean, so stepping backwards silently refused; and one long multi-day entry could cost a public GET millions of date additions — the spread is now bounded by the page being drawn. Switching views also keeps your place now: the switcher carries the day you were looking at, not the first of the month it was filed under.
v0.0.19 — 2026-08-07
No schema changes. Everything below lands in code and templates; nothing touches a table.
One meeting is one row, everywhere. The time grid always collapsed a
meeting that lives on two calendars into a single chip with a multicoloured
dot; every other surface listed each copy separately. The same collapse now
runs in "Happening soon", on shared calendar pages and their .ics feeds, and
in the alert sweep — which was quietly the worst offender, sending two pushes
per reminder for a two-calendar meeting.
Search sorts by date, and says so. Results used to come back relevance-ordered, which put a 2004 eBay mail above last week's. The default is now most-recent, with a dropdown beside the pagination for most-relevant, and the choice follows you to your other devices. Fixed underneath, because it had to be: neither order was total, so paging through tied rows could show a result twice and another never — measured at 148 distinct rows out of 150 before the fix. The search page also finally speaks the reader's language; its headings and filter pills were English written into the template.
Restoring a backup is uploading a backup. The import used to print a wall
of .env lines to paste by hand, built on the assumption those values were
hand-maintained — they are generated on first run, so the import now writes
them where the entrypoint reads them and asks for one restart instead. The
database's own credentials left the export entirely: they are machine-local,
consumed once at initdb, and useless-to-harmful on any restore target. Old
backups still import; those entries are recognised and left alone. Beside the
export form there is now a generate button that fills both password fields and
puts the password on your clipboard — and where the browser withholds its
clipboard, the fields unmask instead of pretending.
The badge that would not die is dead. FrankenPHP keeps services alive between requests, and four topbar helpers memoised per request without ever being reset — so the first answer a worker computed was the answer every later request got. That is the yellow log-alert outline that survived an emptied log table, and on a multi-user install two of the four could show one user's sidebar counts or calendar dot to another. All four now reset per request, like the services that always did.
Registered devices can be removed from settings. A push subscription that
delivers perfectly well to a phone that no longer wants it — an old build's
leftover registration, a retired device — used to be a DELETE typed into
psql. It is now a button in Settings → Notifications, and the delivery log
deliberately keeps the removed device's history.
Shared calendars and booking pages look like plMail now. A share link
renders the app's own month grid — today marked, chips in place, prev/next —
in the owner's theme, falling back to Paper; booking pages get the same
treatment. What a link does not reveal is unchanged, and deliberately so:
chips on a busy/free page carry the owner's accent rather than calendar
colours, because colours would let a stranger group anonymous blocks by which
diary they came from. Also fixed: the share page had printed the literal text
calendar.share.window under its heading since the feature shipped.
v0.0.18 — 2026-08-06
Five schema changes, all additive and applied automatically on boot.
calendar_event gains my_participation and label_binding gains
graph_category_id (neither backfilled — the reasons are in the migrations and
are worth reading before upgrading, because one of them changes when an
invitation appears on your calendar). New tables fcm_config and
push_delivery, and message gains two submission timestamps; those three
carry the push and scheduled-send features below and touch nothing existing.
An invitation lands on the calendar when you accept it, and not before. Yes or Maybe puts it there, No takes it back off, and nothing about that is one-way — the invitation itself never goes anywhere, so changing your mind later moves the meeting on or off again. Until now every invitation was drawn the moment the mail arrived, so a week of unanswered requests looked exactly like a week somebody had agreed to. Only invitations addressed to you are affected: a flight confirmation, a mirrored Google calendar and a meeting you organised yourself all appear as they always did.
Invitations that arrived before this upgrade keep their old behaviour and
stay on the calendar whatever you answered — emptying somebody's calendar during
a docker compose up is not an acceptable way to ship a feature.
app:backfill events re-reads the mail and brings them into line.
Events read out of mail land on your default calendar, not on the
per-account one. A person has one diary; which mailbox a flight confirmation
happened to arrive at is a property of the message, not of the flight, and
filing by it split one day across as many calendars as you had accounts. The
per-account calendars still exist, and Account::SETTING_CALENDAR_TARGET still
points at one for anybody who wants the split.
A meeting you put on a second calendar still hears its own updates. Ticking another calendar in the editor was recorded as an edit, which is the flag that tells plMail to stop letting mail revise an event — so a shared meeting went quiet, and the only symptom was a reschedule that never arrived. Sharing is not correcting; and a later message now updates every copy rather than the one on the calendar extraction happens to file to.
All-day events are no longer two hours long, or on the wrong day. They are floating — a wall-clock date with no zone — and every reader was converting them into one anyway, so an all-day invitation opened in the editor reading "02:00 – 02:00" for anyone east of UTC and was drawn on two days. West of UTC the same arithmetic moved it onto the day before. The container's own timezone was never the problem and is still deliberately UTC.
A twelve- or twenty-four-hour clock, in Settings → General. It reaches every time plMail prints — the mail list, a thread, a calendar chip, the day grid's hour axis. The default is Follow your language, and it stays that state rather than being silently converted into whichever format it currently resolves to.
"Happening soon" lists what you typed as well as what was found in your mail. It filtered on "was this extracted?", which meant the one thing a person had put in the calendar themselves was the one thing missing from the list telling them what is coming up.
Double-clicking empty space on the time grid creates an event there, at the quarter hour under the pointer. A single click deliberately does not — it cannot be told apart from the end of a drag without arbitration nobody would enjoy maintaining — and the + in each day heading stays, because a double-click is not a gesture a keyboard has.
Every day column lines up with its own heading again, and every chip prints the hour it is drawn against. The headings and the all-day band were siblings above the scrolling hours, so they were as wide as the pane while the hours were as wide as the pane minus the scrollbar; all three share one scroll container now. Separately, a block was positioned against the calendar's zone while the time inside it was printed on the reader's, so a meeting on the 15:00 line could say 17:30. Both are the same kind of fault — one grid, two clocks — and neither looked like an error.
The calendar switch has three positions: mail, split, calendar. The third is a full-width calendar without navigating away from the mail — the mail is still there behind it, so coming back is instant. The drag handle reaches the same two ends: past the pane's limits it keeps moving with resistance, and letting go past the threshold switches. The docked pane also draws the same time grid the page does now, rather than a column list of its own, and choosing Week or Month widens it to fit — animated, and only ever upward.
Renaming a label no longer creates a second Outlook category. With label sync on, renaming a category that had come from Outlook created a new one under the new name and left the old one standing, which the next sync imported back as a label beside the renamed one. A master category is now addressed by the name it had, its id is recorded when Outlook is read, and every message carrying the old name is pushed again so it carries the new one. Also fixed alongside: a category id was being written into the column that means "this label is an Exchange folder", so a label plMail created as a tag started being pushed as a location.
Push can reach a phone through Firebase now, beside Web Push. Configured at
runtime under Admin → Push: paste your own Firebase project's service-account
key and its google-services.json, and the session starts advertising the
public half — which is how one stock Play-Store build of the Android app
configures itself against whichever instance it is signed into. No Firebase, no
Google: Web Push and pull-only still work exactly as before, and a client is
told honestly which transports this instance can actually deliver over.
Messages travel as data-only payloads; Google learns that something arrived and
when, never what it says.
Every push attempt is on the record. Admin → Push → Deliveries lists what was sent to which device over which transport, with the outcome, the error name that tells a dead phone from a Firebase outage, and the latency; your own devices and their last delivery appear in Settings → Notifications. Nothing of a payload beyond its type is stored, and the record outlives the subscription it is about. Pruned after 30 days.
A scheduled send is now something the server admits to. EmailSubmission/get
answers a held submission as pending with its real release time, a cancelled
one as canceled, and the changes feed reports both transitions — so a mail
scheduled on one device is visible, and cancellable, from every other. Found
and fixed alongside: cancelling a mail that was never submitted used to arm a
flag that silently swallowed that draft's next send.
JMAP grew the surface a native client was waiting for. Email/set update
applies attachments instead of silently dropping them (a refused patch now
writes nothing at all, the subject beside it included); EmailSubmission/set
honours identityId, so sending as an alias actually sends as the alias;
scheduled send via the standard FUTURERELEASE envelope parameters, up to thirty
days; Contact/autocomplete serves recipient suggestions ranked by the same
query the web composer uses; Appearance/get|set syncs the theme a user chose;
the session states each account's sync window instead of leaving a client to
probe for it; and CalendarEvent/query expands recurrences server-side, so
drawing a month of a repeating series costs one round trip instead of
thirty-one. Every extension sits under a urn:plmail: URN and is documented in
docs/CLIENT_DEVELOPMENT.md.
Your configuration fits in one file you can actually restore. Admin →
Backup exports the instance's secrets and credentials — env values, the JWT
keypair, the provider and Firebase credentials, decrypted so they survive the
move to a machine with a different encryption key — sealed with a password of
yours (Argon2id + secretbox, format documented down to the shell one-liner that
opens it). Import shows its plan before touching anything, applies what it
honestly can, and prints the exact lines for what only you can place. A fresh
install offers the same restore on /install, before the first account exists
and never after.
v0.0.17 — 2026-08-05
Two schema changes, applied automatically on boot. calendar_event gains a
remote_instances map and calendar_event_occurrence a start-only index;
calendar_alert_delivery, calendar_share_link, calendar_share_link_calendar,
booking_page, booking_page_calendar and calendar_booking are new tables.
All additive; nothing existing is altered or rewritten, and an install that
neither shares a calendar nor sets a reminder is unaffected by any of it.
One deployment change, and read it before pulling. compose.yaml now mounts
three named volumes it should always have mounted — app_attachments,
app_raw and app_uploads. If you have been running the stock compose.yaml,
your attachments and raw sources are currently inside the containers rather than
beside them, and mounting an empty volume over that directory hides what is
there now. Nothing is deleted, but the files stop being visible.
Before docker compose up -d, copy what the running containers hold:
docker compose cp php:/app/var/attachments ./attachments-backup
docker compose cp php:/app/var/raw ./raw-backup
then bring the stack up and copy them back into the new volumes:
docker compose cp ./attachments-backup/. php:/app/var/attachments
docker compose cp ./raw-backup/. php:/app/var/raw
An install that already used compose.override.yaml or truenas.compose.yaml
has these mounts and is unaffected.
Fixed
-
Attachments downloaded fine and then 404'd, and everything vanished on a restart. The stock
compose.yaml— the file the README tells you to run — mounted no volume forvar/attachments,var/raworvar/uploads. The workers write those during sync and the web container serves them back, so each kept its own copy in its own layer: the file was written by a process that was not the one answering the request, and both copies died with the next container recreate.compose.override.yaml.distandtruenas.compose.yamlboth mount them, which is why this survived — the one path an operator actually follows was the one that did not. The same omission in the.distcost real data once before; see the deployment note above for moving what is currently stranded. -
A repeating event quietly ran out of dates, and its reminders with it. Occurrences are drawn when an event is saved, two years ahead, and nothing moved that window afterwards — so a weekly standup created today reached six months in eighteen months' time and eventually stopped being drawn at all. Reminders read those rows, so they stopped too. Silently: the event still existed and still said it repeated weekly.
app:calendar:materialisenow rolls the horizon forward nightly. The query it runs had existed since the materialiser did, documented as exactly this sweep, and nothing had ever called it. -
One mailbox with an unusable address could stop everybody's reminders. The reminder mailer built its message outside its own error handling, and an account whose username is not an email address threw before anything was sent — ending the whole minute's sweep for every user on the install, once a minute.
-
The Microsoft setup wizard asked for three permissions out of the nine plMail requests. Following it produced an account with no categories and no calendar, and Microsoft does not upgrade a token already issued when the permission is added later.
Added
-
The week is a time grid you can drag events around on. Hour rows, blocks sized by their real times, meetings that overlap sharing the width, and all-day events in a strip of their own rather than squashed against midnight. Drag one somewhere else to move it, drag its edge to make it longer. Dragging one occurrence of a repeating event asks the same question the editor does — this one, or all of them — and read-only calendars refuse the drop and say why. The docked pane keeps its column list: a 380px pane has no business drawing a grid.
-
Calendars go in and out as
.ics. Download one event or a whole calendar, upload a file into a calendar you pick, or subscribe to a published calendar by URL —https://orwebcal://— and have it refreshed on the same schedule everything else is. A subscribed feed is read-only. Importing the same file twice updates rather than duplicates, and an event that arrived by invitation is recognised as the same meeting rather than added again. -
Reminders. Six one-click offsets — at the time, five, ten or thirty minutes, an hour, a day — plus any number of minutes you like, and as many reminders on one event as you want. They arrive as a browser notification or as mail. A reminder set on a repeating event fires for each occurrence, and a reminder never fires twice however many times a sweep is interrupted or replayed. Reminders travel to and from Google, Microsoft and CalDAV, and are written into an exported
.ics.A fresh install can deliver neither. Browser notifications need the browser to have been given permission, and mail needs an account that can send. Until one of those exists, a reminder is stored and nothing arrives.
-
What is happening soon, beside the mail it came out of. A control in the top bar, which appears only when there is something to show and wears the icon of whatever is next — a plane, a parcel — opens a list of the next fortnight's bookings, each naming the message it was read out of.
-
Calendars over JMAP, under
urn:plmail:params:jmap:calendars:Calendar/get,CalendarEvent/get,CalendarEvent/queryandCalendarEvent/set. An edit made by a JMAP client reaches Google and Microsoft exactly as one made in the browser does. Client authors: an event id is the series rather than a dated occurrence, and a query must name a date window — see the handbook. -
A handbook. The wiki now has 34 pages: every feature and how to use it, installing on Docker, Linux, WSL2, macOS and NAS boxes, a complete environment-variable reference, step-by-step registration of the Google and Microsoft applications with the exact permissions to tick, and seven pages on how the internals work. It is generated from
docs/in the repository, so it cannot drift from the code — and a build now fails when a command, a variable, a link or a page goes undocumented. -
You can send somebody a link to your calendar without giving them an account. Settings → Sharing makes one. Tick-boxes on the link decide what it says: with none of them ticked it shows only when you are busy, and each box adds one thing on top — the title, the location, the description, who is coming. You choose which calendars it covers and whether it shows a rolling window from today or two fixed dates, you can change all of that afterwards without the address breaking, and you can revoke it. An event you marked private stays a plain busy block whatever the link says, and one you marked secret does not appear at all. There is an
.icsbeside the page for people who would rather subscribe than look.The address is shown once, when you create it. It is a password to your diary, so plMail stores only a hash of it — the same treatment device pairing codes get. That means it cannot be shown to you a second time: copy it when it appears, and if you lose it, press regenerate, which gives the link a new address and kills the old one.
-
People can book an hour of your day. A booking page publishes the weekdays and hours you are available, how long an appointment is, how much room to leave around one, how far ahead somebody may reach and which calendar the booking lands on. The times it offers are free against your real calendar — every calendar you nominate, including mirrored ones — so cancelling a meeting brings its hour back and moving one takes the gap with it. Whoever books gives a name, an address and optionally a note, and gets a confirmation with a calendar file attached.
Two people cannot take the same slot. That is enforced by the database rather than by a check, so it holds when both of them press the button at the same instant; the second one is told the time has just gone and shown what is still free. A booked appointment appears in your calendar marked as one, and lands on the calendar you nominated — so if that calendar syncs to Google, Microsoft or a CalDAV server, the booking is on your phone as well.
-
An event can be put on another calendar, including one you sync. The editor's calendar list is now every calendar you have, ticked wherever the meeting already is. Tick one it is not on and a copy is made there — on a mirrored calendar that means it is pushed to Google, Microsoft or your CalDAV server straight away, which is how a booking read out of an email gets onto the calendar on your phone. The copy is the same meeting rather than a second one, so the views still draw a single entry, and a later update from the organiser still reaches every copy of it.
Changed
- The event editor's calendar dropdown is gone. The checkbox list does everything it did and one thing it could not, and two controls answering "which calendars is this on?" can disagree — a calendar picked in one and three boxes ticked in the other is a meeting asked to be in three places at once. Unticking a calendar still leaves that copy exactly as it was rather than removing it; Delete is what removes, and it reads the same ticks. Moving an event to a different calendar is therefore two steps now: tick the new one and save, then re-open, tick only the old one and delete.
v0.0.16 — 2026-08-05
Three schema changes, applied automatically on boot. calendar_event gains a
sync state and a synced-at stamp, calendar gains a last-synced-at and a
last-sync-error, calendar also gains four push-registration columns, and
event_proposal is a new table. All additive; nothing is dropped or rewritten,
and an install that never connects a calendar is unaffected by any of it.
Google and Microsoft accounts now ask for calendar permission at sign-in.
Existing connections keep working for mail either way. To let them carry
calendars, enable the Google Calendar API and add the .../auth/calendar scope
in Google Cloud, or add the Calendars.ReadWrite delegated permission in Azure
— there is no second app to register and nothing to configure in plMail.
Added
-
Calendars from Google, Microsoft and CalDAV, synced both ways. Subscribe from Settings → Calendars: plMail asks the account what calendars it has, you tick the ones to mirror, and changes travel in both directions. Google and Microsoft ride the same sign-in you already use for mail. CalDAV is a connection of its own — give it an address and it finds the rest, with the option to reuse a mail account's stored password rather than typing an app password, off by default because most servers want a separate one anyway. Each mirrored calendar says where it comes from, whether it accepts writes, when it last synced and why it stopped, because a calendar that syncs on a sweep nobody watches is the only kind that can break silently.
-
A date in an ordinary email can be added to the calendar. Not an invitation, just a sentence — "Termin wie vereinbart: 04.08.2026 um 14 Uhr", or "let's meet Saturday at 3pm". A card offers it, quoting the line it read so the guess can be judged at a glance, and nothing reaches the calendar until somebody says yes. German and English, explicit and relative dates, durations where they are stated. "Not an event" is remembered, so it is not offered again.
-
One occurrence of a repeating event can be changed on its own. Saving or deleting a recurring event now asks whether you mean this one or all of them, and only asks when the event actually repeats. Moving a single occurrence leaves its siblings where they were — and choosing "all events" from an editor you opened on one occurrence applies what you changed rather than where that occurrence happened to be, so renaming a weekly meeting from next month's entry does not move the meeting to next month. A single-occurrence change reaches a CalDAV server; Google and Microsoft take the series but not yet the instance, so on those it stays local for now.
-
Google and Microsoft calendars can arrive by push rather than being waited for. Where the installation has a public HTTPS address, a change made elsewhere shows up in seconds instead of at the next quarter-hour sweep. Google additionally refuses to open a channel unless the callback domain is verified in the Cloud project that owns the OAuth client; the admin diagnostics panel says so, and Microsoft needs no equivalent step. Without a public address nothing breaks and nothing needs configuring — calendars stay on the sweep, which remains the backstop either way.
-
One meeting that reached plMail twice is drawn once. An invitation you were sent and the same meeting on a connected calendar are two entries for one appointment; the views now draw them as a single entry with a dot showing each calendar's colour. Editing it asks which copies to change — all of them by default, because that is what editing "the meeting" means — and a copy on a read-only calendar is listed but cannot be ticked. Untick one and it stops matching the others, so it separates back into its own entry, which is the point rather than a side effect. Copies that disagree are never merged: if one says two o'clock and the other says three, you see both, because a tidier screen is not worth hiding that from you.
-
The admin header says which build is running. The release and the commit it was built from, on every admin page — the first thing worth knowing when a deployment behaves unlike the branch you are reading, and not otherwise discoverable now that migrations run on boot and
latestmoves. A checkout that was never built from a tag shows nothing rather than a placeholder. -
An invitation can be answered from the mail it arrived in. Invites were parsed, filed on a calendar and then unanswerable — the participants were read out of the
.icsand written into the event, and nothing rendered them. A message carrying an invitation now shows a card above it: when it is, who called it, who else is coming and how they answered. Yes / Maybe / No records the answer and sends the organiser an iTIPREPLY— same UID, same SEQUENCE, one ATTENDEE line — which is what every other calendar reads to tick a name off. The card says what the answer was; the toast says whether it reached the organiser, because a reply that could not be sent leaves somebody holding a seat for a person who thinks they declined. -
Calendars can be managed. They carried a name, a colour, a time zone, a visibility flag and a default flag from the first pass, and nothing reached any of them: a user with four accounts had four calendars named after their usernames, all shown, with no way to say where a new event should land. Settings → Calendars now does all of it. A provisioned calendar cannot be deleted (it would only be created again) and the default can be neither deleted nor hidden (new events would vanish on save), so neither offers the button.
Fixed
-
A recurring invitation or CalDAV event appeared exactly once. The repeating rule was read and then stored unconverted, and the part of the application that draws occurrences reads the converted form — so a weekly meeting that arrived by mail, or lived on a CalDAV server, drew a single entry and nothing else. One conversion now serves all three sources; a rule that cannot be converted faithfully is still refused outright rather than half-applied, because an event on the wrong days is worse than one that does not repeat.
-
An instance moved out of its series landed in the wrong place, differently on every provider — a duplicate on the new day from Google, the old time from Microsoft. Moved and cancelled instances are now carried as the per-instance exceptions the format has for them, and CalDAV writes them back, so editing a series' title no longer deletes every instance somebody moved.
-
An event accepted from a suggestion could be silently rewritten by a later email. The protection was accidental: the guard only covered events that came out of extraction, and what actually kept mail off these was that plMail mints identifiers no sender collides with. A message carrying the same identifier overwrote the lot. Events a person decided on are now off limits to mail by rule, and the refused claim is still recorded.
-
An invitation from Google Calendar had no organiser, so it could not be answered. The organiser is listed twice in a normal invite — once as ORGANIZER and again as an ATTENDEE, because they are going too — and the participants were keyed by address and written in that order, so the second line landed on top of the first and took the
ownerrole with it. The event ended up with nobody marked as organiser, which is nobody to reply to, so the new invite card correctly offered no answer to what was in fact a perfectly ordinary invitation. Roles accumulate onto the address now instead of replacing each other. Existing events carry the old shape untilapp:backfill eventsre-reads the mail they came from. -
Re-reading an invitation un-answered it. The mail that asked the question says NEEDS-ACTION about you forever, so every re-run of extraction reset an RSVP that had already been sent — the organiser knew, and the screen did not. An answer already recorded is kept unless the incoming copy states a real one.
-
Throwing away an event extraction got wrong put it back. Extraction is re-runnable by design, so deleting an event only lasted until the next backfill walked the mailbox again and re-created it from the same message. "Not an event" now records the refusal against the claim's dedup key — the table for it existed and nothing had ever written a row — so the dismissal survives a re-run and also catches the next message about the same booking.
-
Correcting an invitation's details un-answered it. The event's canonical object is rebuilt from its columns on every edit, and the editor has no participants field, so fixing a meeting's title dropped the RSVP that had been stored on it. (Locally only — the organiser had been told long before.)
-
Typing into a modal that had just been opened could be silently discarded. Closing a dialog cleared where the frame pointed but left the previous form in it, so the next open showed the last dialog until its own fetch landed — pre-filled, focusable and completely convincing. Anything typed in that window was thrown away when the real form arrived. The frame goes back to its spinner on close.
-
The sidebar's account chevron did not say whether it was open. It is a disclosure button, and which way it will go stopped being guessable when the expanded account moved into the user's settings and started surviving navigation. It carries
aria-expandednow, kept in step as it opens and closes. -
Four mailbox pages ran an English word through the translator as a key. The Inbox, Drafts, Sent and Trash titles asked for
'Inbox'|transrather than the key beside them in the catalogue, so they were untranslatable and reported as missing on every visit — and Starred asked for "Drafts", which is what it said.thread_row.unreadwas genuinely missing and read out as its own key by screen readers; a|defaultbehind it could never have fired, because a missing translation comes back as the key and the key is not empty. -
The pirate locale had no calendar at all.
en_PIwas written before the calendar shipped and never caught up, so every string in it fell back to English. It has the lot now, including the new settings section.
v0.0.15 — 2026-08-03
No schema change, no deployment change.
Fixed
- An Outlook account could stop syncing entirely.
meetingMessageTypeis declared on Graph's event-message type, not on the base message, and naming it unqualified does not get ignored — the whole$selectis rejected, every message in the batch answers 400, and nothing arrives. It is asked for through the cast now, and a mailbox that refuses even that gets one retry without it and is remembered, rather than never syncing again. - Deleted Outlook drafts sat in Drafts and Trash at once. Attaching a folder label relied on the source folder's removal notice to take the old one off, which only works while both carry the same message id — and personal outlook.com mailboxes do not reliably provide those. A folder move now replaces the location outright, which is what a single Exchange folder means.
- A filter with no conditions claimed the whole mailbox. "If this is any message → …" read the same whether the rule was scoped to one account or all of them, which is exactly the case where the account is the rule. It says which account now, and picking one updates the sentence and the match count immediately instead of waiting for the next edit.
- A Graph subscription this install no longer has filled the log. Microsoft keeps delivering to a registration it still holds — several times a second, for up to three days — and without the account there is nothing here that can cancel it. Logged once an hour per subscription instead of once per notification, so it stops burying everything else and lighting the unread-log ring over something nobody can act on.
- "Matches 1 existing messages" in the filter editor.
Changed
- The image tag is the first optional setting in the TrueNAS compose file,
with what each channel means:
:latestfor releases,:mainfor every merge, and the pinned forms. Also says the image tag carries no leadingv, and that downgrading is not supported because migrations run forward on boot. - The README tour is current, and anyone can retake it.
app:test:seed-demowrites a believable demo mailbox, so the screenshots no longer depend on one person's installation; all eight are regenerated, and filters and the calendar are in the tour for the first time.
v0.0.14 — 2026-08-03
No schema change, no deployment change.
Fixed
- The sidebar showed labels that no longer existed. It was carried across navigations rather than re-rendered, so after a data reset the deleted labels stayed in the nav linking to 404s, and a label created or renamed anywhere else was invisible until a full reload. It renders per visit now; scroll position, open trees and the expanded account are restored rather than frozen, the last of those server-side so its folder rows are in the first paint instead of arriving one request later and blinking.
- Sidebar highlights were inconsistent. A hover left behind a square bar as it faded, accounts and their folder rows were rounded boxes rather than the pill every other row has, an open folder under an account was not highlighted at all, and a label's parents gave no sign that the open row was inside them.
- An account's folder badges counted every account. Clicking one lists that account's threads alone, so the badge beside it promised mail the list did not then show.
- Snoozing an Outlook message went looking for a folder that does not exist.
Snoozed is plMail's own role with no Exchange counterpart, but every role was
treated as folder-backed — so each snooze logged a missing
graphFolderId, and a message both snoozed and archived looked like it was in two Exchange folders at once. Roles are folders only where the provider has one, and are never published as categories. - The unread-log ring cleared in the database and not on screen. The log browser is a frame and the user menu is not inside it, so reading or clearing the log left the outline until the next full page render.
- The calendar pane kept limits from a window size that was gone, which is what made the resize handle stop at odd places after the window changed.
- The settings pages had no live updates. Their layout named a Stimulus
controller —
mercure— that stopped existing when the controllers were grouped into directories. - Admin and settings pages threw on every load.
mail--mail-panewas attached to every authenticated page but its targets exist only in the mailbox layout, so it failed to connect with "Missing target element". - A failed Microsoft Graph sub-request logged only its status. Graph's own reason was sitting unread in the sub-response body, which is the difference between a diagnosable 400 and a mystery.
Added
- The thread view shows each message's own labels. A rule that files a single reply was visible in the list row and nowhere in the thread it opened.
v0.0.13 — 2026-08-03
No schema change, no deployment change. The encoding fix applies to mail synced from now on — see the note below about what is already stored.
Fixed
- Mail composed in UTF-8 but labelled ISO-8859-1 arrived as "Grüße". A common sender bug, and every layer believed the label: IMAP bodies are converted inside webklex, which had no reason to doubt it, and encoded-word subjects went through iconv, which cannot. Bytes that are valid multi-byte UTF-8 are now read as UTF-8 whatever a single-byte label claims — the label is contradicted rather than merely ambiguous. Genuine latin-1, cp1252 punctuation and multi-byte charsets like Shift_JIS are unaffected. Applies to mail synced from now on; messages already stored keep the bytes they were given.
Added
-
The message details popover says why a message is in the tab it is in. "Promotions — a bulk-mail header: list-unsubscribe", "Primary — you have written to this sender". Recomputed from the same class that made the call rather than stored, so it can never explain a decision the current rules no longer make.
-
A filter no longer needs a condition. "Act on everything arriving in this account" is a rule people want and could not write: the editor demanded at least one condition and the validator rejected a rule without one. The editor still opens with one condition to fill in — removing it is now how the whole-account rule is written, and the panel says what it will then do. The live match count is scoped to the rule's account too, which it never was.
-
The user menu is outlined when something has been logged that nobody has read. Amber for warnings, red once anything reached error, with the count in its tooltip and beside the Admin entry inside the menu, which then links straight to the log browser. Opening that browser is what marks them seen, so anything logged while it is open comes back. Admins only — for everyone else it would be an alarm about a screen they cannot open. The mark lives in the user's settings bag, so there is no schema change.
v0.0.12 — 2026-08-03
No schema change. Dev stacks need the worker fix below applied to their own
compose.override.yaml before anything consumes a queue again.
Added
- Collapsed admin panels say what is inside them. A shut card showed a heading and nothing else, so a collapsed dashboard was a list of nouns. Each now carries a one-line summary in its header — "2 down", "14 waiting", "3/4 armed" — coloured when it is worth noticing.
- The queue panel is searchable and no longer unbounded. The backlog list has a fixed height with its own scrollbar, loads the next page as that scroll reaches the end, and its filter box queries the whole queue rather than the page on screen: handler name, queue, or anything in the payload.
- Search suggests as you type. The box offers the operators it actually
supports, and for
from:,to:andcc:it completes against your contacts, so a sender can be found without remembering how they spell their address. Arrow keys move, Enter or Tab accepts, Escape dismisses the list before it clears the box. label:andcc:search. Both were half-built:cc:did nothing at all andlabel:was understood by the query builder but never parsed.
Fixed
-
A collapsed panel beside an open one kept the open one's height. Grid items stretch by default, so shutting one of a side-by-side pair left a 40px header inside a 400px outline.
-
Search operators failed silently.
in:archivewas documented but never mapped, so it was dropped and the search answered with everything, unfiltered — as did any unknown value, likeis:important. A half-typedfrom:did the same, matching every message through an empty LIKE. Unrecognised operators now fall through to free text, an incomplete one filters nothing, andin:archive,in:snoozedand the aliasesbin,deleted,archivedanddraftare understood. -
The dev override still described the pre-split worker. v0.0.10 replaced
messenger-workerwith three services but leftcompose.override.yaml.distdefining the old one, so a dev stack ran a worker consuming only the retiredasyncqueue: sends and syncs queued up and nothing consumed them, with no error anywhere. Copy the new.distover yourcompose.override.yaml, or add the threeworker-*blocks to it, thendocker compose up -d --remove-orphans.
v0.0.11 — 2026-08-03
No schema change, no deployment change.
Added
- The queue panel says what the workers are doing. It showed a depth and an age per queue, which cannot tell a queue that is empty from a queue that is stuck — both read as "nothing waiting". It now lists the messages a worker is holding right now, each with its handler, its payload and how long it has been held, above a per-queue breakdown of pending and in-flight counts, and the backlog behind them: what is waiting, what is merely scheduled for later, and how often each has already been retried. A message whose envelope no longer deserialises is listed as undecodable rather than skipped — it is stuck forever, and was previously invisible.
v0.0.10 — 2026-08-03
Deployment shape changes. The single messenger-worker service is replaced
by three — worker-export, worker-ingest, worker-maintenance — so remove
the old one when upgrading. Roughly 150MB more resident in total. No schema
change.
Fixed
- Sending waited behind whatever the mailbox was doing. Seventeen message types shared one queue and one worker, so they were ordered by arrival: pressing Send behind a Gmail batch meant waiting for the batch. Outgoing work now has its own queue and its own process, and retries faster than the rest — a relay refusing a connection clears in seconds, not minutes.
- Learning contacts re-read the whole mailbox after every sync. A sync that
brought in twenty messages re-read all fifty thousand, hydrating whole
messages — bodies, headers and search vectors included — to use five address
fields. Measured on a real account at five and a half hours of database time
a day, more than every other query on the server combined. Addresses are
learned from each batch as it arrives; the full sweep is now
app:backfill contacts, run when it is actually wanted.
v0.0.9 — 2026-08-03
Mostly internal. One schema change: five tables gain an updated_at, added
nullable, backfilled from created_at and then made NOT NULL, so it is safe
on a populated database. Restart the workers after upgrading — several service
constructors changed and messenger-worker and imap-supervisor hold a
compiled container.
Fixed
- A draft created by an app went missing from Drafts. A thread's labels are rebuilt from the messages it holds, and a freshly threaded draft was not in that collection yet, so the rebuild took back the Drafts label it had just been given. Drafts written in the browser were unaffected, which is why this survived so long.
- Mail sent from an alias went out as the account's main address. The composer wrote the chosen address onto the message and the save overwrote it one line later. Both the web composer and JMAP were affected.
Mailbox/setcould destroy a mailbox that had just been given a child. The refusal counts children, and a child created earlier in the same request was not visible to the count — which JMAP makes reachable, since create and destroy travel together.- Editing a draft in place, popping the editor out and closing it left the conversation without that draft's row until a reload.
- A phone opened on the calendar if the calendar pane had ever been opened on a desktop. The pane state is one preference shared by every device, and below 1024px the pane replaces the mail rather than sitting beside it.
- Settings gets the same edge-to-edge cards on mobile the admin area got.
Added
- Statement statistics are enabled at boot, so a slow query reported next week is explained by numbers that were already being collected. Where the database refuses, the admin Database tab offers a button; where the server cannot support it at all, it says so instead.
Changed
- Every entity is properties and hooks — no getters, no setters, no fluent
setters — and every one takes
TimestampableTraitrather than tracking its own timestamps. Both timestamps are non-nullable, which removed 35 suppressed type mismatches. - All SQL, DQL and DBAL now lives in a repository, and every hand-written query that stayed carries a comment saying why it could not be a plain Doctrine call.
- Controllers hold their actions and the responses those actions share. Everything else moved into services grouped by purpose — draft persistence, attachments, reply building, JMAP change announcements, OAuth state — several of which were previously implemented twice, once for the browser and once for JMAP.
v0.0.8 — 2026-08-03
Fixed
- A Gmail rate limit looked exactly like a permissions failure, and was fatal either way. Gmail signals throttling with 403 rather than 429, and the only place it names the cause is a response body that was never read — so a transient quota rejection killed an entire account sync and reported nothing but a status. Quota rejections are now retried with a real backoff; permissions failures and daily-limit breaches are never retried.
- Archive, star and trash could vanish silently on Gmail, Outlook and IMAP. All three outgoing pushes swallowed their failures, on the stated grounds that the next sync would reconcile the difference. It does not: sync reads the server's state in, so a change that never arrived leaves nothing to reconcile from and the next pass overwrites it. The action disappeared from the account while still looking applied on screen. Transient failures are now retried; only a refusal that would repeat forever is dropped. An IMAP authentication failure is deliberately still not retried — repeated rejected logins get mailboxes locked and hosts banned.
- Retries gave up after seven seconds. The async queue retried at 1s, 2s and 4s, which is not a backoff for anything that clears in minutes. Now 5s to 300s over five attempts. A send that hits a briefly-unavailable relay may now take up to ~8 minutes to go out instead of failing outright.
- The admin log view showed no log messages on a phone. The message column was zero pixels wide at every phone width and the overflow was clipped rather than scrolled, so each entry displayed its level, time and source and nothing else. Entries also gained a copy button that copies the whole entry, and works over plain HTTP where the clipboard API is unavailable.
- The admin area spent a third of a phone screen on nested padding, because the page padded its content and every section inside it padded its own.
v0.0.7 — 2026-08-02
Fixed
- Three of the four services failed to start on any deploy that carried a
migration. php, imap-supervisor, messenger-worker and scheduler run the same
entrypoint and all migrate on boot, within milliseconds of each other against
one database. All four read the migration ledger before any of them writes to
it, so all four try to apply the same migration; one succeeds and the rest die
on
column "timezone" of relation "user" already exists, which underset -eis a container that never comes up. Boot migrations now run under a Postgres advisory lock held for the whole run (app:db:migrate), so the containers that lose the race wait, find the ledger already current, and start normally. Nothing to do on upgrade.
v0.0.6 — 2026-08-02
Added
- The inbox categories cross JMAP. plMail has classified inbox mail the way
Gmail does for a long time and the web has had a tab bar over it, but no
client except the browser could see any of it.
Thread.categorycarries the conversation's resolved value,Email.categorythe raw per-message signal it was derived from, andEmail/querygained athreadCategoryfilter so a tab is narrowed by the server rather than sieved from a page. Only the thread value is filterable: offering the per-message one would put a newsletter somebody answered into two tabs where the web shows it in one. - A timezone, per user. Settings → General now carries one, and everything
the app renders honours it. New installs fall back to
APP_DEFAULT_TIMEZONErather than to the container's clock, which is how this went unnoticed.
Fixed
- Every date rendered two hours early for anyone outside UTC. PHP's default
timezone is UTC, Twig was never told otherwise, and not one
|date()call in the templates passed a zone — so a correct UTC instant was printed verbatim and an 11:00 appointment read 09:00 in Berlin. Mail dates and extracted calendar events both. - IMAP stored the sender's wall clock, not the instant. The
Date:header's offset was parsed and then kept, and Doctrine writes a datetime in whatever zone the object carries, so a+0200message landed in the column two hours ahead of the UTC it claimed to be. Gmail and Graph already normalised; IMAP now agrees with them. Rows written before this are wrong by the sender's offset; a resync corrects them and no migration is attempted. - Mail written or sent from the web never reached JMAP clients. Composing, sending and discarding all changed messages without recording anything in the change log, so a phone learned about them only if something else happened to move the state. A draft that became sent mail stayed a draft on the device indefinitely.
app:test:seed-mailcould not be used to test sync, because it wrote messages without advancing the Email state — soEmail/changesnever mentioned them and no push fired however much mail it created. This is what made a genuinely broken client sync look like a server changelog gap.
v0.0.5 — 2026-08-01
Added
- schema.org extraction. Reads the markup booking confirmations already carry, so flights, parcels, hotels and restaurant bookings reach the calendar without an invite attached.
- An admin reset ladder, behind a door in the admin panel, exposing the reset the console command already had.
Fixed
- Mail arriving in the wrong alphabet, or not arriving at all, and one 8-bit header byte costing the whole message.
v0.0.4 — 2026-08-01
Fixed
- A batch with two copies of one invite killed the whole run. Found on a
real mailbox, 200 messages into
app:backfill events; three defects that compounded, starting with an invite arriving twice creating two events.
v0.0.3 — 2026-08-01
Added
- Calendar invites on the calendar. Extraction end to end for the one source
that is not a guess: a
text/calendarpart becomes aCalendarEvent, and the confirm/change/cancel life of a booking resolves to one row rather than three.
Fixed
- The two API providers lost calendar invites at ingest.
v0.0.2 — 2026-08-01
Added
- A look. Paper and a retuned Dark, a rebuilt mail row, themed toasts and tooltips, and a Dark reworked into a dim room rather than flat backgrounds that painted nothing.
- The calendar, docked beside the mail and resizable against it, over a new data layer of JSCalendar events with materialised occurrences.
Mailbox.colorover JMAP — nine tokens or null, refused withinvalidPropertiesrather than dropped, and accepted on create as well as update. Outlook and Gmail label colours are mapped onto the same vocabulary by hue, so a real account arrives already coloured.- A push service, so Android does not have to mean Google; the push URL is derived from the host set at first boot.
Changed
- The three sync paths share one post-ingest sequence instead of three.
v0.0.1 — 2026-07-31
Added
- Snooze. Put a conversation away and have it come back later — from the
message row, the list toolbar, or a JMAP client via the
Thread/setextension. Snoozed conversations get their own sidebar entry and view.app:mail:wake-snoozedruns every minute to bring them back. - JMAP draft attachments.
Email/setnow accepts uploaded blobs inattachments, per RFC 8621; drafts created over JMAP previously dropped them silently. - Login throttling. Five attempts per account per fifteen minutes on the password form, and a separate limit on the two-factor code form — which the firewall's throttling does not cover, and which is the form an attacker reaches holding a stolen password.
- Admin user management. plMail's data model has always been multi-user, but
there was no way to create the second user: the setup wizard and
app:setupboth make the first one and then refuse. Admin → Users adds, edits, promotes and removes people. An administrator deliberately cannot change an existing user's password or remove anyone's second factor — both would make an admin session a way into someone else's mailbox. GET /healthz— reports database, queue and worker health without a session, so Docker healthchecks and uptime monitors can use it. The image's healthcheck now points here instead of at Caddy's metrics port, which answered before PHP could reach the database.app:backup— one command for the database dump, the stored files and the generated secrets, and it says explicitly whenAPP_ENCRYPTION_KEYis not in the backup because the install supplies it from the environment.- PHPStan at level 5, with a baseline, in CI. Dependabot for composer, npm and GitHub Actions.
- LICENSE. The project has always said AGPL-3.0 in its README; the licence
text is now actually distributed with it, and
composer.jsonno longer contradicts it by claiming "proprietary".
Fixed
- Snoozing from the web UI did nothing but set a timer. No labels moved and nothing propagated, so the conversation stayed in the inbox — locally and at the provider — while its row vanished from the list. The sweep would then "wake" a thread that had never left.
- The snooze button on a message row cleared a snooze instead of setting one, and the toolbar's snoozed everything to a hardcoded 8am tomorrow.
- "Test connection" in account settings crashed with an
ArgumentCountErrorwhenever the form was incomplete or the password blank — the two cases it exists to report. - The admin Graph category sync caught an exception class that does not exist, so Graph failures propagated instead of degrading quietly.
- User search in the admin area returned everything, and soft-deleted users
were visible to every query that claimed to exclude them. Both were the same
mistake: a Doctrine expression built and never passed to
andWhere(). - Gmail label changes ran inside the HTTP request.
ApplyGmailLabelsMessagewas never routed to the async transport, so archive, trash, star and mark-read made a live Google API call while the user waited — where the IMAP and Graph equivalents were queued. A Gmail outage surfaced as a 500 on a UI action rather than a retry on the worker. - JMAP
Email/setrejected valid mailbox patches. CurrentmailboxIdswere emitted as label ids where the protocol expects per-account binding ids; since both are autoincrement ints, a patch removing one mailbox failed with "No such Mailbox".
Changed
latestis published from release tags only. It previously followed every push tomain, which meant the tag the README tells people to pull could carry unreviewed work and automatic schema migrations.- The login form no longer shows a "forgot password?" link. There is no reset
flow — recovery is a console command — and the link was an
href="#"that did nothing.