* Send In-Reply-To and References on a reply
buildEmail() never wrote them, so every reply from the web composer or
JMAP reached the recipient as a new conversation. References ends with
the parent even when the draft's list lacks it (RFC 5322 3.6.4).
Closes#206
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* References ends with the parent even when the draft listed it earlier
normaliseList() keeps the first of a repeated id, so a draft whose
References already named the parent somewhere other than last (a JMAP
client's own order) sent a chain that did not end with it. The parent is
now taken out of the list before it is appended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <karatektus@gmail.com>
Every sender turned every failure into `false`, so a one-minute Gmail
rate limit or a short outage lost the send like a refused recipient.
The three senders now classify through MailDeliveryInterface::deliver():
deferred (rate limits, 5xx, a connection that never opened, SMTP 4xx),
refused (other 4xx, SMTP 5xx, too large) or outcome unknown (a lost
answer, 502/504). send() stays a bool for invitations, alerts and read
receipts.
SendMessageHandler retries a deferral for at most two minutes, keeping
the provider's Retry-After. A lost answer is never resent blind: Gmail
and Graph look for the Message-ID in Sent first (now committed before
the first attempt), SMTP reports it as possibly sent. A final failure,
from the handler or from SendGaveUpSubscriber when the ladder runs out,
marks the draft, words the toast by its reason, and shows a red warning
triangle at the start of the draft's row and on Drafts in the sidebar.
Migration: four nullable columns and a partial index on message.
Closes#125
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
webklex's header parser hands a repeated Date (or References) to string
functions as an array or as one date with trailing data; the TypeError or
Carbon exception escaped the library's per-message catch and failed the
whole page of the sync or import, every time. ImapQuery now reads such a
message again with the repeated once-only headers collapsed, keeping the
raw header the server sent, so it is imported. A header the repair cannot
mend is recorded against its UID and retried, then skipped, on its own.
Proton Mail Bridge answered the sync's UID SEARCH n:* with "NO no such
message". The range searches read that as an empty result; the
importer's search for a page of known UIDs does not.
Closes#219
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Delivered" anywhere in a carrier's mail made the card Delivered when the
subject named no stage, so "will be delivered tomorrow", "scheduled to be
delivered" and "when it's delivered" put a Delivered badge beside
tomorrow's date. Those future and conditional phrases are now taken out
before the word is looked for.
A bare digit run labelled as another number ("eBay order number",
"Reference", "Bestellnummer", "Order #") is no longer read as a tracking
number, so a FedEx mail for an eBay purchase gives the FedEx number.
Closes#120
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Only amazon.de, amazon.com and amazon.fr counted as a merchant, so an
"Out for delivery" mail from amazon.co.uk gave no card. amazon.co.uk,
amazon.ca, amazon.com.au, amazon.in and amazon.sg are now merchants too,
and the Track button opens the storefront that sent the mail.
A date like "23/10/2026" is read day first from the day-first
storefronts, with the year required. "Arriving today" and "tomorrow" are
counted on the account owner's clock instead of UTC.
Closes#207
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The categorisation stamp was written even when the call never reached a model, so a short outage left every message in flight without a category for good. A timeout, an unreachable host or a refusal by 401, 403, 408, 429 or a 5xx now leaves the message unstamped for the catch-up; an answer, a 404 and a refusal of the request itself (400, 413, 422) are stamped as before. Closes#222.
Co-authored-by: Philippe Delodder <philippe@delodder.be>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
app:prune:blobs looked under a hardcoded var/, so installs with another APP_STORAGE_DIR reclaimed nothing; it and the thumbnail cache now use APP_STORAGE_DIR. A third pass deletes attachment thumbnails whose part no longer exists. Closes#232, closes#233.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An app installed from the browser or added to an iPhone home screen kept the wax seal. The manifest and apple-touch-icon now name the chosen logo; the browser draws the PNGs on a canvas and uploads them per user, so the image needs no librsvg. Builds on #92. Closes#141.
Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
app:prune:blobs deleted week-old profile pictures, signature images and backgrounds under var/uploads because no row named them. The orphan sweep now only enters numeric account directories and only files in each writer's exact layout, so everything else on those volumes is left alone, including directories added later. Closes#214.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The setup wizard built a new connection every time its Integrations step was saved, so saving it again collided with the unique key on (user, provider, name) and answered with a 500. It now edits the oldest existing connection for that service. The settings page had the same 500 for a second connection that kept the default name; a UniqueEntity constraint now reports it on the name field. Closes#216.
Co-authored-by: Philippe Delodder <philippe@delodder.be>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The message body renders in a sandbox that sends no session cookie, so every cid: image was a 302 to the login page. They now load through /mail/inline/{id}, signed with an expiry of one to two days, for the message's own image parts only; parts labelled octet-stream are served when their bytes are an image. Closes#213.
Co-authored-by: Philippe Delodder <philippe@delodder.be>
Co-authored-by: MontyWei <62734446+montywei@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Parts labelled application/octet-stream or image/octet-stream are identified from their bytes; only a raster type on the allow-list can come out, so SVG and HTML stay downloads. Part of #213.
Co-authored-by: MontyWei <62734446+montywei@users.noreply.github.com>
* Single sign-on only: hide the password form and refuse password sign-ins
Adds Admin -> SSO 'Single sign-on only' (and OIDC_DISABLE_PASSWORD_LOGIN).
The login page drops the password form and LoginFormAuthenticator refuses a
password sign-in before looking the user up. It only applies while a whole
provider is in force, the saving administrator must already be linked to the
provider, and app:sso:allow-password-login brings the form back from the
console. Config restore leaves it off. Off by default; one boolean column on
oidc_config.
Closes#169
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Single sign-on only: require a link to the provider being saved
The admin form let the box be ticked by an administrator linked to any
provider, including the one the page held before. Switching providers and
ticking it in one save made a provider they had never used the only way in.
It now compares the stored issuer with the one on the page, without a
trailing slash, as OidcDiscovery does.
Tests for what had none: the console command (clears the setting, keeps the
provider), a restore over a row that had it on, the link to another
provider, and a control showing the same password signs in with the setting
off. The "right credentials" test now posts the right password.
Docs: there is no iOS app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Docs: put the iOS app back in the single sign-on page
The previous commit dropped it on the wrong assumption that there is no
iOS app. There is one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <karatektus@gmail.com>
* Delete a folder-backed label's folder on the server, after moving its mail
Deleting a label that is a folder on IMAP or Outlook left the folder on the
server, and the next sync made the label again. The delete dialog now asks
where the folder's mail goes (Inbox, Archive, Trash or another folder); a
background job moves it, deletes the folder once the server reports it empty
(children first), and removes the label last so no sync can resurrect it.
A folder that never empties is left alone and the job says which.
Also: an Outlook category delete now addresses the category id (it used the
folder id and never reached Outlook), and nested labels are propagated too.
Closes#40
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Label delete: end at once on a refusal, show what it waits for
- A server that refuses the folder delete (IMAP NO/BAD or a dropped
connection, Graph/Gmail 4xx) ends the job immediately with its reason
instead of retrying for ten minutes; throttling and 5xx are still waited out.
- While waiting for a folder to empty, the jobs indicator names the folder.
- The destination Archive/Trash is created on the IMAP server before any mail
moves, so the moved rows point at it; before, they kept pointing at the
folder being deleted and the job waited on them until it gave up.
- A folder plMail creates starts with nothing to import, so the indicator no
longer shows it as "Importing" until the next sync.
- A folder still being imported cannot be deleted yet; the dialog says so.
- confirm-dialog.spec drives the account delete form, as the label delete no
longer uses a browser confirm.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Bump @playwright/test
Bumps the playwright group with 1 update in the / directory: [@playwright/test](https://github.com/microsoft/playwright).
Updates `@playwright/test` from 1.63.0 to 1.64.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.0)
---
updated-dependencies:
- dependency-name: "@playwright/test"
dependency-version: 1.64.0
dependency-type: direct:development
update-type: version-update:semver-minor
dependency-group: playwright
...
Signed-off-by: dependabot[bot] <support@github.com>
* E2E: abort the stubbed reconnect navigation instead of answering 204
Chromium 156 (Playwright 1.64) reports a navigation that ends in a 204 as a
finished request that commits nothing, where Chromium 155 reported it as
failed (net::ERR_ABORTED). Playwright's click waits for the navigation it
started to commit or fail, so with 1.64 the click never returned and the
test timed out reading "" off a closing page. An abort is reported as a
failure by every Chromium and keeps the document just the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* E2E: touch beside the open drawer, after it has stopped sliding
The drawer-open pull touched the middle of the rows (x~196) right after
`inert` came off, at the start of the drawer's 200ms slide. Once the 256px
drawer has arrived, that point is the drawer, not the backdrop, so the
hit-test only passed while it beat the transition; slower CI runners lost
that race on every branch from 2026-10-16. Wait for the drawer to rest,
then touch the backdrop strip to its right, and pull at the same point
once it is closed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Paul Lützner <karatektus@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A send with nobody in To was refused with "Add at least one recipient before sending", even with addresses in Cc or Bcc. The window's guard counted To, Cc and Bcc; the server's Count(min: 1) rule in the send group counted To only.
The rule is now a Callback on the To field that counts all three, read off the bound form since the fields are unmapped. A send with no recipient at all is still refused; a draft may still be saved with none.
Closes#201.
Co-Authored-By: jo2904 <josephdegaullier@gmx.fr>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An end equal to the start (or a zero DURATION) is a stated instant and
now gets one minute instead of the nominal hour, so a 23:59 reminder no
longer runs past midnight and shows again on the next day. The nominal
hour for an event with no end at all now stops at the midnight of the
day it starts on, in DTSTART's own zone.
Closes#170
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The picture brought its own X into the corner of the dialog body, just
below the modal's X. It is gone; while a picture is up, the modal's X is
relabelled "Close the screenshot" and its first press puts the picture
back, the second closes What's new, as Escape already did.
Closes#187
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Leave a message another Gmail account holds with that account
With several Gmail accounts connected, mail forwarded from one to another
reaches the second still Delivered-To the first and is attributed there.
Both merges in SyncGmailMessageBatchHandler then found the first account's
own row by Message-ID and wrote the second mailbox's Gmail id over its own:
they were written for a sibling that syncs over IMAP, and the sibling map is
every active account. The first account's sync then stored the message a
second time, and a later merge by Message-ID picked the other of the two
rows and gave it an id its twin still held: a duplicate key on
uniq_message_gmail_id_account, the whole batch failed, and every retry
fetched it again until Gmail answered 429 (discussion #168).
A row is now looked up by its Gmail id before either merge. A merge only
gives an id to a row of a non-Gmail account that has none yet, or has this
carrier's; anything else is recorded as held elsewhere, so the import counts
it and stops asking for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Link the changelog entry to its PR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
mailbox.uid_validity becomes a BIGINT, so IMAP folders whose UIDVALIDITY is above the signed INT range sync again. Reversible migration; the mapping stays ?int (DBAL 4 hydrates a bigint as an int).
Closes#171
Co-Authored-By: Philippe Delodder <philippe@delodder.be>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking a highlight's screenshot opened the PNG in a new tab. It now grows
out of its thumbnail until it fills the What's new dialog, with an X; the X,
Escape or Back shrink it back, and What's new stays open.
Back goes through a history entry of its own (assets/history_layer.js),
imported first in app.js so its popstate listener runs before Turbo's, which
would otherwise restore the page and close the dialog.
Closes#179
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>
Synced drafts no longer get a sentAt, so the editor opens them. A migration clears it on rows the provider still files as drafts, and a draft that turns up in Sent is settled as sent.
Takes over #123.
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.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>
A streaming controller's set_time_limit() outlived its test in the single PHPUnit process and killed slower runs part-way. A PHPUnit extension resets it after every test.
Co-authored-by: pmdevelopment <4947485+pmdevelopment@users.noreply.github.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>
Settings → Profile opens the same file picker as attaching to a mail, in an avatar mode: albums, people, search and paging, one picture at a time, files that cannot be a picture greyed out. The choice is posted straight back to the profile form, and the connection is matched against the user's own. From Paperless-ngx the original is taken rather than the archived PDF. The setup wizard keeps its small grid for now.
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Save to… chooser drew Paperless tags as folders to open, and clicking one did nothing. A DestinationKind (folder, album, tag) now decides how the chooser is drawn: a tag is the choice itself, with a No tag row. The upload is sent as a string with a Content-Length instead of streamed, which Paperless read as an empty form ("No file was submitted", #79).
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
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>
OidcAuthenticator issued no RememberMeBadge, so an OIDC login rested on the
PHP session alone: a browser-session cookie, a file cleaned up after the 24
minute session.gc_maxlifetime, and lost with the container on every deploy.
The badge now carries the firewall's remember-me parameter, because the
callback has no form to tick it on; always_remember_me would instead make the
password form's unticked checkbox meaningless. The token is stored in the
database and a remember-me cookie is still challenged by two-factor.
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
bulk-select-all's "the pager agrees with the rows after a bulk action"
failed now and then under load. Not the job: it archived the 67
conversations in six seconds. A nudge from the job started a list
refresh while it was still writing, the job's "done" arrived while that
fetch was out, and _refreshList() returned early because one was
already in flight. The fetch came back without the archive, and nothing
was left to refresh the list — it showed the archived conversations
until something unrelated happened.
A request that arrives during a refresh is now remembered and taken
once the current fetch lands, keeping the most urgent of whatever asked
in the meantime. list-refresh-queue.spec.ts holds the first fetch at the
network so the second request always arrives during it; it fails
without this change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compose-formatting's "typed emoticons are left exactly as typed" failed
about a third of the time under load. The signature spec saved a
signature on the worker's user and never took it away, so whichever spec
ran next on that worker composed with one. The emoji test clicked the
editor and assumed the caret landed at the end of the body; with a
signature it lands on the empty line above it, the text went in before
the signature, and the exact assertion failed.
Both ends fixed: the signature spec clears the signature after each
test, and the emoji test places the caret at the end of the body
itself instead of trusting a click.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The delete form had Turbo switched off, which is what asks Turbo's
confirm question — so a tab and its filing went at one click. It goes
through Turbo now, as the account list's delete does.
The tab card is headed "Tabs": the sorting card's first switch is
already called "Inbox tabs".
inbox-categories.spec.ts makes a tab, moves it with the buttons and
reloads to the same order, and switches Forums off and finds no Forums
tab in the inbox.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A phone could see the five built-in categories and nothing of how the
reader arranged them. urn:plmail:params:jmap:inboxtabs adds:
- InboxTab/get and /changes: every tab in the reader's order, built-ins
with isEnabled, the reader's own with their name. Per user, answered
by every account; a change is recorded in each account's log so push
and /changes work as they do for labels.
- Thread.inboxTab: the tab the web shows a conversation in — the
reader's own, the built-in, or Primary for a switched-off category.
- Email/query threadInboxTab: the same list the web shows under a key.
- Thread/set { inboxTab }: a move between tabs, pinned like a drop.
A tab the reader does not have is refused.
Thread.category and threadCategory are unchanged for clients that know
only the five.
The recompute statements wrote every conversation's category back
whether it changed or not and recorded nothing, so a re-sort — any of
the sorting choices, and now switching a tab — left phones drawing the
old tabs. They update only what moves and return it, and every caller
records those as Thread changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What sorts mail into tabs and the tabs themselves were two cards under
General. They are their own page now, in the Mail group: the sorting
card, then every tab on one list in the order the inbox draws them.
- Built-in tabs carry an On/Off switch. Off releases conversations
somebody dragged into the category and queues the re-sort the other
sorting choices queue; on queues it too, and the mail comes back.
- The reader's own tabs keep rename and delete.
- Everything but Primary is dragged into place, or moved with buttons
from a keyboard or a phone, as on the account list. Primary sits
above the list: always first, never off.
The order endpoint keeps only keys the reader has, so a request cannot
plant a key naming somebody else's category.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One order across the built-in tabs and the reader's own, and a set of
built-ins switched off, both beside the other sorting choices on the
user (CategorySorting). The order replaces the categories' own position
column, which never shipped: the strip is one list, so its order is too.
Primary is always first and cannot be switched off — it is where the
rest falls back to.
A switched-off category takes no mail. Whatever would have gone there —
by the header rules, Google's label or the assistant — is in Primary,
and the message panel says it would have been that category. The
answer is mapped rather than the next rule consulted: "Forums is off"
does not make a mailing list Promotions for its List-Unsubscribe.
Until the re-sort a switch queues has run, and for a conversation
somebody pinned there, Primary lists and counts the switched-off
category's conversations too, so nothing is in no tab at all.
InboxTabs is where order and on/off are applied, so the strip, the drop
targets and every key that resolves agree. A restore rewrites the
order's own-tab keys to the categories' new ids.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A filter only filed Gmail-labelled mail while the reader had asked
plMail's sorting to overrule Google's. Google's category and plMail's
rules are both guesses, and the override decides between them; a filter
is an instruction the reader wrote, and outranks both. Out of the filer,
the threader and the two recompute statements alike.
A new tab could be called "Werbung" beside the built-in Werbung: the
reserved names were the enum's English values only. Every interface
language's label is refused now.
The per-account recompute sorted every message on the installation for
each account it was asked about. It reads only that account's now —
the same rows, since a conversation belongs to one account — which
matters more once switching a tab on or off re-sorts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>