Files
pl_mail/config
Paul LütznerandClaude Opus 5 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>
2026-08-13 09:21:35 +02:00
..
2fa
2026-07-31 07:18:26 +02:00
2026-07-31 05:12:08 +02:00
2fa
2026-07-31 07:18:26 +02:00
2026-07-24 12:20:05 +02:00
2026-06-24 14:51:50 +02:00
2026-07-26 18:06:50 +02:00