mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
12842bb3bee616d72e392acc830302afbee896e4
133
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b38aaac04e |
Give the Mercure tokens an identity no installation has to configure
v0.2.31 could not mint a token at all on a TrueNAS install, and nothing an operator set would have fixed it. That release bound `iss` and `aud` to MERCURE_PUBLIC_URL. truenas.compose.yaml leaves that BLANK ON PURPOSE — the setup screen asks for the public URL on first boot and stores it in the database, which is long after the hub container has had to be told what to trust. An empty issuer is not a weak issuer: the factory refuses it outright with `The "iss" additional claim is required by RFC 9068 access tokens`, so there was no subscriber cookie, every publish threw, and the symptom was a red dot indistinguishable from a hub that was simply down. The mistake underneath was deriving a value that must exist at CONTAINER START from one that only exists after SETUP. So it is a literal now, shared by the application and every compose file, and no installation has to know it. An identifier does not have to resolve — `.invalid` is reserved by RFC 2606 for exactly this — and all that matters is that both halves agree on the same string, which a literal guarantees and a derived value cannot. The same string everywhere costs nothing: the signing secret is generated per installation, so one install's token has never been usable against another's hub however it identifies itself. truenas.compose.yaml had been left behind twice more. It still carried the "/healthz" check that v0.2.30 replaced in the other two files, so its hub would have gone unhealthy the moment the image moved; and it had no pull_policy, so the image never moved at all. That is the combination that produced the original report: an application on the 1.0 protocol talking to a 0.x hub that nothing had ever pulled. The regression test empties MERCURE_PUBLIC_URL rather than reading the config, because what broke was the derivation — a test that read the same placeholder the code did would have passed throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d35418c40a |
Speak the Mercure 1.0 protocol, because the hub does now
Live updates were dead: every subscription came back 401 and the inbox sat on "Live updates unavailable" while the hub answered its own healthcheck happily. The hub had moved to the 1.0 protocol and now wants RFC 9068 access tokens — an `iss` it has been told to trust, an `aud` naming the hub the token is for, and `authorization_details` in place of the bare `mercure` claim. plMail was minting the older shape. NOT HAND-ROLLED CLAIMS. symfony/mercure already implements 1.0, so this is configuration: `protocol_version: '1.0'` plus the three claims the bundle requires (it refuses to build the container without `iss`, `sub` and `client_id`, which is how the requirement was found). They are merged into the token FACTORY as well as the publisher provider, so the subscriber cookie gets them too — one block covers both halves. The protocol switch also changes the subscription query parameter from `topic` to `match` and the cookie's name. Both belong to the bundle — the `mercure()` Twig helper writes the URL, Authorization writes the cookie — which is why no JavaScript and no controller is touched here. `resource_identifier` IS PINNED ON THE HUB, and that is not decoration. A 1.0 hub works its expected audience out from each request, and plMail reaches it by two roads: the browser subscribes at MERCURE_PUBLIC_URL, the application publishes to the internal MERCURE_URL. One token cannot name both, so a hub left to derive its own answer would refuse every publish. Issuer and audience are both read from MERCURE_PUBLIC_URL so the app and the hub cannot be configured into disagreeing. THE `__Secure-` COOKIE is the one thing an operator may have to know about. 1.0 names the cookie `__Secure-mercure_access_token`, and a browser drops a `__Secure-` cookie that did not arrive over TLS. Right for every real install, fatal for the test stack, which serves plain HTTP on 127.0.0.1 — so the name is a parameter defaulting to the secure one, and only the test stack overrides it, with the hub told the same name. Verified in a browser against the current hub image: the indicator reads "connected", and the subscription is GET /.well-known/mercure?match=mail%2Fuser %2F1 → 200. Full suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1ec38e2b97 |
Retry a Graph read that comes back 5xx
Graph answers 502 with an empty body and "code": "UnknownError" often enough that it is weather rather than news. Nothing retried it. GraphApiClient sorted 410 into a resync and 429/503 into a throttle, and everything else became a plain GraphApiException — which GraphApiSyncer catches per folder, logs at ERROR with the whole trace, and skips. The skip is safe (the coverage guard stops anything being erased, the delta link is not advanced) but the folder still sat out a fifteen-minute cycle for a blink that one immediate retry would have cleared. GET ONLY, and that is the reason this is a strategy of its own rather than Symfony's defaults. GenericRetryStrategy retries 502 for EVERY method. A 502 means the gateway gave up on the answer, not that Exchange never acted: /me/sendMail, $batch carrying writes, createMasterCategory and /move are all POSTs that may well have landed, and repeating those is how one send becomes two. DELETE is left out too, though IDEMPOTENT_METHODS would have included it — deleting a subscription the first attempt deleted answers 404, which turns a success into an error somebody then has to teach the caller to ignore. 429 AND 503 STAY OUT. Throttling is already answered better one layer up: GraphThrottledException carries Microsoft's own Retry-After and is Recoverable, and the handlers re-slice per-sub-request throttles by hand. Mail throttling is per-mailbox at about four concurrent requests, so a retry here would spend a scarce one of them arguing with a limit that has just said how long to wait. Scoped to graph.microsoft.com, so Ollama, Paperless, Immich, Nextcloud and the CalDAV servers keep their own callers' judgement about what a 500 means. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ab9ecbc9e0 |
Hand a whole-view bulk action to a worker, which it never was
RunBulkStatusHandler exists to take a mark-read or archive over a whole view out of the request — its docblock quotes the thirty-second timeout that produced it, and BulkStatusController answers with an indicator so nobody waits. Neither was true: the message was never listed in messenger.yaml's routing map, and Messenger handles an unrouted message inline in whichever process dispatched it. Every whole-view action ran inside the web request, under the limit it was written to escape, at request concurrency — which is where the production deadlock came from. It gets a queue of its own rather than joining maintenance, because somebody is watching an indicator for it and queueing it behind an embedding backfill would be slower than the inline version it replaces. That means one new container, worker-bulk, in every compose file and in the handbook. The deadlock itself is retried now instead of failing the job, three times per chunk, and a collision that outlasts those hands the job back to the queue rather than ending it. The failure path no longer writes through an EntityManager Doctrine has closed, which is what it was doing for every failure that came out of a flush. And the list catches up: nothing ever refreshed it on the strength of the job, only on the toolbar's own "the write landed" event, which is now the instant the work was QUEUED rather than finished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d53e7c4963 |
Report browser errors to the admin panel, in a card of their own
A JavaScript fault was visible only to whoever had the console open, which on a self-hosted mailbox means nobody. Uncaught exceptions and rejected promises from plMail's own scripts now reach Admin → Logs, together with whatever the Content-Security-Policy blocked. Its own card rather than rows in the log, because it holds a different kind of thing. A server error is one event in one request; a broken line in a controller runs on every page load for every user. So a row is a distinct fault — message, file, line — with a count and a lifetime, and the same fault reported again increments it. Merged into the log, one bug would be four hundred rows and the log would be unreadable past it. THE FILTER IS THE FEATURE. Most of what window.onerror sees is not ours: the console screenshot that prompted this was an ad-attribution metrics script failing inside requestIdleCallback, injected by an extension. A panel that accepted that fills with other people's bugs and stops being read, which is the same as not having one. A report is kept only when it can be attributed to a script this server sent — an injected script has no URL (Chrome's `VM947`), and a cross-origin one arrives as the bare string `Script error.` with no file, line or message. The rule is applied in the browser and again in the recorder, because the browser half is part of the code being reported on. CSP violations come to the same card through /csp-report. That endpoint cannot be CSRF-protected and is public in security.yaml, because the browser generates the request itself: no token, no header we chose, and on a login page no session. The rate limiter is the bound. It is worth having because the full policy is enforced in production, so what arrives is what this installation actually refused to load in a real browser. The count means page loads, not executions — the client reports each fault once per page, capped at five. Measured rather than assumed: throwing the same error twice on one page records one sighting. Documented that way on both sides after the first version claimed the server would count what the client never sent. `column` is a reserved word in Postgres, so the field is `column_number`. The quoted DDL created the table happily and every insert into it was a syntax error — same trap User::$usr already avoids. Verified on a running stack by throwing real errors in a real browser: an error from a script on this origin recorded, an anonymous injected one refused, the card rendering both kinds grouped with counts. Its first catch was a false alarm, and a useful one to record. It reported the Mercure connection as blocked by `connect-src` — which was true, and was me browsing `localhost` while MERCURE_PUBLIC_URL names `127.0.0.1`. Different origins, so the app's own policy refused its own hub. Browsing the host the config actually names produces no violation at all. The compose comment calling that URL "same-origin" is correct on its own terms; it just stops being true if you reach the stack by another name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
39dd7e5857 |
Ask the model again about recent mail, and let a verdict reach the tab
Two things, and the button is useless without the first. A VERDICT NEVER REACHED A TAB. Nothing recomputed a message's category after the classifier stored one: ingest files a message the moment it arrives, the classifier runs afterwards and asynchronously, and it wrote the answer and stopped. That was invisible while sorting was rules-only — the verdict was a tie-break nobody could see — and it stopped being invisible the day sorting became a setting. Somebody chooses "the assistant", new mail keeps arriving into whatever the rules said, and the choice appears to do nothing at all. ClassifyMailHandler now re-files what it just asked about, using each owner's own preference, and resolves only the threads it touched — recomputing per ACCOUNT would scan every message the account has, after every batch of arriving mail. recomputeCategoriesForThreads() is that statement with the threads named, pinned conversations still excluded. AND THE ANSWER GOES STALE WHILE THE MAIL DOES NOT. A message is asked about once, which is right. What changes is the QUESTION: the prompt is editable, the model is a setting, and what plMail sends alongside a message changed one commit ago — every verdict stored before the bulk-header line was reached without evidence the model now gets, and demonstrably differs because of it. So: a button, bounded to the newest 100, 200 or 500 messages, clamped server side because the field is a select and anything else arrived by hand. Nothing re-asks on its own and nothing should — a model call per message is the most expensive thing here, and spending an afternoon of somebody else's GPU because an administrator edited a prompt is not a decision to take for them. ClassifyMailMessage gains a `force` flag, defaulted and last so envelopes already on the transport still deserialise. The stamp means "already asked", which is the right reason to skip on ingest and the wrong one when the answer is what has gone stale. The re-run INVOKES the classify handler rather than dispatching to it, and the transport is why: ClassifyMailMessage routes to `ingest`, correct for a message being classified as it arrives and wrong for several hundred at once — arriving mail would queue behind a button press for the best part of an hour. Called directly, the work stays on `maintenance` and reuses the asking, the batching, the per-message permission check and the re-filing rather than growing a second copy of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e3a4b78d92 |
Re-file the mailbox when the sorting setting changes
The previous commit shipped the setting and told people to go and find an administrator if they wanted their existing mail re-sorted. That was wrong, and the argument against it is the one that should have been made for it. MessageCategorizer reads only PERSISTED data — stored headers, the provider's labels, the sender, the model's stored verdict — which is the entire design of the class. So a re-sort asks no other machine anything: no IMAP, no model, no network. It is a scan of rows the database already has, a PHP cascade over each, and a column write, followed by one statement per account that resolves each thread from its messages. The caution behind the old answer was real — a hundred thousand things do not belong inside an HTTP request — but the fix for work too big for a request is a worker, and this application has four. A summary job was written one commit earlier on exactly that reasoning and it was not applied here. Leaving it undone was the worse option, not the safe one. Mail arriving after the change would be sorted the new way and everything already there the old way, so one inbox would be filed two ways depending on when each message landed — an incoherent state nobody chose, produced by a setting they did, with no symptom that leads back to the radio button that caused it. Dispatched only when an answer actually CHANGED. The card submits on change and a browser will happily post the option already selected; re-filing for that is minutes of a worker writing the answers back unchanged. Threads pinned to a tab by hand are untouched — recomputeCategoriesForAccount has always skipped `category_pinned_at IS NOT NULL`, and it is worth saying out loud on a feature that re-files somebody's inbox. Safe to run twice: the categoriser is a pure function of persisted state, so a retried envelope writes the same answers over the same rows. maintenance rather than ingest, so arriving mail never queues behind somebody's settings change. The command stays and is unchanged — an operator re-sorting every mailbox after a rules change wants exactly this work, for everyone at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
408fcd1bed |
Run full-conversation summaries as a job, with nothing held open
A full run is silent for minutes while the model reads, and for the whole of
that silence the answer depended on a browser connection staying open. That
dependency is what has been failing, and no timeout could have fixed it:
full: true, transcript_chars: 67720, elapsed_seconds: 40, chars_so_far: 0
Forty seconds, four heartbeats already delivered, nothing written yet, and a
model host that was up for seven days either side of it. The connection was
alive and being written to and then was not. Nothing in plMail closed it — a
held-open connection has a proxy, a browser and a network in it, and any of them
may end it without telling this application. Three rounds went into tuning
timeouts before the metrics panel said `cancelled` and named the real shape.
So the full run stops needing a connection. It dispatches SummariseThreadMessage
and answers 202; a worker on the maintenance transport does the work, writes to
the store and publishes a Mercure nudge; the card shows it as queued and fills
in when it lands. The reader may close the conversation, the tab, or the laptop
and still find the summary waiting — which is what somebody just told "this
takes several minutes" would reasonably expect anyway.
maintenance rather than ingest, deliberately: this holds a consumer for minutes,
and mail arriving must not queue behind somebody's summary.
The nudge carries no text. Same shape as JobNotifier and the insights strip —
the store is the record and a missed message costs one stale render — with one
reason of its own: what it would be carrying is a paragraph about somebody's
mail, on a channel that has never held any.
TWO BUGS RETIRED ON THE WAY. The streamed path is now never full, so the
transcript it hashes for freshness is always the one the reader's page
recomputes; a full run used to store a hash that could never be reproduced,
which would have shown every full summary as permanently out of date. And the
handler hashes the ORDINARY transcript for the same reason while sending the
full one to the model — the hash answers "has this conversation changed", and
the only answer that matters is the one the reader will ask.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
422fd1f489 |
fix(imap): read the flag notification instead of re-listing the folder
An idling server announces a flag change as `* 12 FETCH (UID 5001 FLAGS (\Seen))` — which message, and its complete new flag set. The IDLE loop tested that line for the substrings "FETCH" and "FLAGS", learnt only that something had happened, and answered by dispatching a full mailbox sync: a fresh IMAP session and `UID FETCH 1:* (FLAGS)` over the whole folder, to rediscover what the line had just said. On a 1832-message inbox that is one full listing per message somebody marks read on their phone. It is also self-sustaining: the listing opens a second session, the server broadcasts flag state to the idling one, and that dispatches the next listing. Seen in the wild at roughly two per second indefinitely, nine identical jobs in the queue at a time, a sustained ten megabits a second of an installation talking to itself. Nothing was lost or corrupted, which is why it went unnoticed — the only symptom is bandwidth. readFlagChange() now parses the line into an ImapFlagNotice. When it carries a UID — optional in RFC 3501, required in RFC 9051, and sent by Dovecot and Gmail — the change is applied to that one message through ApplyRemoteFlagsMessage: a point lookup on the unique (mailbox, uid) and a write through ThreadStatusUpdater, which is where the echo guard lives and therefore the only door inbound flags may use. No connection, no listing, no round trip. Without a UID the line names a sequence number, and sequence numbers move under every expunge, so there is no honest way to resolve one here — that case still lists, and guessing would write the wrong message's flags with no error anywhere. The listing is now debounced at five seconds, leading edge plus a trailing flush from the read-timeout path so a burst that ends immediately still gets its sync. markSweepDue() moved into that flush: it forces the expensive full listing, and it was being called once per notification rather than once per burst. New mail is unaffected on the leading edge. Second line of defence in the handler, because the debounce is a property of one caller: a sync whose mailbox was synced within three seconds returns instead of reconnecting, so a duplicate from any future dispatcher costs a lookup rather than a folder listing. The parse is stricter than the substring test it replaces and refuses more: a sequence number is now required, which is what excludes the `* FLAGS (…)` and `* OK [PERMANENTFLAGS (…)]` lines every SELECT answers with. An empty `FLAGS ()` is kept — it means every flag was cleared, which is how "marked unread elsewhere" arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cd1e18230b |
feat(caldav): plMail's calendars, in anything that speaks CalDAV
One endpoint and an app password now give Thunderbird, Akonadi, DAVx5 or iOS
every calendar on an account — as separate collections carrying the names
and colours the plMail sidebar already shows, which is the thing an ICS feed
cannot do however many events it carries.
/caldav/ the root
/caldav/principals/{user}/ who you are
/caldav/calendars/{user}/ calendar-home-set
/caldav/calendars/{user}/{calendar}/ one collection
/caldav/calendars/{user}/{calendar}/{uid}.ics one event
WHY THIS IS WRITTEN RATHER THAN sabre/dav
─────────────────────────────────────────
sabre/dav was tried first and reverted. 4.7.1 is its current release and it
still requires sabre/xml ^2.0.1 while sabre/xml is on 4.x, so installing it
downgraded sabre/xml and sabre/uri underneath sabre/vobject — the library
every ICS path here is built on — and closed off vobject 5.0, which needs
the newer xml. The full suite passed with the downgrade; the cost was not
correctness but a five-year-old dependency floor under the most load-bearing
library in the calendar, permanently.
Doing it by hand is smaller than it sounds because the hard parts already
existed for the other direction. CalDavEventConverter maps an event to and
from a VEVENT in 981 lines. MultiStatusParser reads the document
MultiStatusBuilder now writes. And CalendarChangeReader already answered the
only genuinely hard question — what changed since a token — because that was
built first for JMAP, and CalDAV counts in the same numbers.
WHAT sync-collection COSTS NOW
──────────────────────────────
One request and the rows that moved, instead of a full collection listing on
every poll. A deletion comes back as an href and a bare 404, which is why
the change log copies the UID onto the tombstone: by the time the client is
told, there is no row left to read it from.
An unusable token answers 403 with valid-sync-token rather than an empty
delta. "Nothing changed" to a client whose token cannot be placed would
leave it stale with no way ever to notice.
DECISIONS WORTH FINDING LATER
─────────────────────────────
ETags taken from the change log's own sequence, so a resource's
ETag and its collection's sync-token cannot disagree about
whether something moved — they are the same number. Events
predating the log fall back to updatedAt, which is good to
the second; that set only shrinks.
read-only a mirror advertises a read privilege set and refuses PUT and
DELETE with 403. A write that appeared to succeed would show
the user an edit that never leaves plMail.
If-Match checked before anything is written, on both PUT and DELETE.
Without it the second of two clients wins and the first
edit is gone with nothing raised.
no scheduling, no free/busy. plMail does iTIP its own way through
ItipReplyBuilder and InviteResponder; a second system that
also sent invitations would double every RSVP. Not
advertising them is the supported way to say so.
time-range matched on the series' bounds, so a recurring event is
included whenever its rule could still reach the window.
That over-answers for a series that skips it and never
under-answers — one costs a resource the client draws
nothing for, the other loses an appointment from a week.
/.well-known/caldav is public where /.well-known/jmap is authenticated, and
the difference is what they return: the JMAP one answers with the session
object, this one with a 301 to a constant path. A client asks it before it
has anything to authenticate with, which is the whole point of "just type
your server address".
Writes go through IcsImporter — the same door the web UI's .ics import uses,
which already parses, maps and refuses a read-only mirror. Deletes go
through CalendarEventWriter::markLocallyDeleted(), which decides whether the
row may go now or has to survive until the remote it mirrors has been told.
No deployment change. No schema change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
755f3cf257 |
feat(users): an administrator can run the whole life of an account, and you can change your own password
Managing users was reachable and worked — list, search, create, promote, demote, remove — but three things around it did not, and one of them was load-bearing. ── You could not change your own password ────────────────────────────────── Nowhere. Not in Settings, not by mail, not at all; only an administrator setting one for you. Settings → Security now leads with a Password card that asks for the current password (an unmapped UserPassword constraint, so it is checked against the signed-in token rather than against anything the form was handed) and takes the new one twice. The route carries no id and the form has no data_class, so it structurally cannot become the admin form by accident. The floor those passwords are held to was three literal 12s in three files that happened to agree. It is now User::PASSWORD_MIN_LENGTH, read by all three. ── An account could only be removed, never switched off ──────────────────── Nothing sat between "leave it alone" and "destroy it", so the answer to "this person has left, keep their mail" was to delete them. There is a deactivated_at now, and a UserChecker registered on BOTH firewalls — the JMAP one included, because an app password outlives the browser session it was minted from and a check only on `main` would shut somebody out of the web UI while every mail client they had already connected kept syncing. The refusal is in checkPostAuth rather than checkPreAuth on purpose. checkPreAuth runs before the password is verified, so refusing there would answer "is this address a suspended account here?" to anyone who types an address — the question hide_user_not_found exists to keep unanswerable. After verification, the only person told is the one who could otherwise have signed in. Symfony runs user checkers at authentication only, so a session already open would have carried on until it happened to end. UserProvider::refreshUser() refuses the same two states on every request. Suspension also travels through config backup, which would otherwise have handed a suspended account its sign-in back on restore. ── Two smaller lies ──────────────────────────────────────────────────────── A search under three characters silently returned the unfiltered list, which a QueryBuilder has no way to complain about, so the floor was dropped rather than kept as something that could only mislead. And an admin declining to demote themselves got a success toast for a change that never happened; the refusal now says so, while the rest of the form still saves. ── Restoring a removed user is deliberately absent ───────────────────────── Removal overwrites the address, the name and the hash specifically to free the unique address for reuse, so a restore would have to invent all three and the original address may by then belong to somebody else. Doing it properly means snapshot columns and a guided flow that asks for a fresh address — a different change, which alters what removal means. Documented as absent on the screen an administrator would look for the button on, because switching an account off is the answer to the question people are actually asking. Also fixed en route: the modal form theme dropped help_translation_parameters, so the first field to use one rendered "at least %count% characters" on screen. And GermanIsInformalTest only ever globbed messages.de.yaml, leaving validators and the new security catalogue — exactly where a translator reaches for "Sie" — outside the test that exists to catch it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
92d6e8ffab |
feat(ai): index new mail when the model is warm, and once a night
New mail was embedded the moment it arrived. That is the wrong trade on a box
with one GPU: it is rare to search for mail that has just landed, and paying to
index every arrival competes with the two things somebody is actually waiting
on.
Two triggers replace it. A nightly sweep is the backstop, and a search is the
invitation — a search has just loaded the embedding model, so the cheapest
possible moment to index is immediately after one, when the model is warm and
nobody is waiting on it any more.
THE PART THAT MADE THIS MORE THAN A SCHEDULE CHANGE
The backfill stood aside after ANY interactive AI use, and a search counted.
That is precisely backwards for search, and it took both halves of the signal to
say so: lastInteractiveCallAt() matched
`feature IN ('search_query','writing_help')`, and the request subscriber stamped
`app_mail_search` alongside `app_compose_assist`.
The distinction the old code could not make is that there are two models with
completely different costs. The composer spends the 20.3 GiB writing model,
which is worth standing aside for and is the case the yielding was written for.
Search spends the sub-gigabyte embedder — the same model indexing uses. So a
finished search is not a reason to keep away, it is the best moment there is.
Both docblocks now cross-reference each other, so restoring one half alone reads
as obviously wrong.
The opportunistic trigger claims its rate-limit marker BEFORE it queries, so the
mailbox scan is inside the window rather than only the dispatch, and a search
that produced no vector at all spends nothing — one unreachable moment must not
cost the next five minutes of indexing.
EmbedMailStep is deleted rather than emptied. An empty step would still be built
and iterated on every ingest batch while carrying a name asserting that
embedding happens on arrival — an active lie in the one directory somebody reads
to find out what happens to new mail.
The finder matches on model and not on width, deliberately. What consumes its
ids asks about the model alone, so a width-aware finder would hand over a full
budget the handler then drops as already done: a nightly sweep that reports
itself busy and indexes nothing, for ever. The width mismatch already has an
owner in the coverage report's "stale" count, which tells the user to re-index.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
377d6026b8 |
feat(ai): make the language model observable, and the composer honest
"I click the AI button in the composer and nothing happens" turned out not to be about the model at all. #editor() queried data-compose--toolbar-target while the composer renders data-compose--compose-toolbar-target, so run() returned at its first guard: no request, no status, no error. The feature had never worked once. #subject() was wrong the same way and quieter — every prompt went out with an empty subject. And the status element lived INSIDE the dropdown that the same click closes, so even once working it would have written into a hidden subtree. The rest of this is what makes the next such thing findable. RECORDING One row per model call, tagged by the workload that caused it, and nothing about what was in it — no prompt, no completion, no message id, no subject, no address. error_kind is a category from a closed set for the same reason: an HTTP client's exception message routinely quotes the request body back, and here that is somebody's mail. OllamaClient was discarding Ollama's whole timing block; it now returns it, and AiAssistant — the only place that knows both the feature and the model — writes the row. DBAL and deliberately no entity, following EmbeddingStore: BackfillEmbeddings clears the EntityManager after every chunk, so an unflushed ORM row would be detached and silently dropped, fifty at a time, for as long as a backfill runs — which is exactly the window the panel exists to show. AiFeature could not carry this. A four-character search query somebody is waiting on and two thousand characters of mail body written unattended a hundred thousand times both went through embed() with no discriminator, and averaged together the second buries the first — the panel would report backfill throughput under the heading "search". AiCallFeature splits them. THE PANEL Live state from /api/ps, which is passive and safe to poll. Six states that look different, and the one that justifies the work is "partly on the CPU": size_vram < size means layers spilled, throughput collapses, and the model still answers correctly — invisible everywhere else, so everything is simply slow forever. THE COMPOSER Tokens stream into a provisional surface and commit in one operation, because hundreds of per-token insertions destroy the undo stack and Ctrl+Z is the first thing anyone tries. Waiting, generating, done and failed are distinct states with reasons: outside the keep-alive window the first thirteen seconds produce nothing while 20 GiB loads, and a featureless spinner for that IS the bug. Cancellation propagates to the host — an abandoned draft otherwise holds the GPU and everything else queues behind it. SEARCH Silent degradation to keyword-only is replaced by the reason it degraded, and rows found by meaning say so. The semantic arm and its join are now matched on model AND width: vectors from different models are not comparable, and after somebody changes the search model every existing vector is dead weight. On a pgvector install comparing different widths raises an error — a 500 on /mail/search caused by an admin dropdown — and on the shipped loop it silently compares the overlap, which is worse. Coverage is stated where results are shown, because semantic search over an 8%-indexed mailbox is a trap. BACKFILL Progress is counted from the database, never accumulated, so a killed worker resumes. It stands aside while anyone is using the AI and comes back on its own: both compete for one integrated GPU, and a batch in flight when somebody presses the button puts them straight back to "nothing happens". Also carries the cron line for app:jobs:reap from the commit before this one. Verified against the real host: reachable, both configured models installed, timings parsed, 1024-dimension embeddings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
469a2e8107 |
feat(ai): semantic search, writing help, and the pass that embeds a mailbox
The three remaining pieces. Written to the seams the survey found, and
keeping the traps it named.
SEARCH — ANOTHER ARM OF THE SAME UNION
A vector hit is a candidate on exactly the same footing as a lexical one:
it lands in COUNT(*) OVER (), it pages correctly, and it needs no second
statement to reconcile with. Four things had to hold and each is written
down where it applies:
· The query is embedded ONCE, in the controller. buildSearchSql() runs
up to four times per search — the cheap pass, the body rescue, and
twice more when a page past the end recovers its total — and one of
those is inside a statement-timeout transaction, where a slow model
would be reported as a database fault.
· The candidate set is BOUNDED to the most recent 2,000 messages. The
distance function costs 0.11ms a row; over a whole mailbox it is
eleven seconds, worse than the pathology the UNION shape exists to
escape.
· A THRESHOLD, not a top-k. The total rides on the same statement, and
"lexical matches plus up to k" is a number that is only true on page
one.
· The inner LIMIT is parenthesised, because these arms are joined with
a bare ' UNION ' and an unparenthesised LIMIT binds to the whole
union rather than to one arm.
The rank join is 1:1, which is the property the label joins lost and had
to be given back in v0.1.40. Three tests: a message found by meaning that
no lexical arm can reach, one rejected by the threshold, and one with no
embedding still found by words — that last is what proves the LEFT JOIN
does not drop rows.
WRITING HELP — THE SERVER MAKES THE CALL
connect-src 'self' is enforced, so a browser could not reach the model
host even if it should — and it should not: that address is on the
operator's private network, and putting it in a page hands it to every
script the page loads.
The route carries NO draft id. A composer opens before one exists, which
is exactly when somebody wants a first draft; a URL built at render time
with an id in it is stale in that window. Nothing is saved as a side
effect either.
Four tasks, a closed set. A free-form instruction parameter would be a
prompt an attacker could write, with the user's own mail as context and
the answer pasted into the user's own draft. The message being replied to
is read from the DATABASE by id, ownership checked — never from the page.
Generated text is INSERTED, never substituted. A rewrite that overwrote
the original would be a model deleting somebody's words with no undo they
can see. It goes in as a plain text node at the end of the body: a styled
wrapper would be subtracted by the composer's typed-length calculation
and could stop the draft autosaving, and "at the caret" is a position the
writer did not choose, because opening the menu moved focus.
The menu is behind a Twig FUNCTION, not a variable — this window is
included from four places, two of them undo streams that include it
`only`, and strict_variables makes a variable threaded through three of
four a 500 on the undo path rather than a missing button.
EMBEDDING — ARRIVALS ON INGEST, THE REST ON DEMAND
A post-ingest step for new mail, and a command for everything already
there. The backfill re-dispatches itself with a cursor rather than
looping: a hundred thousand messages at a round trip each is hours, and a
single job holding that is killed by any worker restart with nothing to
show for it.
Ascending id, because it is the one ordering nothing can change under a
walk that long — mail arriving during it gets a higher id and is met by
the pass still coming. The vectors already stored ARE the progress
record, so there is no second one to keep in step, and the cursor
advances past everything LOOKED AT rather than everything stored, so one
message the model cannot answer for does not become a wall it restarts at
forever.
On the maintenance transport, not ingest: a backfill running for hours
would otherwise sit in front of every arriving message, and new mail
would stop appearing until an old mailbox had finished being catalogued.
KNOWN FAILING, AND NOT FROM THIS
compose-send-optimistic.spec.ts:124 fails in the FULL e2e run and passes
in isolation and in pairs — verified with the AI menu both present and
removed, 24 passed either way. It is the same spec that broke in the
earlier seed race, and the row it cannot click is present but still
mid-entrance. Cross-file interference, pre-existing, and left for the
debugging pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ac5b5360fe |
feat(ai): a second opinion on mail no rule recognised
Categorisation, built so that the model can only ever break a tie. WHERE IT IS ALLOWED TO COUNT At the very bottom of the cascade, after every rule has declined. At that point the honest answer is "we do not know, so Primary", and that is the one place another opinion helps without doing harm. Anywhere above it, the model could only overrule something that actually matched a header — which would make a tab's contents depend on which model happened to be installed the week the mail arrived, with nothing on the row to say so, decided unattended on every message that arrives. MailClassificationTest asserts it cannot: a matched header, a correspondent and a Gmail label each outrank a verdict that disagrees. STORED BESIDE THE CATEGORY, NEVER IN IT `category` is what the deterministic rules produced and what every list query filters on, and it has to stay explicable without reaching for a machine that may since have been switched off. So the verdict lives in its own column and the cascade reads it only in the branch it would otherwise have answered "default" from. Which makes switching the feature off sufficient on its own: nothing clears the column, nothing needs re-processing, and a stale verdict from a model removed months ago is inert as long as a rule recognises the mail. There is a test for exactly that. NO CALL ON ANY READ PATH MessageCategorizer never talks to anything — it reads a verdict stored asynchronously. explain() is called from the details panel, from ingest and behind list rendering; an HTTP round trip in there would put another machine on the path of drawing a page. The one request lives in a Messenger handler, on a worker, after the transaction, over a batch, for a subset. The step that dispatches it checks the feature is on before enqueueing anything, and the handler checks again — not redundantly: almost every install has this off, and without the first check every one of them would queue a job per batch for a handler whose whole body is `return`; without the second, a job already queued when somebody switches it off would still run. WHAT IT REFUSES TO ASK ABOUT Anything a rule recognised — the answer is known, explicable, and would be discarded anyway. That test needs the correspondent set, which is a query, so it happens in the handler rather than in the step that runs inside a sync holding a mailbox connection. The attempt is stamped even when the answer is unusable, so a backfill can tell "asked, got nothing" from "never asked" and does not retry the same unanswerable message forever. An ambiguous answer — two category names in one reply — is discarded rather than resolved by first match: the model has not answered the question, and leaving the mail where the rules put it is the honest result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e00fe25a14 |
feat: save a signature once instead of drawing it on every document
Settings → Profile gains a pad beside the avatar. Draw a signature there and the PDF reader offers "Use saved signature", which places it without asking again; remove it from the same place. It goes through exactly the same placement path as a fresh scribble, because a stamp has been an IMAGE rather than a set of strokes since the day signing was written. That was the point of expressing it that way: this is six lines, not a second implementation to keep in step. The two pads share trimAlpha from assets/pdf_geometry.js for the same reason — they have to agree about what a stamp is. Deliberately NOT the "Signature" section in the sidebar. That one is the block of text appended to outgoing mail; the two are unrelated and the wording keeps them apart. SignatureStorage is AvatarStorage's shape — one file per user under var/uploads, replaced in place, random filename so a changed image is not served from cache. PNG only, and that is a requirement rather than a narrow allow-list: the image is stamped onto somebody else's document and anything without alpha lands as a white card over their text. It is not a credential. There is nothing to verify against it and it is encrypted by nothing, which is the honest treatment for a picture of a squiggle. FILES OUTLIVE ROWS Erasing a demo visitor now deletes their avatar and signature too. DemoUserEraser finds what to delete from Doctrine's metadata, and a PNG under var/uploads is not a row — so on a public demo minting a throwaway user per visitor, every signature drawn was staying on disk for good. TWO BUGS THE TESTS HID The save endpoint redirected to a route named `app_settings`, which does not exist — every POST was a 500. Three of the four tests passed anyway, satisfied by a signature an earlier failed run had left on disk. They clear any saved signature in beforeEach now, so "it saved" cannot pass without anything being saved. And the first version could not draw at all: the pad sits below the fold, page.mouse works in viewport coordinates, and boundingBox() happily reports a box off-screen. The strokes landed nowhere. 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>
|
||
|
|
e4a642b1bf |
fix(mail): the message frame is authorised by a hash, so Turbo cannot break it
Reported after the last fix: the frame still does not measure itself on the FIRST open of a mail, only after a reload. Sharing the request's nonce was right and not enough. A page's policy is fixed when the page loads, and a conversation is normally rendered by a LATER request — fetched into a Turbo Frame — which has a nonce of its own. The srcdoc inherits the page's policy on top of its own, so the script carried a nonce that policy had never heard of. The two coincide only on a full page load, which is exactly the shape of the report. There is no request that renders both, so no nonce can work here. A hash can. It is a property of the script text: identical in every request, so the page's policy and the frame's own can both name it without either knowing when the other was rendered. It is also tighter than the nonce it replaces — a nonce authorises anything carrying it, a hash authorises one exact script and nothing else. The script has no Twig in it at all, which is what makes this possible. The script moved into templates/mail/_frame_script.js so the hash is DERIVED rather than maintained beside it. A written-down hash drifts the first time somebody edits the script, and that failure is silent, remote and production-only: under debug the policy is Report-Only, so every test would still pass while every real message sat at its 80px floor. Three tests. The one that matters asserts the PAGE's header names the hash — the half the frame cannot check for itself, and the one that fails on the original bug; verified by reverting the listener. The K-07 rendering-security spec now pins the whole directive rather than the prefix, so 'self' or 'unsafe-inline' creeping in beside the hash fails too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
59cbbc9827 |
feat(mail): delete for good, and a way back out of the bin
Reported as: deleting a mail that is already in the trash takes the row off the list and leaves the mail in the trash. And, separately: why can spam and trashed mail be snoozed and archived? Both are the same cause. Trashing is "add the Trash label", so trashing something already trashed is a no-op on the server — while the list removed the row anyway, which is why the mail looked deleted twice and was there both times. Archive and Snooze in the bin are the same question from the other side. ## The action set now depends on where the mail sits MailPlacement reads the conversation's own labels rather than the current route, because a thread reached from a search result, a live update or a URL has no list behind it and a route check would go quietly back to offering the wrong controls in exactly those cases. Archive, Delete and Snooze are hidden in trash and spam, on the row, in the thread header and in the overflow menu. It is a service the Twig function delegates to, not a Twig function alone: the purge route refuses anything MailPlacement does not call discarded, and an interface that hides a control while the route behind it stays open is a habit rather than a rule. ## Delete forever Hiding the control left the bin with no way to be emptied, which is the wrong place to stop, so this is the real thing. Most of it turned out to be written and unconnected: ApplyImapFlagsHandler has implemented `'delete' => $imapMessage->delete(expunge: true)` all along, under a comment saying the row was "on its way to having no row at all", and MailChangeRecorder::emailDestroyed() existed with no callers. What was missing was the part that removes the local copy — and one thing that could not have been reused. **LabelChangePropagator cannot carry a purge, and that is the whole design.** Every propagation message names local Message rows and lets the handler look them up. That works while the row survives the operation. A purge deletes it, so a handler would find nothing, log "no messages found" and return — leaving the mail on the server to come back at the next sync as a message the user had explicitly destroyed. Invisible against a fake provider. So PurgeRemoteMessagesMessage carries the ADDRESS instead: IMAP folder and UID, Gmail id, Graph id. Onto the export queue with every other outbound push, so a purge cannot overtake a relabel already queued for the same mail. Note propagator::delete() is NOT dead code and is untouched — it is ComposeController::discard(), where TRASH at Gmail is right rather than a compromise: a draft that was never sent belongs in the bin its own client would put it in. Its docblock now says so, because I misread it as dead on the way past. Gmail gets messages.batchDelete, needing the full mail scope. Without it the API answers insufficientPermissions, which assertSuccess() already classifies as unrecoverable — so it lands in the log once rather than retrying forever. Falling back to TRASH would be the worst available answer: the user is told it is gone, plMail can no longer see it, and Google still has it. The files go too. A deletion that leaves the bytes on disk is a lie in the way that matters most for a mail client. Parts are removed explicitly rather than by adding cascade to the mapping, which would change every other path that touches a message. ## Restore The counterpart the bin never had: mail could be thrown away and not fetched back, except by finding it and attaching Inbox by hand from the label menu. Removes both Trash and Spam, not only the one it was found under — a spam mail somebody then deleted carries both, and one returned to the inbox still marked SPAM gets filed away again by Gmail without plMail doing anything. ## Also here - Pasted markup is reduced to content (CODE-REVIEW U-02): style, class, id, bgcolor, font and every alignment attribute go; lists, links and bold stay. Whitelisted rather than flattened to plain text, which is simpler and throws away what people are usually pasting for. The `<style>` block was the worst of them — it restyled the whole composer, not just the pasted text. - Confirmations go through the app's dialog, not the browser's. Note the row's Delete forever asks in JavaScript rather than through data-turbo-confirm: Turbo only consults that attribute for links and form submissions, so on a fetch-posting button it would have looked like a confirmation and never been read. Verified: 4009 unit tests green, PHPStan clean over src/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a4d09b8433 |
feat(security): a content security policy on the app's own document
CODE-REVIEW S-03. The reading frame carries a carefully built policy and the
image proxy carries one; the document holding the session — composer, settings,
admin — carried none. That is the amplifier that turned S-01 and S-02 from
"script in a contenteditable" into "take the account". With a policy both would
have been malfunctions.
Enforced in production, reported under debug, because the profiler toolbar and
VarDumper inject inline scripts nothing can nonce and they exist only when debug
is on. The always-safe directives — object-src 'none', base-uri 'none',
form-action 'self', frame-ancestors 'self' — are enforced everywhere. base-uri
is the one worth naming: an injected <base> silently re-points every relative
URL on the page, module imports included, and is invisible in the markup.
It got to "enforced" by removing the violations rather than allowing them, and
the last one was the application's own stylesheets. AssetMapper implements
`import './app.css'` as a `data:application/javascript,` module that appends a
<link> at runtime, so every stylesheet was a script as far as the browser was
concerned. Permitting them would have meant `script-src … data:`, which readmits
one of the oldest injection vectors there is and gives the directive away. Both
stylesheets are <link>ed from the layout now — which is what the AssetMapper
documentation recommends for CSS anyway, and removes a flash of unstyled content
as a side effect, since the browser no longer has to run the module graph to
discover them.
The nonce is one per request, shared by the header and the layout through a
service rather than generated at each end — two nonces is the classic way this
is got wrong. It implements ResetInterface because FrankenPHP's worker mode
keeps the container alive between requests, and a nonce that survives into the
next visitor's page is not a nonce. The spec asserts both: same value in header
and document, different value on the next request.
Also, unrelated but reported while this was in flight: the entrypoint's
symfony/skeleton branch is gone. It was stock scaffolding under a comment saying
it could be deleted, it could only fire when /app held no application, and what
it did then was fail with
Invalid stability string "", expected one of stable, RC, beta, alpha or dev
from six containers in a restart loop — because $SYMFONY_VERSION and $STABILITY
are unset in this image. Nothing in that names the actual problem. It now exits
with a message naming both ways to reach it: running the dev override from
outside the checkout (it bind-mounts ./ to /app), or a stale local
pl_mail-dev:latest being used without the override that builds it —
compose.override.yaml.dist predicted exactly this at its `image:` line.
Reproduced against an empty /app before and after.
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> |
||
|
|
80f3fb5932 |
fix(test): the browser suite ran on a session store production does not have
The E2E flake, and it was never in the specs. framework.yaml carried the Symfony skeleton's `when@test` line pinning session.storage.factory.mock_file. That is right for what the skeleton means by a test — PHPUnit, one CLI process, one request at a time — and this project also SERVES its test environment: compose.test.yaml runs the real FrankenPHP under APP_ENV=test precisely so CI exercises the app as shipped. That stack inherited the mock along with the environment. MockFileSessionStorage reads the session with file_get_contents and writes the whole bag back with file_put_contents, no lock. Two overlapping requests in one session therefore read the same bag and the later writer stamps the earlier one out. The native handler production runs on flocks, and they queue. So: a page render mints a stateful CSRF token into the session, the JavaScript on that page fires its own requests while the response is still being written, whichever finishes last writes its copy of the bag — and when one of those won, the token the form was holding had never existed as far as the next request could tell. "Invalid CSRF token", on a different spec every run, which is why it read as several unrelated flakes rather than one bug. twofactor.spec.ts is where it was pinned: enrolling with a code the server never got as far as checking. csrf.yaml documents the other half of this from the application's side — `compose` was moved to a stateless token id because of this exact race, and the comment says it cost a day. Several dozen stateful ids remain, many with per-entity ids no stateless list can name. They are now exercised under the concurrency semantics they actually ship with. A deletion rather than a per-process switch because PHPUnit does not need the mock: the full suite passes on native storage (3982 tests). That mattered — gating it on an environment variable would have been wrong, since CLI and server share var/cache/test and whichever compiled the container last would have decided for both. Verified: three consecutive full Playwright runs, 537 passed, no failures and no retries. Worth noting 537 rather than the 517 a passing run used to report — the exclusive project (integrations, mercure) declares a dependency on the main one, so every flake was also silently skipping those 19 specs. The flake cost more coverage than it appeared to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a5f53a5b47 |
fix(compose): a reply could be silently thrown away by a concurrent request
The compose form was the one form in the app still protected by a SESSION-STORED CSRF token. ComposeType::configureOptions() overrides `csrf_token_id => 'compose'`, and that id was not in the stateless list — so while every other form derived its token, compose minted one into the session and needed it to survive there for the whole life of the window: minutes of autosaves, not the seconds an ordinary form is on screen. Anything writing the session in the meantime takes it away again, and the storages differ on whether that can happen. The native handler flocks the file, so concurrent requests queue. MockFileSessionStorage — what `when@test` serves the E2E stack with — and every non-locking store a scaled deployment reaches for (Redis, memcached) write the whole bag back and let the last response win. The sequence, measured: opening the inline reply box renders the window, which mints the token; the browser fires POST /mail/threads/listed at the same instant; the two responses finish about 4ms apart. When threads/listed finished last it stamped its copy of the session over the other's and the token was simply gone. The autosave two seconds later was refused with "The CSRF token is invalid", DraftPersister was never reached, and the reply was not saved. Found as a Playwright flake — "keeps the correspondent in the row after you reply", failing 5 runs in 12 — which is the cheap version of the same bug. The expensive version is a user on Redis sessions losing a reply they watched the composer say "Draft saved" about, because a refused form still re-renders with HTTP 200. That second half is a separate fault and is not fixed here. Stateless, the token is derived rather than stored, so there is nothing left for a concurrent write to lose. Verified: 5 of 12 fail without this, 12 of 12 pass with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8533993c4b |
feat(insights): user-facing configuration, straight off the registry
Settings → Insights lists whatever the extractor registry holds — name, hint and an On/Off segment per extractor, so a new extractor appears here by existing. Off is stored as the disabled set, so next release's extractor starts working without anyone finding a new toggle. The endpoint validates keys against the registry and carries the same CSRF discipline as its neighbours; an extractor without catalogue lines falls back to its key rather than printing a translation path. Handbook gains "The radar" in both languages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c7979c7d85 |
feat(appearance): the pl mark drops Google's colours and becomes a choice
The topbar monogram wore Google's literal brand hexes, which is why it read as someone else's product. The mark is redrawn — shared top and bottom lines, a square-ish bowl, a tighter tail turn, ink centred in its frame — and its geometry now lives in exactly one place, a Twig macro that the topbar, the settings tiles and the favicon all draw through. The paint is a per-user setting: thirty-two colourways in LogoStyle (single colours, duotones, ink with one coloured flick, and sweeps), picked under Settings → Appearance → Logo, applied to the topbar live and saved through the same pipeline as every other appearance knob. Berry — the product's own sweep — is the default every account starts on. The favicon follows the person now, so it is a route rather than a file: /branding/favicon.svg answers in the viewer's colourway, revalidated by an ETag that IS the style value, and anonymously it serves the product default. The static icon set (favicon.svg, the PWA PNGs, apple-touch) is repainted in Berry as the sessionless fallback, and the manifest and theme-color meta follow its tile. Two colourways from the design board are deliberately absent: the Google original, and the four-distinct-hues restatement of it. A test pins both out, along with the seven-strokes-per-style contract the macro depends on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
adc70aec0a |
refactor: three classes move to where they belong
DevSyncController was not a dev controller. It is the topbar's sync button — something every user sees on every page — and it served /dev/sync from App\Controller\Dev under a docblock opening "TEMPORARY". A route under /dev is one an operator could reasonably block at the proxy, and the sync button would go with it. It is App\Controller\Mail\SyncController at /mail/sync now. Its queue probe was inline SQL in a controller, which is the one thing §5.3 says a controller may not hold. The reasoning for why it has to be raw — the transport's table has no user column, so the count matches serialised envelope bodies — moves with it into MessengerQueueRepository, beside the other reads over that table. SidebarCounts and NewMailMarkers were in src/Twig and are neither Twig extensions nor Twig-only: they hold no Twig types, they decide things (TOTAL_ROLES, SILENT_ROLES, forRoleBadge), and MailController injects both to answer a JSON endpoint. Being a Twig global does not require living in src/Twig — FilterDescriber and AppVersion are already globals from src/Service. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 43e376c0e07bee30a58da46e5b89ceb0a25f04c5) (cherry picked from commit 68e2d2fdf10238389c1c2dc8b19f6371940043e6) |
||
|
|
e25a3a3eed |
refactor(security): one CSRF check, and a SameSite that is stated rather than assumed
Fifteen copies of the same three lines, under two names — validateCsrf() and assertCsrf() — and they had already drifted: some read `_token` with an empty default and some without, some passed a message to the exception and some did not, and one baked its token prefix into the helper so the call sites read `validateCsrf($request, $id)` for a token actually called `admin_failed_$id`. None of that was a bug. The point of one copy is that the next divergence cannot be one either. It takes the token from `_token` or `X-CSRF-Token`, because the app genuinely uses both and the choice is the frontend's. `cookie_samesite: lax` is now written down. It was already the default and already what every current browser assumes, so nothing changes — the point is that several state-changing POSTs were protected by a token AND by the browser refusing to attach the cookie cross-site, and the second half of that was true only by accident. NOT `strict`, and that is load-bearing: the OAuth callbacks are top-level cross-site navigations and verify a state value held in the session, so under `strict` every account connection would fail its own check. Two authenticated POST controllers that checked nothing now do. Web push takes its own token from the template, and the remote-image trust pair gets the templates for the per-message tokens its controller began asking for. `/settings/push/verify` keeps a documented exemption: a service worker has no document to read a meta tag from, and it already proves itself with a per-subscription secret this server pushed to the device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 0a7e92727a2d1e37107824caace9920adff0da3d) (cherry picked from commit fa48d0c06585003aea1c4cb5e9c07c83bd25a01e) |
||
|
|
d2a2806764 |
fix(push): revoke what teardown registered, and say which way push is broken
Three faults with one root: a push registration lives both here and at the provider, and plMail only ever managed its own half. **Teardown left the provider's half live.** ResetDataCommand had zero references to any push manager — it truncated the rows and left Google and Microsoft holding channels aimed at this install, which then delivered notifications nothing could resolve until they expired days later. That is the "notification for an unknown channel" trickle in the reported logs. Worse, no caller anywhere reached GoogleCalendarPushManager::unsubscribe() or its Graph twin on ANY calendar-removal path, so deleting a calendar, unticking one, or disconnecting a connection did the same thing. Account deletion had always done it correctly, which is what made the omission easy to miss. PushTeardown is that missing call, wired into DataResetter (so the admin panel gets it too), CalendarSubscriber::unsubscribe() and the calendar delete route. It runs BEFORE the truncate — afterwards there is no credential left to revoke with, so revoking late would pass a naive test and fix nothing — and it is best-effort: a provider that refuses, 404s an already-expired channel, or cannot be reached is logged and stepped over. A reset that will not run because Google is unreachable is a worse failure than a channel that lapses on its own within a week. **A dead mail push was invisible for a day and a half.** The old check fired after 36 hours of SILENCE, and silence cannot tell a broken push from a quiet mailbox — which is exactly why the threshold had been made so generous, and exactly why it said nothing while a user watched push deliver nothing for twelve hours. Rather than trade one arbitrary constant for another, the quantity changed: Gmail pushes on any history change, so a history that advanced more than one sweep period (15 min — the poll's own cadence, not a tolerance) after the last push is a change push demonstrably missed. A mailbox that never changes never produces that evidence and never raises anything, at any hour. Both push verdicts are now facts, so both are Warnings that light the indicator; the false-alarm case that justified Notice no longer exists. **Nothing said WHICH failure it was.** Lapsed (expiry passed — renewal did not run) and Degraded (alive, but mail arrived unannounced — the Pub/Sub leg) are separate kinds with separate copy, and each card carries the three dates it was judged from: when the registration expires, when it last delivered, when renewal last ran. PushRenewCommand now records each completed run as a ProcessHeartbeat milestone, exempt from the stale sweep because a milestone that stopped beating IS the finding — that is what makes a scheduler that simply stopped firing visible at all. Also found and fixed while verifying: the accounts pane compared health to the literal 'degraded', so a LAPSED account would have been offered the plain toggle, whose only effect there is to turn push off; the health page's repair returned a turbo-frame fragment for a frame that page does not have, so pressing it reported nothing whatsoever and could not be told from a no-op; and fa-bolt-slash is a Pro icon absent from the Free build, so the degraded card had always rendered a blank where its glyph should be. The re-arm repair already existed and is reused rather than duplicated; it is CSRF-guarded and ownership-checked, and both are now pinned. Migration is additive, nullable, no backfill. |
||
|
|
7dbd6cc890 |
feat(health): surface broken accounts, and repair them without re-adding
One expired Google grant on the reporting install had stopped mail for two days and produced ~5 000 log lines, and the app knew: OAuthTokenManager wrote `invalid_grant` to Account::$oauthLastRefreshError, CalendarSyncService wrote the calendar failures, IntegrationTokenManager already composed "X needs to be reconnected". Nothing read any of it back — grep for oauthLastRefreshError over templates/ and src/Controller/ returned nothing, and no template contained the word "reconnect". The condition was detected, stored, and discarded. WHAT THIS ADDS A health model (AccountHealthInspector) that reads that stored state and answers "what is wrong, and what would fix it". It reads; it never probes. Five problem classes: dead OAuth grant, calendar sync failing, degraded push, integration needing reconnection, and abandoned queue work. Consequences are attributed to their cause. One dead grant taking three calendars and 500 jobs down with it is one thing to do, not nine, so the calendars are listed under the account that explains them and the indicator counts root causes. Severity is drawn by consequence, not by how alarming the error looks. Degraded push is a Notice and deliberately does NOT light the indicator: mail still arrives, and a page that paints "slightly delayed" the same red as "stopped" is a page people learn to close. RECONNECT IN PLACE OAuthAccountLinker::relink() writes a fresh grant onto the existing Account row. Mail, threads, labels, rules, aliases, calendars and settings all key on that id and all survive — the lighter alternative to delete-and-re-add, which is what people resort to today and what costs them their mail. Guarded on identity: the authorised mailbox must be the same address and the same provider, compared case-insensitively, and a mismatch is refused outright rather than resolved. Signing in as the wrong Google account is a mistake anyone makes, and writing those tokens here would file a stranger's mail into these threads with no undo. The repair intent travels in the session, never through the provider redirect, so no account id is user-editable. STOPPING THE LOG FLOOD A calendar that cannot sync never updates lastSyncedAt, so findDueForSync() returned it on every sweep forever. Calendar now carries syncFailureCount and syncBackoffUntil; the sweep skips a calendar inside its window, and the handler logs only when the failure is news. Not a mute button, and not a reclassification — GoogleCalendarApiClient::token() is right to refuse to call a refused refresh permanent. The first failure always reports, a CHANGED message always reports, and the delay is capped at a day so a condition that heals is retried within one. SURFACING Its own settings section rather than a block on Accounts: the problems span accounts, calendars, connections and the queue, and a variable-height alert stack would push the real account controls below the fold exactly when something is wrong. Plus a topbar badge, following LogAlertGlobal's shape including ResetInterface (worker mode) — but not dismissible, because "seen" is the right model for a log and the wrong one for a condition that is still true. Every string is translated (en/de/en_PI) and written for a person: what broke, what it means for their mail, and what the button will do before they press it. Provider error codes stay behind a disclosure. Verified: phpunit 3411 tests green, composer stan clean, 5 Playwright specs green, both themes at 1440px and 393px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c7ec3b36f9 |
Merge feat/new-mail-marker: a thread is new until its row has been shown
Adds MessageThread::listedAt -- new iff null -- with a Gmail-style New badge on the row and a dot on the inbox category tabs and sidebar rows. Lists render first and mark afterwards, so the badge is always seen at least once, and only the rendered page is marked. The migration backfills every existing thread as already-listed. New is deliberately a separate axis from unread: a thread scrolled past but never opened is no longer new, yet is still unread. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
42246c35d9 |
feat(mail): a Gmail-style "New" marker for mail you have not been shown
"New" is deliberately not "unread". Unread asks whether a conversation has been OPENED; this asks whether it has been SHOWN — whether its row has ever been put in front of you in a list. Scroll past something in the inbox and never click it and you have stopped being surprised by it while still not having read it. Both statements are true at once, so neither is derived from the other: MessageThread::$listedAt is its own nullable column, and seenAt and unreadCount are left saying what they already said. A plain column rather than a per-user table, because a thread hangs off an Account and an Account hangs off exactly one User — "was it shown" already has one answer per row. A per-user "last seen" timestamp in the settings bag was considered and rejected: it cannot say "this specific thread was displayed" when fifty threads arrive and only the first page of twenty is looked at. The migration backfills listed_at = NOW() on every existing row, and that half is the point of it. A bare ADD COLUMN leaves every row null, null means new, and the first person to open plMail after the deploy would find their whole archive badged with no way to clear it but to page through years of mail. A one-off UPDATE rather than a column default, which would keep firing and make every newly ingested thread born already-seen. The rethread backfill carries the value across a rebuild for the same reason, by COALESCE, so a maintenance command cannot relight an account either. Marking happens server-side after the list has rendered, following the logsSeenAt precedent in AdminDashboardController: the "is new" set is read before anything is written, so the badge is drawn from the state that was true when the request arrived and only then retired. Scoped to the rendered page, never the query, or page one would clear page two's badges unseen. Three ways a render could retire a badge nobody saw, and what each does: the list refresh is already deferred while document.hidden and held during a write, so a background poll cannot; a response the client discards is one the server rendered no list for, so it marked nothing; and a Turbo 8 hover prefetch — every sidebar row is prefetched, only thread rows opt out — is now detected by X-Sec-Purpose / Sec-Purpose and renders without marking. The row badge is a filled accent pill carrying a word, next to the sender at both container widths, and nothing like the bare green unread dot further down the same row. Categories, roles, labels and Starred get a quiet accent dot instead — "something arrived here", not a count — rendered at every value and hidden at zero so the sidebar controller can patch it from the counts endpoint, which now emits namespaced "new:" keys beside the unread ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a1dd747d08 |
feat(mail): read receipts end to end, and message priority
Wave 3/3a of the compose more-options plan: the two new Message columns, both directions of RFC 8098 read receipts, and the per-alias setting that governs them. Outgoing. MessageSendService::buildEmail() — the single MIME assembly point all three transports share — grows two header pairs. Priority writes X-Priority and Importance together, because a message setting only one is urgent in half the world and ordinary in the other half. A receipt request writes Disposition-Notification-To and Return-Receipt-To, both naming the sending identity rather than the account: mail sent from an alias must have its receipt come back to that alias, or the correlation on the way in has nothing to match — and a request naming anything else is the shape this same codebase refuses to auto-answer. Incoming. ReadReceiptStep queues a scan of each ingested batch; the handler flags the messages whose senders asked, and recognises the ones that ARE receipts. Detection at ingest rather than at read time, because the header bag is already on the row and re-deciding it on every mailbox open puts a security decision on a hot path. Sending. Hung off ThreadStatusUpdater::markRead(), which is the user-initiated path and nothing else. Its inbound twin applyRemoteFlags() deliberately does not call it, so a sync discovering a message was already \Seen elsewhere sends nothing — a receipt claims a person displayed the message, and a sync pass learning about a read that happened last week cannot make that claim. Only genuine null->set transitions fire, so the bulk toolbar re-marking a mixed selection cannot re-send. Async through the bus: opening a mailbox must never block on an outbound SMTP call. The setting is per alias in the existing jsonb bag, no migration, and defaults to Never through ReadReceiptMode::fromSetting() — every unreadable stored shape resolves to silence, so a user who never opens the panel cannot emit a confirmation that their address is live. A Disposition-Notification-To disagreeing with From and Return-Path is pulled back to Ask however the mailbox is configured; not to Never, because the legitimate version of that shape is a bulk sender collecting at its bounce address. Return leg. An inbound MDN stamps readReceiptAt on the message it answers — which is what the sent message renders as "Read at …", without which requesting a receipt tells the user nothing — and the report is marked read and taken off the Inbox rather than deleted. Third column beyond the plan's two: read_receipt_at, in the same migration. The plan asks for "Read at …" on the sent message and there was nowhere to put the timestamp. Verified in a one-off container on the test stack: phpunit 3266 tests OK (baseline 3248, +18 new), composer stan clean with no baseline growth, migration up/down/up clean against the real schema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
363f5df6cc |
fix(mail): the sandboxed frame can load a remote image again
Remote images were broken in the client for two independent reasons, both measured on a live install. BUG 1 — the proxy was behind the session firewall. The mail body renders in a sandbox with no allow-same-origin (opaque origin, the XSS containment), so the frame sends no session cookie with its <img> loads. Behind ROLE_USER those cookieless requests were answered with the login page (200, text/html), which an <img> renders as a broken icon. The route is authorized by its HMAC signature instead — hash_hmac over the URL with %kernel.secret%, no user in the payload, so it is a global capability token only our pipeline can mint (cf. GitHub Camo). Dropped the controller's IsGranted and added a PUBLIC_ACCESS rule for ^/mail/image-proxy$ before the catch-all; every SSRF rule in ImageProxyFetcher still runs on every fetch. BUG 2 — the fetcher threw on every request. It passed extra.curl => [CURLOPT_REFERER => null], and Symfony's HttpClient reserves that option for its own headers handling and throws InvalidArgumentException on any request that sets it there. The exception was swallowed into a null return, so the proxy served its 42-byte placeholder for every image and never fetched one. Removed the option; the client sends no Referer unless one is set, and none is. Also: gate Stimulus debug logging to the runtime environment. startStimulusApp() reads a debug flag baked into the compiled asset at build time, which shipped ~1000+ console lines to prod; app.debug is now read from <html data-app-debug>, the flag of the kernel actually serving the request. Tests: ImageProxyAccessTest (anonymous access + signature matrix), ImageProxyFetcherTest guard for the forbidden curl option, and an E2E spec that asserts a proxied image decodes to real pixels inside the opaque frame — the render the old rendering-security suite never checked because it points at an unfetchable .invalid host. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ade0f9ea6d |
Merge fix/sync-data-polling
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> # Conflicts: # src/Controller/Settings/AccountController.php # src/Controller/Settings/SettingsController.php |
||
|
|
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. |
||
|
|
b82c9bac9a |
fix(mail): stop the idle inbox polling four times a second
Measured on an idle inbox: /mail/sidebar/counts 32x per ten seconds, /mail/inbox 80 KB of HTML 8x per ten seconds. Three separate causes. The sidebar partial is included twice — mobile drawer and desktop column — so two controller instances listened to the same sync events on document and every sync fetched the counts twice. The fetch belongs to the page rather than to the instance, so it moves to module scope: one request, both instances patching from one answer, with a floor between requests. A sync run publishes one mailbox.synced per mailbox per account, so "a sync happened" arrives as a burst. The in-flight guard only stopped two fetches overlapping, which turned a burst of eight into eight sequential requests. A burst is now one refresh plus one trailing one. And the refresh asked for the current URL and got the whole document back, then parsed it, lifted one frame out and threw the rest away. It asks for the frame now — 63.9 KB becomes 12.6 KB — via a header of our own, deliberately not Turbo's: Turbo sets that on ordinary frame navigations and reads the response title to advance history, so answering those with a fragment would have stopped the tab title following the folder. Both pollers stop while the tab is hidden and take the held refresh on return, and the trailing timer is cleared on disconnect — Turbo replaces <body> every visit, so an uncleared one is a fetch per navigation with nothing to write to. Also drops the '[mail-pane] list refreshed' console.log that was shipping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
84a5eed8b3 | Merge branch 'feat/push-monitoring' | ||
|
|
2bf3c38add | small fixes that claude couldnt commit because who knows | ||
|
|
e5596a9b47 |
Send push over FCM for clients that ask for it, configured from the admin page
A second transport beside Web Push: PushSubscription/set create accepts a vendor fcmToken, the dispatcher fans out per transport, and delivery goes through FCM HTTP v1 with an OAuth token minted from the service account the admin pasted - no new dependency, openssl and the http-client do it. The admin page takes the service-account JSON (encrypted at rest) and the instance's google-services.json, refuses mismatched pairs, and the session advertises fcm plus the public client config a stock Play Store build needs to initialise Firebase at runtime against this very instance. Committed by the supervisor: the implementing session hit its usage limit after writing code and tests but before running them; results recorded in the merge that follows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b0a1d07977 |
Share a calendar by link, and let people book a time on it
Two public-facing halves of one idea: showing somebody your calendar without giving them an account, and letting them take an hour of it. **What a link reveals is a per-link decision.** Busy/free is the floor; ticking boxes adds title, location, description or participants, and the ticks are editable afterwards without breaking the URL that has already been sent. The redaction is a DTO rather than a set of conditionals in a template: ShareLinkReader answers SharedOccurrence, and the public templates never see a CalendarEvent at all. A busy/free link therefore cannot leak a title through a tooltip, a data attribute, a JSON payload or an .ics, because the object it is rendering has not got one. SharedIcsBuilder is deliberately not IcsExporter for the same reason — it serialises the redaction, so there is no code path where the real event reaches a stranger. The shared .ics carries a synthetic UID derived from the link digest, so a recipient who also holds the genuine invitation cannot correlate the two. EventPrivacy finally does the job it was named for. Privacy is the ceiling and the checkboxes are the floor: Secret never appears at all, because its existence is the detail, and Private stays a busy block however many boxes are ticked. **Booking.** The owner says which weekdays and hours, how long a slot is, how much buffer sits either side, how much notice they need and how far ahead they will go, which calendar the booking lands on and which calendars count as busy. A stranger sees the slots that are actually free, picks one, and gets a confirmation carrying a real .ics. Double-booking is refused by the database, not by the code. A unique index on (page, starts_at) over a booking row that is written in the same flush as the event, so Postgres rejecting the second insert takes its event with it and the loser is told the slot has gone. Check-then- insert was rejected outright: two requests both read a free slot and both send a confirmation, and the constraint then rejects one of them after a stranger has already been told they have the time. A check is not a lock. starts_at is denormalised onto the booking on purpose — if the constraint spanned the event's own column, the owner dragging a booked meeting on the time grid would silently free an hour that somebody else is holding. A booked event is its own EventSource case: trusted, because it must reach the provider, and never rewritable by mail, because a booker has no standing to move an hour by sending a message. Not an ExtractionKind, which would have put bookings behind every "found in your email" affordance and into Happening Soon. The booker's name and address live on the booking row and deliberately not in JSCalendar participants — pushing an attendee list makes the provider mail a stranger a meeting request nobody asked it to send. Tokens are hashed at rest for both halves, following the device-pairing precedent, which costs the ability to ever show one again: regenerate is the recovery, and there can be no copy button. The public pages get a layout of their own rather than the app's. The app layout renders a CSRF token into a meta tag, which starts a session — one per visitor and one per .ics poll on a link that may be in a stranger's calendar client. The booking POST therefore carries no CSRF token, and the guard is the unguessable URL plus a rate limiter of six attempts an hour per address. The GET is deliberately unbounded: limiting it would let one stranger take a published page offline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ffd77d0019 |
Put the week on a time grid, carry calendars in and out as ICS, and
make a reminder go off Three pieces of calendar work that were built together and are committed together, because they interleave: the reminder checkboxes live in the same editor the drag-to-move endpoint writes through, and the ICS importer and the alert reader both go through CalendarEventWriter. **A time grid for the week.** _view_days.html.twig said a time grid "belongs with drag-to-move, which is its own piece of work", and this is that work. Hour rows, blocks sized by their real times, concurrent meetings sharing the width; all-day events in a strip of their own rather than as zero-height blocks at midnight. The docked pane keeps the column list — that constraint was already reasoned out and stands, because a 380px pane has no business rendering a grid. Dragging a block, or resizing it by an edge, posts to a route of its own rather than to the save: a drag says two things and nothing else, so a route that accepts only those two cannot silently blank a description. Dragging one occurrence of a series asks the same question the editor asks, and applies the delta rather than rebasing — that bug has been fixed once already and is not being re-introduced. Read-only calendars refuse the drop and say so. **ICS in three directions.** Download one event or a whole calendar, upload a file into a calendar, or subscribe to a feed by URL. One iCalendar implementation behind all three: IcsDocumentReader splits a VCALENDAR into the per-UID resources CalDAV's shape already means by "one event", carrying every VTIMEZONE into each, and the existing CalDavEventConverter and RecurrenceRuleConverter do the mapping. There is no second parser and no second RRULE conversion. A feed is a fourth sync driver — read-only, no push, conditional GET on ETag and Last-Modified. A changed feed surrenders its token rather than reporting a listing, because a feed states what exists and says nothing about removals; that is the same trade CalDAV's ctag fallback makes, and it costs two downloads per actual change. webcal:// is rewritten to https:// in the scheme only, never anywhere else in the URL. The address goes through the SSRF guard that already exists for CalDAV and the integrations rather than a second one — which is why the successful subscribe is proven through the controller over a scripted transport and not in the browser: every address the e2e stack can reach is loopback or private, and the guard is right to refuse it. Export streams; import is bounded at 4 MiB and a feed at 8 MiB on bytes arriving rather than on a Content-Length, because sabre has no incremental component API and the parse is necessarily whole. **Alerts that fire.** RFC 8984 `alerts` on the event, in the canonical object, so they round-trip to Google, Graph and CalDAV instead of being a plMail-only column. Six one-click offsets plus an arbitrary number of minutes, and no JavaScript: a client-side repeater would have to build the same labels in a second language with a second set of plural rules. The checkboxes post KEYS, not offsets — the key an alert lives under, resolved on save against the list the template rendered. That is what lets a plain checkbox list carry an alarm the form cannot express, such as one imported from a CalDAV server at an absolute instant, rather than dropping it the first time somebody corrects a title. It also means a crafted post can untick a reminder but cannot invent one. app:calendar:alerts every minute, because a minute is the unit people set a reminder in and the interval is the bound on how late one can be — at five minutes a "five minutes before" reminder could arrive after the meeting started. Delivery goes over the Web Push stack that already exists or as mail, chosen per alert by its action. Not firing twice is structural: a delivery row with a unique constraint, not a comment. A backfill cannot fire the past, and a recurring event means one alert per occurrence, bounded by the materialiser's own horizon. calendar_event_occurrence gains a start-only index. Both existing indexes lead with a user or a calendar, and the sweep asks "what starts near now, anywhere on this install?" once a minute — without it that is a sequential scan of every occurrence ever materialised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
455af44608 |
Open the push channel when the calendar is connected
Subscribing left the calendar polling until the hourly sweep noticed it, so a calendar connected at ten past waited fifty minutes for its first push channel. Registration now happens on subscribe. Dispatched, not called. Opening a channel is an HTTP round trip to Google or Microsoft, and doing it inline would put a third-party call inside the request that ticked a checkbox — slow when it works and a 500 on the subscribe form when the provider is having a bad afternoon. Routed to maintenance rather than ingest, because on ingest it would queue behind the first full calendar read and open the channel minutes later on exactly the large calendars where push matters most. Routing it at all is load-bearing: an unrouted Messenger message is handled in the dispatching process, which is the thing being avoided. The hourly sweep stays, and the two are not redundant. Registration fails for deployment reasons — no public HTTPS address yet, a Cloud project whose domain verification is pending — that have nothing to do with the click that connected the calendar, and a subscribe that failed for one of those must not leave the calendar polling forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
25f1cf74c8 |
Say which build this is, in the admin header
Migrations run on boot and `latest` moves, so "is this the build I think it is?" is a question with real consequences and no cheap answer. The release and the commit now sit in the admin header — in the header rather than in a panel, because it is asked while looking at something else. Stamped at image build and never discovered at runtime. A container has no .git: the source is copied in and the history stays behind, so anything that shelled out to git in production would answer from a mounted volume or from nothing. The release workflow passes both values from the metadata action it already computes; a plain `docker build` passes neither, and an image nobody stamped says "development" rather than guessing, which is also why the chip is absent entirely on a checkout — a permanent "development" badge is furniture. There is deliberately no test that a real checkout describes itself. That would assert a property of wherever the suite runs rather than of the class: the test container has the source but not the history, and where it has both, git refuses the directory for dubious ownership — so it passes on a laptop, fails in CI, and says nothing either way. This suite has been bitten by that exact shape twice already (an id sequence gap that only existed on a well-used database, a sidebar state that only existed after another spec ran). The contract is tested instead: an unreadable repository means "development". config/reference.php is gitignored and untracked. Symfony regenerates it on every cache warm-up and not deterministically — two keys inside one @psalm-type block swap places — so a tracked copy showed a phantom diff after any command that booted the kernel, all session. Its own header says it is auto-generated and for apps only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a674c7fc28 |
Answer a changed calendar instead of waiting for it, and change one day of a series
Two halves of the same complaint: a calendar that only ever catches up
on a timer, and a repeating event that could only be changed all at
once.
Push, for the two providers that have it. Google events.watch channels —
a plain webhook, not the Pub/Sub path Gmail is on — and Graph
subscriptions on a calendar's events, which is the mechanism
GraphSubscriptionManager already runs for mail. Both notifications are
content-free, so a webhook does exactly one thing: dispatch the sync
that already worked. All the intelligence stays where it was.
CalendarPushSubscriptionManagerInterface sits BESIDE the mail one rather
than widening it. Every method over there takes an Account and reads
columns on Account, but Graph subscribes to me/calendars/{id}/events, so
one Microsoft mailbox mirroring six calendars needs six registrations,
six secrets and six expiries. Widening to Account|Calendar would hand
the two mail managers a type they cannot serve and turn a compile-time
contract into an instanceof cascade in three classes.
Four columns on calendar rather than keys in its settings bag, which is
the one storage decision here worth arguing: push_channel_id is what two
unauthenticated, internet-facing endpoints resolve a notification by. It
needs a unique index and a constant-time comparison against a known
column, and neither is something to build on a key inside a jsonb
document whose readers assume their own defaults. push_resource_id is
there because channels/stop takes the pair — discard it and a channel
can never be closed.
Push is never load-bearing. A self-hosted install may have no public
HTTPS callback at all, so a refused registration means "stay on
polling", and the fifteen-minute sweep is untouched. Both stored
expiries are the ones the provider RETURNED, not the ones requested,
which is what makes renewal work rather than nearly work.
Single-occurrence editing on the other side. A chip carries the
occurrence's original start into the editor, and that instant is the key
its patch is filed under — so a second edit of the same instance updates
its patch instead of stacking another beside it. A patch states start,
duration and a title only when the instance has its own; writing the
whole posted object would claim a location and an all-day flag for that
one day that nobody set.
The bug worth naming, found reviewing the agent's own flagged judgement
call: the editor is prefilled with the INSTANCE's times, because "this
event" has to move the occurrence that was clicked. That made the posted
fields useless as the series' times — "all events" rebased the whole
series onto the clicked day, so renaming a weekly meeting from its fifth
occurrence moved it four weeks on, silently. The difference the user
made is applied now, not the value they saw, so a title-only edit shifts
by zero and the series keeps its day to the second.
What reaches a remote is honest about itself. CalDAV round-trips a
per-instance change; Google and Graph each model a moved instance as a
separate resource and neither mapper writes one, so on those the change
is pushed as a series update that says nothing about the instance and
stays local. Documented where somebody will meet it rather than left to
be discovered.
The admin note about Google's domain verification is translated now
rather than an English literal with an apology beside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4e92d148e2 |
Mirror the calendars people already keep somewhere else
Google, Microsoft and CalDAV, subscribed from settings and synced both ways. One CalendarSyncDriverInterface behind three providers; the engine has never seen HTTP, OAuth or a VEVENT and must not learn to. The decision that shapes the rest: Google and Microsoft calendars ride the MAIL grant. todo.md said to give them their own Provider cases with their own grants, and that note is overruled here on purpose — a second OAuth app is a second thing to register, consent to and keep working, and the requirement was that enabling calendars cost an administrator one more permission checkbox and nothing else. Only CalDAV is its own Provider case, because only CalDAV is its own server. Google's consent screen can still refuse the scope by itself, so that surfaces where the calendars would have been rather than being tracked at grant time. Seven conflict rules, written into CalendarSyncService's docblock rather than discovered later. Two decide whether "changes sync back" is a promise or a hope: Push before pull, always. Pull first and every local edit collides with its own echo. A row changed on both sides loses its local copy LOUDLY — the whole discarded object logged before the overwrite. An edit that vanishes without a trace is the one outcome nobody can debug afterwards. Recurrence was the weak spot and is fixed in the same pass, because shipping it broken would have been shipping a wrong calendar. RRULE to JSCalendar existed in one driver's private copy while two other paths stashed the rule unconverted, and the expander reads only the converted form — so a weekly meeting that arrived by mail, or lived on a CalDAV server, drew exactly one occurrence. One conversion now serves all three, and a rule that cannot be converted faithfully is still refused whole: an event on the wrong days is worse than one that does not repeat. An instance moved out of its series had nowhere to live, and all three drivers hit it independently — a duplicate on the new day from Google, the old time from Microsoft. recurrenceOverrides was already read by the materialiser and produced by nothing. Now produced by all three, and CalDAV writes it back, because a PUT replaces the resource and reading overrides without writing them would delete every moved instance at the server the next time somebody renamed the series. Dates in ordinary prose are proposed, never filed. A guess is an EventProposal and not a CalendarEvent, and that is the whole design: the writer materialises occurrences, every range query reads occurrences, and a guess living in calendar_event leaks into a view the first time somebody forgets to exclude it. A proposal materialises nothing and therefore cannot. Accepting converts it through the one writer; dismissing writes the suppression row that already exists. The rule nobody would have predicted came from running the parser over a real mailbox: 56 of 57 gated messages yielded a date and every one was a reply's own furniture — "On Thu, Jul 2 … wrote:", "Sent: Friday …". The horizon rule refused them all, but only because a quoted message is older than the one quoting it, which is an accident. Quoted text is skipped now. Date:/Datum: is not, because German business mail states appointments that way. Two bugs fell out of asking whether a claim actually held. EventSource::isTrusted() was `return self::Llm !== $this`, so every case written after it silently inherited *trusted* — the exact permission the method exists to withhold, and invisible in a way a default arm at least is not. And an event accepted from a proposal could be rewritten by a later message: the guard covered extracted events only, and what really kept mail off these was that plMail mints UIDs no sender collides with. Hand the reconciler the event's own UID and it overwrote it. Both closed on the enum, exhaustively. Groundwork that made the above possible is in the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
24c7df83cd |
Make room for a calendar connection before adding one
Groundwork only: nothing new is offered to a user yet, and the mail grant now asks for one more thing. Integration was written as "a file or photo service", and every list of providers walked Provider::cases() on that assumption. A calendar connection is the same shape — a base URL, a credential, a health check, a settings bag — and belongs nowhere near "Save to…", so services now declare a ServiceKind and both lists ask for the file ones. Declared rather than inferred from an empty capability list, which would be true today and quietly wrong the first time a calendar service also served files. The two matches todo.md asked to close are closed. capabilities() fell through to the full file set, so a case nobody had thought about claimed Browse, Upload and ShareLink and only admitted otherwise after a user had picked it; authKind() fell through to OAuth2 and would have sent a password-only service through a flow it has no endpoints for. Both are exhaustive now, which is what makes adding a case a compile-time conversation instead of a runtime one. verify() moves to VerifiableDriverInterface, which IntegrationDriverInterface extends. It is the one thing every connection owes whatever it connects to, and the connect, test and onboarding paths call it for all of them — the alternative was five throwing file stubs on a driver that holds no files. Same argument SearchableDriverInterface already makes, and the tag moves to the broader interface so the file drivers are unaffected. The mail scopes gain calendar access, on purpose and against the note in todo.md that said to add separate provider cases with their own grants. A second OAuth app is a second thing for an administrator to register, consent to and keep working; the requirement here is that enabling calendar sync costs them exactly one more permission checkbox in Google Cloud or Azure. The admin help text now says so, per provider, next to the credentials it applies to. Google's consent screen lets a user untick a single scope, so a token can still come back without calendar access. That is left to be discovered when a calendar call is refused and reported there, rather than tracked at grant time — there is nothing useful to do with the knowledge until something asks for a calendar. Two browser specs fell out, both newly exposed rather than newly broken. Which account is expanded in the sidebar is a stored preference, and it only started surviving reliably when the chevron learned aria-expanded a commit ago. An expanded account lists its own Inbox and its own copy of each label, so a locator reading "any text saying Inbox in the sidebar" and another reading "any link named E2E Label" each matched two elements. Both now name the row they meant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
05f8f8feea |
Ask Graph for the invite flag through the type that declares it
meetingMessageType lives on microsoft.graph.eventMessage, not on the base message, and naming it unqualified is not ignored — the whole $select is rejected, every sub-request in the batch answers 400, and the account stops syncing. That is what account 10 had been doing: RequestBroker--ParseUri: Could not find a property named 'meetingMessageType' on type 'Microsoft.OutlookServices.Message' Found the same day the failure started carrying Graph's own reason, which is the whole argument for logging it. Selected with the cast now. Mailboxes served by the older Outlook broker refuse even that, so a batch that fails on this property is retried once without it and the mailbox is remembered for the rest of the process — one wasted batch per worker instead of an account that never syncs. The unknown-subscription warning is throttled to one an hour per subscription. Microsoft holds a registration this install no longer has, re-delivers several times a second for up to three days, and nothing can cancel it from here without the account it belonged to — so it filled the log browser and lit the unread-log ring over something no admin can act on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3c176d7e79 |
Outline the user menu when the log has something nobody has read
An error only exists if somebody opens the admin panel to find it, and nothing on any other screen suggested there was a reason to. The avatar now carries a ring — amber for warnings, red once anything reached error — with the count in its tooltip and next to the Admin entry, which then goes straight to the log browser rather than the dashboard. Seen is "the browser was opened", marked from that moment rather than from the newest entry on screen, so anything logged while it is open is still unread afterwards. The mark goes in the user settings bag: it is a read marker for one screen, of no interest to anything that queries users, and that keeps it out of the schema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |