mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6910 lines
423 KiB
Markdown
6910 lines
423 KiB
Markdown
# 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
|
||
|
||
**Mail read, unread, starred or unstarred in Outlook now reaches 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](https://github.com/karatektus/pl_mail/pull/114), by @Flouss.
|
||
|
||
**Saving an attachment to Paperless-ngx works.** 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](https://github.com/karatektus/pl_mail/pull/78), by @Flouss.
|
||
|
||
**A message you read in plMail is read in Outlook too.** 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](https://github.com/karatektus/pl_mail/pull/111), by @Flouss.
|
||
|
||
**A message's header no longer breaks into pieces in a narrow pane.** 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 the folder it came from 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](https://github.com/karatektus/pl_mail/pull/121), by @Flouss.
|
||
|
||
**All Mail, if you want it: every conversation outside Spam and the bin, in one list (#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.
|
||
|
||
**An Outlook account's read, unread, starred, moved and deleted mail reaches an open page 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](https://github.com/karatektus/pl_mail/pull/122), by @Flouss.
|
||
|
||
**Choosing a profile picture from Immich, Paperless-ngx or another connected service opens the
|
||
same picker as attaching to a mail.** 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.
|
||
|
||
**Remote images can be shown in every message without asking (#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.
|
||
|
||
**An account's own inbox can have the category tabs (#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.
|
||
|
||
**Signing in through single sign-on keeps you signed in, as "Keep me signed in" does on the password form.**
|
||
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.
|
||
|
||
**A Gmail import takes in everything in the mailbox, not only mail addressed to the account.**
|
||
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](https://github.com/karatektus/pl_mail/issues/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 kept, not worked on.** 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.
|
||
|
||
**plMail speaks French.** 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.
|
||
|
||
**Inbox tabs are yours to arrange: switch the built-in ones off, make your own, and put them in
|
||
any order.** 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](https://github.com/karatektus/pl_mail/pull/87), by @Flouss.
|
||
|
||
## v0.3.5 — 2026-10-13
|
||
|
||
**Browsers and password managers kept offering your logins in 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 through Authentik, Keycloak or any other OpenID Connect provider.** 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](docs/providers/oidc.md). `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](https://github.com/karatektus/pl_mail/pull/106), by @jo2904.
|
||
|
||
**Happening soon is wider, with Also spotted beside the list.** 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](https://github.com/karatektus/pl_mail/pull/83), by @Flouss.
|
||
|
||
**The calendar no longer reads its times on a synced calendar's clock.** 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.
|
||
|
||
**The browser tab shows the Inbox's unread count on every page that is not a mail folder.** 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](https://github.com/karatektus/pl_mail/pull/72), by @Flouss.
|
||
|
||
**Attachments on Outlook and Hotmail mail show up.** 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](https://github.com/karatektus/pl_mail/pull/77), by @Flouss.
|
||
|
||
**A parcel collected at a relay point now reads 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.
|
||
|
||
**A parcel handed over at a Pickup relay no longer says "Deutsche Post".** 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.
|
||
|
||
**Reading old mail again could turn a parcel's card back to its first 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.
|
||
|
||
**A card made from old mail looked like new mail on Happening soon.** 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.
|
||
|
||
**A one-time code stayed on Happening soon, readable, for a week.** 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.
|
||
|
||
**A parcel, a code or a date in an Outlook mail was only found if it sat in the first few lines.**
|
||
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.
|
||
|
||
**A GLS France parcel never appeared on the radar.** 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.
|
||
|
||
**An invoice card could be numbered "TIFICATIONS", and two bills from one sender became one
|
||
card.** 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.
|
||
|
||
**Signing out holds.** 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.
|
||
|
||
**An address can send under its own name.** 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](https://github.com/karatektus/pl_mail/issues/70), reported by @Node815.
|
||
|
||
**A reply goes out from the address the mail was sent 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](https://github.com/karatektus/pl_mail/issues/41), reported by @YoGo9.
|
||
|
||
**A signature can be pasted as HTML, and its images travel with the mail.** 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](https://github.com/karatektus/pl_mail/issues/32), reported by @eatcodeplay.
|
||
|
||
**The sidebar's numbers follow what happens, without a reload.** 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](https://github.com/karatektus/pl_mail/issues/86), reported by @pmdevelopment.
|
||
|
||
**A reply you sent from a catch-all address shows as "me" in the list.** 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.
|
||
|
||
**A meeting on two calendars splits its colours side by side 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.
|
||
|
||
**An IMAP folder whose server closes idle connections no longer burns a CPU core.** 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](https://github.com/karatektus/pl_mail/issues/94), reported by @montywei.
|
||
|
||
**An import no longer sticks one message short of 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](https://github.com/karatektus/pl_mail/issues/96), reported by @montywei.
|
||
|
||
**One slow mail server can no longer hold up every import.** 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](https://github.com/karatektus/pl_mail/issues/95), reported by @montywei.
|
||
|
||
**A Gmail import finishes, instead of waiting a day on mail that is not coming.** 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](https://github.com/karatektus/pl_mail/issues/90), reported by @karamanliev.
|
||
|
||
## v0.3.4 — 2026-10-13
|
||
|
||
**A Gmail import no longer runs into Google's quota and stays there.** 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](https://github.com/karatektus/pl_mail/issues/90), reported by @karamanliev.
|
||
**An account's page shows your own tabs, so mail filed in one can be found there.** 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.
|
||
|
||
**The tab icon is the one you picked, in Firefox too.** 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](https://github.com/karatektus/pl_mail/pull/74), by @Flouss.
|
||
|
||
**A message with a stray non-UTF-8 byte in the wrong place was never 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](https://github.com/karatektus/pl_mail/issues/81)
|
||
|
||
**The envelope on a row's read button shows what pressing it does.** 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](https://github.com/karatektus/pl_mail/pull/61), by @Flouss.
|
||
|
||
**A Gmail or Microsoft account gets its own colour when you connect it.** 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](https://github.com/karatektus/pl_mail/pull/60), by @Flouss.
|
||
|
||
**The mail list is readable 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](https://github.com/karatektus/pl_mail/pull/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](https://github.com/karatektus/pl_mail/pull/80), by @Flouss.
|
||
|
||
**Each inbox tab shows how many conversations in it are unread.** 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](https://github.com/karatektus/pl_mail/pull/54), by @Flouss.
|
||
|
||
**An account's colour can be changed.** 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](https://github.com/karatektus/pl_mail/pull/69), by @Flouss.
|
||
|
||
**An open message stays open when you change what the list shows.** 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](https://github.com/karatektus/pl_mail/pull/63), by @Flouss.
|
||
|
||
**The "+" beside Labels and Accounts in the sidebar appears when you point at its heading.** 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](https://github.com/karatektus/pl_mail/pull/66), by @Flouss.
|
||
|
||
**Search by meaning can use a model host of its own.** 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.
|
||
|
||
**The model host can be sent extra headers.** 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.
|
||
|
||
**The language model can be any OpenAI-compatible endpoint.** 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](https://github.com/karatektus/pl_mail/pull/58) by @shady2k.
|
||
|
||
**The token in Admin → AI is now 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 what the model host said when a test or a warm-up fails.** 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.
|
||
|
||
**Deleting mail for good is a choice you make, off until you make it.** 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](https://github.com/karatektus/pl_mail/pull/71), by @Flouss.
|
||
|
||
**Delete above a selection in the bin no longer says it deleted what it did not.** 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
|
||
|
||
**The emoji picker is no longer almost empty on an iPhone or in Safari.** 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](https://github.com/karatektus/pl_mail/issues/39).
|
||
|
||
**A new account shows its newest mail first, and no longer holds up everybody else's.** 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](https://github.com/karatektus/pl_mail/issues/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_backlog` by a new `worker-backlog`
|
||
process, a page at a time. Mail arriving now stays on `ingest` and 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 while you watch it.** 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.
|
||
|
||
**A separate password for sending, for the servers that want one.** 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](https://github.com/karatektus/pl_mail/issues/42).
|
||
|
||
**One message could stop an IMAP folder from syncing, for good.** 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](https://github.com/karatektus/pl_mail/issues/45).
|
||
|
||
**A reading pane beside the message list.** 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.
|
||
|
||
**Above a selection the toolbar is three buttons and a menu.** 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.
|
||
|
||
**Keys work after ticking a row with the mouse.** 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.
|
||
|
||
**The category tabs fit a phone.** 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 no longer lists the video half of a Live Photo.** 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](https://github.com/karatektus/pl_mail/issues/46).
|
||
|
||
**Keyboard shortcuts, the ones Gmail uses.** `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](https://github.com/karatektus/pl_mail/issues/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.
|
||
|
||
**A config backup now carries how each person sorts their mail.** 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.
|
||
|
||
**The radar reads French parcels.** 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 folders an IMAP account fetches.** 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.
|
||
|
||
**Gmail's `[Gmail]` folder no longer fails on every poll.** 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
|
||
|
||
**Mail from the wrong sender can no longer change your calendar.** Reported by
|
||
[@jhuber](https://github.com/jhuber) in [#34](https://github.com/karatektus/pl_mail/issues/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.
|
||
|
||
**A mail's styles can no longer draw over plMail when you reply to it or print it.** 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](docs/install/reverse-proxy.md) 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](docs/features/calendar-invitations.md).
|
||
|
||
**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](docs/install/docker.md#starting-it).
|
||
|
||
**Mail can be marked as spam with a 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](docs/features/mail.md#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`.
|
||
|
||
**Messages you send often can be saved as 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](docs/features/templates.md).
|
||
|
||
**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](docs/CLIENT_DEVELOPMENT.md#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.
|
||
|
||
**A public demo can see how much it is used.** 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](docs/install/demo-mode.md#seeing-how-much-it-is-used).
|
||
|
||
**A message no longer grows down the page 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.
|
||
|
||
**The inbox can be one list.** 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](https://github.com/karatektus/pl_mail/issues/29).
|
||
|
||
**A round of first-impression fixes.** All reported by
|
||
[@pmdevelopment](https://github.com/pmdevelopment) in
|
||
[#37](https://github.com/karatektus/pl_mail/issues/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](https://github.com/pmdevelopment) in
|
||
[#33](https://github.com/karatektus/pl_mail/issues/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 insights` reads it again
|
||
(the last 24 hours unless `--since` says 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.ics` was 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](docs/CLIENT_DEVELOPMENT.md#moving-threadset-moveto).
|
||
|
||
**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](docs/features/admin.md#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-live` handles mail that has just
|
||
arrived on an account whose import is over, and nothing else, so new mail is never behind an
|
||
import. `worker-enrich` handles an import's recent mail and then, only when that is done, old
|
||
mail. `worker-release` only ends the wait described below. See
|
||
[Mail ingest → What follows a message](docs/internals/mail-ingest.md#what-follows-a-message-and-where-it-runs).
|
||
- **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 insights` and
|
||
`app:backfill event-extraction` read 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](docs/features/admin.md).
|
||
|
||
**"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](docs/features/mail.md#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.yaml` or `truenas.compose.yaml`, add `interval: 5s` and
|
||
`retries: 30` to the `database` health 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 `message` and two to `ai_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-release` and `worker-enrich`. Nothing to
|
||
configure on the standard images. If you run your own consumers instead of `app:work`, add
|
||
`messenger:consume enrich_live`, `messenger:consume release` and `messenger: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-links` goes 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-…` unless `MERCURE_COOKIE_NAME` said otherwise, and a browser drops such a
|
||
cookie over HTTP: an install opened on `http://ip:port` had a red stream indicator for good. The
|
||
name follows the public address now, prefixed when it is https and bare when it is not. Setting
|
||
`MERCURE_COOKIE_NAME` still wins.
|
||
- **A database password with special characters no longer stops plMail starting.** A `DATABASE_URL`
|
||
carrying percent-encoded characters crashed every process at boot, and one without
|
||
`serverVersion` did too. Both are read as written now, with PostgreSQL 18 assumed when the URL
|
||
does not say.
|
||
- **`APP_STORAGE_DIR` may be an absolute path** inside the project, such as `/app/var/data`. It
|
||
means the same as `var/data`, and files already stored are found either way.
|
||
- **`MERCURE_UPSTREAM`** names the container the web server proxies the hub to, for a stack that
|
||
does not call it `mercure`.
|
||
- **No database password is generated for a database plMail did not set up.** With a `DATABASE_URL`
|
||
that carries its own password, the secrets directory no longer gains a `POSTGRES_PASSWORD` and a
|
||
`postgres_password` file 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_NAME` to 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_URL` as 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-alpine` runs as uid 70,
|
||
and 999 is the Debian image's. `truenas.compose.yaml` chowned 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 insights`
|
||
once 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 `mercure` on 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_NAME` once,** 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/mercure` hub, exactly as before.
|
||
- **To move to three containers,** replace your compose file with the current `compose.yaml`. On
|
||
TrueNAS, paste the current `truenas.compose.yaml` over the app's YAML and put your values back
|
||
into its `x-config` block. The hub keeps its history, because the worker mounts the same
|
||
`mercure_data` volume.
|
||
- **If you maintain your own compose,** the worker needs `init: true`, `stop_grace_period: 30s` and
|
||
the network alias `mercure`. See `compose.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:** `Appearance` reports `logoMotif` and `logoPaint`, both read-only, and the
|
||
Session lists `logoMotifs`. `logoStyle` means 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 using
|
||
`INTEGRATIONS_ALLOWED_HOSTS`, which is now checked on every redirect hop too.
|
||
- **Signing out is a POST.** A page or script that linked to `/logout` has 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/query` pages
|
||
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/get` now 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.de` another, 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_headline` the 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 `rank` had been mixing `ts_rank` with 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 synthetic `priority` and `spoofed` presented 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/get` could hand a client the sender's own markup.** `ts_headline` is 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 for `StartSel=<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/related` block around a body when that block holds exactly one part, so the row is a
|
||
MIME container, not HTML. The repair matched only `text/plain` and `text/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 `related` holding 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.yaml` leaves 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 — `.invalid` is 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.yaml` had been left behind twice.** It still carried the `/healthz` check that
|
||
v0.2.30 replaced everywhere else, so its hub would have reported itself unhealthy the moment the
|
||
image updated; and it had no `pull_policy`, so its hub never updated at all. Both are fixed, and
|
||
the hub now pulls on every `up` — 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/mercure` is deliberately unpinned, but an unpinned tag is not a moving image:
|
||
`docker compose up -d` reuses whatever is already on the box. So pull it:
|
||
|
||
```
|
||
docker compose pull mercure && docker compose up -d mercure
|
||
```
|
||
|
||
The shipped `compose.yaml` now sets `pull_policy: always` on 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`, and `resource_identifier` inside
|
||
`MERCURE_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_NAME` to 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: `/healthz` answers 404 now, `curl -f` reads 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)` in `docker ps` and 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 body `Content-Disposition: inline` and 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-bodies` does it immediately for anyone who would rather not wait.
|
||
|
||
- **A blink from Microsoft cost a folder fifteen minutes of its mail.** Graph answers `502` with 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 `502` means 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
|
||
--status` shows 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-bulk` consumes the new `bulk` queue.
|
||
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 `maintenance` because 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
|
||
`EntityManagerClosed` from 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-lg` on the face, `rounded-r-lg` on 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-mail` version 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-ID` no 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 `main` image 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 two `ENV`s sat above `composer install` — and every layer after an `ENV` is
|
||
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.lock` alone: they change when the
|
||
dependencies change, which is what was always meant. **If you self-host from the `main` tag, 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 category` overwrites 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; `-1` keeps the model loaded for good and `0` unloads 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.
|
||
|
||
**`-1` is 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_ALIVE` deliberately, 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
|
||
`class` that 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% characters`** instead 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:reap` sweeps 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-metrics` keeps 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](https://ollama.com) 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.md` for 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_LEVEL` on 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 on `info` for 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_LEVEL` remains 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_label` and
|
||
`label` were part of the statement unconditionally, and only two filters ever read them — `label:`
|
||
and the mailbox behind `in:`. Both joins are to-many, so every matching row was multiplied by the
|
||
number of labels its conversation carries and then collapsed again by the `GROUP 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 BY` hides 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:
|
||
|
||
```sql
|
||
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 --wait` then 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 | tail` reports 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_MB` sets the floor, `DEMO_COMPOSE_FILES` overrides 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 at `info` — 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 now `error`,
|
||
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 `\Throwable` also 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 answers `application/octet-stream` for 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: set `APP_DB_LOG_LEVEL=info` to 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 with `json_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/report` and both carry `Original-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`, `profile` and `offline_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_at`
|
||
column 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-30` was measured
|
||
inside its own `sticky z-10` header 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.** `html` and `body` are `100dvh` because mobile browsers measure
|
||
`100vh` against the viewport with the URL bar hidden — but the mailbox, settings and admin shells
|
||
asked for `h-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](docs/install/demo-mode.md), 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.synced`
|
||
from IMAP, which names the folder that changed, and `account.synced` from 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.
|
||
- **`/settings` opened 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](docs/install/demo-mode.md).
|
||
|
||
## 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](docs/install/demo-mode.md).
|
||
|
||
### Under the floor
|
||
|
||
- **The demo mailbox is now shared.** `app:test:seed-demo` and 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 `User` today 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 `.gitignore` for 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
|
||
`test` and 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.log`s, 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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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 for `var/attachments`, `var/raw` or `var/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.dist` and `truenas.compose.yaml`
|
||
both 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 `.dist`
|
||
cost 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:materialise` now 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://` or `webcal://` — 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/query` and
|
||
`CalendarEvent/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](https://github.com/karatektus/pl_mail/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 `.ics` beside 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 `latest` moves. 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 `.ics` and 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 iTIP `REPLY` — 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 `owner` role 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 until
|
||
`app:backfill events` re-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-expanded` now, 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'|trans` rather 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.unread` was genuinely missing and read out as its own key by
|
||
screen readers; a `|default` behind 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_PI` was 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.** `meetingMessageType` is
|
||
declared on Graph's event-message type, not on the base message, and naming it
|
||
unqualified does not get ignored — the whole `$select` is 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: `:latest` for releases, `:main` for every merge,
|
||
and the pinned forms. Also says the image tag carries no leading `v`, 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-demo`
|
||
writes 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-pane` was
|
||
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:` and `cc:` 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:` and `cc:` search.** Both were half-built: `cc:` did nothing at all
|
||
and `label:` 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:archive` was documented but never
|
||
mapped, so it was dropped and the search answered with everything, unfiltered
|
||
— as did any unknown value, like `is:important`. A half-typed `from:` did the
|
||
same, matching every message through an empty LIKE. Unrecognised operators
|
||
now fall through to free text, an incomplete one filters nothing, and
|
||
`in:archive`, `in:snoozed` and the aliases `bin`, `deleted`, `archived` and
|
||
`draft` are understood.
|
||
|
||
- **The dev override still described the pre-split worker.** v0.0.10 replaced
|
||
`messenger-worker` with three services but left `compose.override.yaml.dist`
|
||
defining the old one, so a dev stack ran a worker consuming only the retired
|
||
`async` queue: sends and syncs queued up and nothing consumed them, with no
|
||
error anywhere. Copy the new `.dist` over your `compose.override.yaml`, or
|
||
add the three `worker-*` blocks to it, then
|
||
`docker 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/set` could 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 `TimestampableTrait` rather 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 under `set -e`
|
||
is 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.category` carries the
|
||
conversation's resolved value, `Email.category` the raw per-message signal it
|
||
was derived from, and `Email/query` gained a `threadCategory` filter 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_TIMEZONE`
|
||
rather 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 `+0200` message 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-mail` could not be used to test sync**, because it wrote
|
||
messages without advancing the Email state — so `Email/changes` never
|
||
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/calendar` part becomes a `CalendarEvent`, 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.color` over JMAP** — nine tokens or null, refused with
|
||
`invalidProperties` rather 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/set`
|
||
extension. Snoozed conversations get their own sidebar entry and view.
|
||
`app:mail:wake-snoozed` runs every minute to bring them back.
|
||
- **JMAP draft attachments.** `Email/set` now accepts uploaded blobs in
|
||
`attachments`, 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:setup`
|
||
both 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 when `APP_ENCRYPTION_KEY` is
|
||
*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.json` no 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 `ArgumentCountError`
|
||
whenever 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.**
|
||
`ApplyGmailLabelsMessage` was 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/set` rejected valid mailbox patches.** Current `mailboxIds` were
|
||
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
|
||
|
||
- `latest` is published from release tags only. It previously followed every
|
||
push to `main`, 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.
|