Commit Graph
1253 Commits
Author SHA1 Message Date
Paul LütznerandClaude Opus 5.5 fded408d31 Keep Spam and Trash without working on them
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>
2026-10-14 09:16:36 +02:00
Paul LütznerandClaude Opus 5.5 cdc0ce2637 Merge an unclaimed Gmail message into another account's copy
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>
2026-10-14 09:16:36 +02:00
Paul LütznerandClaude Opus 5.5 72d3956b0d Keep every Gmail message in the mailbox it is in, Spam and Trash included
`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>
2026-10-14 08:54:12 +02:00
Paul LütznerandClaude Opus 5.5 ff87ee92d7 Release v0.3.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.3.5
2026-10-13 23:08:49 +02:00
Paul LütznerandClaude Opus 5.5 12faf4d8bd Keep help text from following an input, so Bitwarden stops reading it as a label
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>
2026-10-13 22:59:15 +02:00
Paul LütznerandClaude Opus 5.5 28cce0c9c3 Drop the placeholder that made Bitwarden take Scopes for an email field
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>
2026-10-13 22:52:21 +02:00
Paul LütznerandClaude Opus 5.5 855d0faedc Draw pasted secrets as masked text fields instead of password inputs
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>
2026-10-13 22:46:49 +02:00
Paul LütznerandClaude Opus 5.5 cc7b00311e Send new-password on the secret fields that were overriding it
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>
2026-10-13 22:33:53 +02:00
09c503c61c Sign in through an OpenID Connect provider (Authentik, Keycloak…) (#106)
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>
2026-10-13 22:17:00 +02:00
8efe6d6621 Widen Happening soon and put Also spotted beside the list (#83)
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>
2026-10-13 18:30:19 +02:00
b793faccff fix(calendar): read the calendar page in the user's zone when the default calendar is synced (#105)
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>
2026-10-13 18:28:10 +02:00
5e42e067ab feat(mail): show the Inbox unread count in the tab title on pages that are not mail folders (#72)
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>
2026-10-13 18:25:46 +02:00
f9eaa737c6 Fix Outlook attachments: cast contentId in the attachment listing (#77)
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>
2026-10-13 18:19:30 +02:00
f28e8cd044 fix(insight): name the right carrier on a Pickup relay parcel, and read a collected one as delivered (#103)
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>
2026-10-13 18:17:42 +02:00
938d549347 fix(insight): re-reading old mail no longer rewrites newer cards or fills Happening soon (#101)
* 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>
2026-10-13 18:16:17 +02:00
aeb36afe92 fix(insight): read an Outlook mail's HTML, not its 255-character preview, and read GLS France's code (#102)
* 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>
2026-10-13 18:15:01 +02:00
fc1448ea1b fix(insight): read "invoice no" and "invoice number" as whole words (#100)
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>
2026-10-13 18:14:12 +02:00
4d705a56da Keep remember-me tokens on the server, so signing out holds (#104)
* 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>
2026-10-13 17:18:43 +02:00
Paul LütznerandClaude Opus 5.5 6f24bfabc3 Write down the Gmail messages an import declines, and stop fetching them again
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>
2026-10-13 14:49:22 +02:00
Paul LütznerandClaude Opus 5.5 b67e6dfd2b Close an IMAP conversation a worker has spent five minutes on
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>
2026-10-13 14:28:53 +02:00
Paul LütznerandClaude Opus 5.5 a541feceea Take "OK, and nothing" from an IMAP server as an answer, not a failure
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>
2026-10-13 14:16:50 +02:00
Paul LütznerandClaude Opus 5.5 6c018ded6d Reconnect an IDLE the server closed, instead of spinning on it
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>
2026-10-13 14:16:50 +02:00
Paul LütznerandClaude Opus 5.5 6eaa33a534 Tell the sidebar whenever mail changes, instead of when a button remembers to
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>
2026-10-13 13:58:38 +02:00
Paul LütznerandClaude Opus 5.5 be2e06ff59 Name the reader "me" in the list for a reply sent from a catch-all address
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>
2026-10-13 13:58:38 +02:00
Paul LütznerandClaude Opus 5.5 363f7d8204 Split a merged event's flat month bar side by side, not top to bottom
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>
2026-10-13 13:51:17 +02:00
Paul LütznerandClaude Opus 5.5 439b1196b7 Keep the sender on a draft an app saves
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>
2026-10-13 13:12:31 +02:00
Paul LütznerandClaude Opus 5.5 cce57f1f75 Say in the changelog that a catch-all address answers invitations and is not the whole domain
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:59:36 +02:00
Paul LütznerandClaude Opus 5.5 cadab57a9b Count a catch-all address as the user's only for a mail it arrived by
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>
2026-10-13 12:52:31 +02:00
Paul LütznerandClaude Opus 5.5 f954c4d69d Tell a JMAP client which identity answers a mail, and let it send as a catch-all address
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>
2026-10-13 12:50:37 +02:00
Paul LütznerandClaude Opus 5.5 76bf97c82e Answer from the address a mail came in for, under its own name, and take a signature as HTML
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
                             (&#61; 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>
2026-10-13 12:39:40 +02:00
Paul LütznerandClaude Opus 5.5 780ed01de9 Release v0.3.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.3.4
2026-10-13 11:44:52 +02:00
Paul LütznerandClaude Opus 5.5 fdb582572f Stop a queue-monitor test from failing when the clock has a 99 in it
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>
2026-10-13 11:31:43 +02:00
Paul LütznerandClaude Opus 5.5 748f0f302f Count a Gmail message fetched in full at 60 quota units, as Google bills it
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>
2026-10-13 10:55:27 +02:00
Paul LütznerandClaude Opus 5.5 ec6199ca74 Let the quota probe say which Gmail accounts there are
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>
2026-10-13 10:12:02 +02:00
Paul LütznerandClaude Opus 5.5 350c71a9dd Add app:gmail:quota-probe, to read off what Google charges for a fetch
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>
2026-10-13 10:03:13 +02:00
Paul LütznerandClaude Opus 5.5 ea52678a0b Do not fetch again what an import's earlier listing already brought in
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>
2026-10-13 09:57:52 +02:00
Paul LütznerandClaude Opus 5.5 73ee0525b9 Update the open background-work list where it stands, so it does not flash
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>
2026-10-13 09:57:52 +02:00
Paul LütznerandClaude Opus 5.5 a0c45f9547 Slow an account down when Google refuses it at a limit already known
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>
2026-10-13 09:42:24 +02:00
Paul LütznerandClaude Opus 5.5 0235d9f4d3 Re-read the import line on a timer, so its figures move while watched
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>
2026-10-13 09:29:51 +02:00
Paul LütznerandClaude Opus 5.5 d06125bbf4 Show how many messages a minute a Gmail import is bringing in
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>
2026-10-13 09:20:04 +02:00
Paul LütznerandClaude Opus 5.5 95f3291911 Fetch Gmail messages ten to a request, not a minute's worth in a second
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>
2026-10-13 09:12:21 +02:00
Paul LütznerandClaude Opus 5.5 9286c351fc Put any job Gmail refuses on quota back quietly, not only the import's
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>
2026-10-13 09:03:57 +02:00
Paul LütznerandClaude Opus 5.5 b44283cb1c Learn Gmail's quota from Google's refusal instead of assuming 15,000
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>
2026-10-13 08:30:17 +02:00
Walid ManiandClaude Opus 5.5 5debbdbc01 Link one tab icon so the chosen one is not outvoted by the wax seal (#74)
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>
2026-10-12 13:49:49 +02:00
Walid ManiandClaude Opus 5.5 2a765d8b32 Let the calendar button skip the split view or the full-width calendar (#80)
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>
2026-10-12 13:23:38 +02:00
Paul LütznerandClaude Opus 5.5 1e582bcedb Store a message whose id, body or attachment carries a raw 8-bit byte
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>
2026-10-12 11:26:25 +02:00
Walid ManiandClaude Opus 5.5 f2ec124c8d Draw the action, not the state, on a row's read button (#61)
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>
2026-10-12 10:51:45 +02:00
Walid ManiandClaude Opus 5.5 ed76af23a5 Give a connected Gmail or Microsoft account its own colour (#60)
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>
2026-10-12 10:37:39 +02:00
6c719b3327 Give the mail row two lines between a phone's three and a wide list's one (#62)
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>
2026-10-12 10:19:17 +02:00
Walid ManiandClaude Opus 5.5 f19818fa50 Put the Delete forever buttons behind a Disposal setting, off by default (#71)
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>
2026-10-11 16:01:23 +02:00