10 Commits
Author SHA1 Message Date
b453b79eb4 Single sign-on only: hide the password form and refuse password sign-ins (#210)
* 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>
2026-10-19 13:09:12 +02:00
Paul LütznerandClaude Opus 5.5 0591d9a854 Settings → Mail → Images: always show remote images (#124)
One per-user switch, off until somebody turns it on. With it on,
MessageRenderer treats every sender as if it were on the image allowlist,
so no message asks before loading its pictures. They still come through the
image proxy, so a sender learns that a message was opened but not the
reader's address. Mail in Spam stays blocked, as it does for trusted senders.

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

Closes #124.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:00:44 +02:00
09c503c61c Sign in through an OpenID Connect provider (Authentik, Keycloak…) (#106)
plMail could only sign people in with a local password. On an install that
already runs Authentik or Keycloak, that is one more account and password
per person, outside the provider's access policy.

A second authenticator on the `main` firewall, beside the password form.
Both end in the same token for the same User, so the user checker, the login
throttle and TOTP two-factor apply unchanged. Authorization code flow with
PKCE, state and nonce on league/oauth2-client's GenericProvider; endpoints
come from the provider's discovery document.

- Set up under a new Admin → SSO tab; the OIDC_* variables are the fallback
  for an install configured from its compose file, and a saved provider wins
  whole. This lets an administrator who points it at a provider of their own
  sign in as any account without a second factor; the security model says so.
- The ID token's signature is not verified: it only ever comes from the
  token endpoint over TLS (OIDC Core § 3.1.3.7 step 6). So the issuer and
  endpoints must be https and no request to the provider follows a redirect.
  Accepting an ID token from anywhere else needs JWKS verification first.
- An account is linked by (issuer, sub). The address is used for the first
  link only, and only when the provider reports it verified.
- Auto-creation is off by default and creates an ordinary account with no
  usable password.
- app:user:oidc-unlink forgets a link from the console, for somebody deleted
  and re-added at the provider.

Migration: two nullable columns and a unique index on `user`, and a one-row
table `oidc_config`. Nothing backfilled; reversible.

Not run: Playwright on this branch. No automated test makes a round trip
against a real provider; that was checked by hand on a local test stack.

Closes #91

Co-Authored-By: Paul Lützner <mail@pluetzner.de>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:17:00 +02:00
4d705a56da Keep remember-me tokens on the server, so signing out holds (#104)
* Keep remember-me tokens on the server, so signing out holds

A remember-me cookie that carries only its own signature stays valid
until it expires, and signing out only asks the browser to delete it.
A request already on its way with it - the page's own background
fetches - reached the server after the sign-out and signed the person
straight back in.

The tokens are kept on the server now (Symfony's DoctrineTokenProvider,
table rememberme_token), one per device. Signing out deletes this
device's token, so a late cookie opens nothing; other devices stay
signed in. Symfony's CacheTokenVerifier keeps parallel requests that
rotate the token from looking like theft.

Existing signed cookies are no longer read: people who relied on "Keep
me signed in" sign in once more.

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

* Say what remember-me is now, wherever it was described

Remember-me moved from a signed cookie to tokens kept on the server, and
five places still said "signature-based, not stored": the handbook's
security page, the config-backup page's account of why APP_SECRET stays
behind, and three comments beside the code that depends on it. English
and German both.

RememberMeAcrossRestoreTest proved its point against SignatureHasher,
which the firewall no longer uses. It now asserts what the property rests
on today: the firewall holds the persistent handler, and a backup taken
while a token row exists carries neither its series nor its value.

One test added for the claim the docs repeat: a changed password ends the
cookie, because the token carries a digest of the hash it was issued under.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <mail@pluetzner.de>
2026-10-13 17:18:43 +02:00
Paul LütznerandClaude Opus 5.5 8f7d2cb073 Work through the first-impression feedback in #37
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>
2026-10-09 19:51:39 +02:00
Paul LütznerandClaude Opus 5.5 ebeaf8cffa fix(security): add a per-username login limit
Symfony's default login throttle counts per username+IP and per IP, so a
guesser on many addresses got five tries per address at the same account,
while the comment in security.yaml claimed it was per username. A custom
LoginRateLimiter keeps both and adds a username-only limit (20 per 15
minutes, set above what a mistyping person reaches since it can also be
spent by someone else). Comment and handbook corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:15 +02:00
Paul LütznerandClaude Opus 5.5 dee4257d3f fix(security): check every hop of user-supplied URLs against private networks
The Nextcloud, Paperless and Immich drivers followed up to twenty redirects
unexamined, the URL validator let any hostname through without resolving it
(container names, nip.io, 127.1, 2130706433, mapped IPv6), and push endpoints
were not checked at all. Requests to user-supplied URLs (the three drivers,
CalDAV, ICS feeds, Web Push) now go through UserUrlHttpClient, which wraps
NoPrivateNetworkHttpClient and re-checks every hop. The validator resolves
the host. LAN integrations keep working through the existing
INTEGRATIONS_ALLOWED_HOSTS and admin-pinned server addresses; push endpoints
get their own PUSH_ALLOWED_HOSTS for the bundled ntfy, and never redirect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:15 +02:00
Paul LütznerandClaude Opus 5 376cf4ae72 Add Paperless-ngx as an attachment source and a save destination
Connect it with an address and an API token and it appears beside the other
services; nothing else in the UI had to change, because the picker, the
destination chooser and the connect dialog are all driven by the provider
taxonomy and the driver interfaces.

TAGS ARE THE CONTAINER LAYER. A document archive has no directory tree —
Paperless replaced folders with tags and expects the rest to be found by
searching — so the picker's "descend into a container, pick a file" has
nothing to descend into. The root lists every tag as a folder with the newest
documents beneath it, and opening a tag lists that tag's documents.
Correspondents and document types would serve equally well and are
deliberately not offered: three sideways slices of one archive is a navigation
problem, and search already crosses all three.

Search is Paperless's own full-text index, so it reads the OCR layer as well
as the metadata and a scanned invoice is findable by a line item nobody typed.
That is the feature: there is no longer a reason to open Paperless, find a
document and download it before writing the mail.

Upload is asynchronous — post_document answers with a task id, not a document
id — so upload() returns it prefixed. Ids are opaque outside the driver, which
is what makes the prefix safe, and download() refuses a task id with a
sentence a person can act on rather than letting it 404 later.

ShareLink is not declared. Minting a Paperless share link is a permissioned
feature with its own expiry semantics, not something to do silently. The
taxonomy test that assumed only photo services lack share links had a false
premise; it now states the real rule rather than being relaxed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:51:58 +02:00
Paul Lützner c8dffdf0a1 fix(mail): a message is read on our terms, not the sender's
Opening a message told the sender it had been opened. Eight images loaded from
images.ctfassets.net the moment a newsletter was rendered — one of them 526x5,
which is what a tracking pixel wears when it is pretending to be a spacer — and
each one carried the reader's IP address, their user agent, the time, and, from
a per-recipient URL, which recipient it was. Read receipts, working perfectly,
for anyone who cared to send one.

Remote images are blocked now, and the block is not the interesting half. The
interesting half is that saying yes does not undo it: an opted-in image loads
through /mail/image-proxy, which fetches server-side and streams the bytes back,
so the sender learns that somebody's plMail asked and nothing else. There is
also "always for this sender", per user, in a table with a unique constraint —
because trusting a sender is a button in a message, the same message can be open
in two tabs, and an allowlist that a double click can duplicate is an allowlist
that a race can lose an entry from.

The proxy is a service that connects wherever a stranger's mail tells it to,
from inside the container network, so its rules are stated in order and tested
one by one: https only; port 443 only, which removes port-scanning rather than
blacklisting the interesting ports; every address the host resolves to must be
public, and the connection is then PINNED to a checked address through the
client's `resolve` option, because checking and letting the client resolve again
is the whole rebinding hole; redirects followed by hand, three at most, each hop
re-running all of that, since max_redirects would follow a public host to
169.254.169.254 past every check; an image content type; 8 MB. Every refusal
answers with the same transparent GIF, because telling the caller "refused"
rather than "timed out" against an internal address hands back exactly the
port-scan result the rules exist to deny. The signature on the URL is NOT the
SSRF defence and says so in its own docblock — anyone with an account can mail
themselves a link and be handed a valid signature for it.

Deliberately not IntegrationUrlValidator, which has an allow-list and an
admin-pinned base URL because a user is naming their own Nextcloud on a LAN.
Importing its escape hatches here would mean an admin who allowed a LAN host for
Immich had also allowed every sender's mail to reach it. The docs there claimed
pinning the resolved IP "needs something Symfony's client does not expose";
that is not true, `resolve` does exactly that, and the correction is in this
commit next to the code that disproves it.

The body no longer renders in the app's own DOM. The sanitizer held — nothing
got through it — but "no gaps, forever, against every sender" is a claim that
has to keep being true, and the price of it being wrong once was a same-origin
script with the session cookie. It renders in an iframe srcdoc whose value is
what is absent from its sandbox: no allow-same-origin, so an opaque origin with
no cookie, no storage and no reach into this document; no allow-forms, so a
phishing form cannot even be submitted; no top-navigation, no modals, no
downloads. allow-scripts IS present, and that is a deviation from the brief
worth stating plainly: height is a fact only the framed document can measure,
a frame without allow-same-origin cannot be measured from outside, and the
alternatives were a fixed height or a guess. It is paid for by the frame's own
CSP restricting script-src to a per-render nonce, which our thirty lines of
height-and-hover reporting carry and no markup arriving in an email can. That
CSP also names our origin in img-src explicitly — not 'self', which in an opaque
origin matches nothing — so the blocker and the browser both have to fail before
a pixel fires.

Every warning is drawn OUTSIDE the frame. A banner inside the sandbox is a
banner the mail can paint for itself, and a "Show images" button a message can
forge is worse than no button. The hovered-link preview follows the same line:
detected in the frame, where the links are, drawn by the parent, so a message
cannot paint a status bar claiming a different destination.

A Hetzner-branded invoice phish sat in Spam and rendered at full fidelity with
nothing said about it. Messages in Spam now carry a banner, and the sender-name
check is two rules that fit in a sentence each, because a warning nobody can
check is a warning people learn to click through. Rule 1: the display name
spells out a domain that is not the sender's. Rule 2 runs only when the display
name carries a legal form as a whole word — GmbH, Inc, Ltd — which is what
separates an organisation from a person; then, if none of its non-generic words
of four letters or more occurs in the sender's registrable domain, the names
disagree. "Hetzner Online GmbH" <support@ownkhalsick.com> fires; the same name
at hetzner.com does not; "Deutsche Bahn AG" at bahn.de does not; "Jane Cooper"
is never judged at all. DKIM passing for the sender's own domain suppresses
both, since a domain that signed the message may write what it likes. A
lookalike domain containing the brand is a known miss and has a test saying so
— catching it means edit distances against a brand list, and every false
positive that bought would be spent on ordinary mail.

A trusted sender still gets no images inside Spam. The allowlist records a
belief about a sender; a message in Spam is the provider disagreeing about
whether this really is that sender, and the cautious reading of a disagreement
is the right one.

Blocking runs at RENDER time, not at ingest, and both reasons matter. Every
message already stored was sanitized before this existed, so an ingest-time
block would have protected only mail that had not arrived yet. And the answer
depends on who is asking, because the allowlist is per reader — a stored form
cannot carry an answer that varies by reader.

Two layout fixes ride along, from the same report. Attachments rendered after
the full HTML body, some 750px below the fold on a long message, and an
attachment nobody scrolls to is an attachment nobody was sent; the chips move
under the header, the same partial, relocated rather than duplicated. And the
message header is sticky within the reading pane, so the answer to "who is this
from" is not a scroll away from the sentence that raised the question.

Verified in a browser, because every claim here is a claim about what the
browser does. tests/e2e/rendering-security.spec.ts asserts on route
interception — no connection to the sender's host, before or after opting in —
and its matcher is a hostname predicate rather than the obvious glob, which
would have matched the proxy's own URL because the sender's URL lives inside its
query string, and reported a leak at the exact moment the feature worked.

Two traps found on the way, both now in the handbook. Dom\HTMLDocument rejects
LIBXML_NOWARNING with a ValueError rather than ignoring it, and the blocker
fails closed, so the symptom was every message rendering as plain text:
perfectly private and completely unreadable. It also needs the UTF-8 override,
because MailBodySanitizer drops <head> and the body has no meta charset left.
2026-08-11 17:36:23 +02:00
Paul LütznerandClaude Opus 5 4d103cfbdf Write the handbook, and make it fail the build when it goes stale
README says what plMail is and gets you running; CONTRIBUTING says how to
change it. Nothing covered the part in between — using it, operating it,
and registering the Google and Microsoft applications it talks to. That
is docs/: 34 pages across features, installation, providers and
internals, mirrored to the GitHub wiki.

The wiki is a mirror and never a source. bin/mirror-wiki.php flattens the
tree into the wiki's flat namespace, rewrites every relative link,
generates the sidebar from the index so the two cannot disagree, and
stamps each page with a footer saying where it came from. A browser edit
survives until the next push to main. That cost is deliberate:
documentation not versioned with the code drifts from it, and the drift
is invisible until somebody follows an instruction that stopped being
true.

Which is the whole problem, so the checkable half is checked.
DocumentationCoverageTest fails the build when a command is missing from
the reference or documented and gone, when a variable in .env is absent
from the configuration page, when a relative link dangles, when a page is
unreachable from the index or promised and missing, or when a page has no
"Things that bite" section. It cannot check whether a paragraph is TRUE,
and says so — this is the floor, so review attention goes to the part a
machine cannot have an opinion about. CONTRIBUTING has claimed since it
was written that the command table "is part of the definition of done";
nothing enforced it until now, and it was wrong in three places.

Writing it found things. They are fixed here rather than documented
around, because a handbook describing a bug accurately is still a
handbook nobody can follow.

  compose.yaml            mounted no volume for var/attachments, var/raw
                          or var/uploads. Workers write those during sync
                          and the web container serves them back, so each
                          kept its own copy in its own layer — downloads
                          404, and everything died on a recreate. The
                          override and the TrueNAS file both mount them;
                          the file the README tells an operator to run did
                          not. The same omission cost real data once
                          before. See the CHANGELOG for moving what is
                          currently stranded.

  app:calendar:materialise  new, nightly. Occurrences are drawn when an
                          event is saved and nothing moved that window
                          again, so a weekly standup ran out of dates in
                          two years and its reminders stopped with them —
                          silently. The query it runs had existed since
                          the materialiser did, documented as exactly this
                          sweep, and had never been called.

  Microsoft wizard        asked for three of the nine permissions plMail
                          requests, and a token already issued is not
                          upgraded when the rest are added later.

  GmailWatchService       told you to grant Pub/Sub Publisher to
                          gmail-api@system.gserviceaccount.com. The
                          account is gmail-api-push@; a topic granted
                          exactly what the docblock asked for never
                          delivered a notification.

  IcsExporter             said it wrote no VALARM. It has written them
                          since alerts landed.

  CLIENT_DEVELOPMENT.md   said there was no JMAP calendar API and that
                          SearchSnippet/get was absent. Both shipped.

  CONTRIBUTING            said only app:calendar:push registers a push
                          channel; subscribing has done it since the
                          subscribe flow learned to.

  Three docblocks         named getters removed long ago, or described a
                          method two below the one they sat on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:13:25 +02:00