Commit Graph
1171 Commits
Author SHA1 Message Date
Paul LütznerandClaude Opus 5.5 71e2176fd1 Let the inbox be one list, without category tabs
Some people do not want their inbox divided. A third control on the
sorting card, Inbox tabs, switches the tabs off per person; on by
default and true on every existing row, so nothing changes by
upgrading. One column, user.category_tabs.

Off changes what is drawn and nothing about what is stored. Mail is
still filed under a category as it arrives, so switching back shows
sorted tabs at once and nothing has to be re-filed in either
direction, which is also why the control queues no ResortMailbox.
That includes the assistant: somebody who leaves it sorting into tabs
they have switched off has decided that, and the card says so when
both are true rather than preventing it.

The inbox query takes a null category for the whole inbox, through
one helper shared by the list and its count. ListViewResolver decides
from the person's setting and not from the value the page posts, so
"select all in this view" is the list the server would have drawn: an
untabbed page posts an empty value, and reading that as Primary would
have acted on part of a selection.

What only exists for tabs goes with them: the strip and its drop
targets, the per-category keys in the counts payload and the grouped
reads behind them, and the panel in message details that explains a
filing and takes "wrong tab" reports.

The JMAP session publishes the choice as inboxTabs beside the
appearance hint. Thread.category is served either way, so a client
drawing tabs from it had no other way to learn not to.

Closes #29

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:11:10 +02:00
Paul LütznerandClaude Opus 5.5 f44ef134bf Enable the sponsor button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:11:10 +02:00
Paul LütznerandClaude Opus 5.5 de87014d10 Confirm a copied error reference with a tick that exists
The copy button in an error toast drew its icon in Font Awesome's
regular style. ui--clipboard confirms by exchanging the glyph class
alone, fa-copy for fa-check, and the free set has no regular fa-check:
the confirmation was an empty box. Every other copy button was already
fa-solid, which has both. The copy itself always worked.

Fixes #33

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:57:09 +02:00
Paul LütznerandClaude Opus 5.5 8f7d2cb073 Work through the first-impression feedback in #37
Eleven of the twelve items. The twelfth, a deleted label coming back
after the next sync on IMAP, is left for an issue of its own: there a
label is a folder, and what deleting one should do to the mail in it
needs deciding first.

A selection survives the pager. It was nothing but the ticked
checkboxes, so "next page" replaced the rows and the selection with
them. The rows that are not on screen are now remembered in
assets/mail_selection.js, per list, and the toolbar, the label menu,
the move menu and a drag all read the selection from there. The
checkbox stays the truth for anything visible.

The whole view can be selected from the select menu. It was only
offered by a banner that appears once a page is fully ticked. And a
whole-view selection no longer survives a row being unticked: the
action still ran on everything.

A sync no longer logs a stored message as one it could not build.
MessageSyncer reads the UIDs a folder holds once, before it fetches,
and a move made meanwhile files rows under UIDs that list does not
have. The INSERT was refused on (mailbox, uid), rightly, and each
message was then held back as a failure. The list is read again when
a batch is refused.

A pairing code and the JMAP address under App passwords carry the
configured public address, and the browser's only when none is set.
This reverses the earlier choice in DevicePairingController, which
handed a phone paired at home a LAN address.

Hiding quoted text gives the height back. scrollHeight cannot report
less than the frame it is measured in, so the collapse reports the
root element's box instead and holds other reports until the parent
has resized.

Mail in Sent shows its images without asking. Read off the Sent label
and not the From line, which a forged mail fills in as it likes. Spam
still wins.

Smaller: placeholders are lighter than typed text, the actions on a
connected integration are buttons on one baseline, Save signature and
four composer buttons no longer hover to white on white, the Spam
badge loses the accent, and German says "vor 12s".

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:51:39 +02:00
Paul LütznerandClaude Opus 5.5 a6d75d25f9 Credit the reporter of #34 in the changelog
The reply on the issue says they are credited, and the entry only
linked the issue number.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:12:38 +02:00
Paul LütznerandClaude Opus 5.5 f66b248ae9 Seal a push that carries content before it goes through Firebase
A calendar reminder pushed over FCM carried the event's title as
readable JSON, under a docblock in PushAlertChannel that said the
payload was encrypted end to end. That was true of Web Push, where
every payload is sealed to the browser's key, and had never been true
of FCM. Reported in #34.

The same scheme now, pointed at the other carrier. An FCM
subscription may carry the RFC 8291 keys a browser's does, on create
or added later by update, and FcmSender seals to them: the message
has no `payload`, it has `encrypted`, which is byte for byte the
aes128gcm body a Web Push POST would have had. Reusing 8291 rather
than inventing something means the sealing is the library's, the
other side has a published test vector, and "the body a browser
would have received" is a sentence a client author can act on.

What is sealed is decided by a closed list of what is NOT:
StateChange and PushVerification are tokens and ids by construction
and stay readable. Anything else is content, including types that do
not exist yet.

A device that registered no key is not sent a content payload at
all, and no request is made. That loses nothing: the Android app has
never shown a reminder push — it drops any type it does not know —
so until now the title went to Google and nowhere else.

FcmSenderTest opens a sealed payload with the device's private key
through OpenSSL directly, written out from the RFC rather than
through the library that sealed it, so the two cannot agree by being
the same code.

Also here: EscapingStyleAttributeSanitizer's return type, which
PHPStan flagged after the commit that added it — it was not run
before that one.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:12:11 +02:00
Paul LütznerandClaude Opus 5.5 3e0b4456cf Say what the default TRUSTED_PROXIES trusts, and where to report a hole
#34 points out that the default trusts every private range, so a
machine on the LAN that reaches plMail directly can forge
X-Forwarded-For and step around the per-address limits on the login
form and the booking page.

The default stays. The proxy's own address cannot be known here, and
a proxy missing from the list breaks OAuth sign-in, shares one login
allowance between everybody and strips `secure` off cookies, with
nothing saying why — a worse failure, on every proxied install, than
the one being closed. What was missing is the sentence: the comment
in .env and the reverse-proxy page now say that the default is wide,
what that costs, what it does not (the per-account login limit
ignores the address and still holds), and how to narrow it once the
proxy works.

SECURITY.md is new. The report was filed publicly because there was
nowhere private to send it.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:03:51 +02:00
Paul LütznerandClaude Opus 5.5 219fc9e283 Keep a mail's styles from drawing over the app around it
Inline styles survive the sanitiser on purpose: in mail they are the
design. In the reading pane that costs nothing, because the message
is in a sandboxed frame. The quote under a reply and the print page
are the sender's HTML in the app's own document, and there
`position: fixed` on a full-size element covered the composer — or
the whole window — with a page of the sender's choosing the moment
somebody pressed Reply. Reported in #34.

Two layers, since either alone has a hole:

  the sanitiser   drops `position: fixed` and `sticky` from every
                  body, and `absolute` and `z-index` too from one
                  being composed. Absolute stays for mail that is
                  only read: real newsletters use it, and in the
                  frame it can only rearrange the message.
  the stylesheet  the composer's editor and the print page's body
                  are containing blocks that clip. This is what
                  holds for a body stored before the sanitiser
                  changed, or a declaration written some way the
                  pattern does not anticipate.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:03:51 +02:00
Paul LütznerandClaude Opus 5.5 98ea3948f4 Only let mail change the calendar when it comes from where the event came from
Reported in #34, and both findings held up: eight tests written from
the report's scenarios fail on the code as it was.

An invitation was found by its UID and then judged by SEQUENCE and
arrival time alone. Nothing asked who had sent the update, and a UID
is not a secret — everyone invited holds it, and so does anyone a
copy was forwarded to. Any of them could cancel the meeting or move
it and swap its link, and the reader's earlier "accepted" was carried
over on top. A meeting mirrored from a connected calendar was
rewritable the same way, which the report did not mention.

A booking read out of a mail's markup was keyed on the From line's
domain, which nobody had checked, and drawn at once.

One rule closes both, in EventReconciler::mayApplyUnasked(). It asks
about the sender of the MAIL, never the ORGANIZER line, which a
forger copies along with the rest of the file:

  an invitation's update   from an address the event's content has
                           already come from, or from the organiser
                           it names. "Already come from" rather than
                           "is the organiser" because a calendar
                           service sends the invitation and its
                           updates from one address of its own.
  a booking                only when the receiving server vouches
                           for the From domain.
  a From that failed       nobody, the reader's own address included.

A refused claim is held, not dropped: filed unapplied with a reason,
shown on the message's card, and applied when the reader says so.
The rule is a judgment about a sender and the reader may know better.

SenderAuthentication reads the Authentication-Results header and
checks nothing itself; its job is deciding whose header to believe,
since a sender can write one too. mx.google.com for Gmail, the single
nameless header for Microsoft, and for IMAP a server on the same
registrable domain as the account's IMAP host — learning the name
from the mailbox or asking for it in a setting were both considered
and turned down in the issue.

Mail in Spam or the bin is no longer read for events at all, and a
message taken back out is read then.

What it does not catch: on an account whose server reports nothing, a
forged From is indistinguishable from the real sender, and holding
every such update would hold every update a self-hosted mailbox
receives. There the address is taken at its word. The handbook says
so.

Two things turned up on the way. A copy of a meeting on a second
calendar has no source links of its own, so the first version of the
rule held every update on the copy while its sibling took it —
caught by SharedEventUpdateTest, and the senders are now asked across
every copy under the UID. And there is no HTTP-level test of the new
"apply" action; its logic is covered through the reconciler.

One new nullable column, event_source_link.hold_reason. Nothing
already on a calendar changes.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:01:03 +02:00
Paul LütznerandClaude Opus 5.5 a8aea33874 Open a fresh install from the LAN, not only from the machine it runs on
The stock compose file served https://localhost and nothing else. Reached
by address or hostname, Caddy had no certificate to offer and ended the
handshake, which a browser reports as an SSL protocol error with no way
to proceed. A headless server could not be set up without first setting
SERVER_NAME.

The default now answers every name and address with the self-signed
localhost certificate (default_sni/fallback_sni, and `:443` in the default
SERVER_NAME), so the browser shows its usual warning.

Live updates follow: compose.yaml's https://localhost hub address is
treated as the placeholder it is and gives way to the public address
stored during setup. Any other MERCURE_PUBLIC_URL, and any SERVER_NAME an
operator sets, is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:58:54 +02:00
Paul LütznerandClaude Opus 5.5 34f6ec5951 Read a recipient's name from the chip the app actually draws
Reported from the demo: a template inserted into a reply to Priya
Raman kept its "First name" placeholder, with her name in To.

recipientValues() parsed `Name (address)`. That is how
Contact::__toString() spells a contact, and it is not what the To
field shows: ContactAutocompleteField's choice_label writes
`Name <address>`. So no chip for a named contact ever parsed, and
neither the name nor — for those contacts — the address was filled
in. Only an address typed by hand worked, because that chip is the
bare address.

It now reads the angle-bracket form first and still accepts the
parenthesised one, and strips the quotes address syntax puts round a
name with a comma in it.

The browser test for this passed all along, because it wrote its own
chip label in the format the parser expected: it agreed with the code
and not with the app. It now uses the real format, and the reply test
asserts the first name of a recipient the SERVER put in To, which is
a label no test chooses.

The preview's sample recipient in the three catalogues was in the
same wrong format and is corrected with it.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:09:06 +02:00
Paul LütznerandClaude Opus 5.5 e9222e2e69 fix(test): reach the end of a line the way the machine running the test does
"leaves ordinary typing alone" in compose-link-popover.spec.ts pressed End to
get out of a link before typing. Chromium on macOS binds End to scrolling and
leaves the caret where it was, so on a Mac the words went into the middle of
the link ("plM and moreail"), the caret never left it, and the popover — which
stays up for as long as the caret is in its link, on purpose — stayed up. The
assertion that it had closed then failed. On the Linux runner End moves the
caret and the test has passed since it was written (832050b3).

The popover is not at fault and is not changed: with the caret genuinely at
the end of the link, the text lands after the anchor and the viewer dismisses
itself.

support/keyboard.ts gains pressLineEnd(), which presses Cmd+Right on macOS and
End elsewhere, and both specs that pressed End use it. The link test also now
asserts the link still reads "plMail", so a caret that did not move fails
there, with the reason, rather than two lines later on the popover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:04:16 +02:00
Paul LütznerandClaude Opus 5.5 0e3bf1cfe8 Put what a toolbar button inserts on the line, not inside its line break
Reported as "in a reply the template does not load". The request
succeeded, the picker closed and the reply stayed empty.

A reply opens with the caret parked by _focusCursorAtTop() at offset
0 of the first node of the first block, and in a fresh reply that
node is the <br> of `<p><br></p>`. _insertAtCaret() inserted at the
range it was given, so the node went INSIDE the <br> — where the DOM
holds it and nothing renders or serialises it. Typing never showed
it, because the browser moves the caret out of a <br> before it
inserts a character. A script has to do that itself, and now does: a
range that starts in an element which cannot have children is moved
to just before it.

It was not only templates. The same path serves the emoji picker and
inline images, and an emoji picked in a reply nobody had clicked into
has been vanishing the same way since before templates existed. That
one gets its own test.

Both tests read innerText on purpose. textContent, which is what
Playwright's toContainText compares by default, includes text that
sits inside a <br> — the first version of the reply test passed on
the unfixed code for exactly that reason.

One thing found and left alone: compose-link-popover's "leaves
ordinary typing alone" fails on this machine with and without this
change, and with the compose files as they were before templates.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:01:46 +02:00
Paul LütznerandClaude Opus 5.5 808b2bff53 Give a demo mailbox templates to pick from
A visitor who opened Settings → Templates on a fresh demo found one
empty folder and a button, and the compose window's picker said there
were none. Both true, neither showing what the feature does. The
provisioner now seeds six: a quote follow-up, an invoice reminder, a
payment receipt, a meeting follow-up, an out-of-office and a short
acknowledgement, filed at the top level, under the demo account and in
a folder inside it. Between them they use every variable, and the date
one with offsets in both directions and three formats.

app:test:seed-demo seeds the same six, for the reason it seeds the
same mailbox: a screenshot of the picker should be a picture of the
demo.

They are strings with tokens in them, which nothing checks — a
misspelt variable is braces left in the text — so DemoTemplatesTest
renders every one for a recipient and fails on an open variable or a
brace. It also erases a visitor with nested folders, since the eraser
removes rows one by one and folders point at each other.

The demo account is not given a signature. The signature variable is
in two of the templates and renders as nothing until the visitor
writes one, rather than a signature appearing in every new message
and in the README's compose screenshots to make one variable busier.

One layout bug fell out of looking at it. A long folder path —
"you@example.com / Quotes and invoices" — pushed the editor's Folder
dropdown out past the card's edge; the control may now shrink and the
path is cut with an ellipsis.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:47:02 +02:00
Paul LütznerandClaude Opus 5.5 bf3e1e8336 Serve templates to JMAP clients
A new capability, urn:plmail:params:jmap:templates, and five methods.
Template/get|set and TemplateFolder/get|set read and write what
Settings → Templates does, through the same library and the same
sanitiser: a template written on a phone is inserted into the web
compose window too. They are per user and take no accountId argument;
accountId is a property saying which mail account an object is filed
under. State is a hash of the list, as Appearance's is, because the
change log is keyed by mail account and these are not.

Template/render is the one a client inserts through. The stored body
still has its {{tokens}} in it, and turning them into values needs
the user's clock, the signature rules and the token grammar — three
things that would each be a second implementation in an app. The
client names the account it writes from and, if it has one, the
recipient, and gets a subject, an HTML and a text body, and the list
of recipient variables nobody could answer.

Filling the recipient on the server is a second implementation of a
rule the web runs in the browser, and both ends now say so. The web
keeps doing it client-side because it must not put a request inside a
keystroke; a native app already asks the server for every template
and has the recipient as data. TemplateRecipientFillerTest holds the
cases the two have to agree on.

  TemplateLibrary::deleteFolder   now removes the subfolders through
                                  the entity manager as well as the
                                  cascade, so a request destroying
                                  several folders is not handed a
                                  managed object for a deleted row.

The Session lists the variables and date formats an editor may
offer. No app uses any of this yet.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:38:26 +02:00
Paul LütznerandClaude Opus 5.5 09600802db Let a message be written once and inserted as a template
Settings → Templates is a new section: a tree on the left, one editor
beside it. The tree opens with one folder per mail account, and those
are not stored — an account folder is the account, drawn as a folder,
so the mirror cannot drift. Folders the user makes go inside an
account, inside one another or beside the accounts. Removing an
account or a folder keeps the templates in it and moves them up.

A template holds variables: the recipient's first name, name and
address, the sender's name and address, a signature, and dates with an
offset and a format of their own. They are stored as text tokens in
the body, because the body goes through the inbound-mail sanitiser and
a marked element would not survive it; the editor draws them as chips
and turns them back before it posts.

The compose window gets a button beside the signature one. Dates,
sender and signature are filled in by the server when a template is
inserted. The recipient is not: a template is usually picked before
anyone is addressed, so recipient variables arrive as markers and the
window fills them when To has somebody in it, and asks before sending
with one still open. A template's signature replaces the window's
rather than joining it, and one the template names is pinned so a
change of From leaves it alone.

  TemplateTokens     the notation, read the same way in PHP and in
                     assets/compose/template_variables.js
  TemplateRenderer   what each variable becomes, and who fills what
  TemplateLibrary    the tree, and the only writer of where a
                     template or folder sits

One bug fell out of running it in a browser. Chromium will not put a
caret between a non-editable element and the edge of its block, so a
template ending in a variable could not be given its full stop — the
keystrokes were dropped. The editor keeps a zero-width space beside
each chip and strips them before saving.

Two new tables, mail_template and template_folder. Web only so far:
nothing is published over JMAP yet.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:27:17 +02:00
Paul LütznerandClaude Opus 5.5 4656f8874e Add a spam button that can also filter the sender or their domain
A button beside Delete — on a list row, in an open conversation and
above a selection. On one conversation it opens a menu: move it to
Spam, or move it and create a filter for the sender, or for the
sender's whole domain. The domain filter asks once more first, and for
a mail provider's domain it sits behind "More options". The filter is
an ordinary rule under Settings → Filters, a second press on the same
sender makes no second one, and Undo takes back the move and a filter
the same click created.

The filters match on two new conditions, fromAddress and fromDomain,
which compare the whole address or the whole domain. `from` is a
substring over address and display name, so a filter for a@x.de built
on it also caught ba@x.de. Both are in the filter editor and in JMAP
Email/query.

Refs #31

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:20:26 +02:00
Paul LütznerandClaude Opus 5.5 ec938bca99 Count the sessions a public demo hands out, without keeping an address
Admin → Demo visitors, in demo mode only: sessions started in the last
hour, 24 hours, 7 days and 30 days, the different visitors behind them,
the total and how many have a mailbox now. The users cannot be counted
because the reaper deletes them, so /demo writes a row of its own.

That row holds a keyed hash of the address with its last part cut off
(a /24 or a /48), never the address, and the reaper removes the hash
after 30 days. The demo's privacy notice describes it.

Outside demo mode the section is not in the nav, its route answers 404
and nothing is recorded; the table exists and stays empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:23:27 +02:00
Paul LütznerandClaude Opus 5.5 99b2bd1478 Link the Android and iOS apps from the README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:27:21 +02:00
Paul LütznerandClaude Opus 5.5 fd4b1742f8 Stop a message growing without end when it is sized by the viewport
A mail whose wrapper is `min-height: 100vh` with a footer under it never
stopped growing. In the message frame the viewport is the frame, and the
frame is as tall as the content last reported from inside it: wrapper
plus footer is measured, the frame grows to fit, the wrapper is a
viewport tall again, and the message runs down the page a footer at a
time.

Viewport units in inline styles are removed when the frame script starts,
and again after "show remote content" puts the sender's styles back.

A stylesheet rule is not reached by that, so the resize handler is a
backstop: when the frame grew, its width did not change, and the content
grew by exactly as much as the frame did, the new height is not reported.
The loop stops after one step; such a message can lose its last few
pixels, which is the price of not growing for ever.

"By exactly as much" matters. The first draft of this, left uncommitted
in the working tree, took any height found after a resize as read. Run
side by side in a browser it cut a message off whenever content arrived
between the parent's resize and the handler — five of seven timings of a
late block — which is what an image settling does. An image adds its own
height; only a viewport-sized element adds the frame's. With the narrower
test none of the seven is cut off, and the runaway is still stopped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:49:02 +02:00
Paul LütznerandClaude Opus 5.5 6eb86d6451 Release v0.3.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.3.1
2026-10-08 17:05:36 +02:00
Paul LütznerandClaude Opus 5.5 4ca055a724 Let a backfill that re-reads mail be told how far back to read
`app:backfill events` and `app:backfill insights` re-read stored mail for
what an extractor can now find in it. The usual occasion is a reader that
was just fixed and a mail from this week; run over a whole mailbox they
also dig up every calendar file and parcel since it began — and since
calendar files that name nobody here are now offered rather than added,
that is a card on every one of them.

Both take --since and default to the last 24 hours: a count with h, d or
w, a date, or "all". Anything else is refused and nothing runs. The
command prints the window before it starts.

A separate interface, WindowedBackfillTaskInterface, rather than a
parameter on run(): the other tasks repair a table as a whole, and one
that accepted a window and ignored it would look as though it had done
what it was asked. Given --since, they say they take none.

The insights walk is keyset on the id now instead of OFFSET.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:55:30 +02:00
Paul LütznerandClaude Opus 5.5 1fc6adb146 Offer a calendar file that names nobody here, instead of adding it
Reported as "no insight": an interview invitation from a recruiting
system with the appointment attached as termineinladung.ics, declared as
application/octet-stream, METHOD:PUBLISH, no organiser and no attendee.

Nothing read it. Calendar data was recognised by content type alone, in
three places that each carried their own copy of the test. They now share
CalendarAttachment, which also accepts a part named *.ics — the runner,
the proposer that stands aside for a real event, and the query that picks
stored mail to re-read.

Read under the old rule it would have gone straight onto the calendar:
an event that lists none of the mailbox's addresses resolved to "not an
invitation to us", and that meant "draw it". Anybody could put things on
a calendar by sending mail. Calendar data mailed in by somebody else that
names nobody here is now held as unanswered, like an invitation, and the
card above the message asks: Add to calendar, or No thanks. No reply is
sent, and there is no Maybe. This covers a forwarded or list-addressed
invitation too.

Unchanged: invitations addressed to the mailbox, bookings read from
markup, and a file the owner sent. An event of this shape that is already
drawn with nothing recorded against it stays drawn, so re-reading stored
mail takes nothing off a calendar.

`app:backfill events` picks up mail that arrived before this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:56:59 +02:00
Paul LütznerandClaude Opus 5.5 cf9410ab76 Read insights from HTML-only mail, and an Amazon order from its confirmation
Two mails reported as "no insight", and what each showed.

A shop's shipping notice with no plain-text part. Every insight extractor
read bodyText and nothing else, so it was handed the empty string while
the tracking number sat in a table cell. ReadableBody gives all seven the
plain part where there is one and otherwise the text the HTML shows:
without stylesheets, Outlook's conditional comments or the addresses
behind links, and with cells and paragraphs as lines. The report snapshot
takes the same text, so a report about such a mail no longer arrives with
an empty body.

In that mail "Voraussichtliche Übergabe an den Versanddienstleister" is
followed by a date, which is the day the shop hands the parcel over. It
is no longer taken for the delivery date.

An Amazon order confirmation. "Bestellt: …" was refused at the gate
because it names no shipping word, although it states the order number
and the promised day. A subject that opens with "Bestellt" or "Ordered",
from a merchant sender, now makes the parcel card with a new first stage,
`ordered`. The stage comes from the subject only: the body is the
progress bar with every later stage on it. The card's key is the one the
dispatch mail carries, so that mail moves it on. "Zustellung: Freitag"
gives the date.

Stored mail is not re-read on its own; `app:backfill insights` does it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:56:59 +02:00
Paul LütznerandClaude Opus 5.5 7e7d5177ac Pace Gmail message fetching so an import stays inside the account's quota
Google allows one Gmail user 15,000 quota units a minute. A batch of a
hundred messages costs 500, and nothing counted: the ingest worker fetched
one batch after another as fast as Google answered. Since that worker
stopped doing the follow-up work between batches (88b17443) an import can
use the whole minute.

The import recovered on its own — a partly refused batch re-queues the
refused ids at info. What failed out loud was the next SyncAccountMessage
for the same account, on labels.list, with "Quota exceeded ... Units per
minute per user". New mail waited on old mail being fetched too fast.

GmailQuotaPacer gives each account 9,000 units a minute and a 3,000-unit
reserve for a burst, and GmailApiClient announces each message batch and
each messages.list page to it before sending. A caller that would run
ahead of the allowance sleeps first: a few seconds per batch, so the worst
minute is 12,000 units and the rest is left for what is not counted.

Kept in memory in the worker process. A restart begins with a full
reserve again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:13:55 +02:00
Paul LütznerandClaude Opus 5.5 3a6f4db0f1 Measure mail arrival from when the provider accepted it, and show the slowest
Admin → Performance measured "how late mail arrives" from the date a message
carries, which is the sender's clock. A newsletter dated half an hour before
it was sent, or mail held back by greylisting, was reported as plMail being
that late.

The delay is now taken from the newest Received: header. Each message is
stamped as it is first stored with when the provider accepted it, what
started the sync that fetched it (push, IDLE, schedule, sync button) and
where it was filed. Inbox mail and the rest are counted apart, since
providers only announce the inbox, and the slowest messages of the period
are listed in a section that starts collapsed.

An upgrade task gives the last week of mail its accept time from the stored
headers. What fetched older mail cannot be recovered and shows as a dash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:38:39 +02:00
Paul LütznerandClaude Opus 5.5 8579c3c425 Ask one place whether a label name is taken, and ask it on a rename too
d3292e3a made the check ignore capitals and left it in two shapes: a
repository lookup, plus an "is that just me" comparison written out in
Mailbox/set and missing from the browser. The browser's rename form
asked nothing at all — not before that commit and not after — so a
label could be renamed onto a sibling's exact name there, which is the
same collision by a shorter road.

LabelRepository::findNameConflict() is the whole rule now: sibling,
capitals ignored, the label being renamed left out, an exact match
reported before a close one. Mailbox/set and both of the browser's
actions call it and differ only in how they word the refusal — a JMAP
description naming the label in the way, a translated form error.

The label being renamed is excluded in the query rather than compared
afterwards. The comparison had a hole: where the label itself matched
first it hid a second one that was in the way, so with "receipts" and
"Receipts" both present, renaming the first to "RECEIPTS" passed.

A refused rename in the browser puts the stored values back on the
entity. The form binds onto the managed object before anything is
checked, and "nothing flushes it" is true of this request today rather
than of the object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:46:16 +02:00
Paul LütznerandClaude Opus 5.5 d3292e3a6d Refuse a label name that differs from an existing one only in capitals
"work" could be created beside "Work". The sibling check was an exact
match, and the strictest provider's rule is not: Gmail holds the two to
be one label and answers the second with "409: Label name exists or
conflicts" — in ApplyGmailLabelsHandler, in a worker, after the request
that made the label had answered "created". So the label existed here,
could be put on mail here, and never reached Gmail, with an error in
the log and nothing on any screen. It was found from the Android app,
which had been told the create worked.

The check in front of a person typing a name now ignores case, in
Mailbox/set (create and rename) and in the browser's new-label form.
It is a second repository method rather than a change to
findOneChildByName(), and the two must stay apart: that one is
find-or-create for mail arriving from a provider, where an exact match
is the only safe one — a case-sensitive IMAP server may really hold
`work` and `Work` as two folders, and folding them would file two
folders' mail under one label.

Mailbox/set's description is written for a person, because a client
shows it to one. When capitals are the whole difference it names the
existing label as it is spelled — somebody who typed "work" and is told
"work already exists" goes looking for a label that is not there — and
it carries `properties: ["name"]`.

Roles are included: a custom "inbox" beside the Inbox is the same
collision with a name the provider reserves. A label may still
recapitalise itself.

Nothing is done about pairs that already exist, and the handler still
logs the 409 for them: adopting Gmail's label on a conflict would bind
two labels here to one there. The browser's rename form has no
duplicate check at all, before or after this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:39:40 +02:00
Paul LütznerandClaude Opus 5.5 3fae44956b Let an app ask for "Move to", instead of rebuilding it from label patches
"Move to" (464df704) is a web route. A JMAP client had no way to ask for
it, so the Android app composed its own: one Email/set patch, target on,
view off. That is a second implementation of what MoveToService decides,
and the two part company exactly where the service earns its keep —
leaving the Inbox for a tag is archive() on plain IMAP, leaving the bin
is restore(), and only the messages that wore the view's label lose it.
The app could not move mail out of Trash under a label at all.

Thread/set takes a third key, and it is an instruction rather than a
property — nothing reads it back:

  moveTo: { mailboxId, fromMailboxId }

Both are Mailbox ids, binding ids on the account like every other one on
this API. The client names the target and the list it was looking at;
plan() decides what that takes off, for the reason LabelMove gives. On
Thread/set because a conversation is the unit of a move, as it is of a
snooze, and for the same reason snooze is there: the meaning lives in a
service the browser calls too.

  no fromMailboxId     a list with no mailbox of its own: Inbox comes off
  one that is unknown  refused, not read as "no view" — the two differ
                       in whether the Inbox comes off, and a stale id
                       would archive mail somebody meant to relabel
  a target accepts()   refused: Sent and Drafts say how a message came
  does not take        to exist

Nothing is flushed or recorded in the method. ThreadStatusUpdater, under
the service, records the Email and Thread state and queues the provider
jobs, which is the point of going through it.

An older server answers `"moveTo" is not a settable Thread property`,
and CLIENT_DEVELOPMENT.md names that as the one case where falling back
to a patch is right.

Checked over HTTP on the test stack: Inbox to a tag came back with the
tag and Archive on and the Inbox off, and the same call reversed put it
back. Not verified against a live Gmail, Exchange or plain IMAP account:
the tests pin local label state and that the method reaches the service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:31:26 +02:00
Paul LütznerandClaude Opus 5.5 2dfc9b3b9b Give the admin panel a page for how long things take
The line under the hold switch in Admin → AI said "typically 6 s, 95%
within 13 s": enough to see that one message took twice as long as the
rest, and nothing about why. System says whether plMail is working;
nothing said how long anything took.

Admin → Performance, directly below System:

  How late mail arrives     per account, provider-received to stored.
                            Seconds is push working; minutes is the
                            quarter-hourly poll, which "works" and so
                            showed up nowhere. Imports and plMail's own
                            sent copies are not arrivals.
  What each worker has      the queue numbers by the process that owns
  waiting                   them, a worker's queues in the order it
                            looks at them. Live; the transport keeps no
                            history.
  New mail waiting for      one row per held message, its wait split
  the assistant             three ways: queued, the model call, and how
                            much of the call was the model loading.
  Model host                moved from Admin → AI, unchanged.

To split a wait, the three parts have to be recorded with the message:
ai_queue_ms, ai_call_ms, ai_load_ms. ai_call_metric has the same
numbers and no idea which message they were for, and its timestamps are
the database's clock where the message's are PHP's, so a join by time
is not one to rely on. AiAssistant::ask() returns the whole result for
that; chat() is ask()?->content.

The model host's card and the search index backfill were one fragment
polled by one endpoint. They are about different things — a measurement
and a job with Start and Pause on it — so the fragment renders either
half, each page names the one it wants, and a caller that names neither
gets both, as before.

BackgroundProcesses::consumers() is the worker-to-queue map, read by
all() to start the processes and by this page to label them. They were
two facts that had to agree.

Rows name accounts. Nothing about any mail's content is shown.

Not looked at in a browser; the E2E card-inset spec covers the section
and has been given its frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:07:20 +02:00
Paul LütznerandClaude Opus 5.5 9c4cebf8b9 Stop rewriting the cache directory at every start
Starting plMail took over two minutes on a NAS with spinning disks, and
the log was silent for all of it — between "Statement statistics are
enabled" and the web server's first line. An empty install on the same
box, from the same image, was up in seconds.

The entrypoint ended with two `setfacl -R` passes over var/cache and
var/log. var/cache is part of the image: about 2,200 files. Changing
anything about an image file from inside a container, an ACL included,
makes the storage driver copy the whole file into the container's layer
and flush it. So every new container rewrote its cache one synchronous
file at a time, in every container of the stack at once, on one pool.

Measured in a fresh container on an SSD: 1.2 s for the first pass,
0.03 s for the second. On the warm production container, 0.06 s — which
is why timing it there showed nothing.

It ran only as root, so the catalogue install (uid 568) never paid it.
Nothing needed the entries: nothing runs as www-data, root needs no
grant, and the Dockerfile already makes both directories writable.

This is the third time that block has been the answer to a slow start.
It walked all of var/, then var/cache and var/log, and now nothing.

Also, on the compose files here: the database health check had no
interval, so Docker's first probe came thirty seconds after start and
the web server and workers could not begin before it. Five seconds now,
with retries raised so a database in trouble is called unhealthy after
the same two and a half minutes as before.

The README sends TrueNAS users to the catalogue app instead of walking
them through truenas.compose.yaml. The file stays: installs made from
it are still running.

Not confirmed on the box that reported it; the copy-up cost is measured
locally and inferred there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.3.0
2026-10-07 17:07:40 +02:00
Paul LütznerandClaude Opus 5.5 9dda420b70 Release v0.3.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:40:12 +02:00
Paul LütznerandClaude Opus 5.5 6ef9c07986 Stop a background request deciding where signing in takes you
Three things a test install of main turned up. None is from the ingest
change; all three were there before it.

The two-factor form could hang on "Checking…". The code had been
accepted — a reload showed the inbox. While the code is outstanding the
form's own layout still fetches things, the two-factor bundle turns each
one away, and its handler remembers every GET it turns away as the
destination, the last one winning. LoginFormAuthenticator has filtered
that since the avatar report; the bundle's handler never did. When the
remembered URL was /mercure/auth, which answers 204, the browser stayed
on the form. It showed on an install whose hub was down, because a
failing connection is what makes the page keep asking for that cookie.

  DocumentNavigation   the test both places now share, and a Turbo
                       Frame is refused first: it asks for text/html
                       and is not a page
  TwoFactorRequired    the bundle's handler with that one condition

A fresh install had no live updates until the container next restarted.
The public address is saved by the install form, the hub's browser
address is derived from it, and the web server is one kernel in worker
mode that booted before either existed. Every page was told to connect
to https://localhost, which its own Content-Security-Policy refuses.
The install now ends by restarting the web process, on the page Admin →
Restart uses. That page waited a fixed six seconds; it asks /healthz
until something answers.

A provider still rate-limiting after five retries was a CRITICAL with
two stack traces and a parked job, whatever the job. For
SyncAccountMessage that is noise — the next sync resumes from a cursor
that did not move. For the message batches it hid a real loss: they are
queued after the cursor has moved, nothing lists those messages again,
and the parked job was the only copy. GiveUpQuietlyOnThrottling drops
the first with a warning and puts the second back for a quarter of an
hour with a fresh set of retries, ten times, before it may fail.

Not verified: the restart at the end of install runs only where the web
process is PID 1, so it needs the built image. The hub failing on the
test box was a root-owned volume left by an older install, not a bug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:28:07 +02:00
Paul LütznerandClaude Opus 5.5 88b1744385 Leave the ingest worker to fetch mail, and nothing else
Adding an account put new mail behind hours of follow-up work. What
plMail does to a message once it has it — a model call where the
assistant sorts, event extraction, insight cards, receipts — was queued
on `ingest`, beside the syncs, for every message of the import, oldest
first. One consumer serves that queue. 988 envelopes pending and the
oldest an hour old was a mailbox being asked about newsletters from
2019 while mail on the server waited to be fetched.

`ingest` is now what talks to a mail provider. The rest moved:

  enrich_live     worker-live      follow-up work for mail that has just
                                   arrived on an account whose import is
                                   over; never behind an import
  enrich          worker-enrich    the same for an import's recent mail,
                                   and search indexing
  enrich_backlog  worker-enrich    old mail for the assistant, consumed
                                   only when `enrich` is empty
  release         worker-release   ends a hold (below); nothing else

Which queue is decided per batch, not per message class: the pipeline
asks InitialImportState once, carries the answer on PostIngestResult,
and EnrichmentRouter turns it into a stamp.

Only mail from the last 30 days (ENRICH_RECENT_DAYS) gets follow-up
work on arrival. Older mail is stored, threaded and filed by the rules.
The assistant is asked about it afterwards — newest first, a bounded
handful per run, and not until every account of that person has
finished importing (app:ai:classify-backlog, ClassificationCatchUp).
For the other steps `app:backfill` remains the way to read history.

ExtractInsightsStep and ProposeEventsStep ran their work inside the
sync and argued for it; both dispatch now.

New mail no longer appears in one tab and moves to another. For
somebody sorting by assistant the rules' category is a guess about to
be replaced, so where the verdict could still change it the message is
held: stamped before threading, its new thread given no category, which
every tab query already reads as "in no tab". The answer releases it.
Failing that a timed release does, after AiSettings::holdMaxSeconds —
60 by default — and the mail is shown where the rules put it; the model
call carries on and moves the mail when it answers. A message is asked
about once. Admin → AI has the switch, the ceiling, and the measured
delay over the last week beside them.

Three things about the release that were each wrong once first:

  - It ran on the live worker, so it could not fire while that worker
    was inside the call that was taking too long. It was moved to
    export, which mixed mail appearing with mail leaving. It has a
    queue and a worker of its own.
  - The call was cut off at the ceiling and the message asked about
    again later. The hold and the question are separate; only the hold
    has a deadline.
  - A late verdict was stored and moved nothing. It moves the mail.

Both workers still end at one model host, so bulk classification looks
at the live queue before each call and stands aside (LiveMailPriority).

One migration: two columns on `message`, two on `ai_settings`.

Not verified against a real import or a live model host: the tests pin
the queues, the hold and the release against in-memory transports and a
mocked host. Known and left: IMAP still imports oldest first; a reply
into an existing thread cannot be hidden; a Gmail account counts as
importing for up to an hour after its last message lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:18:44 +02:00
Paul LütznerandClaude Opus 5.5 464df70473 Move a conversation to a label, and take it out of the list it was in
Issue #25. Filing mail the way Gmail does took two actions here: the
label button adds a label and leaves the mail where it is, and a drop on
a folder needs a pointer and a sidebar in reach. "Move to" is one click,
beside the label button, in the list toolbar and in the reading pane:
the target goes on and the label of the list being looked at comes off.

The client names the view; the server decides what that removes. A body
that lists labels to strip is not an instruction the route has.

  Inbox            target on, archived (Archive on, Inbox off) — or
                   into the folder, where the target is one
  a label          that label swapped for the target
  Trash, Spam      target on, out of the bin, not into the inbox
  anywhere else    target on, Inbox off where it was on
  to Inbox         restore(), then the view's label off
  to Trash, Spam   the existing trash and spam paths, not a label swap

MoveToService composes ThreadStatusUpdater's operations rather than
writing labels itself, inside one transaction, because each of those
carries provider behaviour that took a bug report to get right. Only
the messages that carry the leaving label lose it: a sent reply in an
inbox thread gains the target and is not archived.

The issue asks for "tag(s)", plural. This is Gmail's single target;
several labels are still what the label button is for.

Undo came with it, for archive and labels as the issue asks, and for
the drag onto a folder since the mechanism was there. It remembers each
message's labels before the action, in the session, and puts exactly
those back — not the inverse of the action, which is wrong for a mixed
selection. A whole-view action runs in a worker and has none.

Three things fell out:

  - The toolbar's label menu posted once per ticked row. With a toast
    and an Undo on the answer that was five of each, so it posts the
    selection once now, to /status/bulk/label, which learned `attach`.
  - Inbox, Spam and Trash are asked for by role. Their labels are made
    lazily, so a picker that carried ids had no Trash row for anybody
    who had never deleted anything.
  - Font Awesome keeps its folder-with-arrow in Pro, so the button is
    an inline SVG.

Not verified against a live Gmail or Exchange account: the tests pin
local label state and the IMAP jobs that are queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:56:35 +02:00
Paul LütznerandClaude Opus 5.5 8a7d56862c Read the time when the clock connects, so Back does not show an old one
The clock only wrote the time when the minute turned. A page the browser's
Back button restores carries the reading it was made with, and showed it
until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:57:06 +02:00
Paul LütznerandClaude Opus 5.5 26f06166fa Release v0.2.58
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.2.58
2026-10-04 11:31:03 +02:00
Paul LütznerandClaude Opus 5.5 c8a21d1f7b Keep a link around the table it was wrapped around
A job alert whose every card is one link around a table had no working
links to the jobs. The step that flattens a mail's stylesheet onto its
elements parsed with libxml's HTML 4 parser, and HTML 4 does not let an
<a> contain a table, so that parser closed the link where the table
began. The stored body had an empty link and, after it, a card that went
nowhere. The href survived, so nothing looked stripped.

The inliner now works on a tree the HTML5 parser built, handed to the
library as XML so the old parser never sees the markup. Every other step
already parsed that way; this was the first one and the only one that
did not. A body the hand-over cannot carry falls back to the old parse.

Two things change with it for mail that was not broken, both towards
what a browser does: misnested inline tags are repaired by the HTML5
rules, and Outlook's <o:p> no longer leaves empty paragraphs behind.
The library's pretty-printing whitespace between blocks is gone too.

  app:backfill safe-html-table-links

re-sanitizes stored mail with a table after a link. Checked against a
planted message in the test stack: repaired, and the card shows its
link on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:26:32 +02:00
Paul LütznerandClaude Opus 5.5 fcde58c031 Release v0.2.57
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.2.57
2026-10-04 10:06:02 +02:00
Paul LütznerandClaude Opus 5.5 1ff8c81f4b Let the image run as a user that is not root
Nothing here needs root at run time, and a platform that chooses the uid
itself and drops every capability could not start it. Three things were
in the way, found by running it as uid 568 with nothing granted:

  frankenphp     carried cap_net_bind_service as a file capability, and
                 a process without that capability may not execute such
                 a binary at all. Removed: in a container the
                 unprivileged port range starts at 0 anyway.
  var/           cache, log and share were root's. Writable by anyone
                 now, since the uid is not known when the image is built.
  /data, /config root's as well, so the hub could not open its database
                 on a fresh volume. Same answer.

The entrypoint's setfacl step is skipped for a non-root user, who can
grant nothing and has no name for whoami to print.

Installs that run as root, which is every compose file here, are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:06:02 +02:00
Paul LütznerandClaude Opus 5.5 889aeec925 Release v0.2.56
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.2.56
2026-10-03 12:34:45 +02:00
Paul LütznerandClaude Opus 5.5 211034daf6 Take a database URL as it is written
Two things a DSN from somewhere else ran into, both at boot and in every
process.

A password that needs percent-encoding: the URL was read through
`resolve:`, which expands %parameter% references, so `s3cr%26t…` asked
the container for a parameter called "26t". plMail's own generated
password is hex and never showed it.

A DSN without serverVersion: the bundle cannot choose a platform before
it connects and stopped on 'Invalid platform version ""'. 18 is the
default now, which is what the image is built against; a DSN that names
a version still wins.

Found by running plMail against a database set up by a catalogue's own
helper, with a password somebody would actually choose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:34:45 +02:00
Paul LütznerandClaude Opus 5.5 533b773b33 Stop needing to be told what a catalogue can work out
Packaging plMail for a catalogue that forbids per-app workarounds turned
up four places where the image expected its compose file to compensate.

  cookie name      Chosen from the public address: the __Secure- name on
                   https, a bare one otherwise. It used to be prefixed
                   unless an operator knew to set MERCURE_COOKIE_NAME, and
                   an install opened on http://ip:port never got a live
                   update. The hub always reads the bare name and the web
                   server renames the cookie in the proxy, so the two
                   cannot disagree after only one of them restarted.
  storage dir      An absolute APP_STORAGE_DIR inside the project is
                   accepted and normalised to the relative form, so the
                   stored attachment paths do not change. One outside it
                   is refused at start.
  hub address      MERCURE_UPSTREAM names the container the web server
                   proxies the hub to, for a stack that does not call it
                   mercure.
  database secret  No POSTGRES_PASSWORD is minted, and no password file
                   written, when DATABASE_URL already carries a password:
                   that database is somebody else's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:17:47 +02:00
Paul LütznerandClaude Opus 5.5 32dc5eecd7 Stop the web server waiting for ever on the way out
"Restart now" in the admin panel stopped PHP and then hung. Caddy's
default on shutdown is to wait for every open connection, and the web
container proxies the live-update stream, which never closes by itself.
With one browser tab open the process neither served nor exited, and
compose cannot restart a container that has not exited; on TrueNAS the
app sat in "deploying" until it was stopped by hand.

It went unseen because `docker stop` kills the process after its own
timeout, and because the button is most useful exactly when live
updates were not working, so no stream was open.

Five seconds, as the hub already has. Reproduced against the test stack
with a stream held open: before, still running 25 seconds after SIGTERM;
after, exited within six.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:24:52 +02:00
Paul LütznerandClaude Opus 5.5 1dd5cd80b5 Release v0.2.55
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.2.55
2026-10-02 11:47:07 +02:00
Paul LütznerandClaude Opus 5.5 eedaa0bea9 Retake the README screenshots with the wax seal in the top bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:47:07 +02:00
Paul LütznerandClaude Opus 5.5 f0380c47fa Make the wax seal the icon an account starts on
The default icon was the pl mark. It is the wax seal now, in its own
design: the mark pressed into wax is still recognisably plMail, and it
is a picture rather than two letters, which is what an icon on a
catalogue page or a home screen has to be.

Three things move together:

  LogoMotif::DEFAULT         the seal, for new accounts and for pages
                             with nobody signed in
  Appearance::$logoOriginal  true by default, so the seal arrives the
                             way a picked motif does and not in the
                             theme's colourway
  public/icons/              favicon.svg and the four PNGs redrawn as
                             the seal on its tile

A migration moves the column defaults and every account that never
chose an icon: the pl mark, still in berry, still linked to the theme.
That cannot tell an untouched account from one that deliberately stayed
on the mark; those get the seal too and can pick the mark again.

The static files are drawn by hand from the motif's geometry, so a test
now holds favicon.svg to the default icon's own colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:41:08 +02:00
Paul LütznerandClaude Opus 5.5 43027ef56c Release v0.2.54
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.2.54
2026-10-02 10:31:41 +02:00
Paul LütznerandClaude Opus 5.5 be3b27cf32 Let an administrator change the public address after setup
Setup asked for it once and nothing could answer it again. An install
restored from another machine's backup therefore kept the old machine's
address: browsers were sent there for live updates and the stream
indicator stayed red, with an environment variable in the compose file
as the only remedy.

Admin → Address edits the same value setup writes. The change is saved
to the generated file and takes a restart to apply. Making it live was
left out on purpose: the entrypoint exports that file into every
container's environment at start, and telling a stale export from a
deliberate pin needs more machinery than a restart costs. The page shows
the address in use beside the saved one whenever they differ, and offers
the web container's restart after every save.

One thing fell out. A hub address on file that is only the previous
public address with the hub's path on it is dropped on save, so it is
derived from the new address again; a hub address of its own is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:25:56 +02:00
Paul LütznerandClaude Opus 5.5 0507c1e716 fix(truenas): chown the Postgres directory to the uid the alpine image runs as
postgres:18-alpine runs as uid 70; 999 belongs to the Debian image. The
install worked anyway because the Postgres entrypoint re-chowns PGDATA as
root, but the chown, the setfacl fallback and the docs named the wrong user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:11:55 +02:00