A Gmail import now takes in Spam and Trash, and everything that reads
new mail for its meaning would have read those too. Message::isSetAside()
says whether a message is filed in either, and the pipeline asks it:
ClassifyMailStep no language-model categorisation
the hold not kept out of the tabs awaiting a verdict
that is not coming
ExtractInsightsStep no parcel, invoice or other cards
ExtractEventsStep no events read out of it; a forged invitation
in Spam stays out of the calendar
ProposeEventsStep no proposals
contact harvesting Spam only: a spammer is not a correspondent,
and somebody whose mail was deleted still is
The three read-for-meaning steps share RecentMailPolicy::recentIds(),
which leaves set-aside mail out unless asked. ReadReceiptStep asks:
pairing a receipt with its mail reads nothing, and a receipt filtered
into Spam is still that mail's.
Unchanged: storing, threading, the rule-based categoriser, the user's
filter rules, JMAP state. It applies to every provider, not only Gmail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
72d3956b keeps a message for the Gmail account when nothing else claims
it. One case makes that a duplicate: Gmail collected the message from
another connected account (Gmailify or "check mail from other
accounts"), that account's own sync holds it, and none of its address
survived in the headers — a Bcc'd newsletter, say. The old rule dropped
it; the new one would have stored it a second time.
When a message reaches the Gmail account only because nobody claimed
it, the user's other accounts are asked for the same Message-ID first,
and a copy found there is enriched (gmailId, labels) as a Gmailify
sibling's is. A message whose headers name the Gmail account itself is
not looked up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`app:gmail:skipped` on a real mailbox (#90) showed what the ownership
filter was dropping: the account owner's own mail.
202 sent from "send mail as" addresses (From: pl@pmdevelopment.de)
89 forwarded in from an address not connected here
43 Gmail drafts
14 received, with no recipient header naming anyone
4 addressed to the googlemail.com spelling of the account
6 chats
resolveOwningAccount() kept a message only when its headers named the
account. It now keeps one for the account unless another connected
account has the better claim (a Gmailify sibling named in its headers):
SENT or DRAFT the sibling the From names, else this account
received resolveReceivedAccount()'s answer, else this account;
with Gmailify attribution switched off, still only mail
addressed to this account
CHAT nobody, and recorded as before
GmailAddressFilter reads googlemail.com as gmail.com.
The listing never asked for Spam and Trash although its comment said
it did: messages.list leaves both out without includeSpamTrash, and
getProfile's messagesTotal, the import's total, counts them. How much
of the 11,200 that mailbox was short of this accounts for is not known
yet: its import was still running when it was counted.
A finished import does not list again, so the migration clears the
record of declined messages (chats aside) and every Gmail account's
backfill markers. Each lists its mailbox once more, fetching only what
it does not hold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scopes on Admin → SSO still opened Bitwarden's menu after its placeholder
went, offering a "New identity".
Bitwarden takes the text after a field as part of the field's label, and in
the form theme the help paragraph was the input's next sibling. The Scopes
help explains the `email` scope and says the word twice, which made the
field an identity's email address — whatever autocomplete and data-bwignore
said on the input itself. The diagnosis came from inspecting the live page;
the placeholder removed in the previous commit was a second place the word
stood, not the one that mattered.
form_row now puts the widget in an element of its own, so nothing follows an
input as a sibling. `display: contents` on the wrapper, so the row lays out
exactly as before: measured 8px above and below the input, full width,
unchanged. This covers every field with help text, not this one.
Not checked against Bitwarden itself, which this environment does not have.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reported from Admin → SSO after the last fix: every other field was left
alone, and Scopes still opened Bitwarden's menu, now offering a "New
identity".
Its placeholder read "openid email profile". Bitwarden classifies identity
fields by keywords in a field's attributes, the placeholder among them, and
"email" there is enough — with autocomplete="off" and data-bwignore both on
the field. It was the only field on the page with that word in an attribute.
The placeholder goes; the help text under the field already names the
default scopes.
Not checked against Bitwarden itself, which this environment does not have.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reported from Admin → SSO after the previous fix: Chrome no longer filled
the saved login in on load, but a click in the client secret still listed
every saved password, Bitwarden offered to generate a password into it, and
Bitwarden opened its menu in "Name on the button" and "Scopes" below.
All of it follows from the field being type="password", and three rounds of
attributes each moved the symptom. `off` is ignored by Chrome on a password
field. `new-password` stops the fill and makes Bitwarden read the form as a
sign-up. The vendor opt-outs stop extensions filling and not Bitwarden's
menu opening.
So client secrets, API tokens and mail-server passwords are a new SecretType:
a text input drawn as dots by CSS, never rendered back, not trimmed. With no
password input in the form there is nothing to classify. The sign-in form,
the first administrator's password and the change-password form stay real
password inputs, where offering to save is the point.
Bitwarden's menu in the two text fields had a second cause: they were the
only ones with no `autocomplete` at all, and the two that said `off` were
left alone. The form theme now says `off` for every text field that asks
for nothing.
PasswordManagerIgnore::SECRET goes; nothing uses it. The test added with the
previous fix is replaced by two: a secret renders as masked text and comes
back empty, and no form outside the three login ones uses PasswordType.
Checked in a browser that the field masks and the frame holds no password
input. Not checked: Bitwarden and Chrome's password manager themselves,
which this environment does not have.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reported from Admin → SSO: Chrome put the administrator's own mail address
in the client ID and their password in the client secret.
PasswordManagerIgnore::SECRET exists to stop exactly that, and carries
autocomplete="new-password". Six credential fields wrote
'autocomplete' => 'off' in the array they unioned SECRET onto, and with `+`
the left side wins — so the earlier fix reached the Admin → AI token field,
which spreads the constant, and none of the others. Chrome ignores `off` on a
password field. The new single sign-on form copied the pattern.
The fields no longer set an autocomplete of their own. The two mail account
password fields set new-password twice and lose the duplicate, so one rule
holds everywhere.
A test reads every form for an array that sets `autocomplete` and is unioned
with SECRET, because the mistake is in how the array is written and a new
form can make it without being in any list. Shown to fail with the line put
back.
Not run: the full suite. tests/Form passes apart from the one test that
needs a database, which this run did not have.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plMail could only sign people in with a local password. On an install that
already runs Authentik or Keycloak, that is one more account and password
per person, outside the provider's access policy.
A second authenticator on the `main` firewall, beside the password form.
Both end in the same token for the same User, so the user checker, the login
throttle and TOTP two-factor apply unchanged. Authorization code flow with
PKCE, state and nonce on league/oauth2-client's GenericProvider; endpoints
come from the provider's discovery document.
- Set up under a new Admin → SSO tab; the OIDC_* variables are the fallback
for an install configured from its compose file, and a saved provider wins
whole. This lets an administrator who points it at a provider of their own
sign in as any account without a second factor; the security model says so.
- The ID token's signature is not verified: it only ever comes from the
token endpoint over TLS (OIDC Core § 3.1.3.7 step 6). So the issuer and
endpoints must be https and no request to the provider follows a redirect.
Accepting an ID token from anywhere else needs JWKS verification first.
- An account is linked by (issuer, sub). The address is used for the first
link only, and only when the provider reports it verified.
- Auto-creation is off by default and creates an ordinary account with no
usable password.
- app:user:oidc-unlink forgets a link from the console, for somebody deleted
and re-added at the provider.
Migration: two nullable columns and a unique index on `user`, and a one-row
table `oidc_config`. Nothing backfilled; reversible.
Not run: Playwright on this branch. No automated test makes a round trip
against a real provider; that was checked by hand on a local test stack.
Closes#91
Co-Authored-By: Paul Lützner <mail@pluetzner.de>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat(calendar): widen Happening Soon and put Also spotted beside the list
The panel opened at the narrowest dialog width, so a fortnight of
bookings pushed the undated "Also spotted" cards far down the scroll.
Both openers now ask for the wide dialog, and from the md breakpoint up
the panel is two columns: what is coming up (with "On your radar") on
the left, "Also spotted" on the right. Below md it stacks as before.
The two sections stay direct flex children of the radar scope, so
dismissing the last undated card (which removes its section) lets the
list take the full width instead of leaving an empty column. With
nothing in the main column, "Also spotted" has the panel to itself.
No migration.
docs: changelog entry for the wider Happening soon panel (#83)
fix(calendar): give Also spotted a wider column in Happening soon
At 18rem the cards under Also spotted truncated their titles and pushed
the status chip onto its own line. 24rem fits a typical issue or pull
request title with its chip on one line, checked in a browser with
seeded cards.
Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
A subscribed Google/CalDAV calendar carries the zone its remote account
holds for it, often UTC for a shared one. CalendarTimeResolver::zoneFor()
let that zone win whenever such a calendar was the default, so the whole
page was drawn in UTC: an 11:30 Europe/Paris appointment, stored correctly
as 09:30 UTC, showed at 09:30.
zoneFor() now uses the user's own zone (profile, else install default) when
the default calendar isSynced(); a local calendar keeps its zone.
UpcomingEventIndicator repeated the same decision by reading the default
calendar's column itself, so the top bar's "today" stayed on the remote
clock. It now asks the resolver.
Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
The "(3)" in front of the tab's title appeared on the Inbox and vanished on Settings, the calendar and the admin pages, although the sidebar there still showed the number. Those pages extend the base layout directly and named neither a title prefix nor a title-count-key, so the server rendered no count and the sidebar sync had no key to keep it current.
The base layout now prints the Inbox's count (SidebarCounts::forRoleBadge, the figure the sidebar badge and the Inbox title already use) in a title_prefix block and defaults title-count-key to role:inbox. The mailbox layout switches both off, so every mail folder keeps its own figure or none: the bin still counts a total, and Sent and Archive do not borrow the Inbox's.
Nothing prints for a count of zero or with nobody signed in.
Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
fix(graph): list Outlook attachments with a select Graph accepts
The attachment listing selected contentId unqualified. contentId is declared
on microsoft.graph.fileAttachment, not on the attachment type the collection
is typed as, so Graph rejects the whole $select with a 400. In the $batch the
sync uses, a failed sub-request is skipped, and a skipped listing reads as "no
attachments": every Outlook message with a file on it was stored with none,
and nothing was logged.
Select it through its own type (microsoft.graph.fileAttachment/contentId) in
both listings. contentId stays because it is what tells a cid: image from a
file the user attached.
The sync handler now warns when Graph reports hasAttachments but the listing
returns nothing for a message, so a refused listing is no longer invisible.
Messages already stored keep their missing attachments until re-fetched.
Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
fix(insight): name the carrier of a Pickup relay parcel, not Deutsche Post
The Pickup relay network (network1/network2.pickup.fr) hands over the
parcels of several carriers, so its sender domain names none of them. An
S10-shaped number fell through to the Deutsche Post default, which exists
for German mailboxes, and every such card read "Deutsche Post".
For a relay sender the carrier now comes from the display name when it
starts, as a whole word, with a listed carrier (Chronopost, Colissimo);
that name is then not a shop, so no merchant is set. Any other name
names no carrier: the payload carrier and tracking link are null and the
card is headed "Pickup". What the number states is untouched: an S10
number ending in FR is still La Poste's and Colissimo's own shape is still
Colissimo's. The default for every other sender is unchanged.
A card's identity is its number, so cards already on the radar are
corrected in place the next time their mail is read.
fix(insight): read a parcel collected at a relay as delivered
"Votre colis a été retiré" and "... récupéré par son destinataire" name
no stage the extractor knew, so a collected parcel stayed "announced".
Both now read as delivered, with "colis" required nearby so the same
words about something else do not count.
The negation guard that already stopped "n'a pas été livré" is widened
to retiré and récupéré: "n'a pas été récupéré par son destinataire"
contains the delivered phrase whole.
Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(insight): let an older mail leave a card as a newer one made it
The harvester's upsert lets the last mail it reads overwrite a card's status,
payload, ETA and link, on the assumption that the last one read is the newest.
A sync reads in date order, but the insights backfill walks by id, and the
Graph import stored mail newest-first: a parcel's "out for delivery" mail was
read before its "announced" one, and the older statement overwrote it, so a
re-read left every card on the first status of its series.
A mail strictly older than the one the card already stands on now changes
nothing. Mail of the same instant keeps the old behaviour (the last read
wins), and so does a card whose mail was deleted, which has nothing to be
older than. land() reports whether it wrote, so the backfill's count does not
include skipped drafts.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(insight): window undated cards on their mail's date, not their row's
The undated cards (GitHub activity and the like) are listed for the last seven
days, windowed and ordered on createdAt, the instant the harvester made the
row. That is the mail's age only while the harvester runs as mail arrives. The
insights backfill re-reads old mail and creates its cards now, so a card made
from a month-old notification was a card of the last minute and took a place
among the newest twenty for a week; the Graph import's newest-first ids put
the same distortion into the order.
The query now joins the card's mail and windows and orders on its receivedAt,
which is the newest mail of the series because the card links that one. A card
whose mail was deleted keeps its row's date, the only one it has. createdAt
keeps meaning when the row was created, so nothing is stamped differently and
no migration is needed.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(insight): offer a code that states no expiry for fifteen minutes
OtpExtractor sets a code card's happensAt to its expiry, so the radar drops it
when that passes, and leaves it null when the mail states no lifetime, because
a guessed default would retire a code that might still be good. The radar's
list of undated cards then took such a code like any undated card (GitHub
activity and the like): seven days, with the code readable on the card.
Undated codes are now offered for fifteen minutes of their mail's date instead
of the week. That is a guess about how long a code lives, made on the display
side and not in the extractor, and it errs towards showing too little. The
extractor's decision not to invent an expiry stands, the code is still on the
mail itself, and codes that do state an expiry take the dated path as before.
Every other undated kind keeps its week.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(insight): read an Outlook mail's HTML, not its 255-character preview
For an HTML mail, GraphMessageBuilder stores Microsoft's bodyPreview as
bodyText, and the preview is cut at 255 characters. ReadableBody::of()
returned that text whenever it was non-empty, so every insight extractor saw
only the first 255 characters of an Outlook mail: a tracking number further
down (Pickup, IKEA, GLS France relay notices) was never found.
ReadableBody now reads the HTML for a message that has a graphId, and falls
back to the preview only when the HTML has no words in it. A Graph mail with
a text body has no HTML, so its full text still comes back. Other providers
are unchanged.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* feat(insight): read the match code in GLS France mail
GLS France states no tracking number of a recognisable shape: its mails carry
an eight-character code under the label "Numéro de colis", and gls-france.com
was not a known carrier sender, so no card was ever made for one.
The sender is listed as GLS, and the code is read from its mail only, behind
that label, in capitals, with at least one letter in it: eight bare digits are
Mondial Relay's shape, and a shop that prints the same label is not GLS. The
card links to moncolis.gls-france.com, GLS France's own tracking page; the
eleven-digit gls-group.eu numbers keep their own link.
GLS's survey mail quotes the same code and is still refused.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
The invoice-number pattern announced a number with `invoice\s*(?:number|no\.?|#)`
and then took whatever followed. With no word boundary, "invoice no" matched
the start of "Manage invoice notifications", a footer line in a great many
billing mails, and the number came out as "TIFICATIONS". It is the same for
every bill a sender sends, and the number is the card's identity, so each bill
overwrote the one before it. "invoice numbering" went the same way.
It stayed hidden while a mail's footer was not in the text the extractor read.
Reading an Outlook mail's HTML in full puts the footer there, so the flaw
surfaces for those accounts the moment that lands.
Both words now end at a word boundary. A real "Invoice no. 4711" and "Invoice
number: INV-2026-001" are still read, each pinned by a row of its own.
Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* Keep remember-me tokens on the server, so signing out holds
A remember-me cookie that carries only its own signature stays valid
until it expires, and signing out only asks the browser to delete it.
A request already on its way with it - the page's own background
fetches - reached the server after the sign-out and signed the person
straight back in.
The tokens are kept on the server now (Symfony's DoctrineTokenProvider,
table rememberme_token), one per device. Signing out deletes this
device's token, so a late cookie opens nothing; other devices stay
signed in. Symfony's CacheTokenVerifier keeps parallel requests that
rotate the token from looking like theft.
Existing signed cookies are no longer read: people who relied on "Keep
me signed in" sign in once more.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Say what remember-me is now, wherever it was described
Remember-me moved from a signed cookie to tokens kept on the server, and
five places still said "signature-based, not stored": the handbook's
security page, the config-backup page's account of why APP_SECRET stays
behind, and three comments beside the code that depends on it. English
and German both.
RememberMeAcrossRestoreTest proved its point against SignatureHasher,
which the firewall no longer uses. It now asserts what the property rests
on today: the firewall holds the persistent handler, and a backup taken
while a token row exists carries neither its series nor its value.
One test added for the claim the docs repeat: a changed password ends the
cookie, because the token carries a digest of the hash it was issued under.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <mail@pluetzner.de>
An import sat at 21,964 of 24,199 and said "waiting" (#90). The 2,235
are messages the batch handler fetches and does not store: its headers
tie the message to no account here (a draft, a chat, mail sent from a
"send mail as" address), or Gmail lists it and answers 404.
Declining left a debug line and nothing else, and the import's only test
for "still to come" is "not stored". So:
every hourly listing named all 2,235 again, and each was fetched in
full again: 134,100 units of the 360,000 an
hour a 6,000 project has
the import could only end at BACKFILL_MAX_ATTEMPTS, a day
of listings later; the account in the report
was on attempt 18
the progress line could never reach Google's total, which counts
them
gmail_skipped_message records each one when it is declined: the reason,
its labels and its From, To, Cc and Delivered-To.
newGmailIds() treats a recorded id like a stored one, so a
listing with nothing else outstanding settles
at once
an onlyMissing batch queued by an earlier listing drops them too
ImportProgress counts them as done
app:gmail:skipped prints the record by kind, label and address;
--forget clears it so the next listing asks
again
A row goes when its message is stored after all.
Whether some of these messages ought to be stored is a separate
question and is not touched: the filter decides exactly as before. The
record is what that question can now be answered from.
Migration: one new, empty table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both import consumers sat twenty minutes and more inside one fetch from
a server delivering a few kilobytes every twenty seconds (#95). The
thirty-second read timeout is per read and a trickle never trips it;
nothing timed the command. A consumer handles one message at a time, so
every other account's import waited behind it, and a stop signal did
nothing: a worker stops between messages.
ImapDeadline runs an alarm around every message a worker handles
(AbortStalledImap, for a received envelope only). When it goes off, the
IMAP connections opened since are shut down under their reader. The
read is at end-of-file at once, the library reports a lost connection,
and the message takes its retry ladder like any other network failure.
not an exception thrown from a signal handler it would land in the
from the alarm middle of a flush as readily as a read
shutdown, not webklex's logout() and destructor call
fclose stream_get_meta_data() on the stream and die with
a TypeError on a closed resource
a total, not a so it has to fit honest work: five minutes, which
rate is seconds for an ordinary page of fifty messages
and tight only for ~300 MB of attachments on a
slow line. Wrong, it shows as the class's warning
repeating for one folder
Checked against imap.gmail.com over TLS: after the shutdown a read
returns empty in under a millisecond with eof set.
The IDLE process holds its connection for twenty-nine minutes on
purpose and is not a worker handling a message; it is never armed.
Also the changelog for #94 and #96.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>