mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
main
17
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> |
||
|
|
994c00b5cd |
What's new opens after an update, with a way to send feedback (#166)
* 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> |
||
|
|
ffbf7bdd9b |
Clean up the compose files (#160)
Compose files keep only a short header; the reasoning lives in docs/install. truenas.compose.yaml is gone (the TrueNAS catalog app replaces it) and so is compose.prod.yaml (compose.yaml already runs the published prod image). compose.override.yaml.dist becomes compose.dev.yaml, passed with -f, so there is no untracked copy that compose loads on its own. Docs, CODESTYLE, CONTRIBUTING and the entrypoint's error text follow. Every remaining file resolves to the same `docker compose config` as before. Closes #30 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
a8aea33874 |
Open a fresh install from the LAN, not only from the machine it runs on
The stock compose file served https://localhost and nothing else. Reached by address or hostname, Caddy had no certificate to offer and ended the handshake, which a browser reports as an SSL protocol error with no way to proceed. A headless server could not be set up without first setting SERVER_NAME. The default now answers every name and address with the self-signed localhost certificate (default_sni/fallback_sni, and `:443` in the default SERVER_NAME), so the browser shows its usual warning. Live updates follow: compose.yaml's https://localhost hub address is treated as the placeholder it is and gives way to the public address stored during setup. Any other MERCURE_PUBLIC_URL, and any SERVER_NAME an operator sets, is left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
533b773b33 |
Stop needing to be told what a catalogue can work out
Packaging plMail for a catalogue that forbids per-app workarounds turned
up four places where the image expected its compose file to compensate.
cookie name Chosen from the public address: the __Secure- name on
https, a bare one otherwise. It used to be prefixed
unless an operator knew to set MERCURE_COOKIE_NAME, and
an install opened on http://ip:port never got a live
update. The hub always reads the bare name and the web
server renames the cookie in the proxy, so the two
cannot disagree after only one of them restarted.
storage dir An absolute APP_STORAGE_DIR inside the project is
accepted and normalised to the relative form, so the
stored attachment paths do not change. One outside it
is refused at start.
hub address MERCURE_UPSTREAM names the container the web server
proxies the hub to, for a stack that does not call it
mercure.
database secret No POSTGRES_PASSWORD is minted, and no password file
written, when DATABASE_URL already carries a password:
that database is somebody else's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
be3b27cf32 |
Let an administrator change the public address after setup
Setup asked for it once and nothing could answer it again. An install restored from another machine's backup therefore kept the old machine's address: browsers were sent there for live updates and the stream indicator stayed red, with an environment variable in the compose file as the only remedy. Admin → Address edits the same value setup writes. The change is saved to the generated file and takes a restart to apply. Making it live was left out on purpose: the entrypoint exports that file into every container's environment at start, and telling a stale export from a deliberate pin needs more machinery than a restart costs. The page shows the address in use beside the saved one whenever they differ, and offers the web container's restart after every save. One thing fell out. A hub address on file that is only the previous public address with the hub's path on it is dropped on save, so it is derived from the new address again; a hub address of its own is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
9c25eae1bf |
fix(secrets): the JWT keypair lives beside the generated secrets
.env named var/secrets/jwt outright, which is beside the generated
secrets only while APP_SECRETS_FILE keeps its default. The TrueNAS
layouts point it into their data dataset, so there the keypair landed on
each container's own throwaway filesystem: every service generated one
on every start ("Generated the JWT keypair." in the log), and a token
one of them signed would have been refused by the others and by the
next restart. Nothing issues JWTs yet - the Android app pairs into an
app password - so nothing broke, but the promise in the comments that
every service verifies what the others sign did not hold.
- config/bootstrap_generated_secrets.php derives both paths from the
directory APP_SECRETS_FILE is in, for every PHP process including a
`docker exec` console. A real environment variable still wins; the
.env defaults are empty, as for the other derived values. On the
standard compose the paths are the ones they always were.
- app:secrets:init generates under a lock, rechecked once held, as
GeneratedSecretsFile::ensure() does. lexik's --skip-if-exists is a
check followed by two writes, so services starting together could
leave one's private key beside another's public key - a narrow window
that seven concurrent runs did not hit in five rounds, and closed now
rather than left rare: with the lock every round generated exactly
once and left a matching pair.
- A reset that rotates secrets removes the two key files where they
are, instead of emptying a fixed var/secrets/jwt.
- The security.yaml note on "the future first-party app" is corrected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
a8364e34d2 |
feat(deploy): one worker container runs every background process
The stack was nine long-running containers: the web server, the database, the Mercure hub, and six of plMail's own image, one per background process. It is three now: php, worker and database, with secrets-init still running once and exiting. The worker runs `app:work`, a small supervisor keeping seven processes alive the way Docker's restart policy kept seven containers alive: the IMAP supervisor, one consumer per queue, the scheduler and the hub. A clean exit (the hourly recycle, the restart signal) comes straight back; a crash backs off from one second to thirty. Output passes through a whole line at a time, so two processes' JSON records never interleave, and SIGTERM reaches every child at once with 25s to finish. Each queue keeps a process of its own, which is the part the split was for: a consumer inside a long handler cannot take a send. And each process keeps its container's old name as APP_CONTAINER_NAME, so heartbeats, /healthz and the log filters are unchanged. The hub is the official Mercure 1.x binary, copied into the image and run as one of those processes. Not FrankenPHP's built-in hub, which is 0.24 and refuses the RFC 9068 tokens plMail has minted since v0.2.31. The worker answers to `mercure` on the network, so the Caddyfile proxy and MERCURE_URL reach it where they reached the hub container, and older compose files keep working with this image unchanged. It is configured from the application's own settings, including MERCURE_COOKIE_NAME, so a plain-HTTP install names the cookie once for both halves. A 5s shutdown grace stops it waiting on open subscriptions, which made every stop take the whole 25s. The dev override loses its per-worker blocks, one of which (worker-bulk) had been missing, so that queue ran the published image's code in development. The demo overlay runs app:work --without=imap-supervisor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
5f648864ab |
feat(admin): set the log level from the page that shows the logs
Asked for directly: "let the admin panel set the log level. i do not want to mess with env vars." APP_DB_LOG_LEVEL meant editing a file on the host and restarting the stack, and the moment it is actually wanted is the moment that is most awkward — something is wrong, the answer is one level further down, and restarting throws away whatever was in flight. So the level gets turned down, forgotten, and left down. WHAT IT IS NOT Not another control in the filter row. That row narrows what is SHOWN out of what was stored; this decides what gets STORED. They look alike and mean opposite things, and side by side they would invite an admin to change the wrong one and conclude the log was broken. Its own row, its own form, its own wording. NULL IS NOT "TODAY'S VALUE" A stored level of null means "follow the environment", which is deliberately different from copying APP_DB_LOG_LEVEL's current value into the row. An install that has never chosen keeps following its configuration including later changes to it, and there is a way back to that after a level has been set — which storing the resolved value would have removed, silently, on the day the env var next changed. PER RECORD, NOT PER PROCESS Monolog fixes a handler's level at construction. That is right for one configured in a file and useless for one an administrator can turn down from the page they are reading, so DoctrineLogHandler decides in isHandling() instead, and the constructor's level is now only the answer when no resolver was given — which keeps directly-constructed handlers, and every existing test, behaving as they did. LogLevelResolver caches for ten seconds. A query per log line would put a database round-trip inside the thing that runs when the database is in trouble; a value cached for the life of the process would leave every long-lived worker ignoring the change until the next deploy, and the worker logs are usually the ones it was lowered for. It uses DBAL, not the ORM, for the same reason the handler does, and every failure — no table yet, no database, a name that is not a level — falls back to the environment rather than throwing. A logger that throws while deciding whether to log is worse than one briefly out of date. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
748e1cdcdf |
feat(demo): a privacy notice, and two fixes found by using the demo
**The privacy notice gets its own page and its own link.** A section inside the Impressum is not one — it has to be reachable in its own right — and it is required even though this instance asks for nothing. A web server writes the caller's IP address into a log on every request and an IP address is personal data, so "we collect almost nothing" is a statement that has to be made rather than a reason to omit the page. Written from what the software does rather than from a template. The cookies named are the three it actually sets, observed on a real visit; the claim that nothing reaches a third party is the content security policy, which permits connections to this server only; and the retention period is read off APP_DEMO_TTL through DemoMode::ttlDescription() rather than typed into the prose, because a notice stating a period the reaper does not honour is worse than one saying nothing, and two copies drift the first time somebody tunes the setting. APP_DEMO_PRIVACY_HOST names whoever runs the machine, who is a processor. Unset, the page says so in red — the same rule the Impressum follows, for the same reason: a legal notice naming nobody looks compliant from a distance. **Pressing Receive twice looked broken.** The second press appeared to do nothing for up to thirteen seconds. _refreshList() coalesces bursts through a fifteen-second floor measured from the LAST refresh, so the press right after a delivery was the one certain to be inside it. The floor is right — a sync publishes one event per mailbox per account, and it turns eight fetches into one — but it was being applied to somebody who had just pressed a button and was watching for the result. refreshNow() skips it; onAccountSynced() still does not. The difference is whether anybody is waiting. **The user menu still wore its own tooltip.** The previous fix stopped a NEW bubble appearing over an open menu but left the one already on screen, which is why it vanished only when the pointer left. There is a document-level click listener whose whole job is to take it down, and ui--user-menu#toggle calls stopPropagation(), so the click never reached it. The listener runs in the CAPTURE phase now — at the document, before the event reaches its target, where no handler can hide a click from it. The same reason the scroll listener above it already uses capture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e9a441333 |
fix: an install page a demo cannot close, and eleven flashes nobody translated
Two bugs found by using the thing. **A demo visitor could lock an install out of /install.** InstallGuard closes that page as soon as a user exists, and demo mode mints one per arrival — so on a fresh demo instance the first passer-by or crawler took the install page away with them, permanently, before the operator had made themselves an administrator. Nothing announced it either: the page 404s, which is exactly what it does on a correctly installed instance, so the symptom is indistinguishable from working. Both the guard and createFirstAdmin's advisory-locked re-check now count real users only. **Flash messages reached the page as raw translation keys.** The flash bag on this install holds sentences, not keys — the toast region prints what it finds, and cannot translate, because most of these interpolate a name or a count only the controller has. Eleven call sites across TwoFactorController, SecurityStepHandler and AdminDashboardController pushed keys instead, so a rejected code produced a red toast reading "two_factor.flash.code_rejected". The strings existed and had been translated into all three locales months ago; nothing looked them up. A static test now scans src/ for flash calls passing a dotted literal, because nothing about that failure is visible in review — the call site reads exactly like a correct one. That fix broke two E2E tests, which is the interesting part: they asserted on "Two-factor authentication is on" and had been matching the panel heading ALONE because the toast beside it was showing a key. Translating it made both match and strict mode rightly refused. They were passing because of the bug. **Demo mode, from running one publicly.** The account form opened in a turbo-frame, so the guard's redirect had nowhere to land and the modal span forever — it now renders a panel saying why the form is not coming. The receive button was a full browser navigation, which reloaded the page on top of mail Mercure had already delivered AND wiped motion.js's in-memory set of seen rows, so the whole list replayed its entrance; it is a stream touching only the demo bar now, and mail simply arrives. Plus an Impressum, because a public demo in Germany needs one — blank by default and loudly saying so, since § 5 TMG wants a real operator and a notice naming nobody looks compliant without being so. **Interface, all reported from use.** The snooze menu's two distant options were taller than the near ones, because anything past two days carries the date and "Sat, Aug 29, 08:00" wrapped inside min-w-56. Clicking a dropdown trigger focused it, and focus shows a tooltip — so opening the user menu painted its own hint across the menu. The eight recovery codes ran together on one line: they were joined with real newlines and <code> collapses those without whitespace-pre-line, so nothing was rendering what was already there. Confirming a 2FA code gave no sign anything was happening, and the reply carries codes shown exactly once. Layout was the last <select> on a panel where every other short choice is a segmented group. Solar and paper put a white field on a warm off-white surface, which under the boxed layout's translucency left the search box with no visible edge. Read receipts had a tab of their own coming — the card holds nothing else, and signatures left the alias tab before it for the same reasons. And /settings opened on its fourth entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
231d0ab0da |
feat(demo): a public demo mode, with mail that arrives on demand
plMail is hard to show. It is a mail client, so everything interesting only
happens once there is mail — and a stranger cannot reasonably be asked to hand
a demo server their IMAP password to see it. APP_DEMO_MODE fills that gap: mail
that is nobody's, and a button that makes more of it arrive.
One switch for the whole install, not a per-account flag. The promise is that
nothing reaches the network, and that is only checkable if there is one place
to check; a per-account flag would move the promise into every outbound path
individually, where it holds until the first one that forgets to ask. Read
through `default:` so an install that never heard of the variable is a normal
install — the accident worth designing against is a real deployment becoming a
demo, not the reverse.
**A mailbox per visitor.** /demo provisions a throwaway user, signs them in and
drops them in a seeded inbox. One user each rather than one shared account: two
people arriving at once would otherwise be marking each other's mail read and
reading each other's drafts, and the product would look broken while working
perfectly. app:demo:reap deletes them on a timer.
**Receiving.** A bar at the foot of the page walks a scripted queue — a reply,
an invoice with a real attachment, a delay notice, an HTML newsletter — and
wraps at the end, naming what comes next. Delivery goes through the real
PostIngestPipeline rather than writing rows by hand, because what is being
demonstrated is that plMail *does* something when mail arrives: it threads, it
categorises, it runs the filters the visitor just built, and it appears without
a reload. A hand-written row would put mail on screen and none of that would be
true, which is a worse demo than no button.
**Sending.** DemoMailSender is registered above every real sender and claims
every account while demo mode is on, so SmtpMailSender is never asked — "a demo
cannot reach a relay" becomes a property of the wiring rather than a rule each
sender has to remember. It answers a few seconds later, from whoever the visitor
addressed, on the thread they are still looking at.
**What is switched off**: adding or editing a mail account, OAuth and CalDAV
connect, calendar subscribe — a stranger typing their real credentials into a
box on someone else's server has handed their mail away, and there is no version
of that form that is safe to show. Mail sync, push renewal and calendar sync
no-op, since a demo's accounts point at documentation domains. Everything else
stays clickable: locking down all the non-mail surfaces produces a tour of a
product with the interesting doors locked.
Two things this needed that did not exist. The demo mailbox moved out of
SeedDemoMailboxCommand into DemoMailbox, shared with the provisioner, so the
README screenshots and the hosted demo cannot drift into being pictures of
different things. And deleting a user — this project had no such path, because
a self-hosted install's users leave by having their password changed. The
eraser discovers owned classes from Doctrine's metadata rather than a list:
twenty entities point at User today under two different property names, and a
hand-written list would be right until the next entity was added, at which
point the reaper would start failing on a foreign key at three in the morning.
compose.demo.yaml hardcodes APP_DEMO_MODE rather than interpolating it, and
ComposeEnvironmentTest now holds it to that. Compose reads Symfony's committed
.env, which ships the variable as 0, so `${APP_DEMO_MODE:-1}` would have
resolved to 0 for everyone who cloned the repo — and the failure is the bad
kind, where the stack comes up healthy and simply is not a demo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
8c3080d00a |
fix(security): an SVG attachment is not an image, and a shipped install gets headers
CODE-REVIEW S-02 (high), S-04, S-08, S-05, S-06. S-02 — three controllers decided what may render inline with `str_starts_with($contentType, 'image/')`, and `image/svg+xml` satisfies it while executing script as a top-level document. nosniff does not help: nothing is mislabelled, the type genuinely is what it says. The type comes from the MIME headers of an incoming mail. Reachable from the mail itself. MailBodySanitizer::resolveCids() rewrites EVERY `cid:` in the document rather than only `img src`, and the sanitiser allows relative links — so `<a href="cid:logo">Rechnung ansehen</a>` has the part id filled in by the application, and the click opens the attachment route without the `?download=1` that every template adds. Now an allow-list (InlineDisposition), which is the difference between "everything not yet known to be dangerous" and "the formats actually wanted", plus `default-src 'none'; sandbox` on the response — so a later widening of the list is a bug rather than a vulnerability. Parameters are stripped before comparing, because `image/svg+xml; charset=utf-8` would otherwise walk past the one entry meant to stop it. S-08 pulled along: the JMAP download had the same prefix test. Guarded today only by the jmap firewall being stateless and wanting a header a browser navigation does not send, which is a thin thing to depend on. S-04 pulled along: the file-picker preview carried no Content-Disposition at all — implicitly inline, in our origin, with a type chosen by Nextcloud, Immich, Dropbox, OneDrive or Drive. A file an attacker drops in a shared folder is a realistic shape for the setups these integrations exist for. A non-image now falls back to the placeholder rather than to a download: a download prompt fired by a thumbnail is worse than a grey box. S-05 — the Caddyfile now sends HSTS, X-Frame-Options, nosniff, Referrer-Policy and Permissions-Policy, and drops the Server banner. None of these existed anywhere in the repository, so the ones a live instance showed were its operator's proxy — while the README's own install instructions are `docker compose up -d` and "open https://localhost". `defer`, so the per-response policies on the attachment and image-proxy routes still win. Referrer-Policy is the one that matters most for a mail client: it sends its users to strangers' links all day. S-06 — mercure subscribe defaults to [] rather than '*'. Never reached today, since both cookie-minting paths pass explicit topics, but a future createCookie($request) without a topic argument would have issued a subscription to every user's mail events and looked entirely ordinary in review. Also: mailpit removed from the dev override. MailerInterface is injected in no service in src/ — plMail's own outgoing mail leaves through the user's account via MailSenderRegistry — and the override never set MAILER_DSN either, so the container has been catching nothing. Both configuration pages said the dev stack pointed at it; they now say what is actually true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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>
|