Over IMAP, Gmail lists "[Gmail]" as a folder, but it is only a placeholder
that groups All Mail, Sent Mail and the rest. The server marks it \Noselect
and refuses to open it, so plMail's attempt to sync it failed on every poll
with an ERROR-level log line.
The folder could also never be swept, and one never-swept folder makes
MailboxRepository::earliestSweepAcross() answer null, which is the signal
VanishedMessageReconciler::reap() reads as "no coverage yet, erase nothing".
So for as long as the account existed, mail deleted elsewhere was never
removed from it.
MailboxSyncer now reads \Noselect off the raw LIST attributes and creates
such folders with sync off; a row that already exists is switched off on the
next folder sync, so installs heal without a migration. The attribute is
matched without regard to case because RFC 3501 makes attributes
case-insensitive, while Folder::$no_select in the library matches only two
spellings.
The rule only ever disables. A folder somebody turned off stays off across
syncs, and a folder the server later makes selectable is not switched back on,
because nothing records whether the flag was the rule's doing or a person's.
Tests stage a Gmail-shaped listing, including the sweep-coverage consequence,
and each behaviour was mutation-checked. The IMAP provider page and its German
twin carry a note, and so does the changelog.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every row offered a snooze menu and the conversation itself did not,
so "not now" after reading a mail meant going back to the list to
find its row. It was also the last key in #36 with nothing to press:
b did nothing in an open conversation.
The toolbar gets the clock button and the same menu, handled by
mail--message-actions. Snoozing closes the pane, as archiving does,
since the conversation has left the list it was opened from; clearing
a snooze leaves the reader where they are. Offered where the row
offers it, not for mail in the bin or in Spam.
The menu opens from the button's left edge there. It opened from the
right everywhere, which suits a button at the end of a row and would
run off the pane from one at the start of a toolbar.
Refs #36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`?` opens the list of keyboard shortcuts, and a key nobody has told
you about is not a way in. A keyboard button on the list toolbar opens
the same dialog, beside the sort menu, on the bar the keys act on.
Only for somebody who has the shortcuts on: switched off it would
advertise keys that do nothing, and the list is still in Settings for
deciding to switch them on. Not drawn at phone widths.
Refs #36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
User::$categorySorting (source, overrideProvider, tabs) was never written
into a config backup, so a restore put everyone back on rules, the
provider's categories and a tabbed inbox. It now travels through
toArray()/applyArray() like Appearance and AiPreferences do. A backup
without the key restores the defaults; an unknown source falls back to
the rules without losing the two switches beside it.
ConfigBackupCompletenessTest now fails for any #[ORM\Embedded] on User
that the export does not carry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
j and k move a cursor through a list, o opens, u goes back, x
selects, e archives, # deletes, ! is spam, s stars, Shift+i and
Shift+u mark read and unread, l v b open label, move and snooze,
r a f reply, c composes, / searches, g then a letter goes to a list,
z presses Undo and ? lists all of it.
A key presses the control the page already has and does nothing else.
ui--shortcuts archives nothing itself: it finds the toolbar's Archive,
the row's star, the Reply link, and clicks it. The request, the Undo
toast and the refusal where an action is not offered are the
button's, so a shortcut cannot drift from the button it stands for.
What it acts on, first that exists: the selection through the list
toolbar, the open conversation through its own, the row under the
cursor through the row's buttons. Label, move and snooze have no
button on a row, so the row is ticked and the toolbar's menu opened.
The cursor is an attribute repainted from a remembered id, not focus:
rows are replaced by every refresh, and focus would be dropped to
body each time.
Keys are left alone while something is being typed into, while a
dialog or a menu is open, and with Ctrl, Alt or the Command key held.
A message body is a sandboxed frame with a document of its own, so
its script relays the facts of a keydown to the parent; otherwise the
shortcuts stopped the moment a mail was clicked on.
On for everybody, off per person under Settings, General. Off is the
controller not being mounted, so there is no flag for the script to
forget to read. Stored in the settings bag as a false only, so no
migration, and carried by config backups.
Closes#36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"What sorts your mail" named two of the card's three controls. With
the tabs switch on it as well, the card is about categories as a
whole: whether the inbox shows them, which one a message lands in,
and who decides.
Refs #29
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
`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>
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>
"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>
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>
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>
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>
Yes or Maybe redrew the card and put a toast in the corner, and the
calendar pane beside it went on showing the week without the meeting
until a reload: the response replaced the card and nothing told the
pane.
calendar--invite makes the calendar part of the answer, in three layers
that each work without the next:
- Preview. Hovering or focusing Yes or Maybe outlines the slot the
meeting would take, dashed, in its calendar's colour, at every motion
level. The slot is worked out in the browser from the card's UTC
instants on the grid's own clock (data-grid-zone), full width; the
real block takes its lane when it arrives.
- Flight. At Full motion the pressed answer arcs to that slot and turns
into the meeting on the way, or shrinks into a dot on the calendar
button when the pane is closed. It reads --motion-travel, a new
MotionLevel token: 580ms at Full, zero at Minimal and None and under
prefers-reduced-motion. DESIGN.md names it the second exception to the
260ms ceiling, beside new mail.
- Redraw. Once the server has answered, the pane is fetched and morphed
in place with Idiomorph, since a pane embedded at page load has no src
to reload, and the real block is lit where the placeholder stood. A
screen reader hears where it went.
Nothing waits on the iTIP reply the server sends first: the button takes
its answer at once and the flight lands on a placeholder the real block
replaces. A failed request takes all of it back.
A meeting answered Maybe is striped in its calendar's colour with a
dashed edge, in every view, and says "Maybe" in its accessible name.
Nothing drew a tentative meeting differently before. Only the reader's
own answer stripes it, not an event's STATUS.
app:test:seed-invite hangs an unanswered invitation on seed-mail's
"E2E Read Me". calendar-invite.spec.ts answers it at Full (Yes flies,
no reload) and under reduced motion (Maybe outlines a striped slot,
nothing flies, the block is striped).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.env named var/secrets/jwt outright, which is beside the generated
secrets only while APP_SECRETS_FILE keeps its default. The TrueNAS
layouts point it into their data dataset, so there the keypair landed on
each container's own throwaway filesystem: every service generated one
on every start ("Generated the JWT keypair." in the log), and a token
one of them signed would have been refused by the others and by the
next restart. Nothing issues JWTs yet - the Android app pairs into an
app password - so nothing broke, but the promise in the comments that
every service verifies what the others sign did not hold.
- config/bootstrap_generated_secrets.php derives both paths from the
directory APP_SECRETS_FILE is in, for every PHP process including a
`docker exec` console. A real environment variable still wins; the
.env defaults are empty, as for the other derived values. On the
standard compose the paths are the ones they always were.
- app:secrets:init generates under a lock, rechecked once held, as
GeneratedSecretsFile::ensure() does. lexik's --skip-if-exists is a
check followed by two writes, so services starting together could
leave one's private key beside another's public key - a narrow window
that seven concurrent runs did not hit in five rounds, and closed now
rather than left rare: with the lock every round generated exactly
once and left a matching pair.
- A reset that rotates secrets removes the two key files where they
are, instead of emptying a fixed var/secrets/jwt.
- The security.yaml note on "the future first-party app" is corrected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Starred takes a drop the way Unread does: the conversation is starred
and stays where it was. It was not a drop target at all, and there was
no way to star several conversations at once - the row's own star
button is the only star control.
- A new explicit-ids-only bulk action, `star`, beside `read`. Drop-only
like move, label and category, so a whole-view selection is refused.
- ThreadStatusUpdater::starThreads() switches stars on and never off.
The row button's star() toggles, reading the answer off the first
message; a drop is a destination, so a conversation that is already
starred - by the thread's starredAt, which the Starred view lists by -
is left starred. The write is star()'s: the first message carries the
flag, the thread the timestamp, the whole conversation is pushed.
- mail--dnd: a data-dnd-star target, lit like every other target.
BulkStarTest drops a starred and an unstarred conversation together and
finds both starred; dnd.spec drags a row onto Starred with a real
pointer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new system label, Unread, off until it is switched on in Settings ->
Labels. Its sidebar row sits under Inbox and opens every unread
conversation in one list with no category tabs - archived and labelled
mail included, the bin, spam and snoozed mail left out. Dropping a
conversation on the row marks it unread and leaves it where it is.
- The label is never bound to an account (LabelResolver::
userSystemLabel()), so it is not a JMAP Mailbox and no provider push
can reach it. The labels settings page creates it, the one place it
can be switched on.
- The list is read off the unread counts (MessageThreadRepository::
findForUnread()) rather than off the label, which nothing attaches.
Its sidebar badge counts the same rows.
- A drop posts the bulk "read" action with read: false through a new
data-dnd-unread target in mail--dnd - the toolbar's Mark as unread,
by pointer. Moves into it are refused (LabelRole::acceptsMoves()).
- The list refreshes on a sync of any folder, as "*" does, since it
gathers mail from all of them.
Web only for now: the Android app does not list it.
UnreadViewTest pins which mail the list holds and that the row is
opt-in; dnd.spec drags a read conversation onto the row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The eye beside Inbox, Sent, Drafts, Trash or Archive in Settings ->
Labels saved the change, greyed the label out in the list, and left the
row in the sidebar. The sidebar only asked about Spam; every other
system row was drawn unconditionally. Even Spam changed only after a
full page load, because the toggle's answer redrew the custom label tree
and the settings list and nothing above them.
- Each system row now asks SidebarCounts::isRoleHidden(), filled by the
role-label read the sidebar already makes. A role with no label yet
counts as shown - system labels are created lazily, and a fresh
install must not lose its Inbox for that. "More" is not drawn when
everything in it is switched off.
- Toggling a system label answers with both sidebar copies re-rendered,
for the tab that clicked only: the toggle lives on the settings page
alone, and another tab sitting in Archive would have its "More"
snapped shut by a re-render.
- Archive is created visible. It was created hidden from when it was
IMAP bookkeeping, which is why 6edf33fe stopped the sidebar asking.
Upgrading: Archive labels that already exist keep the hidden flag they
were created with, so Archive leaves the sidebar until it is switched on
in Settings -> Labels - which is what that list has been showing all
along.
testHidingTrashTakesItsRowAwayAndShowingItBringsItBack fails against the
old sidebar and stream.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retaken after the all-day band learned to span days: the shot still
showed "Jonas - birthday" as a chip on each of its two days.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retaken after 66b9701b: the picture still showed a Monday-to-Sunday week
labelled Week. The spec's own events move two days on, because
app:test:seed-demo already fills the first days from today - on top of
it they took the same slots, a "Standup" beside its "Stand-up" at nine,
and left the last two columns empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The week ran Monday to Sunday, so by Friday the docked pane spent four of its
columns on days that were over and pushed today towards its right edge, where
seven columns do not fit and the rest scrolls out of sight. It is now the
seven days from the anchor: today on arrival and after Today, the day you
moved to otherwise, and Previous and Next still step seven days. That is no
longer a calendar week, so it is labelled "7 days" ("7 Tage"); the URL segment
and the stored preference stay `week`, so bookmarks and remembered views keep
working.
Month keeps its Monday, as a grid of weeks. The public shared page keeps a
real Monday-to-Sunday week and the label "Week": it builds its own range, and
the toolbar now takes per-view label keys so the two can differ.
The grid fixtures' daily series starts today rather than on Monday, so its
five occurrences stay in view, and the README screenshot lays its events out
from today for the same reason.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since a summary whose connection drops is finished and stored anyway
(932c3f1f), Stop did neither thing it says. The abort it sends looks, from
the server, exactly like a dropped connection, so the model ran to the end
and the summary was stored under a card reading "Stopped. Nothing was saved."
So Stop is now also said. The card names each run with a random id, and Stop
posts that id to /mail/thread/{id}/summary/stop, with the same per-action
CSRF token and ownership check as the run. The flag lives in a DBAL cache
pool, because the stop request need not land on the container streaming the
run. The run looks for it before it starts, on every heartbeat and at most
once a second while words arrive, when it finds its reader gone, and once
more before it stores. Finding it returns from the loop, which frees the
generator and drops the call to the model host; the AI panel records it as
cancelled.
Only the button says no. Opening another thread or closing the tab still
counts as leaving, and that summary is still finished and waiting. A page
loaded before this sends no run id, and its run is finished the old way
rather than refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stack was nine long-running containers: the web server, the
database, the Mercure hub, and six of plMail's own image, one per
background process. It is three now: php, worker and database, with
secrets-init still running once and exiting.
The worker runs `app:work`, a small supervisor keeping seven processes
alive the way Docker's restart policy kept seven containers alive: the
IMAP supervisor, one consumer per queue, the scheduler and the hub. A
clean exit (the hourly recycle, the restart signal) comes straight back;
a crash backs off from one second to thirty. Output passes through a
whole line at a time, so two processes' JSON records never interleave,
and SIGTERM reaches every child at once with 25s to finish. Each queue
keeps a process of its own, which is the part the split was for: a
consumer inside a long handler cannot take a send. And each process
keeps its container's old name as APP_CONTAINER_NAME, so heartbeats,
/healthz and the log filters are unchanged.
The hub is the official Mercure 1.x binary, copied into the image and
run as one of those processes. Not FrankenPHP's built-in hub, which is
0.24 and refuses the RFC 9068 tokens plMail has minted since v0.2.31.
The worker answers to `mercure` on the network, so the Caddyfile proxy
and MERCURE_URL reach it where they reached the hub container, and older
compose files keep working with this image unchanged. It is configured
from the application's own settings, including MERCURE_COOKIE_NAME, so a
plain-HTTP install names the cookie once for both halves. A 5s shutdown
grace stops it waiting on open subscriptions, which made every stop take
the whole 25s.
The dev override loses its per-worker blocks, one of which (worker-bulk)
had been missing, so that queue ran the published image's code in
development. The demo overlay runs app:work --without=imap-supervisor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Admin → Updates: an administrator picks which builds count as an update
(Releases, the `latest` image; Main, the `main` image; or Off), and
every hour app:updates:check asks whether that channel has a newer one.
Until somebody picks, an installation follows the tag its own build
came from, so a `main` image follows main and an unbuilt checkout asks
nothing of anyone.
The registry decides what exists: the channel's image is read the way
`docker pull` finds it (OCI distribution API, anonymous token from the
401's challenge, the index entry for this architecture, the config's
labels), so a commit that builds no image is never announced. GitHub's
compare decides which way two builds differ, so a main build following
releases is not told to go back to an older release, and its commit
list is the page's "what's new". An image from anywhere but GitHub falls
back to what the version numbers alone can say.
Each new build is announced once: a push to every administrator's
registered devices (a new UpdateAvailable payload the service worker
shows), a badge beside the build chip in the admin header, and a dot on
the nav entry. A check that gets no answer keeps the last one and says
why, and the hourly command exits 0 for it: an installation behind a
firewall is not a fault.
Images are stamped with where they are published (APP_IMAGE), so a fork
that publishes its own checks its own. The channel travels in config
backups; what the last check found does not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first picture now shows what a desktop opens on, and what the
README's first line promises. The fixture user stays on the mail alone
for the regression suite, so the capture asks for split and restores
the mail afterwards.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Anyone who has not moved the switch now gets Split: mail and calendar
side by side, which is what plMail is for and what a demo visitor, who
never chooses anything, should see. A pane shut under the old boolean
setting stays shut; only a missing key means nobody chose.
Phones keep opening on the mail. ui--split already demoted every
remembered position there on connect, but the first paint came before
it: the server rendered the stored mode, the rule for split below lg
hid the mail, and the pane itself is hidden below lg, so the frame was
blank until JavaScript put the mail back. With Split the default that
would have been every phone on every page load. The shell now carries
data-calendar-restored until connect, and below lg a restored position
does not step the mail aside.
The E2E users stay on the mail alone (app:test:seed-user, as the mail
seeder already did), since the suite was written against a list with the
whole row. The list query budget closes the pane for the same reason:
an open pane embeds its body, a fixed few queries that are not the
list's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch asks Gmail to announce changes to INBOX alone, and Gmail
pushes only for changes relating to it. The health check read the
unfiltered history feed and took any record in it as a change push
should have announced: the copy of a sent message, an archived thread,
mail a filter kept out of the inbox, spam. So a working push was
reported as broken whenever one of those came more than fifteen minutes
after the last inbox mail. The next inbox push cleared the card and the
next reply raised it again.
gmailHistoryAdvancedAt is now written only for a record whose message is
in the inbox after the change, the label taken from one constant on
GmailWatchService so the watch and the evidence cannot drift. Mail moved
out of the inbox is left out on purpose: whether Gmail announces that is
not knowable here, and leaving it out only means a dead push is noticed
at the next inbox mail.
A change recorded before the current watch was registered no longer
counts against it. "Turn instant delivery back on" re-registers, and the
card used to outlive that repair until the next push arrived.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two icon captures now show the horn in the top bar without its
tile, and the rest show the pl mark at its new size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tab icon was drawn from the session, and the appearance pane points
the tab at a new choice the moment it is clicked, before the save lands.
So it drew the choice from before the click, and the browser kept that
drawing under the new URL for a week: every tab icon was the previous
pick, except Classic, whose URL the page had already loaded correctly.
/branding/favicon/{motif}/{paint}.svg now draws what its path says, like
the icon tiles, and is public and cached the same way.
The top bar's logo is the glyph without its tile, from
/branding/logo/{motif}/{paint}.svg: the tile is the phone's app icon,
and in a web page's corner it read as one. The @-horn keeps its tile,
because its cream @ is nothing without the field behind it. Every
top-bar logo, the pl mark included, sits in one 36px box, up from 32.
On a dark bar only the parts that would vanish change: the pl mark and
the horn read the colourway's dark-chrome strokes, and the mailbox post
turns grey. The page renders a light and a dark image and the stylesheet
shows one, so a theme that follows the system is right as well.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two new captures — an icon chosen in its own colours, and the same icon open
on the colourway grid — with the topbar in both frames, since where the icon
ends up is the point of choosing it. The rest are retaken against the demo
mailbox as usual before a tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The appearance page describes choosing an icon and then its colour, with
Original as the motif's default, what a colourway leaves alone, and two new
traps (Follow the theme on a classic theme; picking a motif resets to
Original). The client guide documents the two read-only JMAP properties,
logoMotifs, and the paint table behind app:branding:export-paints. German
prose follows both; translated-from hashes recomputed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both tables grew forever: TrustedDeviceRepository::pruneExpired() and
BackgroundJobRepository::findFinishedBefore() had no caller. The nightly
app:monitoring:prune now deletes expired trusted devices and background
jobs finished more than 30 days ago (--job-days). The job lookup became a
bulk DELETE, since hydrating would scale with the history and trip over any
row whose kind the enum no longer lists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>