Commit Graph
214 Commits
Author SHA1 Message Date
Philippe Delodder cbe14ea5ae Drop a header block the server repeats at the top of the body (#177)
Closes #173.

Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
2026-10-16 18:48:07 +02:00
Paul LütznerandClaude Opus 5.5 57ea27490e A label a rule applies to new mail stays on its conversation (#185)
Rules run after threading and before the batch's flush, and ThreadLabelSynchronizer took the union over $thread->messages, which did not yet hold the message the threader had just attached. A rule's label was taken straight off the conversation again. The sync now counts the messages that point at the thread in memory (collection, identity map and pending inserts).

Closes #184

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:29:20 +02:00
7a5a7350bf A conversation no longer splits when its replies are synced first (#176)
Threading looked only from a reply to the message it answers, so replies synced before that message (INBOX before Sent Items) opened threads of their own and stayed there. MessageThreader now also looks the other way: after a message gets its thread, mail that already names its Message-ID in In-Reply-To or References is folded in by the new ThreadMerger. The older thread survives, keeping its JMAP id, summary, insights, star, snooze, labels and pinned tab. A GIN expression index makes the reverse lookup an index probe.

Review follow-up: moved mail is added to the surviving thread's collection so a label rule later in the same batch keeps its labels, plus tests for that, bracketed legacy references and provider-keyed survivors.

Closes #172

Co-authored-by: Philippe Delodder <philippe@delodder.be>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:11:37 +02:00
Paul LütznerandClaude Opus 5.5 0fd9e84a11 What's new: the ? menu above the calendar, main builds, and feedback with a screenshot (#167)
* The ? menu draws above the calendar, and a main build announces its newest release

The menu was an ordinary dropdown inside the header's z-20 stacking
context, so the calendar's day headings painted over it. It now opens in
the top layer through ui--user-menu, like the account menu, and closes
when an item is chosen.

A build that is not a tag (main, sha-…, a checkout) counted as no
release, so installs that follow main never saw the dialog. It now
counts as the newest tagged release in its own changelog; Unreleased is
still never shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Feedback with a screenshot stays in the dialog, whatever comes back

Sending with a screenshot closed the dialog without the sent screen. The
form is now posted with fetch() and its answer drawn into the dialog;
anything that is not the dialog (a proxy refusing a large body, a server
error) is said in the form with its status, and the dialog stays.

The screenshot is a JPEG at most 1600 px wide instead of a full-size
PNG, a fraction of the size, so a reverse proxy's default body limit is
far away. The From is the first field of the account that is an
address; an account with none is refused in the form instead of
throwing at send time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Find the open conversation's toolbar by type in the keyboard-shortcuts spec

The spec looked for the conversation by id alone, and the messages
inside it carry the same attribute with their own ids. On CI a message
id equalled the conversation's, so two elements matched and the strict
locator failed on the first try and the retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:21:10 +02:00
Paul LütznerandClaude Opus 5.5 994c00b5cd What's new opens after an update, with a way to send feedback (#166)
* What's new opens after an update, with a way to send feedback

The release notes from CHANGELOG.md, read from the image, in a dialog that
opens by itself the first time somebody uses plMail after an update and
never on a fresh install. A ? in the top bar opens it again, with a dot
while something is unseen; "Don't show this after updates" silences it,
and Settings -> General -> What's new undoes that. Highlights are marked
in the changelog with an HTML comment and may carry a screenshot taken by
tests/e2e/whats-new-screenshots.spec.ts.

Send feedback is a mail from one of the person's own accounts to
APP_FEEDBACK_TO (feedback@pluetzner.de by default), with an optional
rating and a screenshot of the page behind the dialog.

Existing users are recorded as having seen 0.3.1 by a migration; anyone
created later starts on the release running at their first request.

Closes #165.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Plain changelog headlines from 0.3.2 on, and highlights with screenshots

Every bold headline users see under "New to you" now says what changed in
a few words. Each release gets a handful of highlights, eight of them
with a picture cropped to the card or pane they are about, taken by
tests/e2e/whats-new-screenshots.spec.ts.

Relative links in the notes keep their words and lose the target, and
the parsed changelog's cache key carries a format version so a new
image never serves the previous one's parse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep highlight pictures for ten releases, and none that nothing names

Every file in public/whats-new ships in every image. ChangelogReaderTest
now fails on a picture no highlight names, and on one whose release is
more than ten back; the release pull request that crosses the line
deletes it and leaves the entry a highlight without a picture.
Documented in CONTRIBUTING.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Credit #166 in the changelog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Match the Back button by its whole name in the back-navigation specs

Playwright matches a string name as a substring, so "Back" also found the
new "Help and feedback" button in the top bar, which comes first, and the
specs opened its menu instead of going back to the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:17:57 +02:00
550dd9cf89 Pull the mail list down to sync on a phone (#163)
Pulling the mail list down from its top, with a finger below md, runs the topbar's sync. A shared touch-gesture module decides what a touch is and who owns it, for the row swipes and long-press to come. Mouse, trackpad and wider windows are unchanged.

Takes over #115.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:29:01 +02:00
b940bd3a31 The installed app says New mail only when mail arrived (#162)
The in-app Web Push device is now told only when mail lands in the Inbox of a mailbox that has finished importing, and Outlook stops recording folder moves for messages that did not move. JMAP clients and FCM phones keep every StateChange. Migration: jmap_push_subscription.arrivals_only, set once for in-app devices.

Takes over #112.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:12:06 +02:00
280f58b7b5 feat(mail): switching category tabs replaces the history entry, as in Gmail (#161)
* feat(mail): replace the history entry when switching category tabs on a phone

Below md, a category tab navigates the inbox list frame with Turbo's
replace action, so the browser's Back leaves the inbox instead of
stepping back through every tab. At md and up the link keeps advance,
so desktop behaviour is unchanged.

The choice is made on click by the ui--tab-history controller, not on
connect: the tab strip is morphed on every list refresh, and a morph
removes attributes the server did not render.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* feat(mail): category tabs replace the history entry at every width

#150 asked for this on a phone only; the maintainer wants it everywhere,
as Gmail does. With no breakpoint to ask about, the tab links render
data-turbo-action="replace" from the server, so the click-time controller
goes: a server-rendered attribute survives the strip's morph on its own.
The account page's tab strip, added on main since, gets the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): credit the tab history entry to #161, which took over #113

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 14:44:47 +02:00
Paul LütznerandClaude Opus 5.5 ffbf7bdd9b Clean up the compose files (#160)
Compose files keep only a short header; the reasoning lives in
docs/install. truenas.compose.yaml is gone (the TrueNAS catalog app
replaces it) and so is compose.prod.yaml (compose.yaml already runs the
published prod image). compose.override.yaml.dist becomes compose.dev.yaml,
passed with -f, so there is no untracked copy that compose loads on its
own. Docs, CODESTYLE, CONTRIBUTING and the entrypoint's error text follow.

Every remaining file resolves to the same `docker compose config` as
before.

Closes #30

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:43:22 +02:00
1b04e697f6 All Mail: every conversation outside Spam and the bin, as an opt-in system label (#57)
Archived mail had no list to be browsed in, only search. All Mail lists every conversation outside Spam and the bin (what arrived, what was sent, drafts and archived mail), as Gmail's All Mail does, across the reader's active accounts or within one.

It is LabelRole::AllMail, a view and not a place, like Unread: nothing attaches it to mail, it is created hidden when label settings is opened, it is bound to no account and it refuses moves. Its eye in Settings → Mail → Labels shows or hides both the row beside Drafts and the row at the top of each account's folders. Archiving or snoozing from it leaves the row in place. One list scope, `all_mail`, with the account id as its value or empty for every account; AllMailView holds the rules the row buttons, the bulk toolbar, Move to and select-all ask.

No migration.

Co-authored-by: shady2k <3366415+shady2k@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:36:25 +02:00
210fae090e Show the category tabs on an account's own inbox, behind a setting that is off by default (#55)
Clicking an account in the sidebar opened a plain list, so on an Outlook account (or any account) the category tabs were only on the top-level Inbox. An account's page can now draw the same tabs, the reader's own included, chosen by the same rule as the unified inbox and counted from that account's mail alone. The strip is plainer than the unified one: no "new" pills, sender hints, unread tint or drop targets, since those count every account. It scrolls the open tab into view on a phone.

Settings → Mail → Categories has a new "Tabs on each account" switch, off by default. Off, an account's page stays its whole inbox in one list, as before, and a stale `?tab=` link opens that too. The switch is offered only while "Inbox tabs" is on. Stored in the user's settings bag and carried by a config backup. No migration.

Co-authored-by: Walid Mani <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:37:10 +02:00
Paul LütznerandClaude Opus 5.5 0591d9a854 Settings → Mail → Images: always show remote images (#124)
One per-user switch, off until somebody turns it on. With it on,
MessageRenderer treats every sender as if it were on the image allowlist,
so no message asks before loading its pictures. They still come through the
image proxy, so a sender learns that a message was opened but not the
reader's address. Mail in Spam stays blocked, as it does for trusted senders.

Kept apart from the per-sender allowlist: no "Stop showing" line for a
sender nobody trusted, and switching it off leaves those entries as they
were. Stored in the user's settings bag and carried by a config backup.
No migration.

Closes #124.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:00:44 +02:00
Paul LütznerandClaude Opus 5.5 0139616c57 Document the inbox tabs for client authors, backups and the ingest internals
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:46:07 +02:00
Paul LütznerandClaude Opus 5.5 bdffac32e4 Describe the tab page, switching tabs off and their order in the handbook
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:53:59 +02:00
Paul Lützner 7898182e00 Merge branch 'pr-87' into review-87
# Conflicts:
#	docs/de/features/mail.md
#	docs/de/install/config-backup.md
#	docs/de/internals/mail-ingest.md
2026-10-14 10:42:46 +02:00
Paul LütznerandClaude Opus 5.5 b4b4eb1a72 Read French mail as German and English mail is read
The French interface came with a note that appointments in French mail
were left for later. They are read now, for every user, since the
interface language says nothing about the language of the mail:

- The date detector and its gate know the French months and weekdays,
  "1er", 14h30 / 14 h 30 / "à 9h" / "vers 15 heures" / midi, demain,
  après-demain (before demain, as übermorgen before morgen),
  aujourd'hui, ce soir, and "mardi prochain" either way round. A bare
  single-digit "2h" is not a time: beside a date it is a length.
- A French reader reads 04/08/2026 day first, as a German one does.
- French quote attributions ("… a écrit :") and forwarded headers
  ("Envoyé :", "Message transféré") are not appointments.
- Durations: "2 heures", "une heure".
- Invoices: facture, net à payer, montant dû, échéance, numéro de
  facture, and "Total HT" is not the total.
- Login codes: code de vérification and its relatives, "valable 10
  minutes", and French order and customer numbers are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:03:22 +02:00
Paul Lützner 245a84facf Merge branch 'pr-110' into review-110
# Conflicts:
#	CHANGELOG.md
2026-10-14 09:49:38 +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
jo2904andClaude Opus 5.5 5550727888 AppLocale : proposer le français
Ajoute 'fr' à AppLocale et à framework.enabled_locales, qui doivent
rester synchronisés (le commentaire de translation.yaml le dit). Le
sélecteur de langue des réglages et celui de l'installation lisent
AppLocale::cases() : « 🇫🇷 Français » y apparaît sans autre
modification.

Deux réglages suivent la langue et devaient connaître le français :
- ClockFormat::forLocale() : le français écrit 14 h 00, donc 24 heures
  par défaut comme l'allemand. Sans ça, un utilisateur francophone qui
  n'a rien choisi verrait « 2 PM ». Testé dans ClockFormatResolverTest.
- Le sélecteur d'emoji charge ses noms et mots-clés par langue (la
  recherche « cœur » ne trouve rien dans les données anglaises).
  assets/emoji/fr/data.json vient de emoji-picker-element-data, comme
  en/ et de/. J'ai vérifié que ces deux fichiers sont identiques octet
  pour octet à ceux publiés par le paquet, donc fr/ vient bien de la
  même source. Le gabarit choisit maintenant de, fr ou en d'après la
  langue au lieu du seul test « starts with 'de' ».

Laissé tel quel : DeterministicDateDetector, qui lit les dates dans le
contenu des mails selon leur langue, et non la langue de l'interface.
Apprendre les dates françaises (« le 4 août ») est un autre chantier.

Docs : README, features/appearance.md (et sa traduction allemande, avec
le hash mis à jour), et une entrée de CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
2026-10-13 23:33:38 +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
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
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
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 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 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 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 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 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
maniandClaude Sonnet 5.5 c795eeeee1 fix(mail): re-resolve what a deleted tab held, and say exactly what the guard guarantees
Removing a category cleared the pins of the conversations in it but left them
in whatever built-in tab they had when they were dragged, until their next
message; the removal now re-resolves exactly those conversations, in the same
transaction. The "wrong tab" report is no longer offered for a conversation in
a tab the reader made: it is evidence about plMail's rules, and "should be
Promotions" was refused there as already true.

The inbox keeps its own two query ceilings (28 and 16) while the other lists
keep 27 and 15, so one view's extra read cannot loosen five guards. The two
partial indexes are declared in the mappings so Doctrine agrees with the
migration. A restored category name is trimmed and cut to the column.

The docs, changelog and the filer's own docblock now state the guard's limits:
it checks the message, not the conversation; it needs a filter that names the
sender; and a filter that does not name one files any sender that
authenticates for its own domain. Dead delegating methods, a reference to a
class that does not exist and two stale comments are removed or corrected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:16 +02:00
maniandClaude Sonnet 5.5 44d5cefec5 docs: describe inbox tabs of your own and the filter action that fills them
The filters page gets the action, a worked carrier example with the marketing
exclusion, what the action will and will not file (authenticated senders only;
on Gmail only when plMail is told to sort over Google; nothing but a tab) and
three "Things that bite". The mail page gets "Tabs of your own", the backup page
the new rows, the ingest page why the verdict has a column of its own, and the
JMAP README why the five-value vocabulary is untouched. German pages follow with
their hash markers refreshed. CHANGELOG notes the migration and the one extra
inbox read.

Also pins, in the JMAP category test, that a conversation in a reader's own tab
is still served as its built-in category and that a custom key is refused.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:15 +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
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
Walid ManiandClaude Opus 5.5 99d2da3da2 Show each inbox tab's unread count, with a setting to switch it off (#54)
The sidebar's Inbox badge could read 1 while Primary, the tab that opens, had nothing unread: the conversation was in Promotions, and the only hint on the strip was a colour on that tab's icon. Each tab now shows how many conversations in it hold unread mail. It is the Inbox badge split by category, so the tabs add up to it, and it is patched in place on sync.

This reverses the documented rule that the "3 new" pill is the only number on a tab. The badge and the bold rows say that there is unread mail and cannot say where.

On for everyone, and a per-user appearance setting switches it off, beside the account corner. One column on the user table; the migration is reversible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:49:58 +02:00
Walid ManiandClaude Opus 5.5 92116fcdcb Change an account's colour from its row in settings (#69)
An account's colour was handed out once, when the account was added, and nothing could change it. Every row in Settings → Mail accounts now has a swatch that opens the palette of eight.

It is a control on the row and a route of its own, not a field in the edit form, because that form refuses OAuth accounts and the swatch would have been missing on every Gmail and Outlook account. The value is range-checked rather than wrapped. Two accounts may share a colour. No migration: colorIndex already exists and is already in config backups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:45:44 +02:00
Walid ManiandClaude Opus 5.5 ff1344ceb9 Keep the open message open when the list beside it changes (#63)
With the reading pane on the right, choosing another folder, switching a category tab, turning the page, sorting or searching closed the message being read. All of those swap one Turbo frame, and the mail pane answered every swap by showing the list, which is right while the message covers the list and wrong beside it.

The swap still adopts the new list's URL, so the back arrow returns to the folder that was switched to. With one pane at a time nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:43:45 +02:00
88197578d5 Let search by meaning use a model host of its own
One host held both models. The two jobs often want different machines:
writing wants the largest model somebody can run or rent, and search wants
a small one that is asked constantly, for every query and once for every
message of a mailbox. Writing from a hosted endpoint with search on an
Ollama at home indexes the mailbox on a machine the operator owns and
bills nothing per message, and one host for both could not express it.

Admin → AI has a tick box that gives search its own provider, address,
token and extra headers. AiSettings::connectionFor() returns that
connection for search when the box is ticked, and nothing else had to
change, because every call already asked for the connection of its
feature. The second host is sent only its own token and headers, and none
when it has none. A feature is usable when the host it runs on is filled
in. Test asks both hosts, each for the model that lives on it.

Stored vectors stay filed under the model name alone, so moving search to
a host that serves the same model keeps the index and no stored row is
touched. Five columns on ai_settings, unused until the box is ticked; the
migration is reversible.

The idea of a separate embedding connection comes from #58. Its vector
identity, which hashed the address into every stored row, is left out: it
forced a full re-embed whenever a host changed its address.

Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:11:45 +02:00
cd7828395d Send the model host extra headers
A bearer token is not all that stands in front of a model. A reverse proxy
with basic auth, a zero-trust gateway that wants a client id and a secret,
a routing gateway with a key of its own, a provider that takes a project
by header: each of those made plMail unusable with it.

Admin → AI has an Extra headers box under the token, one `Name: value` per
line. ConnectionHeaders reads them strictly while somebody can still fix
what they typed, and forgivingly on the way to the wire, so a column that
cannot be read costs the headers and not the feature. They are stored
encrypted and ride on AiConnection, so both clients send them on every
request without knowing they exist.

The values are never shown again; the page lists the names on file. An
empty box keeps them, typing replaces them, a tick box removes them, and a
change of address or provider drops them as it drops the token. A typed
Authorization replaces the bearer. The headers the HTTP client writes
itself are refused.

Fixed values only. The idea of extra headers comes from #58; its per-task
session header is left out, because nothing plMail does needs one.

Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:01:54 +02:00
3c7a03b843 Talk to an OpenAI-compatible endpoint as well as Ollama
The language model had to be an Ollama container. Admin → AI now has a
Provider field, and the second choice is the request format OpenAI
published and nearly everything else has copied: llama.cpp, vLLM, LM
Studio, LiteLLM, the hosted providers, and Ollama itself under /v1.

OpenAiClient speaks three paths, /chat/completions, /embeddings and
/models, and answers in the value objects and error categories
OllamaClient already uses. AiAssistant reads the connection for the
feature being served and picks the client; nothing above it knows there
are two. The body carries only what the format defines, because several
servers answer 400 to a field they do not know. The stream is read a line
at a time, skips comment lines, does not require [DONE], and fails on an
error object delivered inside a 200. Redirects are never followed.

What only Ollama can be asked is not asked of the other dialect:
no keep-alive is sent, the composer is not told to expect a cold start,
Warm up refuses with its own reason, and the panel reads no /api/ps. The
page puts those controls away while the other provider is chosen and
keeps their values.

A stored token is dropped when the provider changes, as it is when the
address does. One column on ai_settings, defaulting to ollama; the
migration is reversible.

The SSE handling, the strict reading of an embedding and the rule that a
key stays with its endpoint come from #58.

Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:54:49 +02:00
Paul LütznerandClaude Opus 5.5 351c92b8a3 Send the model host its token
Admin → AI has a Token field for an Ollama behind a reverse proxy. The
token was stored, encrypted, and never sent: every call site passed the
address alone. A host that asks for one answered 401 to everything while
the page said a token was on file.

The address and the token now travel as one AiConnection, and OllamaClient
scopes its HTTP client to it, so every request carries the header —
including the fallback embedding dialect and the version read behind a
probe. Redirects are not followed once a token is attached.

The token belongs to the address it was typed for. The form drops a stored
token when the address changes and none is typed, in Admin → AI and in the
setup wizard alike; the rule lived in both controllers and is now in the
form. The Test button binds to a copy of the settings, shows the typed
token to the typed address, and asks nobody without a valid CSRF token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:42:26 +02:00
Paul LütznerandClaude Opus 5.5 896f30eb02 Import an account's history without holding up new mail
A first sync used to be the import: every IMAP folder from its oldest
message to its newest, every Gmail message listed before one was fetched,
every Microsoft folder enumerated to its end, all in one job on the queue
new mail arrives on. A large account showed an empty Inbox for most of an
hour and every other account's mail waited behind it (#42).

The sync now only arranges for new mail to be seen. History is
MailImporter's, a page at a time on a new `ingest_backlog` queue with a
worker of its own, newest mail first and the Inbox before anything else;
Spam and the bin come last. Accounts take turns, a page that fails costs
itself, and every sync restarts an import that has gone quiet.

- IMAP walks each folder down from the top in pages of fifty.
- Gmail asks for the newest 200 of the Inbox, then pages the listing.
- Microsoft reads each folder's first delta round a page at a time; a
  folder being read is asked only what it received since. Removals that
  cannot be judged while a folder is unread are kept and judged later.
- Sync requests a later sync has already covered are skipped.
- A chip beside the search box shows that an import is running and how
  far it has got. The indicator never updated on mail pages, because its
  controller was not mounted there.

Also from #42: an account can have a separate password for sending,
behind a button under the SMTP fields. One field by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:43:03 +02:00
9ed0440bc7 Read parcels from French carriers and Amazon.fr (#50)
The radar's parcel extractor only knew German carriers and German and
English wording, so parcel mail from French carriers was refused at the
subject gate or read under the wrong carrier.

  Carriers   Colissimo, Chronopost, Mondial Relay, Colis Privé, DPD
             France and Cainiao by sender domain; Amazon.fr as a
             merchant, with its button opening amazon.fr.
  Numbers    Colissimo's and Cainiao's own shapes. Mondial Relay's
             eight bare digits only from Mondial Relay and only after
             "colis". Colis Privé's seventeen digits out of the links
             of the html part, where alone they appear.
  Wording    French stage phrases, whole phrases only, with the
             negated forms kept from reading as delivered; French
             months, weekdays and relative days; day-first slash dates
             after a French word for the promise.
  Stage      ready_for_pickup, for a parcel waiting at a relay point
             or locker.
  Surveys    a carrier's satisfaction survey is refused, since the
             newest mail overwrites a card's status.

A competitor's name is context only when no other carrier sent the
mail, and a French S10 number from an unknown shop reads as La Poste.

Not read yet: GLS France and the Pickup relay network, and the
"returned" and "attempt failed" stages. The French tracking links
were built from public URL shapes and not checked against real mail.
No migration.

Review kept three of the French rules from firing on other mail: slash
dates, numbers anywhere in the html part, and "expérience" anywhere in
a subject.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <mail@pluetzner.de>
2026-10-10 20:48:13 +02:00
24d3c19b1d Choose which IMAP folders to sync (#52)
Mailbox::$isSyncEnabled already decides whether a folder is polled, held
with IDLE and counted by the remote-deletion check, but nothing let a
person change it. Settings → Accounts has a button on every IMAP
account's row that opens the account's folders, each with a switch.
Switching one off stops plMail fetching it; mail already stored stays,
and switching it back on resumes from where it stopped.

The inbox is listed with its switch disabled, and the controller refuses
it too. Gmail and Microsoft accounts sync over their provider's API and
have no folder rows, so neither route exists for them. The path carries
two ids: the account is checked for ownership, then the folder against
that account, then a per-folder CSRF token.

Review added, in the same change:

  Tree          the list is drawn as the tree the paths describe, with
                connector lines, each folder under its nearest listed
                ancestor.
  Placeholders  a \Noselect folder is greyed out with no switch to
                press. Mailbox::$isSelectable records what the server
                said; one migration adds the column.
  Dialog        a narrow modal preset and striped rows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <mail@pluetzner.de>
2026-10-10 20:39:26 +02:00
Walid ManiandClaude Sonnet 5.5 d919d02bee Show the open message beside the list, as a per-user option (#49)
A per-user Reading pane setting, Off or Right. Right shows the message
list and the open message side by side when the mail card is at least
52rem wide, with a divider that is dragged, moved with the arrow keys or
reset with a double-click. The message's share of the card is stored as
a percentage, 25 to 75, so it keeps its meaning when the calendar pane
opens or is resized. Off is the default and changes nothing.

The mode and the share live in the user's settings bag, not in the
Appearance embeddable: where a message is drawn is not part of a theme
somebody might export. A config backup carries both. No migration.

A message opened by its own address has no list behind it, so beside
the list it is shown next to the Inbox.

Review added, in the same change:

  Divider      one pixel of layout with the grip laid over it, so no
               strip of bare card sits between the list and the line.
  Toolbars     the list's and the open message's are the same height.
  Selection    Archive, Delete and Move to stay above a selection; the
               other six actions are under More.
  Shortcuts    a key acts on ticked rows wherever the list is on
               screen, and ticking a row with the mouse no longer
               leaves every key after it dead.
  Settings     a Features group with a page each for the reading pane
               and the keyboard shortcuts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:13:08 +02:00
Walid ManiandClaude Sonnet 5.5 eb8f4c005d Leave \Noselect folders out of sync (#51)
Over IMAP, Gmail lists "[Gmail]" as a folder, but it is only a placeholder
that groups All Mail, Sent Mail and the rest. The server marks it \Noselect
and refuses to open it, so plMail's attempt to sync it failed on every poll
with an ERROR-level log line.

The folder could also never be swept, and one never-swept folder makes
MailboxRepository::earliestSweepAcross() answer null, which is the signal
VanishedMessageReconciler::reap() reads as "no coverage yet, erase nothing".
So for as long as the account existed, mail deleted elsewhere was never
removed from it.

MailboxSyncer now reads \Noselect off the raw LIST attributes and creates
such folders with sync off; a row that already exists is switched off on the
next folder sync, so installs heal without a migration. The attribute is
matched without regard to case because RFC 3501 makes attributes
case-insensitive, while Folder::$no_select in the library matches only two
spellings.

The rule only ever disables. A folder somebody turned off stays off across
syncs, and a folder the server later makes selectable is not switched back on,
because nothing records whether the flag was the rule's doing or a person's.

Tests stage a Gmail-shaped listing, including the sweep-coverage consequence,
and each behaviour was mutation-checked. The IMAP provider page and its German
twin carry a note, and so does the changelog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:42:29 +02:00
Paul LütznerandClaude Opus 5.5 1bae047d77 Snooze the open conversation from its toolbar, and with b
Every row offered a snooze menu and the conversation itself did not,
so "not now" after reading a mail meant going back to the list to
find its row. It was also the last key in #36 with nothing to press:
b did nothing in an open conversation.

The toolbar gets the clock button and the same menu, handled by
mail--message-actions. Snoozing closes the pane, as archiving does,
since the conversation has left the list it was opened from; clearing
a snooze leaves the reader where they are. Offered where the row
offers it, not for mail in the bin or in Spam.

The menu opens from the button's left edge there. It opened from the
right everywhere, which suits a button at the end of a row and would
run off the pane from one at the start of a toolbar.

Refs #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:40:06 +02:00