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>
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>
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>
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>
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>
#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>
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>
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>
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>
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>
"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>
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>
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>
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>
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>
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>
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>
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>
`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>
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>
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>
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>
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>
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>
"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>
"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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
"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>
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>
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>
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>