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>
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>
* 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>
* 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>
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>
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>
* 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>
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>
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>
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>
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>
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>
`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>
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
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>
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>
fix(insight): name the carrier of a Pickup relay parcel, not Deutsche Post
The Pickup relay network (network1/network2.pickup.fr) hands over the
parcels of several carriers, so its sender domain names none of them. An
S10-shaped number fell through to the Deutsche Post default, which exists
for German mailboxes, and every such card read "Deutsche Post".
For a relay sender the carrier now comes from the display name when it
starts, as a whole word, with a listed carrier (Chronopost, Colissimo);
that name is then not a shop, so no merchant is set. Any other name
names no carrier: the payload carrier and tracking link are null and the
card is headed "Pickup". What the number states is untouched: an S10
number ending in FR is still La Poste's and Colissimo's own shape is still
Colissimo's. The default for every other sender is unchanged.
A card's identity is its number, so cards already on the radar are
corrected in place the next time their mail is read.
fix(insight): read a parcel collected at a relay as delivered
"Votre colis a été retiré" and "... récupéré par son destinataire" name
no stage the extractor knew, so a collected parcel stayed "announced".
Both now read as delivered, with "colis" required nearby so the same
words about something else do not count.
The negation guard that already stopped "n'a pas été livré" is widened
to retiré and récupéré: "n'a pas été récupéré par son destinataire"
contains the delivered phrase whole.
Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(insight): let an older mail leave a card as a newer one made it
The harvester's upsert lets the last mail it reads overwrite a card's status,
payload, ETA and link, on the assumption that the last one read is the newest.
A sync reads in date order, but the insights backfill walks by id, and the
Graph import stored mail newest-first: a parcel's "out for delivery" mail was
read before its "announced" one, and the older statement overwrote it, so a
re-read left every card on the first status of its series.
A mail strictly older than the one the card already stands on now changes
nothing. Mail of the same instant keeps the old behaviour (the last read
wins), and so does a card whose mail was deleted, which has nothing to be
older than. land() reports whether it wrote, so the backfill's count does not
include skipped drafts.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(insight): window undated cards on their mail's date, not their row's
The undated cards (GitHub activity and the like) are listed for the last seven
days, windowed and ordered on createdAt, the instant the harvester made the
row. That is the mail's age only while the harvester runs as mail arrives. The
insights backfill re-reads old mail and creates its cards now, so a card made
from a month-old notification was a card of the last minute and took a place
among the newest twenty for a week; the Graph import's newest-first ids put
the same distortion into the order.
The query now joins the card's mail and windows and orders on its receivedAt,
which is the newest mail of the series because the card links that one. A card
whose mail was deleted keeps its row's date, the only one it has. createdAt
keeps meaning when the row was created, so nothing is stamped differently and
no migration is needed.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(insight): offer a code that states no expiry for fifteen minutes
OtpExtractor sets a code card's happensAt to its expiry, so the radar drops it
when that passes, and leaves it null when the mail states no lifetime, because
a guessed default would retire a code that might still be good. The radar's
list of undated cards then took such a code like any undated card (GitHub
activity and the like): seven days, with the code readable on the card.
Undated codes are now offered for fifteen minutes of their mail's date instead
of the week. That is a guess about how long a code lives, made on the display
side and not in the extractor, and it errs towards showing too little. The
extractor's decision not to invent an expiry stands, the code is still on the
mail itself, and codes that do state an expiry take the dated path as before.
Every other undated kind keeps its week.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(insight): read an Outlook mail's HTML, not its 255-character preview
For an HTML mail, GraphMessageBuilder stores Microsoft's bodyPreview as
bodyText, and the preview is cut at 255 characters. ReadableBody::of()
returned that text whenever it was non-empty, so every insight extractor saw
only the first 255 characters of an Outlook mail: a tracking number further
down (Pickup, IKEA, GLS France relay notices) was never found.
ReadableBody now reads the HTML for a message that has a graphId, and falls
back to the preview only when the HTML has no words in it. A Graph mail with
a text body has no HTML, so its full text still comes back. Other providers
are unchanged.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* feat(insight): read the match code in GLS France mail
GLS France states no tracking number of a recognisable shape: its mails carry
an eight-character code under the label "Numéro de colis", and gls-france.com
was not a known carrier sender, so no card was ever made for one.
The sender is listed as GLS, and the code is read from its mail only, behind
that label, in capitals, with at least one letter in it: eight bare digits are
Mondial Relay's shape, and a shop that prints the same label is not GLS. The
card links to moncolis.gls-france.com, GLS France's own tracking page; the
eleven-digit gls-group.eu numbers keep their own link.
GLS's survey mail quotes the same code and is still refused.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* Keep remember-me tokens on the server, so signing out holds
A remember-me cookie that carries only its own signature stays valid
until it expires, and signing out only asks the browser to delete it.
A request already on its way with it - the page's own background
fetches - reached the server after the sign-out and signed the person
straight back in.
The tokens are kept on the server now (Symfony's DoctrineTokenProvider,
table rememberme_token), one per device. Signing out deletes this
device's token, so a late cookie opens nothing; other devices stay
signed in. Symfony's CacheTokenVerifier keeps parallel requests that
rotate the token from looking like theft.
Existing signed cookies are no longer read: people who relied on "Keep
me signed in" sign in once more.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Say what remember-me is now, wherever it was described
Remember-me moved from a signed cookie to tokens kept on the server, and
five places still said "signature-based, not stored": the handbook's
security page, the config-backup page's account of why APP_SECRET stays
behind, and three comments beside the code that depends on it. English
and German both.
RememberMeAcrossRestoreTest proved its point against SignatureHasher,
which the firewall no longer uses. It now asserts what the property rests
on today: the firewall holds the persistent handler, and a backup taken
while a token row exists carries neither its series nor its value.
One test added for the claim the docs repeat: a changed password ends the
cookie, because the token carries a digest of the hash it was issued under.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <mail@pluetzner.de>
An import sat at 21,964 of 24,199 and said "waiting" (#90). The 2,235
are messages the batch handler fetches and does not store: its headers
tie the message to no account here (a draft, a chat, mail sent from a
"send mail as" address), or Gmail lists it and answers 404.
Declining left a debug line and nothing else, and the import's only test
for "still to come" is "not stored". So:
every hourly listing named all 2,235 again, and each was fetched in
full again: 134,100 units of the 360,000 an
hour a 6,000 project has
the import could only end at BACKFILL_MAX_ATTEMPTS, a day
of listings later; the account in the report
was on attempt 18
the progress line could never reach Google's total, which counts
them
gmail_skipped_message records each one when it is declined: the reason,
its labels and its From, To, Cc and Delivered-To.
newGmailIds() treats a recorded id like a stored one, so a
listing with nothing else outstanding settles
at once
an onlyMissing batch queued by an earlier listing drops them too
ImportProgress counts them as done
app:gmail:skipped prints the record by kind, label and address;
--forget clears it so the next listing asks
again
A row goes when its message is stored after all.
Whether some of these messages ought to be stored is a separate
question and is not touched: the filter decides exactly as before. The
record is what that question can now be answered from.
Migration: one new, empty table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RFC 8621 names the identity on the submission alone, and JmapDraftWriter
stored every draft from the account's address. A reply an app autosaved
from an alias therefore had no sender of its own: reopened from Drafts,
on that phone or another, it came back on the primary address, and
replyFrom on it could only report the account.
Email/set now takes identityId on a draft, on a create and in an update
patch. It is resolved through IdentityResolver::addressFor(), the
submission's resolver, so an address identity for a catch-all address
works and there is still one answer to what an account may send as.
fromAddress and fromName are written from it.
an id that does not resolve ignored, and the rest of the save goes
through. The patch is an autosave; the
submission names its identity again and
is where forbiddenFrom is answered
a message that is not a draft invalidPatch, as for any draft property
draftIdentity: true in the account's mail capability. A
server from before this refuses a patch
naming the property, so a client has to
know before it sends one
Clients that send no identityId get what they always got.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
76bf97c8 made every address at a catch-all domain the account's own.
A catch-all catches what has no mailbox, and on a shared domain other
people have one: with shop.test declared, anna@shop.test was dropped
from a reply-all, a reply to her mail could open with her address in
From, and her mail was read as the user's own sent mail.
OwnAddressHelper answers per message instead:
caughtRecipients() the catch-all addresses in Delivered-To,
X-Original-To or Envelope-To; only where the server
left none, those in To and Cc
sentByAccount() an alias in From, or a catch-all address in From
on a message filed under Sent or Drafts
forMessage() the aliases plus caughtRecipients()
ReplyFromResolver and ReplyDraftBuilder use it, and Account::ownsAddress()
is gone with its one caller.
Invitations use it too, which they did not before at all: an invite to
yo@shop.test named no address the account knew, so the card had no "me",
no answer buttons, and the event was treated as naming nobody here
(InviteReader, EventReconciler, EventProposer). The reply already goes
out from the attendee address.
With no delivery header and two catch-all addresses in To, both are
still taken for the user's; the docs say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The browser opens a reply on the address the mail was sent to (#41). The
phone apps build their replies themselves and pick a sender from
Identity/get, so they got none of it: every reply opened on the primary
address, and a catch-all address could not be sent as at all, having no
identity and EmailSubmission/set refusing any id it did not publish.
Email/get replyFrom the Identity that answers this Email, or null.
ReplyFromResolver picks the address, the same
call the web composer makes, so no client
carries a copy of the rule. Only returned when
named in `properties`: it carries a signature,
and an app written before it asks for
everything
IdentityResolver forAddress() gives the listed identity for an
alias, and for an address at a catch-all
domain an address identity, id `addr-` plus
the base64url of the address. addressFor()
decodes such an id and asks
Account::maySendAs() on every submission, so
the id is a spelling and not a grant: one for
a domain the account does not catch is
forbiddenFrom like any unknown id
EmailSubmission/get reports that id for mail sent that way
Identity/get is unchanged, and so is everything an installed app sends
or reads. `*@domain` as an identity (RFC 8621 §6) would have shown up in
their From pickers as an address; a writable `from` on Email/set would
have been ignored or refused by an older server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three things about who a mail is from, grouped as v0.3.5.
The sender name (#70). EmailAlias::$displayName was shown in the From
selector and published as the JMAP identity name, and nothing sent it:
MessageSendService built From with the account's name whatever the
address. There was also no field to set it in the browser.
Account::senderNameFor() the alias's name, else the account's; the
selector, the draft row and the From header
all read it
Settings -> Aliases a sender name field under each address
EmailSubmission/set writes fromName with the address it resolves
The address a reply goes out from (#41, the reply half). Every window
was pre-set from the account alone, so a reply to mail for an alias
opened on the primary address, and a draft saved from an alias reopened
on the primary and was stored that way by the next autosave.
ReplyFromResolver the first of From (own mail), To, Cc, then
Delivered-To / X-Original-To / Envelope-To
that the account may send as
SenderResolver::tokenFor() opens a draft on the address it carries
catch-all domains an account setting, not an alias row: any
address at one may be answered from, is
left out of a reply-all, and signs with the
account signature. ComposeType admits such
a token on submit by the same rule. Not
offered for Gmail and Microsoft accounts.
Typing an arbitrary From into a new message is not in this.
Signatures as HTML (#32). Pasting source into the contenteditable gave
the source back, and a linked image never showed, the page's img-src
being 'self' data: blob:.
Rendered | HTML a textarea beside the editor. Rendered asks
the server (signature/preview) and shows
what a save would keep, with the elements
and attributes the sanitiser dropped named
underneath
SignatureImporter sanitises, and fetches https images through
ImageProxyFetcher into data: sources, 256 KB
each and 768 KB a signature; the rest stay
linked and are counted
DataImageEmbedder on send, a data: image becomes an inline
part and a cid: reference, since Gmail and
Outlook draw no data URI. It reads the
entity-encoded form the sanitiser stores
(= for the padding).
No migration: the catch-all domains live in Account::$settings and are
carried by a config backup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
Settings → Mail → Disposal has one per-user switch, off until somebody turns it on. It decides whether Delete forever exists at all: the flame on a row in Spam and the bin, and a new Delete forever above a selection there. Both purge routes answer 403 while the switch is off.
A selection extended with Select all N empties the whole of Spam or the bin in a background job. Any other view is refused, and the worker checks the switch and each conversation's placement again.
The bin no longer offers Delete above a selection: it trashed mail that was already in the bin, took the rows off the page and they were back on the next load.
The choice lives in the user's settings bag and a config backup carries it. No migration.
Continues #56.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sidebar's Inbox badge could read 1 while Primary, the tab that opens, had nothing unread: the conversation was in Promotions, and the only hint on the strip was a colour on that tab's icon. Each tab now shows how many conversations in it hold unread mail. It is the Inbox badge split by category, so the tabs add up to it, and it is patched in place on sync.
This reverses the documented rule that the "3 new" pill is the only number on a tab. The badge and the bold rows say that there is unread mail and cannot say where.
On for everyone, and a per-user appearance setting switches it off, beside the account corner. One column on the user table; the migration is reversible.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An account's colour was handed out once, when the account was added, and nothing could change it. Every row in Settings → Mail accounts now has a swatch that opens the palette of eight.
It is a control on the row and a route of its own, not a field in the edit form, because that form refuses OAuth accounts and the swatch would have been missing on every Gmail and Outlook account. The value is range-checked rather than wrapped. Two accounts may share a colour. No migration: colorIndex already exists and is already in config backups.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the reading pane on the right, choosing another folder, switching a category tab, turning the page, sorting or searching closed the message being read. All of those swap one Turbo frame, and the mail pane answered every swap by showing the list, which is right while the message covers the list and wrong beside it.
The swap still adopts the new list's URL, so the back arrow returns to the folder that was switched to. With one pane at a time nothing changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One host held both models. The two jobs often want different machines:
writing wants the largest model somebody can run or rent, and search wants
a small one that is asked constantly, for every query and once for every
message of a mailbox. Writing from a hosted endpoint with search on an
Ollama at home indexes the mailbox on a machine the operator owns and
bills nothing per message, and one host for both could not express it.
Admin → AI has a tick box that gives search its own provider, address,
token and extra headers. AiSettings::connectionFor() returns that
connection for search when the box is ticked, and nothing else had to
change, because every call already asked for the connection of its
feature. The second host is sent only its own token and headers, and none
when it has none. A feature is usable when the host it runs on is filled
in. Test asks both hosts, each for the model that lives on it.
Stored vectors stay filed under the model name alone, so moving search to
a host that serves the same model keeps the index and no stored row is
touched. Five columns on ai_settings, unused until the box is ticked; the
migration is reversible.
The idea of a separate embedding connection comes from #58. Its vector
identity, which hashed the address into every stored row, is left out: it
forced a full re-embed whenever a host changed its address.
Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bearer token is not all that stands in front of a model. A reverse proxy
with basic auth, a zero-trust gateway that wants a client id and a secret,
a routing gateway with a key of its own, a provider that takes a project
by header: each of those made plMail unusable with it.
Admin → AI has an Extra headers box under the token, one `Name: value` per
line. ConnectionHeaders reads them strictly while somebody can still fix
what they typed, and forgivingly on the way to the wire, so a column that
cannot be read costs the headers and not the feature. They are stored
encrypted and ride on AiConnection, so both clients send them on every
request without knowing they exist.
The values are never shown again; the page lists the names on file. An
empty box keeps them, typing replaces them, a tick box removes them, and a
change of address or provider drops them as it drops the token. A typed
Authorization replaces the bearer. The headers the HTTP client writes
itself are refused.
Fixed values only. The idea of extra headers comes from #58; its per-task
session header is left out, because nothing plMail does needs one.
Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The language model had to be an Ollama container. Admin → AI now has a
Provider field, and the second choice is the request format OpenAI
published and nearly everything else has copied: llama.cpp, vLLM, LM
Studio, LiteLLM, the hosted providers, and Ollama itself under /v1.
OpenAiClient speaks three paths, /chat/completions, /embeddings and
/models, and answers in the value objects and error categories
OllamaClient already uses. AiAssistant reads the connection for the
feature being served and picks the client; nothing above it knows there
are two. The body carries only what the format defines, because several
servers answer 400 to a field they do not know. The stream is read a line
at a time, skips comment lines, does not require [DONE], and fails on an
error object delivered inside a 200. Redirects are never followed.
What only Ollama can be asked is not asked of the other dialect:
no keep-alive is sent, the composer is not told to expect a cold start,
Warm up refuses with its own reason, and the panel reads no /api/ps. The
page puts those controls away while the other provider is chosen and
keeps their values.
A stored token is dropped when the provider changes, as it is when the
address does. One column on ai_settings, defaulting to ollama; the
migration is reversible.
The SSE handling, the strict reading of an embedding and the rule that a
key stays with its endpoint come from #58.
Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Admin → AI has a Token field for an Ollama behind a reverse proxy. The
token was stored, encrypted, and never sent: every call site passed the
address alone. A host that asks for one answered 401 to everything while
the page said a token was on file.
The address and the token now travel as one AiConnection, and OllamaClient
scopes its HTTP client to it, so every request carries the header —
including the fallback embedding dialect and the version read behind a
probe. Redirects are not followed once a token is attached.
The token belongs to the address it was typed for. The form drops a stored
token when the address changes and none is typed, in Admin → AI and in the
setup wizard alike; the rule lived in both controllers and is now in the
form. The Test button binds to a copy of the settings, shows the typed
token to the typed address, and asks nobody without a valid CSRF token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 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>
The radar's parcel extractor only knew German carriers and German and
English wording, so parcel mail from French carriers was refused at the
subject gate or read under the wrong carrier.
Carriers Colissimo, Chronopost, Mondial Relay, Colis Privé, DPD
France and Cainiao by sender domain; Amazon.fr as a
merchant, with its button opening amazon.fr.
Numbers Colissimo's and Cainiao's own shapes. Mondial Relay's
eight bare digits only from Mondial Relay and only after
"colis". Colis Privé's seventeen digits out of the links
of the html part, where alone they appear.
Wording French stage phrases, whole phrases only, with the
negated forms kept from reading as delivered; French
months, weekdays and relative days; day-first slash dates
after a French word for the promise.
Stage ready_for_pickup, for a parcel waiting at a relay point
or locker.
Surveys a carrier's satisfaction survey is refused, since the
newest mail overwrites a card's status.
A competitor's name is context only when no other carrier sent the
mail, and a French S10 number from an unknown shop reads as La Poste.
Not read yet: GLS France and the Pickup relay network, and the
"returned" and "attempt failed" stages. The French tracking links
were built from public URL shapes and not checked against real mail.
No migration.
Review kept three of the French rules from firing on other mail: slash
dates, numbers anywhere in the html part, and "expérience" anywhere in
a subject.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <mail@pluetzner.de>
Mailbox::$isSyncEnabled already decides whether a folder is polled, held
with IDLE and counted by the remote-deletion check, but nothing let a
person change it. Settings → Accounts has a button on every IMAP
account's row that opens the account's folders, each with a switch.
Switching one off stops plMail fetching it; mail already stored stays,
and switching it back on resumes from where it stopped.
The inbox is listed with its switch disabled, and the controller refuses
it too. Gmail and Microsoft accounts sync over their provider's API and
have no folder rows, so neither route exists for them. The path carries
two ids: the account is checked for ownership, then the folder against
that account, then a per-folder CSRF token.
Review added, in the same change:
Tree the list is drawn as the tree the paths describe, with
connector lines, each folder under its nearest listed
ancestor.
Placeholders a \Noselect folder is greyed out with no switch to
press. Mailbox::$isSelectable records what the server
said; one migration adds the column.
Dialog a narrow modal preset and striped rows.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <mail@pluetzner.de>
A per-user Reading pane setting, Off or Right. Right shows the message
list and the open message side by side when the mail card is at least
52rem wide, with a divider that is dragged, moved with the arrow keys or
reset with a double-click. The message's share of the card is stored as
a percentage, 25 to 75, so it keeps its meaning when the calendar pane
opens or is resized. Off is the default and changes nothing.
The mode and the share live in the user's settings bag, not in the
Appearance embeddable: where a message is drawn is not part of a theme
somebody might export. A config backup carries both. No migration.
A message opened by its own address has no list behind it, so beside
the list it is shown next to the Inbox.
Review added, in the same change:
Divider one pixel of layout with the grip laid over it, so no
strip of bare card sits between the list and the line.
Toolbars the list's and the open message's are the same height.
Selection Archive, Delete and Move to stay above a selection; the
other six actions are under More.
Shortcuts a key acts on ticked rows wherever the list is on
screen, and ticking a row with the mouse no longer
leaves every key after it dead.
Settings a Features group with a page each for the reading pane
and the keyboard shortcuts.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Over IMAP, Gmail lists "[Gmail]" as a folder, but it is only a placeholder
that groups All Mail, Sent Mail and the rest. The server marks it \Noselect
and refuses to open it, so plMail's attempt to sync it failed on every poll
with an ERROR-level log line.
The folder could also never be swept, and one never-swept folder makes
MailboxRepository::earliestSweepAcross() answer null, which is the signal
VanishedMessageReconciler::reap() reads as "no coverage yet, erase nothing".
So for as long as the account existed, mail deleted elsewhere was never
removed from it.
MailboxSyncer now reads \Noselect off the raw LIST attributes and creates
such folders with sync off; a row that already exists is switched off on the
next folder sync, so installs heal without a migration. The attribute is
matched without regard to case because RFC 3501 makes attributes
case-insensitive, while Folder::$no_select in the library matches only two
spellings.
The rule only ever disables. A folder somebody turned off stays off across
syncs, and a folder the server later makes selectable is not switched back on,
because nothing records whether the flag was the rule's doing or a person's.
Tests stage a Gmail-shaped listing, including the sweep-coverage consequence,
and each behaviour was mutation-checked. The IMAP provider page and its German
twin carry a note, and so does the changelog.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every row offered a snooze menu and the conversation itself did not,
so "not now" after reading a mail meant going back to the list to
find its row. It was also the last key in #36 with nothing to press:
b did nothing in an open conversation.
The toolbar gets the clock button and the same menu, handled by
mail--message-actions. Snoozing closes the pane, as archiving does,
since the conversation has left the list it was opened from; clearing
a snooze leaves the reader where they are. Offered where the row
offers it, not for mail in the bin or in Spam.
The menu opens from the button's left edge there. It opened from the
right everywhere, which suits a button at the end of a row and would
run off the pane from one at the start of a toolbar.
Refs #36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>