33 Commits
Author SHA1 Message Date
4738d514dd Hear mail read, unread, starred or unstarred in Outlook (#114)
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:33:17 +02:00
31b4943a3f Tell the open list when Outlook changed, moved or deleted mail (#122)
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:11:44 +02:00
0f817b7b0c A message moved in another app leaves its old folder on the thread too (#121)
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:58:41 +02:00
9fb03587ee Push the read state and star the user chose to Outlook (#111)
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:58:36 +02:00
19266223e8 Choose a profile picture through the compose file picker (#65)
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>
2026-10-15 11:38:44 +02:00
3d7a25c1e6 Save to Paperless-ngx: tags are choices, and the upload goes through (#78)
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>
2026-10-15 11:38:06 +02:00
fdbe79c231 Keep a message header on three lines in a narrow pane (#73)
Below 36rem of pane width the date, the menu and the arrow move onto a row of their own under the sender, so the sender gets the full width. A container query on the message list, so it follows the pane, not the window.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:24:00 +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
77251f5038 fix(security): remember a single sign-on sign-in (#116)
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>
2026-10-14 16:39:04 +02:00
8efe6d6621 Widen Happening soon and put Also spotted beside the list (#83)
feat(calendar): widen Happening Soon and put Also spotted beside the list

The panel opened at the narrowest dialog width, so a fortnight of
bookings pushed the undated "Also spotted" cards far down the scroll.
Both openers now ask for the wide dialog, and from the md breakpoint up
the panel is two columns: what is coming up (with "On your radar") on
the left, "Also spotted" on the right. Below md it stacks as before.

The two sections stay direct flex children of the radar scope, so
dismissing the last undated card (which removes its section) lets the
list take the full width instead of leaving an empty column. With
nothing in the main column, "Also spotted" has the panel to itself.

No migration.

docs: changelog entry for the wider Happening soon panel (#83)

fix(calendar): give Also spotted a wider column in Happening soon

At 18rem the cards under Also spotted truncated their titles and pushed
the status chip onto its own line. 24rem fits a typical issue or pull
request title with its chip on one line, checked in a browser with
seeded cards.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:30:19 +02:00
b793faccff fix(calendar): read the calendar page in the user's zone when the default calendar is synced (#105)
A subscribed Google/CalDAV calendar carries the zone its remote account
holds for it, often UTC for a shared one. CalendarTimeResolver::zoneFor()
let that zone win whenever such a calendar was the default, so the whole
page was drawn in UTC: an 11:30 Europe/Paris appointment, stored correctly
as 09:30 UTC, showed at 09:30.

zoneFor() now uses the user's own zone (profile, else install default) when
the default calendar isSynced(); a local calendar keeps its zone.

UpcomingEventIndicator repeated the same decision by reading the default
calendar's column itself, so the top bar's "today" stayed on the remote
clock. It now asks the resolver.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:28:10 +02:00
5e42e067ab feat(mail): show the Inbox unread count in the tab title on pages that are not mail folders (#72)
The "(3)" in front of the tab's title appeared on the Inbox and vanished on Settings, the calendar and the admin pages, although the sidebar there still showed the number. Those pages extend the base layout directly and named neither a title prefix nor a title-count-key, so the server rendered no count and the sidebar sync had no key to keep it current.

The base layout now prints the Inbox's count (SidebarCounts::forRoleBadge, the figure the sidebar badge and the Inbox title already use) in a title_prefix block and defaults title-count-key to role:inbox. The mailbox layout switches both off, so every mail folder keeps its own figure or none: the bin still counts a total, and Sent and Archive do not borrow the Inbox's.

Nothing prints for a count of zero or with nobody signed in.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:25:46 +02:00
f9eaa737c6 Fix Outlook attachments: cast contentId in the attachment listing (#77)
fix(graph): list Outlook attachments with a select Graph accepts

The attachment listing selected contentId unqualified. contentId is declared
on microsoft.graph.fileAttachment, not on the attachment type the collection
is typed as, so Graph rejects the whole $select with a 400. In the $batch the
sync uses, a failed sub-request is skipped, and a skipped listing reads as "no
attachments": every Outlook message with a file on it was stored with none,
and nothing was logged.

Select it through its own type (microsoft.graph.fileAttachment/contentId) in
both listings. contentId stays because it is what tells a cid: image from a
file the user attached.

The sync handler now warns when Graph reports hasAttachments but the listing
returns nothing for a message, so a refused listing is no longer invisible.

Messages already stored keep their missing attachments until re-fetched.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:19:30 +02:00
f28e8cd044 fix(insight): name the right carrier on a Pickup relay parcel, and read a collected one as delivered (#103)
fix(insight): name the carrier of a Pickup relay parcel, not Deutsche Post

The Pickup relay network (network1/network2.pickup.fr) hands over the
parcels of several carriers, so its sender domain names none of them. An
S10-shaped number fell through to the Deutsche Post default, which exists
for German mailboxes, and every such card read "Deutsche Post".

For a relay sender the carrier now comes from the display name when it
starts, as a whole word, with a listed carrier (Chronopost, Colissimo);
that name is then not a shop, so no merchant is set. Any other name
names no carrier: the payload carrier and tracking link are null and the
card is headed "Pickup". What the number states is untouched: an S10
number ending in FR is still La Poste's and Colissimo's own shape is still
Colissimo's. The default for every other sender is unchanged.

A card's identity is its number, so cards already on the radar are
corrected in place the next time their mail is read.

fix(insight): read a parcel collected at a relay as delivered

"Votre colis a été retiré" and "... récupéré par son destinataire" name
no stage the extractor knew, so a collected parcel stayed "announced".
Both now read as delivered, with "colis" required nearby so the same
words about something else do not count.

The negation guard that already stopped "n'a pas été livré" is widened
to retiré and récupéré: "n'a pas été récupéré par son destinataire"
contains the delivered phrase whole.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:17:42 +02:00
938d549347 fix(insight): re-reading old mail no longer rewrites newer cards or fills Happening soon (#101)
* fix(insight): let an older mail leave a card as a newer one made it

The harvester's upsert lets the last mail it reads overwrite a card's status,
payload, ETA and link, on the assumption that the last one read is the newest.
A sync reads in date order, but the insights backfill walks by id, and the
Graph import stored mail newest-first: a parcel's "out for delivery" mail was
read before its "announced" one, and the older statement overwrote it, so a
re-read left every card on the first status of its series.

A mail strictly older than the one the card already stands on now changes
nothing. Mail of the same instant keeps the old behaviour (the last read
wins), and so does a card whose mail was deleted, which has nothing to be
older than. land() reports whether it wrote, so the backfill's count does not
include skipped drafts.

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

* fix(insight): window undated cards on their mail's date, not their row's

The undated cards (GitHub activity and the like) are listed for the last seven
days, windowed and ordered on createdAt, the instant the harvester made the
row. That is the mail's age only while the harvester runs as mail arrives. The
insights backfill re-reads old mail and creates its cards now, so a card made
from a month-old notification was a card of the last minute and took a place
among the newest twenty for a week; the Graph import's newest-first ids put
the same distortion into the order.

The query now joins the card's mail and windows and orders on its receivedAt,
which is the newest mail of the series because the card links that one. A card
whose mail was deleted keeps its row's date, the only one it has. createdAt
keeps meaning when the row was created, so nothing is stamped differently and
no migration is needed.

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

* fix(insight): offer a code that states no expiry for fifteen minutes

OtpExtractor sets a code card's happensAt to its expiry, so the radar drops it
when that passes, and leaves it null when the mail states no lifetime, because
a guessed default would retire a code that might still be good. The radar's
list of undated cards then took such a code like any undated card (GitHub
activity and the like): seven days, with the code readable on the card.

Undated codes are now offered for fifteen minutes of their mail's date instead
of the week. That is a guess about how long a code lives, made on the display
side and not in the extractor, and it errs towards showing too little. The
extractor's decision not to invent an expiry stands, the code is still on the
mail itself, and codes that do state an expiry take the dated path as before.
Every other undated kind keeps its week.

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

---------

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:16:17 +02:00
aeb36afe92 fix(insight): read an Outlook mail's HTML, not its 255-character preview, and read GLS France's code (#102)
* fix(insight): read an Outlook mail's HTML, not its 255-character preview

For an HTML mail, GraphMessageBuilder stores Microsoft's bodyPreview as
bodyText, and the preview is cut at 255 characters. ReadableBody::of()
returned that text whenever it was non-empty, so every insight extractor saw
only the first 255 characters of an Outlook mail: a tracking number further
down (Pickup, IKEA, GLS France relay notices) was never found.

ReadableBody now reads the HTML for a message that has a graphId, and falls
back to the preview only when the HTML has no words in it. A Graph mail with
a text body has no HTML, so its full text still comes back. Other providers
are unchanged.

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

* feat(insight): read the match code in GLS France mail

GLS France states no tracking number of a recognisable shape: its mails carry
an eight-character code under the label "Numéro de colis", and gls-france.com
was not a known carrier sender, so no card was ever made for one.

The sender is listed as GLS, and the code is read from its mail only, behind
that label, in capitals, with at least one letter in it: eight bare digits are
Mondial Relay's shape, and a shop that prints the same label is not GLS. The
card links to moncolis.gls-france.com, GLS France's own tracking page; the
eleven-digit gls-group.eu numbers keep their own link.

GLS's survey mail quotes the same code and is still refused.

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

---------

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:15:01 +02:00
fc1448ea1b fix(insight): read "invoice no" and "invoice number" as whole words (#100)
The invoice-number pattern announced a number with `invoice\s*(?:number|no\.?|#)`
and then took whatever followed. With no word boundary, "invoice no" matched
the start of "Manage invoice notifications", a footer line in a great many
billing mails, and the number came out as "TIFICATIONS". It is the same for
every bill a sender sends, and the number is the card's identity, so each bill
overwrote the one before it. "invoice numbering" went the same way.

It stayed hidden while a mail's footer was not in the text the extractor read.
Reading an Outlook mail's HTML in full puts the footer there, so the flaw
surfaces for those accounts the moment that lands.

Both words now end at a word boundary. A real "Invoice no. 4711" and "Invoice
number: INV-2026-001" are still read, each pinned by a row of its own.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:14:12 +02:00
Walid ManiandClaude Opus 5.5 5debbdbc01 Link one tab icon so the chosen one is not outvoted by the wax seal (#74)
Every page linked the per-account SVG tab icon and, after it, a static 192px wax-seal PNG. Firefox stores an SVG icon at width 65535 and a PNG at its declared size, and picks the smallest icon that is big enough for a tab, so the PNG won and every account saw the wax seal whatever it picked in Settings → Appearance → Logo.

The PNG tab-icon link is gone, so the SVG is the only tab icon. The apple-touch-icon and the manifest keep their PNGs. A browser with no SVG tab-icon support gets no tab icon instead of the wrong one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:49:49 +02:00
Walid ManiandClaude Opus 5.5 2a765d8b32 Let the calendar button skip the split view or the full-width calendar (#80)
The button in the top bar is a three-way switch: mail, the calendar beside the mail, the calendar full width. Settings → Calendar → Behaviour has one choice, Calendar button, that can make it a two-way one: All three (the default), Full width only, or Split only.

The position the button leaves out is not removed: a stored one closes to the mail on the first press, and the drag handle still reaches both ends. A mail link clicked while the calendar is full width goes back to the mail when the split is skipped.

One value in the user's settings bag (calendar.switch_skip), included in the config backup. Nothing is added to the database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:23:38 +02:00
Walid ManiandClaude Opus 5.5 f2ec124c8d Draw the action, not the state, on a row's read button (#61)
The row's read toggle drew the envelope the conversation is: closed while unread, open once read. So an unread row's "Mark as read" wore the closed envelope, and a read row's "Mark as unread" the open one. The selection toolbar draws the action instead, so the two disagreed.

It only looked like a Promotions problem because that tab is where mail is unread; in a list of read mail the row looked right by accident. The row now matches the toolbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:51:45 +02:00
Walid ManiandClaude Opus 5.5 ed76af23a5 Give a connected Gmail or Microsoft account its own colour (#60)
AccountCreator hands out Account::$colorIndex from the lowest free palette slot, but OAuthAccountLinker builds its Account without going through it, so every OAuth account kept the column default of 0 and wore the same dot in the sidebar and the same corner mark on every message row.

A new OAuth account now takes the lowest free slot, as a password account does. A reconnect finds the existing row and leaves its colour alone. Accounts connected before this keep the colour they have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:37:39 +02:00
6c719b3327 Give the mail row two lines between a phone's three and a wide list's one (#62)
A row went to one line once the list was 36rem wide, which with the reading pane open left the subject about 120px. One line now starts at 75rem. Below that the row is two lines, sender and subject then the preview across the whole row, and under 36rem the three it always was.

The hover actions show on any pointer that can hover, not only in one-line rows. A scheduled draft keeps its snippet beside the badge from 36rem. With the two-line preview setting on, a one-line row no longer wraps its preview.

Co-authored-by: Walid Mani <Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:19:17 +02:00
Walid ManiandClaude Opus 5.5 f19818fa50 Put the Delete forever buttons behind a Disposal setting, off by default (#71)
Settings → Mail → Disposal has one per-user switch, off until somebody turns it on. It decides whether Delete forever exists at all: the flame on a row in Spam and the bin, and a new Delete forever above a selection there. Both purge routes answer 403 while the switch is off.

A selection extended with Select all N empties the whole of Spam or the bin in a background job. Any other view is refused, and the worker checks the switch and each conversation's placement again.

The bin no longer offers Delete above a selection: it trashed mail that was already in the bin, took the rows off the page and they were back on the next load.

The choice lives in the user's settings bag and a config backup carries it. No migration.

Continues #56.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:01:23 +02:00
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
Walid ManiandClaude Opus 5.5 c7c9942c0e Show the sidebar's "+" buttons only on their heading row (#66)
The "+" beside Labels and Accounts was on screen all the time, for something done a few times a year. Each now appears while its own heading row is hovered, when it is reached with the keyboard, and always on a device with no hover to reveal it with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:41:50 +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 cb34fa8e28 Let the category tabs scroll instead of overlapping on a phone (#53)
The tab strip let every tab shrink (min-w-0) while keeping its label on one
line, so on a narrow card the labels ran underneath the next tab's icon.
Tabs now keep their own width and the strip scrolls inside itself, with
tighter padding below the list's @xl breakpoint. A small Stimulus controller
puts the current tab in view, since the strip is re-rendered on every tab
change and would otherwise reset to the left edge.

The strip has no visible scrollbar and a wheel only reports vertical
movement, so with a mouse on a narrow window the tabs past the edge could
not be reached (touch and a trackpad's sideways swipe already worked).
A vertical wheel over the strip now scrolls it sideways, and hands the
wheel back to the page once the strip has run out in that direction.

Covered by a Pixel 5 e2e spec: no tab is squeezed, the last tab is reachable
by scrolling, the current tab is in view on load, and the wheel scrolls the
strip until it runs out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:45:28 +02:00
Walid ManiandClaude Sonnet 5.5 a42ab3b762 Leave Live Photo motion clips out of the Immich picker (#47)
Immich stores the video half of a Live Photo as an asset of its own with
visibility "hidden", never shows it in its timeline and never makes a
preview for it. A metadata search lists every visibility but "locked"
unless asked for one, so those clips came back as grey placeholder tiles
that could only be attached as a three-second video.

They are dropped when an entry is built rather than filtered in the
request: the request can name one visibility only, and "timeline" would
take archived photos away with the clips. A server that predates the
field sends none, and such an asset is kept. Library, search, album and
person views all share the mapping, and a page of nothing but clips
still carries its cursor so the scroll goes on.

A page can now arrive with no entries and a cursor. The picker said
"empty" for any entry-less listing and rendered no results container,
which is what the next page is appended into, so an all-clips first page
would have swallowed every page after it. "Empty" is said only when
there is no cursor either, with two render tests for the two sides.

Fixes #46

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:44:07 +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