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>
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>
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>
* 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>
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>
* 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>
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>
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>
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>
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>
- The migration is dated after the ones main has run since, under a name
no open branch uses (#109 and #112 hold the earlier slots today).
- The inbox's query ceiling is one above the shared one again: main
raised that to 28 for the sidebar's change counter, and the own-tabs
read is still the one statement the inbox adds on top.
- French for the new strings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
The badges are patched from /mail/sidebar/counts, and what made a tab
ask was a list: the sync's hub events, and announceWrite() called by
each controller that wrote. Whatever was not on the list had a stale
badge. Measured in a browser (#86): a draft autosaved, the server
counted 1, the badge stayed at 0, and in forty seconds the page made no
counts request at all. The composer saves through a Turbo form on a
timer and had no controller to call from; the send that follows is a
worker's, ten seconds later, and a mail read on a phone is another
client's. No browser code can announce those.
Every change to a user's mail already passes one place, StateManager,
because a phone's /changes has to be complete. That is now what tells
the browser too:
X-Mail-Changed: 1 on the response to any request that recorded a
change (MailChangedHeaderSubscriber). mail_writes.js
reads it off every fetch and every Turbo request
and announces the write
mail.changed on the hub, from the drain that already tells the
phones (MailChangeNotifier), for a change this tab
did not make
state:sequence the change sequence the counts were read at, in
the counts answer and on the rendered sidebar. A
tab ignores a nudge that is not newer
The sequence is what keeps it to one request per change. The hub's
nudge for a tab's own write was measured arriving before that tab's
counts request had left, so the comparison waits 400 ms and for
whatever is on the wire. announceWrite() gathers calls for 120 ms, since
a write is now announced by the header and, where one exists, by its
controller.
Opening a conversation marked it read every time, read or not: seenAt
was replaced, the provider told again and a JMAP change recorded. With
the header that became a counts request behind every click on read mail.
ThreadStatusUpdater::markRead() now writes only the messages that
change, keeps the first seenAt, and records nothing when nothing moved.
One query more on a page that draws a sidebar (the sequence); none on a
list-frame navigation.
Not exercised end to end here: the worker's own send. The test stack's
send transport is in-memory, so the browser test for "a change made
elsewhere" uses a second tab.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
Select-all while looking at a tab the reader made must act on that tab, never
on Primary; and the counts endpoint must carry the right values for the tab's
new-mail dot and sender hint, not merely the keys.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A card on Settings -> General beside the sorting card: a list of the reader's
categories with rename and delete, and a form to add one. A name is trimmed,
at most 40 characters, not a built-in tab's name and not one the reader already
has (case-insensitively); a refused name comes back as a toast saying why.
Removing a category un-files its mail through the foreign keys and clears the
pins of everything in it first, in one transaction: a foreign key cannot clear a
pin, and a conversation pinned into a category that has gone would be skipped
by every recompute for ever. A pin to a built-in tab or to another category is
left alone. The filter list shows a category chip naming its category, or
"(deleted category)". The unused colour column is dropped from the new table.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dropping on one of the reader's own tabs files the conversation there and pins
it, leaving the built-in category alone (JMAP clients are served that column).
Dropping on a built-in tab from one clears the reader's tab, so a conversation
is never in two. "Already there" is a statement about both columns. The route
resolves the posted key against the dropping reader's own categories, so
somebody else's category is refused like a made-up one and nothing moves.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A category the reader made is a tab beside the five, shown while it holds mail
(or is being looked at) and otherwise only as a drop target. A conversation is
in exactly one tab: its own when it has been filed in one, else the built-in
one, so a carrier's notice is listed in Shipping and no longer in Promotions.
The grouped counts, the new-mail dots, the sender hint, the select-all view and
the counts endpoint all key a tab the same way.
InboxTab is the one value for "a tab", whose key for a built-in is exactly what
it always was. InboxTabs resolves a key against the asking reader's own
categories, answers a key that cannot be custom without a query, and reads the
categories once per request. Another reader's category, or a deleted one, is no
tab and opens the inbox like any unknown key.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The inbox visit was at its query ceiling of 27 and the inbox frame at 15, both
exactly. The reader's own tabs need one indexed read of inbox_category, made
once per request, so the two ceilings move to 28 and 16 with the reason written
beside each. The other five lists are unchanged.
This is the only pre-existing assertion the feature changes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A filter can now file the mail it matches under one of the reader's own
categories. RuleCategoryFiler decides whether it may: only a message whose
sender passes authentication (a forged From line must not sit in a tab named
after the real sender), only into a category the rule's owner owns, and, on a
Gmail label, only when the reader asked plMail to overrule the provider. A
refusal leaves the message to the ordinary rules. When two filters match, the
one that runs last decides.
The editor offers the action only to somebody who has a category (or whose
rule already carries one), so nobody else's editor changes. Ownership voting,
the plain-English restatement and the three catalogues learn the new action.
RuleActionExecutor takes the filer as a constructor argument, so the one test
that builds it by hand passes a real one.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
`?` opens the list of keyboard shortcuts, and a key nobody has told
you about is not a way in. A keyboard button on the list toolbar opens
the same dialog, beside the sort menu, on the bar the keys act on.
Only for somebody who has the shortcuts on: switched off it would
advertise keys that do nothing, and the list is still in Settings for
deciding to switch them on. Not drawn at phone widths.
Refs #36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
j and k move a cursor through a list, o opens, u goes back, x
selects, e archives, # deletes, ! is spam, s stars, Shift+i and
Shift+u mark read and unread, l v b open label, move and snooze,
r a f reply, c composes, / searches, g then a letter goes to a list,
z presses Undo and ? lists all of it.
A key presses the control the page already has and does nothing else.
ui--shortcuts archives nothing itself: it finds the toolbar's Archive,
the row's star, the Reply link, and clicks it. The request, the Undo
toast and the refusal where an action is not offered are the
button's, so a shortcut cannot drift from the button it stands for.
What it acts on, first that exists: the selection through the list
toolbar, the open conversation through its own, the row under the
cursor through the row's buttons. Label, move and snooze have no
button on a row, so the row is ticked and the toolbar's menu opened.
The cursor is an attribute repainted from a remembered id, not focus:
rows are replaced by every refresh, and focus would be dropped to
body each time.
Keys are left alone while something is being typed into, while a
dialog or a menu is open, and with Ctrl, Alt or the Command key held.
A message body is a sandboxed frame with a document of its own, so
its script relays the facts of a keydown to the parent; otherwise the
shortcuts stopped the moment a mail was clicked on.
On for everybody, off per person under Settings, General. Off is the
controller not being mounted, so there is no flag for the script to
forget to read. Stored in the settings bag as a false only, so no
migration, and carried by config backups.
Closes#36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some people do not want their inbox divided. A third control on the
sorting card, Inbox tabs, switches the tabs off per person; on by
default and true on every existing row, so nothing changes by
upgrading. One column, user.category_tabs.
Off changes what is drawn and nothing about what is stored. Mail is
still filed under a category as it arrives, so switching back shows
sorted tabs at once and nothing has to be re-filed in either
direction, which is also why the control queues no ResortMailbox.
That includes the assistant: somebody who leaves it sorting into tabs
they have switched off has decided that, and the card says so when
both are true rather than preventing it.
The inbox query takes a null category for the whole inbox, through
one helper shared by the list and its count. ListViewResolver decides
from the person's setting and not from the value the page posts, so
"select all in this view" is the list the server would have drawn: an
untabbed page posts an empty value, and reading that as Primary would
have acted on part of a selection.
What only exists for tabs goes with them: the strip and its drop
targets, the per-category keys in the counts payload and the grouped
reads behind them, and the panel in message details that explains a
filing and takes "wrong tab" reports.
The JMAP session publishes the choice as inboxTabs beside the
appearance hint. Thread.category is served either way, so a client
drawing tabs from it had no other way to learn not to.
Closes#29
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Eleven of the twelve items. The twelfth, a deleted label coming back
after the next sync on IMAP, is left for an issue of its own: there a
label is a folder, and what deleting one should do to the mail in it
needs deciding first.
A selection survives the pager. It was nothing but the ticked
checkboxes, so "next page" replaced the rows and the selection with
them. The rows that are not on screen are now remembered in
assets/mail_selection.js, per list, and the toolbar, the label menu,
the move menu and a drag all read the selection from there. The
checkbox stays the truth for anything visible.
The whole view can be selected from the select menu. It was only
offered by a banner that appears once a page is fully ticked. And a
whole-view selection no longer survives a row being unticked: the
action still ran on everything.
A sync no longer logs a stored message as one it could not build.
MessageSyncer reads the UIDs a folder holds once, before it fetches,
and a move made meanwhile files rows under UIDs that list does not
have. The INSERT was refused on (mailbox, uid), rightly, and each
message was then held back as a failure. The list is read again when
a batch is refused.
A pairing code and the JMAP address under App passwords carry the
configured public address, and the browser's only when none is set.
This reverses the earlier choice in DevicePairingController, which
handed a phone paired at home a LAN address.
Hiding quoted text gives the height back. scrollHeight cannot report
less than the frame it is measured in, so the collapse reports the
root element's box instead and holds other reports until the parent
has resized.
Mail in Sent shows its images without asking. Read off the Sent label
and not the From line, which a forged mail fills in as it likes. Spam
still wins.
Smaller: placeholders are lighter than typed text, the actions on a
connected integration are buttons on one baseline, Save signature and
four composer buttons no longer hover to white on white, the Spam
badge loses the accent, and German says "vor 12s".
Refs #37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A visitor who opened Settings → Templates on a fresh demo found one
empty folder and a button, and the compose window's picker said there
were none. Both true, neither showing what the feature does. The
provisioner now seeds six: a quote follow-up, an invoice reminder, a
payment receipt, a meeting follow-up, an out-of-office and a short
acknowledgement, filed at the top level, under the demo account and in
a folder inside it. Between them they use every variable, and the date
one with offsets in both directions and three formats.
app:test:seed-demo seeds the same six, for the reason it seeds the
same mailbox: a screenshot of the picker should be a picture of the
demo.
They are strings with tokens in them, which nothing checks — a
misspelt variable is braces left in the text — so DemoTemplatesTest
renders every one for a recipient and fails on an open variable or a
brace. It also erases a visitor with nested folders, since the eraser
removes rows one by one and folders point at each other.
The demo account is not given a signature. The signature variable is
in two of the templates and renders as nothing until the visitor
writes one, rather than a signature appearing in every new message
and in the README's compose screenshots to make one variable busier.
One layout bug fell out of looking at it. A long folder path —
"you@example.com / Quotes and invoices" — pushed the editor's Folder
dropdown out past the card's edge; the control may now shrink and the
path is cut with an ellipsis.
Refs #26
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings → Templates is a new section: a tree on the left, one editor
beside it. The tree opens with one folder per mail account, and those
are not stored — an account folder is the account, drawn as a folder,
so the mirror cannot drift. Folders the user makes go inside an
account, inside one another or beside the accounts. Removing an
account or a folder keeps the templates in it and moves them up.
A template holds variables: the recipient's first name, name and
address, the sender's name and address, a signature, and dates with an
offset and a format of their own. They are stored as text tokens in
the body, because the body goes through the inbound-mail sanitiser and
a marked element would not survive it; the editor draws them as chips
and turns them back before it posts.
The compose window gets a button beside the signature one. Dates,
sender and signature are filled in by the server when a template is
inserted. The recipient is not: a template is usually picked before
anyone is addressed, so recipient variables arrive as markers and the
window fills them when To has somebody in it, and asks before sending
with one still open. A template's signature replaces the window's
rather than joining it, and one the template names is pinned so a
change of From leaves it alone.
TemplateTokens the notation, read the same way in PHP and in
assets/compose/template_variables.js
TemplateRenderer what each variable becomes, and who fills what
TemplateLibrary the tree, and the only writer of where a
template or folder sits
One bug fell out of running it in a browser. Chromium will not put a
caret between a non-editable element and the edge of its block, so a
template ending in a variable could not be given its full stop — the
keystrokes were dropped. The editor keeps a zero-width space beside
each chip and strips them before saving.
Two new tables, mail_template and template_folder. Web only so far:
nothing is published over JMAP yet.
Refs #26
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A button beside Delete — on a list row, in an open conversation and
above a selection. On one conversation it opens a menu: move it to
Spam, or move it and create a filter for the sender, or for the
sender's whole domain. The domain filter asks once more first, and for
a mail provider's domain it sits behind "More options". The filter is
an ordinary rule under Settings → Filters, a second press on the same
sender makes no second one, and Undo takes back the move and a filter
the same click created.
The filters match on two new conditions, fromAddress and fromDomain,
which compare the whole address or the whole domain. `from` is a
substring over address and display name, so a filter for a@x.de built
on it also caught ba@x.de. Both are in the filter editor and in JMAP
Email/query.
Refs #31
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>