webklex raises a FETCH that completed with no FETCH lines as a
ResponseException ("Empty response"): its Response counts as successful
only with data, or for the few commands marked as allowed to be empty.
Two places took that for the server not answering.
The import (#96). A UID the server lists in SEARCH and returns nothing
for in FETCH - iCloud, most likely a draft deleted and not expunged -
ends up alone on the last page. The fetch threw before the page's
attempts were counted, so MAX_PAGE_ATTEMPTS never applied: 486 of 487
for good, retried every few minutes, and the account's other folders
waiting behind it. It is now a page that held something back, counted
and passed over after five tries like a message that will not store.
The folder listing. `UID FETCH 1:* (FLAGS)` on a folder with nothing in
it is this same answer, and read() returned null for it. The folder was
never swept, so mail deleted from it elsewhere stayed here, and an
unswept folder withholds reaping from its whole account. It returns the
empty listing now; whether to believe one is still the sweep's bulk
guard.
Only this failure. ImapEmptyAnswer tells it from a dropped connection
(webklex's RuntimeException, never a Response) and from NO/BAD
(ImapServerErrorException), which say nothing about the messages: an
hour's outage must not pass over a page of mail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The library says "empty response" for a read that timed out and for a
connection the server has closed, and the IDLE loop took both for a
quiet mailbox. On a closed connection every read returns empty at once,
so the loop never blocked again: a core per folder until the 29-minute
re-issue. Lark and QQ close idle sessions within minutes, so for them
this was every session (#94: 85 minutes of CPU in three hours for one
inbox, against half a second for a Gmail one).
connectionEnded() tells them apart by the stream being at end-of-file,
or by the read having come back in under a second when a quiet one
blocks for thirty — a TLS stream does not always report end-of-file.
A connection that lived a minute or more is reconnected at once and
counts as no failure. One lost sooner is thrown to the retry ladder, so
a server that accepts IDLE and hangs up cannot become a reconnect loop
in place of a read loop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The badges are patched from /mail/sidebar/counts, and what made a tab
ask was a list: the sync's hub events, and announceWrite() called by
each controller that wrote. Whatever was not on the list had a stale
badge. Measured in a browser (#86): a draft autosaved, the server
counted 1, the badge stayed at 0, and in forty seconds the page made no
counts request at all. The composer saves through a Turbo form on a
timer and had no controller to call from; the send that follows is a
worker's, ten seconds later, and a mail read on a phone is another
client's. No browser code can announce those.
Every change to a user's mail already passes one place, StateManager,
because a phone's /changes has to be complete. That is now what tells
the browser too:
X-Mail-Changed: 1 on the response to any request that recorded a
change (MailChangedHeaderSubscriber). mail_writes.js
reads it off every fetch and every Turbo request
and announces the write
mail.changed on the hub, from the drain that already tells the
phones (MailChangeNotifier), for a change this tab
did not make
state:sequence the change sequence the counts were read at, in
the counts answer and on the rendered sidebar. A
tab ignores a nudge that is not newer
The sequence is what keeps it to one request per change. The hub's
nudge for a tab's own write was measured arriving before that tab's
counts request had left, so the comparison waits 400 ms and for
whatever is on the wire. announceWrite() gathers calls for 120 ms, since
a write is now announced by the header and, where one exists, by its
controller.
Opening a conversation marked it read every time, read or not: seenAt
was replaced, the provider told again and a JMAP change recorded. With
the header that became a counts request behind every click on read mail.
ThreadStatusUpdater::markRead() now writes only the messages that
change, keeps the first seenAt, and records nothing when nothing moved.
One query more on a page that draws a sidebar (the sequence); none on a
list-frame navigation.
Not exercised end to end here: the worker's own send. The test stack's
send transport is in-memory, so the browser test for "a change made
elsewhere" uses a second tab.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ThreadParticipants knew the reader by Account::$ownedAddresses, and an
address at a catch-all domain is in no list. A reply sent from
yo@shop.test put the account's name in the participants column as though
a third person had joined.
Not every From at the domain, for the reason OwnAddressHelper gives:
somebody else may have a mailbox there. It is the reader's when the
message is filed under Sent or Drafts.
The list page reads its rows as fields, not entities, so that fact is
one more column of the same statement (a count of the message's Sent and
Drafts labels) and no extra query. For loaded messages it is only asked
of a From the account catches, since asking walks the message's labels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A meeting on two calendars is drawn as one chip whose mark is split
between their colours. The split was always top to bottom, which suits
the mark standing upright beside a title. A month cell too narrow for
words lays the mark flat (the @max-[5.5rem] classes), and there the two
colours were stacked hairlines in a bar six pixels tall.
The gradient's direction is now a custom property, so the container
query that lays the bar flat turns the split with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RFC 8621 names the identity on the submission alone, and JmapDraftWriter
stored every draft from the account's address. A reply an app autosaved
from an alias therefore had no sender of its own: reopened from Drafts,
on that phone or another, it came back on the primary address, and
replyFrom on it could only report the account.
Email/set now takes identityId on a draft, on a create and in an update
patch. It is resolved through IdentityResolver::addressFor(), the
submission's resolver, so an address identity for a catch-all address
works and there is still one answer to what an account may send as.
fromAddress and fromName are written from it.
an id that does not resolve ignored, and the rest of the save goes
through. The patch is an autosave; the
submission names its identity again and
is where forbiddenFrom is answered
a message that is not a draft invalidPatch, as for any draft property
draftIdentity: true in the account's mail capability. A
server from before this refuses a patch
naming the property, so a client has to
know before it sends one
Clients that send no identityId get what they always got.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
76bf97c8 made every address at a catch-all domain the account's own.
A catch-all catches what has no mailbox, and on a shared domain other
people have one: with shop.test declared, anna@shop.test was dropped
from a reply-all, a reply to her mail could open with her address in
From, and her mail was read as the user's own sent mail.
OwnAddressHelper answers per message instead:
caughtRecipients() the catch-all addresses in Delivered-To,
X-Original-To or Envelope-To; only where the server
left none, those in To and Cc
sentByAccount() an alias in From, or a catch-all address in From
on a message filed under Sent or Drafts
forMessage() the aliases plus caughtRecipients()
ReplyFromResolver and ReplyDraftBuilder use it, and Account::ownsAddress()
is gone with its one caller.
Invitations use it too, which they did not before at all: an invite to
yo@shop.test named no address the account knew, so the card had no "me",
no answer buttons, and the event was treated as naming nobody here
(InviteReader, EventReconciler, EventProposer). The reply already goes
out from the attendee address.
With no delivery header and two catch-all addresses in To, both are
still taken for the user's; the docs say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The browser opens a reply on the address the mail was sent to (#41). The
phone apps build their replies themselves and pick a sender from
Identity/get, so they got none of it: every reply opened on the primary
address, and a catch-all address could not be sent as at all, having no
identity and EmailSubmission/set refusing any id it did not publish.
Email/get replyFrom the Identity that answers this Email, or null.
ReplyFromResolver picks the address, the same
call the web composer makes, so no client
carries a copy of the rule. Only returned when
named in `properties`: it carries a signature,
and an app written before it asks for
everything
IdentityResolver forAddress() gives the listed identity for an
alias, and for an address at a catch-all
domain an address identity, id `addr-` plus
the base64url of the address. addressFor()
decodes such an id and asks
Account::maySendAs() on every submission, so
the id is a spelling and not a grant: one for
a domain the account does not catch is
forbiddenFrom like any unknown id
EmailSubmission/get reports that id for mail sent that way
Identity/get is unchanged, and so is everything an installed app sends
or reads. `*@domain` as an identity (RFC 8621 §6) would have shown up in
their From pickers as an address; a writable `from` on Email/set would
have been ignored or refused by an older server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three things about who a mail is from, grouped as v0.3.5.
The sender name (#70). EmailAlias::$displayName was shown in the From
selector and published as the JMAP identity name, and nothing sent it:
MessageSendService built From with the account's name whatever the
address. There was also no field to set it in the browser.
Account::senderNameFor() the alias's name, else the account's; the
selector, the draft row and the From header
all read it
Settings -> Aliases a sender name field under each address
EmailSubmission/set writes fromName with the address it resolves
The address a reply goes out from (#41, the reply half). Every window
was pre-set from the account alone, so a reply to mail for an alias
opened on the primary address, and a draft saved from an alias reopened
on the primary and was stored that way by the next autosave.
ReplyFromResolver the first of From (own mail), To, Cc, then
Delivered-To / X-Original-To / Envelope-To
that the account may send as
SenderResolver::tokenFor() opens a draft on the address it carries
catch-all domains an account setting, not an alias row: any
address at one may be answered from, is
left out of a reply-all, and signs with the
account signature. ComposeType admits such
a token on submit by the same rule. Not
offered for Gmail and Microsoft accounts.
Typing an arbitrary From into a new message is not in this.
Signatures as HTML (#32). Pasting source into the contenteditable gave
the source back, and a linked image never showed, the page's img-src
being 'self' data: blob:.
Rendered | HTML a textarea beside the editor. Rendered asks
the server (signature/preview) and shows
what a save would keep, with the elements
and attributes the sanitiser dropped named
underneath
SignatureImporter sanitises, and fetches https images through
ImageProxyFetcher into data: sources, 256 KB
each and 768 KB a signature; the rest stay
linked and are counted
DataImageEmbedder on send, a data: image becomes an inline
part and a cid: reference, since Gmail and
Outlook draw no data URI. It reads the
entity-encoded form the sanitiser stores
(= for the padding).
No migration: the catch-all domains live in Account::$settings and are
carried by a config backup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
testTheFilterReachesTheClassAndThePayload filters the queue for "99" to
find the SyncAccountMessage for account 99. The filter searches the
serialised body, and the body carries requestedAt — the Unix time the
test ran. Whenever that number contained "99" the other message matched
too, and the test found two where it expects one. It did on main's CI
run of 2026-10-13 09:16 UTC.
Both messages are given a fixed requestedAt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Google's quota table gives messages.get one cost, 20. An import paced on
that was refused at a third of its limit with no other call being made,
so app:gmail:quota-probe fetched thirty messages each way in a minute of
their own and the project's quota meter was read afterwards (#90):
format=full, batched 1,800 units 60 each
format=full, one at a time 1,800 60
format=raw 1,800 60
format=metadata 600 20
format=minimal 600 20
The table's 20 is a message without its body. The import's own traffic
agrees: 96 fetches a minute beside 5,800 units on the meter.
the cost 60 a message, so the pace starts where Google's count is
and is not found by being refused
requests five messages each, three seconds' worth of a 6,000 limit
the share 85% of the limit, where 60% of a count three times too
low had been nearly twice the limit
the probe sets the meter's reading beside the table's 20, not
beside whatever the pacer assumes
On a project allowed 6,000 units that is 85 messages a minute, and the
time left on the import line is reckoned from it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It asked for an account id, which nobody knows and the web interface
does not show. The argument now takes an id or an address, and without
one the command lists the active Gmail accounts and asks which — or, run
without a terminal to ask on, prints the list and stops.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Google's table says a messages.get is 20 quota units; its meter charged
about 59 for each of an import's (#90). The traffic shows no other call
to blame, and Google reports no cost per request, only the minute's
total in the Cloud console. So this makes a minute's total mean
something: a fixed number of stored messages fetched one way — batched
as an import does it, or singly in each format — started just after a
clock minute begins and followed by an empty one. The chart's value for
that minute over the number of requests is the cost of one.
The account's bulk fetching is held while it runs, by a new
GmailQuotaPacer::hold(): the wait a refusal causes without what a
refusal means, so no limit is learnt and no pace is cut. Nothing is
stored and nothing in the mailbox is changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unfinished Gmail import lists the mailbox again every hour and queues
whatever is still missing. On a mailbox that takes most of a day those
ids were already on the queue from the listing before, and the batch
handler fetches everything it is handed — it has to, for a sync's batch,
which names stored ids because their labels changed. So each hour put
another full copy of the outstanding mail on the queue to be fetched in
full again, on the quota the import was short of (#90).
A listing's batch is now marked onlyMissing and asks which of its ids
are stored before it fetches; a batch of ids that have all arrived costs
one lookup. A sync's batch is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-reading the indicator replaced the whole frame, dropdown included,
and opened the new dropdown again. Now that an import re-reads it every
five seconds, somebody watching saw the list go away and come back for
as long as they looked.
With the list open the new rows are fetched and put into the list that
is already there, and the button's label and icon are changed only where
they differ. The frame is still replaced when the list is shut, and when
there is nothing left to show.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An import paced to a learnt 6,000 units a minute had counted 3,240 of
them when Google refused it (#90). The count is this application's and
not Google's: another client on the same quota, a cost reckoned
differently, a limit not enforced as a plain sum over the minute. Which
of those it was is not known.
So a refusal that teaches nothing about the limit is taken as "slower
than this", whatever the count said. The account's pace drops by a
quarter, never below a fifth of its allowance, and wins back two
hundredths of it for every minute without another refusal. Refusals
that pile up inside one pause count once. The time-left estimate
follows the pace.
Each refusal is logged once at info as "Gmail quota: refused", with the
limit name and value Google sent, what had been counted, and the pace
it left — the figures needed to find out why the two counts differ.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The indicator is re-read when the server publishes, and an import
publishes when a page of its listing is done. A Gmail import spends most
of its time fetching what the listing named and says nothing meanwhile,
so the count, the messages a minute and the seconds until Google lets it
continue all stood still.
While the indicator shows an import it is now re-read every five seconds
with the list open and every thirty with it shut; never in a hidden tab,
and never when nothing is importing.
The import line's own test spent more than the pacer's reserve and was
made to wait eight seconds for it; it spends less.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The import line had the quota and the time left, both in units and
minutes. What somebody watching wants to see move is mail: a line with
roughly how many messages were fetched in the minute just gone, worked
out from what the pacer counted. Not shown while Google has the account
paused, when none are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a project allowed 6,000 units a minute an import fetched one batch,
was refused, waited a minute and was refused on the next batch: the
indicator read "paused" beside "about 1,005 of 6,000 units". Fifty
messages in one batch request are 1,000 units in one second, and Google
does not let a per-minute limit be spent that way (#90).
requests ten messages each, the pacer asked before every one;
a refused request stops the rest of its batch, which is
handed back unfetched
the reserve five seconds of allowance instead of twenty, so the
pacer no longer lets a burst through that Google will
refuse
the line while paused it shows what had been counted when Google
refused, not a running count that has fallen to nothing
That a burst is what Google refused is inferred from the figures, not
documented.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b44283cb made a quota refusal "not now" for a batch, an import page and
a sync. Everything else that calls Gmail still threw it to Messenger: a
label change pushed while an import had the quota was retried with a
WARNING and two stack traces per attempt (#90).
GiveUpQuietlyOnThrottling now takes a Gmail quota refusal on the first
attempt, for every message alike: the same message goes back onto the
queue it came from, delayed until the account's minute is over, with one
line at info. The job's own retries are not spent on it. After sixty
such waits in a row the refusal is a failure again and goes to the
retry ladder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Google lowered the per-user limit to 6,000 units a minute for projects
created since May 2026 and raised a messages.get from 5 units to 20.
The pacer had 15,000 and 5 written into it, so what it took for 9,000
units a minute was 36,000: most of what an import asked for was
refused, the log filled with retries, and a refused listing ended the
import's chain at CRITICAL (#90).
the limit starts at 15,000 and is read out of the first 403,
which names the limit and its value; forgotten after
a day so a raised one is found again. Nothing to set.
the cost 20 a message, and fifty messages a batch
shared spend, refusal and limit are in a database-backed
cache pool, so worker-backlog, worker-ingest and the
web container draw on one allowance
a refusal is "not now", not an error: a batch, an import page
or a sync that meets one is put back until the
account's minute is over, and a refused sync still
restarts an import that has gone quiet
The import line in the background-work indicator shows the limit,
roughly what is in use of it, how long the rest takes, and says so
when Google has paused the account.
No migration: the pool is a key prefix in cache_items.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every page linked the per-account SVG tab icon and, after it, a static 192px wax-seal PNG. Firefox stores an SVG icon at width 65535 and a PNG at its declared size, and picks the smallest icon that is big enough for a tab, so the PNG won and every account saw the wax seal whatever it picked in Settings → Appearance → Logo.
The PNG tab-icon link is gone, so the SVG is the only tab icon. The apple-touch-icon and the manifest keep their PNGs. A browser with no SVG tab-icon support gets no tab icon instead of the wrong one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The button in the top bar is a three-way switch: mail, the calendar beside the mail, the calendar full width. Settings → Calendar → Behaviour has one choice, Calendar button, that can make it a two-way one: All three (the default), Full width only, or Split only.
The position the button leaves out is not removed: a stored one closes to the mail on the first press, and the drag handle still reaches both ends. A mail link clicked while the calendar is full width goes back to the mail when the split is skipped.
One value in the user's settings bag (calendar.switch_skip), included in the config backup. Nothing is added to the database.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Postgres refuses a byte that is not UTF-8, and six places still handed
it one. The message was held back, asked for on each of the next five
syncs and then let go, so it never arrived (#81).
body, text and html webklex skips convertEncoding() for a part
labelled us-ascii, so the decoder that guards
every other body was never asked
Message-ID, never decoded by anything; repaired in
In-Reply-To, MessageIdHelper, which all three go through, so
References a reply still finds what it answers
attachment type, id the same, in persistAttachment()
All of them go past CharsetHelper::ensureUtf8() now, as a subject and a
file name already did. BatchStorageTest stores one message per case
through the real batch path; each fails with its guard taken out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The row's read toggle drew the envelope the conversation is: closed while unread, open once read. So an unread row's "Mark as read" wore the closed envelope, and a read row's "Mark as unread" the open one. The selection toolbar draws the action instead, so the two disagreed.
It only looked like a Promotions problem because that tab is where mail is unread; in a list of read mail the row looked right by accident. The row now matches the toolbar.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AccountCreator hands out Account::$colorIndex from the lowest free palette slot, but OAuthAccountLinker builds its Account without going through it, so every OAuth account kept the column default of 0 and wore the same dot in the sidebar and the same corner mark on every message row.
A new OAuth account now takes the lowest free slot, as a password account does. A reconnect finds the existing row and leaves its colour alone. Accounts connected before this keep the colour they have.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A row went to one line once the list was 36rem wide, which with the reading pane open left the subject about 120px. One line now starts at 75rem. Below that the row is two lines, sender and subject then the preview across the whole row, and under 36rem the three it always was.
The hover actions show on any pointer that can hover, not only in one-line rows. A scheduled draft keeps its snippet beside the badge from 36rem. With the two-line preview setting on, a one-line row no longer wraps its preview.
Co-authored-by: Walid Mani <Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings → Mail → Disposal has one per-user switch, off until somebody turns it on. It decides whether Delete forever exists at all: the flame on a row in Spam and the bin, and a new Delete forever above a selection there. Both purge routes answer 403 while the switch is off.
A selection extended with Select all N empties the whole of Spam or the bin in a background job. Any other view is refused, and the worker checks the switch and each conversation's placement again.
The bin no longer offers Delete above a selection: it trashed mail that was already in the bin, took the rows off the page and they were back on the next load.
The choice lives in the user's settings bag and a config backup carries it. No migration.
Continues #56.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sidebar's Inbox badge could read 1 while Primary, the tab that opens, had nothing unread: the conversation was in Promotions, and the only hint on the strip was a colour on that tab's icon. Each tab now shows how many conversations in it hold unread mail. It is the Inbox badge split by category, so the tabs add up to it, and it is patched in place on sync.
This reverses the documented rule that the "3 new" pill is the only number on a tab. The badge and the bold rows say that there is unread mail and cannot say where.
On for everyone, and a per-user appearance setting switches it off, beside the account corner. One column on the user table; the migration is reversible.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An account's colour was handed out once, when the account was added, and nothing could change it. Every row in Settings → Mail accounts now has a swatch that opens the palette of eight.
It is a control on the row and a route of its own, not a field in the edit form, because that form refuses OAuth accounts and the swatch would have been missing on every Gmail and Outlook account. The value is range-checked rather than wrapped. Two accounts may share a colour. No migration: colorIndex already exists and is already in config backups.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the reading pane on the right, choosing another folder, switching a category tab, turning the page, sorting or searching closed the message being read. All of those swap one Turbo frame, and the mail pane answered every swap by showing the list, which is right while the message covers the list and wrong beside it.
The swap still adopts the new list's URL, so the back arrow returns to the folder that was switched to. With one pane at a time nothing changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The "+" beside Labels and Accounts was on screen all the time, for something done a few times a year. Each now appears while its own heading row is hovered, when it is reached with the keyboard, and always on a device with no hover to reveal it with.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One host held both models. The two jobs often want different machines:
writing wants the largest model somebody can run or rent, and search wants
a small one that is asked constantly, for every query and once for every
message of a mailbox. Writing from a hosted endpoint with search on an
Ollama at home indexes the mailbox on a machine the operator owns and
bills nothing per message, and one host for both could not express it.
Admin → AI has a tick box that gives search its own provider, address,
token and extra headers. AiSettings::connectionFor() returns that
connection for search when the box is ticked, and nothing else had to
change, because every call already asked for the connection of its
feature. The second host is sent only its own token and headers, and none
when it has none. A feature is usable when the host it runs on is filled
in. Test asks both hosts, each for the model that lives on it.
Stored vectors stay filed under the model name alone, so moving search to
a host that serves the same model keeps the index and no stored row is
touched. Five columns on ai_settings, unused until the box is ticked; the
migration is reversible.
The idea of a separate embedding connection comes from #58. Its vector
identity, which hashed the address into every stored row, is left out: it
forced a full re-embed whenever a host changed its address.
Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bearer token is not all that stands in front of a model. A reverse proxy
with basic auth, a zero-trust gateway that wants a client id and a secret,
a routing gateway with a key of its own, a provider that takes a project
by header: each of those made plMail unusable with it.
Admin → AI has an Extra headers box under the token, one `Name: value` per
line. ConnectionHeaders reads them strictly while somebody can still fix
what they typed, and forgivingly on the way to the wire, so a column that
cannot be read costs the headers and not the feature. They are stored
encrypted and ride on AiConnection, so both clients send them on every
request without knowing they exist.
The values are never shown again; the page lists the names on file. An
empty box keeps them, typing replaces them, a tick box removes them, and a
change of address or provider drops them as it drops the token. A typed
Authorization replaces the bearer. The headers the HTTP client writes
itself are refused.
Fixed values only. The idea of extra headers comes from #58; its per-task
session header is left out, because nothing plMail does needs one.
Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The language model had to be an Ollama container. Admin → AI now has a
Provider field, and the second choice is the request format OpenAI
published and nearly everything else has copied: llama.cpp, vLLM, LM
Studio, LiteLLM, the hosted providers, and Ollama itself under /v1.
OpenAiClient speaks three paths, /chat/completions, /embeddings and
/models, and answers in the value objects and error categories
OllamaClient already uses. AiAssistant reads the connection for the
feature being served and picks the client; nothing above it knows there
are two. The body carries only what the format defines, because several
servers answer 400 to a field they do not know. The stream is read a line
at a time, skips comment lines, does not require [DONE], and fails on an
error object delivered inside a 200. Redirects are never followed.
What only Ollama can be asked is not asked of the other dialect:
no keep-alive is sent, the composer is not told to expect a cold start,
Warm up refuses with its own reason, and the panel reads no /api/ps. The
page puts those controls away while the other provider is chosen and
keeps their values.
A stored token is dropped when the provider changes, as it is when the
address does. One column on ai_settings, defaulting to ollama; the
migration is reversible.
The SSE handling, the strict reading of an embedding and the rule that a
key stays with its endpoint come from #58.
Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Admin → AI has a Token field for an Ollama behind a reverse proxy. The
token was stored, encrypted, and never sent: every call site passed the
address alone. A host that asks for one answered 401 to everything while
the page said a token was on file.
The address and the token now travel as one AiConnection, and OllamaClient
scopes its HTTP client to it, so every request carries the header —
including the fallback embedding dialect and the version read behind a
probe. Redirects are not followed once a token is attached.
The token belongs to the address it was typed for. The form drops a stored
token when the address changes and none is typed, in Admin → AI and in the
setup wizard alike; the rule lived in both controllers and is now in the
form. The Test button binds to a copy of the settings, shows the typed
token to the typed address, and asks nobody without a valid CSRF token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed Test or Warm up in Admin → AI names its reason by key and hands
the details to the translation as parameters. The parameters were keyed
`status` and `error`, the translations say `%status%` and `%error%`, and
Symfony replaces by exact key. It found the word inside the placeholder and
left the percent signs standing: "HTTP %403%".
AiProbe and AiWarmUp now hand the template their parameters wrapped. The
bare keys stay as they are for the callers that read them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The emoji picker was almost empty on an iPhone and in Safari: two of nine
category tabs, a handful of emoji scattered through a blank grid. The
compose body names the same family, so emoji typed there were at risk of
the same (#39).
Noto Color Emoji is vendored as COLRv1, which WebKit does not draw. Safari
loaded the font, found the code point in it and painted nothing, and a
browser does not fall through to the next family for a glyph it believes
it has.
The faces are now declared inside `@supports font-tech(color-COLRv1)`.
Where that is false the two families do not exist, and the lists that name
them fall through to the system's emoji.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor offers the next full hour, so the recurring cases tested a
different event depending on when the suite ran. Late in the evening in
October the series sat at 00:00 and "moving all events" put it at 02:00 to
03:00. The agenda those cases count chips in is thirty days long and
reached the end of daylight saving, where that hour happens twice: one
occurrence read "2:00 – 2:00" among the rest reading "2:00 – 3:00", and a
case asserting that every chip matches failed on every run until midnight.
The event is the right one, an hour long; it was the test that depended on
the clock. New events in these cases now start at noon, an hour no zone
changes its clock in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
screenshots.spec.ts seeded the demo mailbox onto the shared per-worker
fixture user, and onto the shared admin for its last picture. The seed does
not add a mailbox, it replaces what the user has: the calendar is wiped and
rewritten, the address book goes, other accounts and custom labels are
removed. Nothing cleaned up afterwards, and the one command the docblock
offered removed the account and neither its labels nor its events.
A retake on the 9th failed live-counts, unread-view, bulk-select-all and
calendar-narrow-week on the 10th — an inbox badge one too high, a demo
birthday as the first all-day chip — and because the demo is dated relative
to the day it is seeded, the failures moved with the clock.
The spec now signs in as two users of its own, TEST_DEMO and
TEST_DEMO_ADMIN, which no other spec knows about. A cleanup in afterAll was
the alternative and could not work: what the seed deletes from a fixture
user is not there to put back.
app:test:seed-demo also refuses a user who already owns a mailbox, before
it writes anything, so the next caller cannot repeat this. It no longer
removes a user's other accounts, which was only ever needed because it was
pointed at somebody else's user.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A first sync used to be the import: every IMAP folder from its oldest
message to its newest, every Gmail message listed before one was fetched,
every Microsoft folder enumerated to its end, all in one job on the queue
new mail arrives on. A large account showed an empty Inbox for most of an
hour and every other account's mail waited behind it (#42).
The sync now only arranges for new mail to be seen. History is
MailImporter's, a page at a time on a new `ingest_backlog` queue with a
worker of its own, newest mail first and the Inbox before anything else;
Spam and the bin come last. Accounts take turns, a page that fails costs
itself, and every sync restarts an import that has gone quiet.
- IMAP walks each folder down from the top in pages of fifty.
- Gmail asks for the newest 200 of the Inbox, then pages the listing.
- Microsoft reads each folder's first delta round a page at a time; a
folder being read is asked only what it received since. Removals that
cannot be judged while a folder is unread are kept and judged later.
- Sync requests a later sync has already covered are skipped.
- A chip beside the search box shows that an import is running and how
far it has got. The indicator never updated on mail pages, because its
controller was not mounted there.
Also from #42: an account can have a separate password for sending,
behind a button under the SMTP fields. One field by default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A multipart message whose header reads `boundary = "…"`, with spaces
around the sign, is legal under RFC 2045 and was unreadable: the
library's pattern wants the sign hard against the word, found no
boundary, and threw "no content found". That is thrown while a page of
mail is being fetched, so it took the page and every page after it,
the mark never moved, and the folder failed at the same message on
every poll until somebody deleted it on the server (#45).
Two changes, because the header is one way to be unreadable and there
will be others.
Boundary ImapConnectionFactory configures a pattern that allows
whitespace on both sides of the sign, takes a quoted value
whole and ends an unquoted one at a semicolon or a space.
The library's pattern with \s* added would not do: it
keeps the space before a following semicolon, and the
boundary then matches no line.
Fetch ImapPagedFetch replaces the library's chunked(). Soft-fail
is on, and the pages are counted from the search rather
than from what was handed over — chunked() counts the
latter, so with one message dropped it asks for empty
pages without end. What each page dropped is handed to
processBatch() beside it and held like any message that
would not store: five syncs, then skipped and logged.
The page is walked in UID order with the unreadable messages standing
where they would have stood. holdForRetry() counts the lowest failure
of a run by the order it is told of them, and in any other order two
such messages would take the counter from each other for good.
ImapConnectionFactory::config() is split out of connect() so a test can
parse a message the way a sync does. One test pins that the library's
own pattern still throws on the report's fixture, so the day it is
fixed upstream is the day that test says so.
Fixes#45
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The radar's parcel extractor only knew German carriers and German and
English wording, so parcel mail from French carriers was refused at the
subject gate or read under the wrong carrier.
Carriers Colissimo, Chronopost, Mondial Relay, Colis Privé, DPD
France and Cainiao by sender domain; Amazon.fr as a
merchant, with its button opening amazon.fr.
Numbers Colissimo's and Cainiao's own shapes. Mondial Relay's
eight bare digits only from Mondial Relay and only after
"colis". Colis Privé's seventeen digits out of the links
of the html part, where alone they appear.
Wording French stage phrases, whole phrases only, with the
negated forms kept from reading as delivered; French
months, weekdays and relative days; day-first slash dates
after a French word for the promise.
Stage ready_for_pickup, for a parcel waiting at a relay point
or locker.
Surveys a carrier's satisfaction survey is refused, since the
newest mail overwrites a card's status.
A competitor's name is context only when no other carrier sent the
mail, and a French S10 number from an unknown shop reads as La Poste.
Not read yet: GLS France and the Pickup relay network, and the
"returned" and "attempt failed" stages. The French tracking links
were built from public URL shapes and not checked against real mail.
No migration.
Review kept three of the French rules from firing on other mail: slash
dates, numbers anywhere in the html part, and "expérience" anywhere in
a subject.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <mail@pluetzner.de>
Mailbox::$isSyncEnabled already decides whether a folder is polled, held
with IDLE and counted by the remote-deletion check, but nothing let a
person change it. Settings → Accounts has a button on every IMAP
account's row that opens the account's folders, each with a switch.
Switching one off stops plMail fetching it; mail already stored stays,
and switching it back on resumes from where it stopped.
The inbox is listed with its switch disabled, and the controller refuses
it too. Gmail and Microsoft accounts sync over their provider's API and
have no folder rows, so neither route exists for them. The path carries
two ids: the account is checked for ownership, then the folder against
that account, then a per-folder CSRF token.
Review added, in the same change:
Tree the list is drawn as the tree the paths describe, with
connector lines, each folder under its nearest listed
ancestor.
Placeholders a \Noselect folder is greyed out with no switch to
press. Mailbox::$isSelectable records what the server
said; one migration adds the column.
Dialog a narrow modal preset and striped rows.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <mail@pluetzner.de>
A per-user Reading pane setting, Off or Right. Right shows the message
list and the open message side by side when the mail card is at least
52rem wide, with a divider that is dragged, moved with the arrow keys or
reset with a double-click. The message's share of the card is stored as
a percentage, 25 to 75, so it keeps its meaning when the calendar pane
opens or is resized. Off is the default and changes nothing.
The mode and the share live in the user's settings bag, not in the
Appearance embeddable: where a message is drawn is not part of a theme
somebody might export. A config backup carries both. No migration.
A message opened by its own address has no list behind it, so beside
the list it is shown next to the Inbox.
Review added, in the same change:
Divider one pixel of layout with the grip laid over it, so no
strip of bare card sits between the list and the line.
Toolbars the list's and the open message's are the same height.
Selection Archive, Delete and Move to stay above a selection; the
other six actions are under More.
Shortcuts a key acts on ticked rows wherever the list is on
screen, and ticking a row with the mouse no longer
leaves every key after it dead.
Settings a Features group with a page each for the reading pane
and the keyboard shortcuts.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tab strip let every tab shrink (min-w-0) while keeping its label on one
line, so on a narrow card the labels ran underneath the next tab's icon.
Tabs now keep their own width and the strip scrolls inside itself, with
tighter padding below the list's @xl breakpoint. A small Stimulus controller
puts the current tab in view, since the strip is re-rendered on every tab
change and would otherwise reset to the left edge.
The strip has no visible scrollbar and a wheel only reports vertical
movement, so with a mouse on a narrow window the tabs past the edge could
not be reached (touch and a trackpad's sideways swipe already worked).
A vertical wheel over the strip now scrolls it sideways, and hands the
wheel back to the page once the strip has run out in that direction.
Covered by a Pixel 5 e2e spec: no tab is squeezed, the last tab is reachable
by scrolling, the current tab is in view on load, and the wheel scrolls the
strip until it runs out.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Immich stores the video half of a Live Photo as an asset of its own with
visibility "hidden", never shows it in its timeline and never makes a
preview for it. A metadata search lists every visibility but "locked"
unless asked for one, so those clips came back as grey placeholder tiles
that could only be attached as a three-second video.
They are dropped when an entry is built rather than filtered in the
request: the request can name one visibility only, and "timeline" would
take archived photos away with the clips. A server that predates the
field sends none, and such an asset is kept. Library, search, album and
person views all share the mapping, and a page of nothing but clips
still carries its cursor so the scroll goes on.
A page can now arrive with no entries and a cursor. The picker said
"empty" for any entry-less listing and rendered no results container,
which is what the next page is appended into, so an all-clips first page
would have swallowed every page after it. "Empty" is said only when
there is no cursor either, with two render tests for the two sides.
Fixes#46
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>