Commit Graph
7 Commits
Author SHA1 Message Date
Lucas RayanandClaude Opus 5 8b69521c76 #34: fix the mail counters, the reader pane, and the state between surfaces
The counts were computed inline in JSX where nothing could test them. The
sidebar matched a folder by a special-use role folders do not always carry, its
badges were fetched once and then froze - new mail did not raise them and
reading a message did not lower it - and the list header printed the size of
the loaded page, capped at 50, as the size of the folder. The rules now live in
mailCounts.ts behind tests, useFolders keeps them current from the server's
push and from this tab's own changes, and each filter chip carries its number.

Inline images never left the server: RenderMessage dropped every part a body
references as cid:, so mail written by any normal client rendered with broken
icons. They now come back as bounded data URIs, refusing image/svg+xml, and the
reader resolves them, sizes its frame from the message instead of a fixed
500px, and renders on the light surface mail assumes.

Reading mail no longer asks for a second factor - the mailbox is behind the
account's own password - while the console still requires one unconditionally,
now collected by a step-up that does not ask again for the password the session
in hand just proved. An admin sign-in issues the webmail session too, so
leaving the console is a link rather than another login.

Two defects found while testing the above:

  - The admin sign-in checked the password and the second factor and never the
    role, so any account that had enrolled a factor from its own settings could
    obtain an admin-audience session.
  - Session tokens carried nothing that varied below one second, so signing in
    twice within the same second minted the identical token and collided with
    the unique index on web_sessions.refresh_token_hash. A correct password
    answered 500, and a double-clicked sign-in was enough to do it. Tokens now
    carry a jti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:51:56 -03:00
Lucas RayanandClaude Opus 5 57198e0263 #28: stop enrolment from silently replacing a secret the authenticator already has
Enrolling a second factor could reach a state where the authenticator showed
perfectly valid codes and every one of them was rejected, with the only
feedback being "TOTP verification failed".

The browser holds the enrolment secret in component state alone. Reloading the
settings page - or leaving and coming back - brought the "enable" button back,
and pressing it called the enrol endpoint again, which deleted the pending row
and stored a brand new secret. The entry already saved in the authenticator
then belonged to a secret the server no longer had. Nothing about that is
visible from either side: the app generates codes correctly, the server
compares them correctly, and they are for different keys.

Enrolment now resumes. The endpoint returns the pending secret if there is
one, so the QR shown after a reload is the same one already scanned and
rescanning is harmless. Starting over is still possible - ?reset=1, exposed in
the settings screen - because someone who deleted the entry from their app
needs a secret they can actually generate codes for. A pending row that can no
longer be decrypted is replaced rather than resumed, since nobody could ever
confirm it.

Also: the submitted code now has every non-digit stripped rather than just
surrounding whitespace. Authenticators display the code grouped as "123 456",
and a value typed or pasted with that space failed the six-character length
check before it was ever compared - which, to someone reading the right number
off their phone, looks like the server being wrong. The inputs sanitise as you
type for the same reason.

Ruled out first, rather than guessed at: the algorithm passes all six RFC 6238
reference vectors, base32 round-trips, and the deployment's clock is exactly in
sync with real time, so neither the implementation nor drift explains it.

Verified: the resume test fails without the change and passes with it; 47 auth
tests green against a real Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:47:59 -03:00
Lucas RayanandClaude Opus 5 ee240ae227 #23: show real data, unify the surfaces and make enrolment scannable
The interface was reporting things that were not true. Several screens were
mockups wired to nothing, and a reader with the console in Portuguese was
served a mixture of two languages.

Fabricated data, removed:

- The outbound queue was two invented jobs held in component state. Retry and
  Freeze only edited that local array, so the screen showed the same two rows
  on every deployment and reported success for actions that never happened.
  "Freeze" named an operation the backend has never implemented. It now reads
  /api/v1/admin/queue, which was already serving the real thing, and offers
  retry and cancel, which exist.
- Every dashboard KPI fell back to an invented constant (1420 received, 890
  sent, 3 queued, 18.4% disk), so an unreachable API produced a dashboard of
  plausible traffic for a server that might have delivered nothing. Two of
  those fields were never returned by the endpoint at all, so the fake disk
  figure was what everyone always saw. DMARC did the same with 1250/98%/97%.
- The domains screen showed thirteen DNS records for example.com, all marked
  VERIFIED, and posted {domain_id: "default-domain"} to endpoints expecting a
  real id. DKIM rotation sends "domain", not "domain_id", so its 400 was
  swallowed and a made-up TXT record displayed as though a key had been
  issued: rotation had never once worked from this screen.
- The TLS panel asserted a Let's Encrypt RSA 4096 certificate and verified
  DANE from static markup, on a deployment currently serving a self-signed
  one. It now reads the cert watcher via /api/v1/admin/tls.
- The readiness scorecard printed "10 / 10 all checks passed" regardless of
  what preflight reported, including failures.
- A message trace that found nothing invented a five-step delivery - TLS 1.3
  accepted, SPF pass, spam score 0.8 - for a message that does not exist.
- Both sidebars showed a fixed admin@lambdamail.local with a hardcoded
  "SUPER_ADMIN (2FA)", claiming a second factor whether or not one existed.
- Settings seeded a "[Boletim]" Sieve rule. Saving posts the whole list, so
  the first edit wrote that invented filter into the account's real script.

The reconcile endpoint itself answered {records_verified: 13, status:
"VERIFIED"} for any domain id, including one that does not exist, without
resolving a name. It now reports what it does, which is record the request.

Surfaces:

The root was a chooser listing webmail and the console side by side, which
asked visitors a question they should not have to answer and advertised cookie
names and audiences to anonymous readers. It now redirects into the mail
client. Accounts that may administer the server get a link to the console from
inside the app - a link to /admin/login, so crossing over still costs the
second factor the admin audience requires - and the console gets a plain link
back, since the webmail session was never given up to get there.

Enrolment:

Both enrolment screens printed the base32 secret as text and left the operator
to type 32 characters into their phone. The enrol endpoint already returned an
otpauth:// URI; it simply was not drawn. Added a shared component that renders
it as a QR locally - the seed is already in the page, and sending it to an
image service would put it in another request log for nothing.

The recovery-code step rendered its own "sign in" button while the form's
submit button stayed visible below it: two identical buttons, and no way to
copy the codes it insisted you save. It is now terminal, with copy and
continue.

Language:

translate() had no interpolation, so "Undo Send ({seconds}s remaining)" showed
the placeholder and a second hardcoded countdown was appended after it. It now
substitutes, leaving an unmatched token visible rather than blanking it.

The pt-BR bundle had lost its diacritics - "Destinatario", "Ultimo erro",
"Acoes" - from the ASCII-source rule being applied to the translations, which
are data. Restored, and the three bundles are back in key parity at 270 each,
with roughly 120 strings that were hardcoded in the source now keyed.

Healthchecks:

web had none, despite being the only service users reach directly: a wedged
Next process kept its container running and Traefik kept routing to it. Added,
using node's own fetch since the image ships no curl. rspamd and clamav gained
the timeouts and start_periods whose absence would fail them during startup.
migrate stays without one and says why: it is a one-shot job, and a probe
would report unhealthy exactly when it has succeeded.

Verified: 36 webmail tests and 41 auth tests against a real Postgres, both new
admin/me cases confirmed to fail without the route (404). lang-lint clean,
production build clean, 0 vulnerabilities, compose validates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:13:53 -03:00
Lucas RayanandClaude Opus 5 3a82d82428 #22: fix the admin lockout, and the regressions the UI rewrite brought back
The console demanded a second factor and refused to open without one, but
enrolment lived on the other surface - so an operator whose only account
was the administrator had no way in. The password was already verified at
that point, so the refusal now carries a short-lived grant that permits
enrolling a factor and nothing else, and the admin login screen walks
through it: secret, confirmation, recovery codes, sign in. Two tests cover
it, including that the grant opens no other endpoint.

The rewrite also undid work that was already in place:

  - The postcss override was dropped and the advisory came back, along
    with a critical one in vitest. postcss is a direct dependency now, so
    the fix is the version plus an override scoped to Next's own copy;
    vitest moves to 3.2.7. The lockfile needed regenerating for either to
    take effect. Zero advisories now.

  - 100 language-lint violations: the admin console had been rewritten
    with its strings in Portuguese, hardcoded. That fails CI, and it also
    puts those strings beyond the reach of the translation bundles - an
    English or Spanish user would have read Portuguese. They are English
    in the source again.

  - The mail screen put a fixed 320px list beside the reader with no
    breakpoint, so on a phone the viewport overflowed and the message was
    a few pixels wide. One pane at a time below md, with a way back.

  - The icon-only navigation toggles had nothing for a screen reader to
    announce.

The layouts' own mobile navigation and every previously fixed behaviour
survived the rewrite - checked rather than assumed.

Also fixes a marker collision I introduced: the E2E helper locates the
repository by a migrations directory, and services/migrations satisfied
that one directory too early. It now requires the SQL to actually be
there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 16:13:14 -03:00
Lucas RayanandClaude Opus 5 132cecd41c #21: guard the web surfaces and connect them to the auth service
Three separate gaps, all of which made the F4 interface non-functional or
unprotected:

The admin console rendered for anyone. There was no middleware and no
guard in the layout, so /admin/dashboard returned 200 unauthenticated -
only its data calls failed. middleware.ts now redirects both surfaces to
their login when the session cookie is missing, expired, carries the wrong
audience, or is a pre-MFA challenge token. It reads the claims without
verifying the signature on purpose: the signing key belongs to the auth
service and must not reach the browser bundle. That is sound because this
is not the boundary - every /api/v1 request is verified for real upstream.

Nothing served /api/v1/*. next.config.ts had no rewrites and there were no
route handlers, so every call the pages made 404'd against the Next
server, while AUTH_SERVICE_URL sat unused. A catch-all route now proxies
to the auth service, forwarding the client address and preserving multiple
Set-Cookie headers, which logout needs to clear both surfaces. Session
cookies also move to Path=/, since a cookie pinned to /user or /admin was
never sent with the /api/v1 requests that needed it.

i18n existed only as files. The three bundles had perfect key parity and
real translations, but no library, no imports and no switcher: the UI was
hardcoded English and i18n.test.ts compared keys while the feature was
absent. There is now a provider, a hook, a language switcher, and
server-side Accept-Language negotiation that honours q-values and matches
a bare "pt" to pt-BR. Logins, both layouts and the settings screen consume
it; the admin pages still have English titles.

The settings screen invented app passwords in the browser with Math.random
and never stored them. It now calls the API, which returns the password
once and keeps only its hash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 09:33:49 -03:00
Lucas Rayan 23da5f43e4 #20: fix non-ASCII lint violations in webmail and router source files 2026-08-02 08:39:52 -03:00
Lucas Rayan 6e01fc7ffb #20: implement F4 webmail, admin console, 2FA TOTP, surface isolation, and i18n 2026-08-02 08:39:00 -03:00