Commit Graph
9 Commits
Author SHA1 Message Date
Lucas RayanandClaude Opus 5 2b92abd185 feat(admin): paginacao na interface, edicao de utilizadores e pagina de testes
- fila e utilizadores passam a pedir uma pagina de cada vez, com pesquisa e
  filtros; nada e cortado no browser, porque o que faltava nunca chegou a ser
  buscado. A pesquisa espera que se pare de escrever: cada tecla seria mais
  uma consulta, e as respostas podem chegar fora de ordem
- pageWindow e pageRange tem testes proprios: uma pagina fora do intervalo vem
  de um URL velho ou de uma lista que encolheu, e nao pode dar um paginador
  vazio ou negativo, nem dizer "76 a 100 de 82"
- editar utilizador: papel, quota, idioma, com os apelidos que entregam
  naquela caixa a mostra no mesmo painel
- "Caixas de e-mail" passa a chamar-se Utilizadores, que e o que sao

Pagina nova /admin/tests: verificacoes que o operador corre a mao, sobre o
servidor ou sobre um utilizador. Todas leem estado; nenhuma envia correio nem
altera definicoes -- uma pagina de diagnostico que mexe naquilo que
diagnostica e pior do que nao existir.

As regras de cada verificacao sao funcoes puras com testes, separadas da
recolha dos factos. Alguns casos que estes testes fixam: uma quota zero
significa sem limite, e um racio marcaria todas as caixas ilimitadas como
cheias; um bloqueio ja expirado nao bloqueia ninguem e nao deve mandar
ninguem investigar; e uma resposta de ausencia ligada com o periodo ja
passado esta ligada sem fazer nada, que e o que parece avariado a quem a pos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:36:35 -03:00
Lucas RayanandClaude Opus 5 52bf43b74f feat(admin): paginacao e filtragem no servidor, e edicao de utilizadores
As listas do painel respondiam com um LIMIT fixo e nenhum filtro: 200 caixas,
50 trabalhos na fila. Numa instalacao maior nao havia forma de chegar ao
resto, e a fila so podia ser inspeccionada pela cabeca -- justamente quando
esta acumulada e que alguem precisa de olhar para ela.

- parsePageParams com testes proprios: pagina e tamanho tem de ser inteiros
  positivos, o tamanho e limitado, e um valor invalido cai no seguro em vez de
  chegar ao SQL como NaN, LIMIT negativo ou OFFSET fraccionario
- o termo de pesquisa vai como parametro ligado, nunca interpolado; ha um
  teste que tenta terminar a instrucao e largar a tabela
- a contagem usa o mesmo filtro da pagina, senao o numero de paginas descreve
  um conjunto de linhas diferente do que e mostrado
- PATCH /admin/mailboxes/:id edita papel, quota, idioma, estado e liberta um
  bloqueio; so um super admin pode conceder super admin, ou um admin de
  dominio promovia-se para fora do seu proprio dominio

Apelidos por utilizador: um apelido nao pertence a um utilizador, pertence ao
dominio e leva uma lista de destinos que pode incluir enderecos noutros
servidores, e um catch-all e do dominio inteiro. O que pode ser mostrado
dentro de um utilizador sao os apelidos que entregam na caixa dele, e e isso
que /admin/mailboxes/:id/aliases responde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:19:49 -03:00
Lucas Rayan f330c4324e Add end-to-end tests for TLS RPT report ingestion and WebSocket event handling
- Implemented tests for ingesting TLS RPT reports from emails and ensuring they are stored correctly in the database.
- Added tests to verify that the same report can be ingested multiple times without duplication.
- Created tests for backfilling reports that were previously delivered to the inbox.
- Developed WebSocket event handling tests to ensure events are correctly routed to subscribers based on mailbox ownership.
- Included tests for the mail API to validate session handling, mailbox isolation, and error mapping.
- Ensured that the API rejects unauthorized requests and handles malformed JSON gracefully.
2026-08-21 09:55:37 -03:00
Lucas RayanandClaude Opus 5 6935f9feeb #30: stop enrolment from killing the auth service, and from enabling MFA without recovery codes
Confirming a second factor returned "Upstream service is unreachable" - the
Next proxy's message for a backend it cannot reach. The backend was not
unreachable, it was dead: the process had just been OOM-killed.

generateRecoveryCodes hashed all ten codes with Promise.all. Argon2id here is
configured for 64 MiB per hash, so ten concurrently reserve 640 MiB against a
container limited to 512 MiB. Hashing is now sequential, peaking at 64 MiB and
taking a few seconds - the right trade for something that happens once per
enrolment. A timing-based test covers it, against the real hash rather than a
mock, because the cost being guarded is memory held concurrently and only the
real implementation reserves it.

The worse half is what the crash left behind. The enrolment row was marked
CONFIRMED before the codes were generated, so the account came out of this with
a second factor enabled, no recovery codes, and nobody having seen any: locked
behind an authenticator entry with no way back. That is how an account ends up
"assuming" it has a factor its owner cannot use.

The order is now verify, store the codes, then confirm. Anything that fails in
between leaves the enrolment pending and retryable, which is the recoverable
end of that failure.

Both bugs sat behind the one fixed in #28 - the code check failed first, so the
crash was never reached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:49:00 -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 30014f0f41 #21: fix the DKIM, onboarding and console defects found reviewing the new work
DKIM rotation broke the domain it was run on. It retired the live key and
inserted a placeholder as ACTIVE - private_key_enc was the literal string
"dummy-encrypted-key" - so FindActiveKeys then failed for the whole
domain and outbound mail stopped being signed, with the real key already
gone. Reproduced against a database before changing anything.

The root cause runs deeper: the auth service derives its encryption key
with a plain SHA-256 of the master key while the protocols service uses
HKDF with its own info string, so nothing the auth service seals can ever
be opened by the signer. Rotation therefore moves to the protocols
service, which owns that vault and the real key generator. It retires the
previous key with a seven-day overlap in the same transaction rather than
deleting it, because mail already sent under it is still being verified.

Domain onboarding had three defects in one function. It built the alias
destination by interpolating the domain name into the SQL text, which was
injectable; it reused one placeholder for a CITEXT and a VARCHAR column,
which made the statement fail and happened to hide the injection; and it
ignored its scope argument entirely, so any DOMAIN_ADMIN could create
domains. It also provisioned postmaster, abuse and security, missing the
dmarc and tlsrpt aliases that the published records point at - PLAN.md
section 7.4b is explicit that reports bounce without them.

The TLS panel returned fixed values: acme_status "VALID" and 72 days to
expiry, whatever the process actually held. It now reads the certificate
watcher, and a compile-time assertion keeps the wiring from silently
regressing into "disabled". The audit log and the readiness scorecard were
fixtures too, and their field names did not match the API they now read.

Two tests failed and a third asserted a fabricated constant: one depended
on test order through a shared fixture that had a second factor enrolled,
one used a fixed domain name and so passed once per database, and one
expected "records: 13" from a function that never touches DNS. All three
are fixed, and the suite now passes twice in a row against one database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:33:33 -03:00
Lucas RayanandClaude Opus 5 eec4a4e520 #21: complete the account area and audit every admin mutation
Sessions and password changes were the two things PLAN.md section 14.2
asks of the account area that were still missing, and the admin console
had no audit trail at all despite section 14.3 requiring one for every
mutation.

Session revocation is enforced on every request, not just recorded. A JWT
carries its own validity, so revoking a row without checking it would have
made "sign out this device" a button that did nothing until the token
expired eight hours later. Changing a password revokes every other session
for the same reason: a password change that leaves the attacker's session
alive has not locked anyone out.

Admin queries are scoped by role in the SQL rather than in the UI, as
section 14.3 requires: a DOMAIN_ADMIN calling the API directly sees only
its own domain, and a mailbox outside that scope answers 404 rather than
403 so the scope cannot be used to enumerate what exists elsewhere.

The two mutations that exist so far - enabling or disabling a mailbox, and
retrying or cancelling a queued delivery - write actor, IP and target to
audit_log, and there is an endpoint to read it back.

Covered by tests against a real database: a revoked token stops working
mid-session, a password change needs the current password and takes the
other sessions down with it, and the minimum length is enforced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:27:14 -03:00
Lucas RayanandClaude Opus 5 833dc76c2e #21: make the auth service actually authenticate
The F4 auth service issued a valid session for any email and any password,
and accepted any non-empty string as a second factor. Reproduced against
the router before touching it: an invented password plus the code "000000"
returned a SUPER_ADMIN token that opened /api/v1/admin/dashboard.

Passwords. Hashing moves from PBKDF2 to Argon2id via hash-wasm, which is
what PLAN.md section 9 and the Go service already use - the previous
scheme meant a password set through the mail stack could never verify
here. Interoperability was checked in both directions before adopting the
library: a hash written by alexedwards/argon2id verifies in Node and one
written here verifies in Go. hash-wasm is pure WASM, so the multi-arch
Alpine images need no native build.

verifyPassword no longer falls back to comparing the password against the
stored hash. That fallback turned every stored hash into a working
credential: anyone with a database dump could authenticate by sending the
hash back. Anything that is not an argon2id PHC string is now refused.

Logins are decided against the database, with the failure counter and the
temporary lockout of PLAN.md section 5.2. An unknown address and a wrong
password give the same answer, and the unknown case still pays for a hash
so response time does not enumerate valid addresses.

Second factor. The TOTP secret is generated server-side, stored encrypted
and never accepted back from the client - the confirm step used to take
whichever secret the caller sent, so anyone could confirm with their own.
Recovery codes are Argon2id, single-use, and consumed under a guard so two
concurrent requests cannot spend the same one. App passwords are issued
and revoked through real endpoints instead of browser state.

Sessions gain a "purpose" claim. Without it the challenge token - handed
out after the password but before the second factor - satisfied every
check a real session did, which skipped MFA entirely.

The dev seed carried a hand-written password_hash whose digest was 24
bytes instead of 32, so it matched no password at all. Nothing noticed
while passwords went unverified; it is a real hash now.

Verified with 30 tests, 13 of them against a real Postgres: wrong password,
unknown account, pass-the-hash, wrong TOTP code, challenge token replay,
lockout, single-use recovery codes, and the surface isolation that F4's
acceptance criteria require.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 09:33:29 -03:00