Commit Graph
100 Commits
Author SHA1 Message Date
Lucas RayanandClaude Sonnet 5.5 0ff9aaaf88 Merge ci/gitea-registry: publish to the Gitea registry, workflows on Gitea Actions
CI / TypeScript (auth) (push) Successful in 2m53s
CI / Trivy (dependências, segredos e Dockerfiles) (push) Successful in 1m8s
Release / Namespace das imagens (push) Successful in 1s
CI / Lint de idioma (R9) (push) Successful in 4s
CI / TypeScript (storage) (push) Successful in 14s
CI / Go (protocols) (push) Successful in 3m16s
CI / Imagem (auth) (push) Successful in 1m0s
CI / Build (webmail) (push) Successful in 2m15s
CI / Imagem (migrations) (push) Successful in 26s
CI / Imagem (rspamd) (push) Successful in 27s
CI / Imagem (protocols) (push) Successful in 1m32s
CI / Imagem (storage) (push) Successful in 1m19s
CI / Imagem (webmail) (push) Successful in 3m31s
CI / Stack completa (push) Successful in 3m42s
Release / ci (push) Successful in 11m19s
Release / Build migrations (linux/amd64) (push) Successful in 2m12s
Release / Build auth (linux/amd64) (push) Successful in 4m25s
Release / Build rspamd (linux/amd64) (push) Successful in 42s
Release / Build storage (linux/amd64) (push) Successful in 2m47s
Release / Build protocols (linux/amd64) (push) Successful in 12m49s
Release / Build auth (linux/arm64) (push) Successful in 43s
Release / Build migrations (linux/arm64) (push) Successful in 28s
Release / Build protocols (linux/arm64) (push) Successful in 1m29s
Release / Build rspamd (linux/arm64) (push) Successful in 33s
Release / Build storage (linux/arm64) (push) Successful in 42s
Release / Build webmail (linux/arm64) (push) Successful in 2m43s
Release / Build webmail (linux/amd64) (push) Successful in 21m34s
Release / Publica auth (push) Successful in 22s
Release / Publica migrations (push) Successful in 22s
Release / Publica rspamd (push) Successful in 21s
Release / Publica protocols (push) Successful in 21s
Release / Publica webmail (push) Successful in 21s
Release / Publica storage (push) Successful in 21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 09:59:03 -03:00
Lucas RayanandClaude Sonnet 5.5 7baaab2fd7 test(auth): give real Argon2id hashing a 30s test budget
CI / TypeScript (auth) (push) Successful in 2m51s
CI / TypeScript (storage) (push) Successful in 14s
CI / Go (protocols) (push) Successful in 3m12s
CI / Trivy (dependências, segredos e Dockerfiles) (push) Successful in 1m9s
CI / Lint de idioma (R9) (push) Successful in 4s
CI / Build (webmail) (push) Successful in 2m9s
CI / Imagem (auth) (push) Successful in 58s
CI / Imagem (migrations) (push) Successful in 25s
CI / Imagem (rspamd) (push) Successful in 28s
CI / Imagem (protocols) (push) Successful in 1m32s
CI / Imagem (storage) (push) Successful in 1m18s
CI / Imagem (webmail) (push) Successful in 3m30s
CI / Stack completa (push) Successful in 3m44s
The default 5s does not fit a chain of 11 hashes on a shared CI runner. The
production cost parameters are untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 09:47:16 -03:00
Lucas RayanandClaude Sonnet 5.5 a28a9bc9d4 ci: run Go tests as a non-root user
CI / Lint de idioma (R9) (push) Successful in 22s
CI / TypeScript (auth) (push) Failing after 3m33s
CI / Go (protocols) (push) Successful in 5m6s
CI / Trivy (dependências, segredos e Dockerfiles) (push) Successful in 1m9s
CI / Build (webmail) (push) Successful in 2m6s
CI / Stack completa (push) Skipped
CI / Imagem (rspamd) (push) Successful in 29s
CI / Imagem (storage) (push) Successful in 1m19s
CI / TypeScript (storage) (push) Successful in 17s
CI / Imagem (migrations) (push) Successful in 2m24s
CI / Imagem (auth) (push) Successful in 2m58s
CI / Imagem (protocols) (push) Successful in 1m47s
CI / Imagem (webmail) (push) Successful in 2m35s
initdb, used by the embedded Postgres in the e2e suites, refuses to run as root.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 09:33:43 -03:00
Lucas RayanandClaude Sonnet 5.5 c30d618b3f ci: copy files into containers instead of bind-mounting, patch two advisories
CI / Lint de idioma (R9) (push) Successful in 9s
CI / Go (protocols) (push) Failing after 2m22s
CI / TypeScript (storage) (push) Successful in 12s
CI / TypeScript (auth) (push) Failing after 3m42s
CI / Imagem (migrations) (push) Successful in 1m45s
CI / Build (webmail) (push) Successful in 1m53s
CI / Stack completa (push) Skipped
CI / Trivy (dependências, segredos e Dockerfiles) (push) Successful in 1m3s
CI / Imagem (auth) (push) Successful in 2m53s
CI / Imagem (rspamd) (push) Successful in 27s
CI / Imagem (protocols) (push) Successful in 2m10s
CI / Imagem (storage) (push) Canceled after 2m9s
CI / Imagem (webmail) (push) Canceled after 1m6s
The job workspace is a volume the DinD daemon cannot see, so migrations and the
Trivy filesystem scan now docker cp their inputs. Trivy's image gate flagged
next 15.5.27 (unauthenticated RCE in image optimisation, fixed in 15.5.28) and
go-jose 4.1.4 (DoS, fixed in 4.1.6); both are patch bumps.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 09:22:42 -03:00
Lucas RayanandClaude Sonnet 5.5 248e857b85 ci: rerun on the runner with a shared /workspace
CI / Lint de idioma (R9) (push) Successful in 10s
CI / Go (protocols) (push) Failing after 29s
CI / TypeScript (auth) (push) Failing after 37s
CI / TypeScript (storage) (push) Successful in 25s
CI / Trivy (dependências, segredos e Dockerfiles) (push) Successful in 1m6s
CI / Build (webmail) (push) Successful in 1m50s
CI / Stack completa (push) Skipped
CI / Imagem (migrations) (push) Successful in 1m39s
CI / Imagem (auth) (push) Successful in 2m58s
CI / Imagem (rspamd) (push) Successful in 29s
CI / Imagem (protocols) (push) Failing after 1m50s
CI / Imagem (storage) (push) Successful in 1m20s
CI / Imagem (webmail) (push) Failing after 2m40s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 09:11:32 -03:00
Lucas RayanandClaude Sonnet 5.5 33fa05b019 ci: run Trivy from its official image and drop the gha cache
CI / Imagem (protocols) (push) Failing after 1m29s
CI / Lint de idioma (R9) (push) Successful in 3s
CI / Go (protocols) (push) Failing after 30s
CI / TypeScript (auth) (push) Failing after 36s
CI / TypeScript (storage) (push) Successful in 16s
CI / Trivy (dependências, segredos e Dockerfiles) (push) Successful in 1m14s
CI / Build (webmail) (push) Successful in 1m57s
CI / Stack completa (push) Skipped
CI / Imagem (auth) (push) Successful in 57s
CI / Imagem (migrations) (push) Successful in 39s
CI / Imagem (rspamd) (push) Successful in 30s
CI / Imagem (storage) (push) Successful in 1m20s
CI / Imagem (webmail) (push) Canceled after 2m58s
aquasecurity/trivy-action clones the Trivy repository from the workflow's own
server, which on Gitea is not GitHub. The gha buildx cache has no Gitea
counterpart.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 09:01:45 -03:00
Lucas RayanandClaude Sonnet 5.5 91c5df433e ci: build linux/amd64 under QEMU on the arm64 runner
CI / Lint de idioma (R9) (push) Successful in 10s
CI / Go (protocols) (push) Failing after 31s
CI / TypeScript (auth) (push) Failing after 37s
CI / TypeScript (storage) (push) Successful in 23s
CI / Build (webmail) (push) Successful in 1m47s
CI / Stack completa (push) Skipped
CI / Trivy (dependências, segredos e Dockerfiles) (push) Failing after 3m8s
CI / Imagem (auth) (push) Canceled after 3m32s
CI / Imagem (migrations) (push) Canceled after 2m4s
CI / Imagem (protocols) (push) Canceled after 48s
CI / Imagem (rspamd) (push) Canceled after 48s
CI / Imagem (storage) (push) Canceled after 0s
CI / Imagem (webmail) (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 08:56:34 -03:00
Lucas RayanandClaude Sonnet 5.5 59363734ff ci: publish images to the Gitea registry, use the gitea context
CI / Lint de idioma (R9) (push) Successful in 4s
CI / Go (protocols) (push) Failing after 5s
CI / TypeScript (auth) (push) Failing after 6s
CI / TypeScript (storage) (push) Successful in 12s
CI / Build (webmail) (push) Successful in 1m38s
CI / Stack completa (push) Skipped
CI / Trivy (dependências, segredos e Dockerfiles) (push) Failing after 1m32s
CI / Imagem (auth) (push) Failing after 11s
CI / Imagem (migrations) (push) Failing after 11s
CI / Imagem (protocols) (push) Failing after 11s
CI / Imagem (rspamd) (push) Failing after 11s
CI / Imagem (storage) (push) Failing after 11s
CI / Imagem (webmail) (push) Failing after 11s
Release now pushes to git.lucasrguerra.dev.br instead of ghcr.io, tags each
architecture separately (no artifact upload, no gha cache) and joins them with
imagetools. GITHUB_OUTPUT and GITHUB_STEP_SUMMARY stay: they are the runner's
own interface and the GITEA_* equivalents are not set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 08:48:44 -03:00
Lucas RayanandClaude Sonnet 5.5 9a722bc5ac ci: publish images to the Gitea registry and move workflows to .gitea
CI / Lint de idioma (R9) (push) Successful in 3s
CI / Go (protocols) (push) Failing after 20s
CI / TypeScript (auth) (push) Canceled after 4s
CI / TypeScript (storage) (push) Canceled after 0s
CI / Build (webmail) (push) Canceled after 0s
CI / Trivy (dependências, segredos e Dockerfiles) (push) Canceled after 0s
CI / Imagem (auth) (push) Canceled after 0s
CI / Imagem (migrations) (push) Canceled after 0s
CI / Imagem (protocols) (push) Canceled after 0s
CI / Imagem (rspamd) (push) Canceled after 0s
CI / Imagem (storage) (push) Canceled after 0s
CI / Imagem (webmail) (push) Canceled after 0s
CI / Stack completa (push) Canceled after 0s
Release now pushes to git.lucasrguerra.dev.br instead of ghcr.io, tags each
architecture separately (no artifact upload, no gha cache) and joins them with
imagetools. Workflows live in .gitea/workflows and use the gitea context.
GITHUB_OUTPUT and GITHUB_STEP_SUMMARY stay: they are the runner's own interface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 08:48:14 -03:00
Lucas RayanandClaude Sonnet 5.5 fa2dcb578e Merge docs/readme-style: a README with a face, and complete entity decoding in previews
CI / Lint de idioma (R9) (push) Successful in 43s
CI / Go (protocols) (push) Failing after 42s
CI / TypeScript (auth) (push) Failing after 42s
CI / TypeScript (storage) (push) Successful in 12s
CI / Build (webmail) (push) Successful in 1m37s
CI / Stack completa (push) Skipped
CI / Imagem (webmail) (push) Canceled after 0s
CI / Imagem (storage) (push) Canceled after 0s
CI / Imagem (rspamd) (push) Canceled after 0s
CI / Imagem (protocols) (push) Canceled after 0s
CI / Imagem (migrations) (push) Canceled after 3m38s
CI / Imagem (auth) (push) Canceled after 5m39s
Release / ci (push) Canceled after 12m51s
Release / Namespace das imagens (push) Canceled after 0s
Release / Build auth (linux/amd64) (push) Canceled after 0s
Release / Build auth (linux/arm64) (push) Canceled after 0s
Release / Build migrations (linux/amd64) (push) Canceled after 0s
Release / Build migrations (linux/arm64) (push) Canceled after 0s
Release / Build protocols (linux/amd64) (push) Canceled after 0s
Release / Build protocols (linux/arm64) (push) Canceled after 0s
Release / Build rspamd (linux/amd64) (push) Canceled after 0s
Release / Build rspamd (linux/arm64) (push) Canceled after 0s
Release / Build storage (linux/amd64) (push) Canceled after 0s
Release / Build storage (linux/arm64) (push) Canceled after 0s
Release / Build webmail (linux/amd64) (push) Canceled after 0s
Release / Build webmail (linux/arm64) (push) Canceled after 0s
Release / Publica auth (push) Canceled after 0s
Release / Publica migrations (push) Canceled after 0s
Release / Publica protocols (push) Canceled after 0s
Release / Publica rspamd (push) Canceled after 0s
Release / Publica storage (push) Canceled after 0s
CI / Trivy (dependências, segredos e Dockerfiles) (push) Failing after 3m16s
Release / Publica webmail (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 07:55:12 -03:00
Lucas RayanandClaude Sonnet 5.5 1e06491019 docs: a README with a face, in English and Portuguese; decode every HTML entity in previews
The README opens like a product page: the mark, a line on what it is, the
links, the language switch and a picture of the real interface running on an
invented mailbox, in dark and light and in both languages. scripts/screenshots
holds that invented mailbox and the script that photographs it, so the pictures
can be redone. Previews now decode any HTML entity through the browser rather
than a short list, so a Portuguese or marketing preview no longer prints
&ccedil; or &middot;.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 07:55:12 -03:00
Lucas RayanandClaude Sonnet 5.5 8226c76d7a Merge docs/update-2026-10: document the new webmail
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:24:36 -03:00
Lucas RayanandClaude Sonnet 5.5 58d6f8e9e2 docs: sessions, accounts, trusted senders, conversations, themes and the new reader
README: what the webmail now does, the architecture roles, a short guide with the
shortcuts, testing with a real database, and an honest list of what is not
finished. New docs/WEBMAIL.md as the reference for each feature and for the
colour system. OPERATIONS: sessions and trusted senders. DEPLOYMENT: the
two-step release as API calls, and what to expect when upgrading. MAILBOX-CORE:
the conversation invariants and migrations 0015 to 0018.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:24:36 -03:00
Lucas RayanandClaude Sonnet 5.5 c00134f2eb chore: keep the logo generator in the repository
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 06:12:49 -03:00
Lucas RayanandClaude Sonnet 5.5 8db69c5af9 Merge feat/viewer-and-logo: a drawn logo and a reader that suits both themes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 05:57:52 -03:00
Lucas RayanandClaude Sonnet 5.5 fece870d49 feat(webmail): a better reader and a drawn logo
The mark is rebuilt as a lambda of two square-cut strokes of one weight, joined
without a seam, on a flat ultramarine tile with continuous corners: the rounded
strokes and the gradient are gone. A message written plainly is now drawn in the
app's own colours instead of a white slab, with text too dark for the ground
lifted and white backdrops cleared; a designed one (newsletter, receipt) keeps
its white sheet. A reply's quoted history folds behind a button. A narrower
reading column, a larger title, and a way to see a plain message as sent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 05:57:52 -03:00
Lucas RayanandClaude Sonnet 5.5 9e50b47b5d chore(wolf): record what this stretch of work taught
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:44:54 -03:00
Lucas RayanandClaude Sonnet 5.5 d29ecec8d9 Merge feat/new-interface: logo and icons, dark and light themes, command menu
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:33:15 -03:00
Lucas RayanandClaude Sonnet 5.5 e667843b59 feat(webmail): the new interface - logo, dark and light themes, command menu
A mark (a lambda on an ultramarine tile with the flap of an envelope), the
favicon, home-screen icons and an install manifest. Every colour is a variable
defined once per theme, and the Tailwind scales the screens already use read
them, so dark (the default) and light are a change of values and not of
classes. Geist is served by the app itself. Ctrl+K opens a command menu for
folders, actions, accounts and appearance, and typing something that matches no
command searches the mail; c writes a message and / goes to the search field.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:33:15 -03:00
Lucas RayanandClaude Sonnet 5.5 6b97807856 Merge feat/conversations: conversations in cascade, answered marker, reply under the conversation, and Undo send that undoes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:07:40 -03:00
Lucas RayanandClaude Sonnet 5.5 897f0122f4 fix(webmail): Undo send really undoes, and a send is not made twice
The countdown sent from inside a state updater and Undo's button submitted the
form, so Undo stopped the timer and then started the send over. The timer is
now held in a ref, Undo is type=button, and leaving the screen flushes a
pending send once. Snippets drop a stylesheet the preview was cut inside of.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:05:43 -03:00
Lucas RayanandClaude Sonnet 5.5 2080489319 feat(webmail): conversations - list, stacked reader, inline reply, settings switch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:46:11 -03:00
Lucas RayanandClaude Sonnet 5.5 86a69d1a96 refactor(webmail): the composer is a component, so a reply can be written under a conversation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:41:59 -03:00
Lucas RayanandClaude Sonnet 5.5 997dd8848f feat(auth): conversation_view preference, on by default
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:40:36 -03:00
Lucas RayanandClaude Sonnet 5.5 67d0c8aa5a feat(protocols): conversations - thread_id and in_reply_to written at delivery, list and read APIs
A reply now joins the conversation of the message it answers (Message-ID,
In-Reply-To, References), falling back to the subject for replies with no
headers. /mail/threads lists a folder as conversations and /mail/thread/{id}
returns one. Replying from the webmail flags the answered message.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:38:36 -03:00
Lucas RayanandClaude Sonnet 5.5 ff89e9dc44 chore(wolf): log the discard-draft bug
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:03:20 -03:00
Lucas RayanandClaude Sonnet 5.5 657a43d612 fix(webmail): Discard really discards the draft
The button was a link to the inbox, so the autosaved draft stayed in Drafts.
It now removes the stored draft for good (not into Trash), stops the autosave,
and cleans up a save that was already in flight.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:02:27 -03:00
Lucas RayanandClaude Sonnet 5.5 d283ff6f8d Merge feat/trusted-senders: a sender the mailbox trusts skips spam and always shows images, kept on the server
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:48:35 -03:00
Lucas RayanandClaude Sonnet 5.5 d2611d39fa feat(webmail): trusted senders on the server, Not spam, and a trust pill in the reader
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:48:35 -03:00
Lucas RayanandClaude Sonnet 5.5 d7f9659b5e feat(protocols): trusted senders are not filed as spam, when the message proves its sender
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:45:10 -03:00
Lucas RayanandClaude Sonnet 5.5 b536ed13e7 feat(auth): trusted senders table and API
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:42:30 -03:00
Lucas RayanandClaude Sonnet 5.5 cc0adcd94d fix(auth,webmail): list a pre-accounts session in the switcher; preview as plain text
A browser signed in before account cookies existed has only lm_user_session,
so the switcher showed it as loading forever. The active session now always
counts and is filed on first listing. Message previews drop the HTML and
entities the server stored.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:23:40 -03:00
Lucas RayanandClaude Sonnet 5.5 026f24a64a fix(auth): sign the webmail token for 30 days, not only its cookie
issueSession raised the cookie and the session row to 30 days but still let
createJwt default to 8 hours, so the session would have died at 8 hours with
a cookie that outlived it. Also covers account switching, sign-out and
renewal against a real database.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:04:24 -03:00
Lucas RayanandClaude Sonnet 5.5 54203d596e Merge feat/persistent-sessions-accounts: 30-day sliding sessions, several accounts at once, mail rendered with its own styles
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:47:44 -03:00
Lucas RayanandClaude Sonnet 5.5 641ab4bbe8 feat(webmail): keep a message's own stylesheet so designed mail renders as sent
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:45:34 -03:00
Lucas RayanandClaude Sonnet 5.5 96e624dd12 feat(webmail): account switcher for the several signed-in accounts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:44:50 -03:00
Lucas RayanandClaude Sonnet 5.5 0efc8f096a feat(auth): 30-day sliding webmail sessions and several accounts signed in at once
The webmail session now lasts 30 days and is swapped for a fresh token once a
day while in use; the console keeps its fixed 8 hours. Each sign-in is also
filed under lm_acct_<mailbox id>, with new endpoints to list and switch
accounts, and sign-out now leaves the other accounts signed in.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:42:59 -03:00
Lucas RayanandClaude Sonnet 5.5 aea4c1ee5e Merge fix/bimi-per-domain-urls: each domain's BIMI logo under its own URL, and the DNS publisher wired
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:53:50 -03:00
Lucas RayanandClaude Sonnet 5.5 60c6f5731c fix(bimi): each domain's logo under its own URL, and the publisher actually wired
Two bugs found by the setup check right after the last deploy:

- every domain's BIMI record named the deployment's own mail host, and the
  server picked the domain from the host name, so only the primary domain's
  logo could be fetched: ciberlab.seg.br's record pointed at a 404. The logo
  and the certificate are now served at /.well-known/bimi/<domain>/..., the
  record names that, and the webmail routes it. The old path still answers
- SetBimiPublisher ran before SetBimiAPI, found no BIMI API to attach to and
  was dropped, so saving a logo never published the DNS record by itself. It is
  kept on the router now, so the order does not matter

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:49:52 -03:00
Lucas RayanandClaude Sonnet 5.5 e3366ef09d Merge feat/bimi-dns-reconcile: publish the BIMI record to Cloudflare and keep it in step with the logo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:38:16 -03:00
Lucas RayanandClaude Sonnet 5.5 6e49ca00e2 feat(bimi): reconcile the BIMI record to Cloudflare and keep it in step with the logo
The reconciler never wrote the BIMI record: only the console's verification
knew about it, so a stored logo was never published to the zone.

- SyncDnsRecordsInput carries the BIMI logo and certificate URLs; the record is
  created, updated (a= added or dropped) and, when the logo is removed,
  deleted. Only a record carrying this reconciler's comment is retired: one
  made by hand is left alone
- bimi.Publish decides the record in one place for the timer sweep, the
  console's button and the panel's verification. A held certificate is
  advertised only while it is valid for the logo being served: replacing the
  logo ends the certificate, and a record that kept naming it makes every
  receiver reject BIMI, including those that need no certificate
- saving or removing the logo or the certificate triggers the reconciliation in
  the background; the panel shows the record as the server writes it, says when
  a certificate is held back and why, and has a publish-now button
- a read error is not 'no logo': the sync leaves the record alone instead of
  deleting it over a database hiccup

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:17:07 -03:00
Lucas RayanandClaude Sonnet 5.5 0ff9028927 Merge feat/bimi-studio-and-avatar-cache: BIMI logo studio, certificate hosting and instant profile photos
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:15:05 -03:00
Lucas RayanandClaude Sonnet 5.5 e0e5b1b880 test(webmail): keep the byte-count test ASCII (lang-lint R9)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:08:41 -03:00
Lucas RayanandClaude Sonnet 5.5 dabd1568d1 feat(bimi): logo studio, certificate upload and a setup check in the console
The console took pasted SVG text and a URL. It now:

- takes a PNG or SVG and makes the logo BIMI accepts (SVG Tiny PS, square,
  at most 32 KB). An SVG has what the format forbids removed and is told what;
  a PNG is traced into vectors in the browser (imagetracerjs, public domain),
  walking down quality presets until it fits. The person positions it as in the
  photo editor: drag, zoom, background colour, preview in a circle or rounded
  square, and downloads the exact SVG to give a certificate authority
- holds the .pem the authority returns, validated against the logo and domain,
  and serves it at /.well-known/bimi/vmc.pem (routed by the webmail)
- shows the DNS record and runs the setup check (logo, DMARC, record, public
  URLs, certificate)
- a golden-file contract: the SVGs the studio produces are validated by the
  server's own validator (Go) and pinned by the studio's tests (TS)
- found by those tests: an element inside a link kept its onclick when the
  link was unwrapped; links are now unwrapped before cleaning
- the BIMI and photo texts are in Brazilian Portuguese with accents (they had
  been written without), and in Spanish with accents

A VMC cannot be generated from an image: it is issued by a certificate
authority. The README and the panel say so.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:04:35 -03:00
Lucas RayanandClaude Sonnet 5.5 109b8360dd feat(bimi): hold the certificate, check the whole setup, serve the VMC
A Verified Mark Certificate cannot be made here: an authority issues it after
checking the organisation. What the service can do is hold the file the
authority returns, serve it where the DNS record points, and refuse one that
would make receivers ignore BIMI without a word.

- migration 0015: domain_bimi keeps the PEM chain next to the logo
- bimi.InspectCertificate reads a chain and reports every reason it would not
  work: private key blocks refused outright, the brand-indicator usage, the
  validity period, the domain (exact, wildcard or organisational), and that
  the logo embedded in the certificate (gzipped data URI in the logotype
  extension) is byte for byte the logo being published. Tested against two
  real public VMCs (eBay via DigiCert, CNN via Valimail) plus mutations
- GET /.well-known/bimi/vmc.pem (public, conditional, cacheable for an hour),
  and PUT/DELETE /api/v1/admin/bimi/certificate; the GET of the logo now
  reports the certificate as it stands against the logo being served, and
  replacing a logo under a held certificate says it no longer matches
- GET /api/v1/admin/bimi/check looks at what a receiver would: the logo, DMARC
  enforcement, the BIMI record, the public logo URL, the certificate and its
  public URL. Its HTTP client is HTTPS only and refuses non-public addresses
  after DNS, since the URLs come from DNS and an operator field
- the DNS record points a= at the held certificate when there is one

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:50:33 -03:00
Lucas RayanandClaude Sonnet 5.5 c0f556b190 fix(webmail): a new profile photo shows up everywhere at once
The photo endpoint was cached for five minutes with no revalidation, and only
the settings page added a version to the URL, so the header, the message list
and the reader kept the old photo until it expired.

- every <Avatar> reads a per-address version (and refetches when it changes);
  saving or removing a photo bumps it, in this tab and the other open tabs
- the server answers private, max-age=60, must-revalidate, so a stale copy
  lives for a minute at most and then costs one 304

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 16:44:14 -03:00
Lucas Rayan d82cf71f5b Merge branch 'refactor/mailbox-core' 2026-10-16 13:47:10 -03:00
Lucas RayanandClaude Sonnet 5.5 f2951c0ff4 docs: describe the mailbox core, its invariants and lock order
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:36:21 -03:00
Lucas Rayan 183191d18d refactor(mail): one send path for Sent, a fixed lock order, and concurrency tests
- the webmail's Send no longer files its own Sent copy: it asks Submit to
  (FileSentCopy), the same path SMTP clients use, so the rule exists once
- every operation now locks the folders first (lowest id first) and the message
  after. Opposite moves, and a move racing a flag change, deadlocked (SQLSTATE
  40P01) because they took those locks in different orders; the new test
  reproduces it (10-19 s of retries, errors) and passes in ~1 s with the order
- TestOneMailboxEverywhere drives SMTP, IMAP and the webmail on one mailbox,
  mixing them, and checks after each step that every view agrees with the others
  and with the rows. It fails on the previous code and passes now.
2026-10-16 13:30:59 -03:00
Lucas Rayan 67bbe7a50a refactor(mail): IMAP and the webmail read folders and messages through one MailStore
The folder list and the message list were two queries over the same tables. IMAP
took a folder's message count from a cached column and the webmail counted rows,
so the two could disagree. Both now ask MailStore, which counts the messages.
2026-10-16 13:22:05 -03:00
Lucas Rayan a8941a6191 refactor(mail): one definition of the standard folders, and reconcile the drift
The console created Reports, the CLI that makes the first administrator did not,
so that account had no folder for DMARC and TLS-RPT reports. The list now lives
in one SQL function, provision_default_folders (migration 0014), which both call.

The same migration reconciles what the two paths had left wrong in existing
mailboxes: folder counters and the mailbox's used bytes are recomputed from the
rows (only where they differ, so it is safe to run again).

create-admin can now be tested: fail() throws instead of exiting, and the command
runs only when invoked as one. The new test fails on the old code (no Reports).
2026-10-16 13:19:55 -03:00
Lucas Rayan caf52c0225 refactor(mail): one MailStore changes message state for every path
Flags, expunge, move, copy and folder deletion each existed twice: once for IMAP
(FlagRepository, ExpungeRepository, CopyRepository) and once for the webmail
(WebmailRepository). They kept the cached numbers true on their own and drifted:

- reading a message over IMAP never moved the folder's unread counter
- deleting in the webmail, or deleting a folder, never gave the quota back
- an IMAP expunge could push a counter below zero

MailStore now holds each state change once, in one transaction, with the same
rules everywhere: counters never go negative, a change that alters what a client
can observe raises the folder's modseq (one that alters nothing does not), the
quota follows the bytes, and a message is located by folder. The four repositories
are thin adapters that only translate how each path names a message.

The contract test added before this change (which failed on 8 of its cases)
now passes, as does the full suite.
2026-10-16 13:15:32 -03:00
Lucas Rayan e63b68acb2 test(mail): contract that every path must keep counters, modseq and quota true
Runs each state change through IMAP and through the webmail and checks the cached
numbers against the rows. It fails today where the two stacks drifted: IMAP never
moved the unread counter, and the webmail never gave the quota back.
2026-10-16 13:12:24 -03:00
Lucas Rayan 9c8b9c00fc fix(smtp): keep a copy of every submitted message in the sender's Sent folder
A message sent over SMTP submission was delivered but left nothing behind: the
account's Sent folder stayed empty. Webmail files its own copy after sending,
and a mail client cannot do it either, since IMAP APPEND is not implemented, so
nothing ever filled Sent for SMTP clients.

- ProcessOutboundEmailInput.FileSentCopy asks Submit to file the signed message
  in the sender's Sent folder, as read (not unread), sharing the blob the
  delivery jobs already reference; one copy per submission, not per recipient
- the SMTP submission session sets it; the webmail leaves it off because it
  files its own copy without the Bcc line, and asking twice would duplicate it
- filed only after the message is durably queued, so a submission refused by the
  send limit leaves nothing behind; a failure to file is logged, never returned,
  because the mail is already on its way and the client would send it again

Tests: unit cases (asked/not asked, shared blob, failure swallowed, limit
refusal, no repository) and an end-to-end test on the real submission server
that fails with "Sent holds 0 messages" without the fix. Full go test ./...
(25 packages) with TEST_DATABASE_URL, go vet and lang-lint are clean.
2026-10-16 12:51:46 -03:00
Lucas Rayan 110a240cd9 fix(auth): app passwords now sign in SMTP, IMAP and POP3
The webmail creates app passwords (shown once, stored hashed, with scopes) but
the protocol servers only compared a login against mailboxes.password_hash, so
an app password was always refused with 535 / authentication failed. They exist
because mail clients cannot do a second factor.

- port.MailboxAuth carries the mailbox's app passwords (newest 20) with their
  scopes; the Postgres repository loads them and stamps last_used_at on use
- verifyCredential accepts the mailbox password or an app password that holds
  the scope of the protocol; SMTP, IMAP and POP3 use it. ManageSieve keeps the
  mailbox password only, as no default scope covers it
- a malformed app password hash is skipped instead of failing the login; a
  malformed mailbox hash still surfaces as the server-side error it was

Tests: unit cases for scopes and malformed hashes, the three entry points,
repository tests against Postgres, and an end-to-end test on the real
submission server that fails with 535 without the fix and passes with it.
Full `go test ./...` with TEST_DATABASE_URL, go vet and lang-lint are clean.
2026-10-16 09:21:08 -03:00
Lucas RayanandClaude Sonnet 5.5 8e7c0abd0a fix(lint): codigo so em ASCII (lang-lint R9) nos arquivos de destinatarios
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:32:38 -03:00
Lucas RayanandClaude Sonnet 5.5 931ddcd004 fix(deps): corrige CVEs HIGH/CRITICAL que bloqueavam o release
O scan Trivy do CI falhou no push anterior e impediu a publicacao das
imagens, entao a producao seguiu na versao antiga.

- webmail: next 15.5.21 -> 15.5.27 (RCE no otimizador de imagens e
  cache poisoning), sharp -> 0.35.5, source-map-js -> 1.2.2,
  dompurify -> 3.4.17
- protocols: golang.org/x/net 0.58 -> 0.60 (DoS HTTP/2) e toolchain
  Go 1.25 -> 1.26 (stdlib corrigida a partir de 1.26.9)

Verificado localmente: imagens e repositorio sem achados HIGH/CRITICAL,
testes e build verdes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:27:37 -03:00
Lucas RayanandClaude Sonnet 5.5 50a67531ba feat(webmail,auth): leitor estilo Outlook, responder a todos, importantes e isolamento do admin de dominio
Webmail
- Leitor de mensagem em componente proprio, com barra de comandos fixa,
  cabecalho compacto, faixa de imagens bloqueadas e chips de anexo.
- Divisor arrastavel entre lista e leitor (mouse, setas, duplo clique),
  largura persistida e limites que preservam os dois paineis.
- Destinatarios em chips (Para, Cc, Cco): aceita nomes com virgula,
  colagem de listas, validacao e bloqueio de envio com endereco invalido.
- Responder a todos, sem o proprio endereco e sem repetidos; In-Reply-To
  e References nas respostas; prefixo Re:/Fwd: sem repeticao.
- Marcar como importante (estrela, filtro, linha da lista) e enviar com
  alta importancia (Importance/X-Priority), com etiqueta no leitor.
- Sanitizador: a regexp de URI customizada descartava todo atributo que nao
  fosse link (width, colspan, bgcolor...), o que desmontava tabelas de
  e-mail; imagens de fundo agora sao bloqueadas de forma reversivel.

Protocols
- POST /api/v1/mail/flagged (\Flagged) com bump de modseq para IMAP.
- Renderizacao expoe message_id, in_reply_to e important.
- Cabecalhos de resposta validados contra injecao.

Auth
- As views do admin de dominio eram globais: dashboard, fila de saida,
  DMARC, preflight e diagnostico mostravam dados de todos os dominios.
  Agora todas respeitam o escopo da sessao; super admin segue vendo tudo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:09:55 -03:00
Lucas RayanandClaude Opus 5 b5baf33a63 fix(bimi): as rotas existiam no protocols e o proxy nao as conhecia
O painel respondia "API endpoint not found" e o logotipo devolvia a pagina de
404 do Next. As rotas estavam registadas no servico protocols, mas o proxy do
webmail decide o upstream por uma lista propria, e eu nao acrescentei a "bimi"
-- exactamente o erro contra o qual o comentario dessa lista avisa.

Ficam ambas cobertas por testes: cada area administrativa que o protocols
possui, e o caminho well-known do logotipo. Esquecer isto nao volta a ser
silencioso.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:41:00 -03:00
Lucas RayanandClaude Opus 5 c59548e311 Revert "revert: foto de perfil e BIMI, para restaurar o servico"
Repoe as duas funcionalidades. A reversao foi uma precaucao enquanto o site
estava em baixo, e acabou por ser ela a provar que a culpa nao era delas: o
502 continuou depois de reverter.

A avaria era o servico web nao declarar porta nenhuma, o que levava o Coolify
a gerar um servico Traefik apontado ao porto 80. Fica corrigida no commit
anterior, com expose 3000.

As migracoes 0012 e 0013 nunca sairam do repositorio nem da base de dados, por
isso o codigo reposto encontra as tabelas que espera.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:29:12 -03:00
Lucas RayanandClaude Opus 5 e93fa19bdd fix(compose): o web nao declarava a porta, e o proxy adivinhava mal
O servico web nao tinha ports nem expose -- so a etiqueta do Traefik com
loadbalancer.server.port=3000. Essa etiqueta serve os routers escritos aqui,
mas o Coolify gera os seus proprios a partir dos dominios configurados, e para
esses precisa de descobrir a porta. Sem expose e sem porta fixada no dominio,
cai nos 80, onde nada escuta: ligacao recusada, 502 em todos os hostnames, com
o contentor perfeitamente saudavel e o Coolify a reportar running:healthy.

Aconteceu agora porque a migracao de servidor de 2026-09-02 recriou a
aplicacao e a afinacao anterior perdeu-se. O expose deixa a porta descoberta
sozinha, para nao depender de uma configuracao que vive fora do repositorio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:02:11 -03:00
Lucas RayanandClaude Opus 5 e30d000e43 revert: foto de perfil e BIMI, para restaurar o servico
O site ficou a responder 502 em todos os hostnames depois do deploy destas
duas funcionalidades. A reversao vem primeiro; o diagnostico vem a seguir, com
o servico de pe.

As migracoes 0012 e 0013 ficam. Sao aditivas, nada no codigo revertido as usa,
e apaga-las deixaria a base de dados numa versao sem ficheiro correspondente,
o que parte o proprio migrate no arranque seguinte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:45:16 -03:00
Lucas RayanandClaude Opus 5 3e06162fa0 fix(test): TestBccNeverAppearsInTheHeaders falhava por acaso
Procurava a sequencia "bcc" em qualquer sitio do bloco de cabecalhos, e o
Message-ID e um UUID aleatorio. Quando calha conter essa sequencia -- o CI
apanhou <1bcc5e82-9f6a-4e92-a1e5-65dc96b20208@mail.example.test> -- o teste
falha num commit que nao lhe tocou. Sao algumas vezes em mil execucoes.

Passa a procurar o campo Bcc no inicio de uma linha de cabecalho, que e o que
lhe interessa, mantendo a verificacao de que o endereco em si nao aparece em
lado nenhum. Confirmado por mutacao: com um cabecalho Bcc injectado, falha.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:18:51 -03:00
Lucas RayanandClaude Opus 5 6a602ab88f feat(bimi): logotipo do dominio validado, alojado e publicado no DNS
A foto de perfil nao viaja: nem o Gmail nem o Outlook leem uma imagem por
utilizador do servidor de quem envia, e nao existe protocolo que o permita. O
avatar que esses mostram vem dos contactos de quem recebe ou da conta Google
ou Microsoft do remetente.

O que eles leem e BIMI, que e por dominio. Fica implementado: validacao,
alojamento e o registo.

O validador reporta todas as violacoes de uma vez, e nao a primeira. Um
receptor que recusa a marca nao explica nada -- simplesmente nao desenha -- por
isso descobrir as regras uma viagem de cada vez seria a pior maneira possivel
de aprender este formato. Sao verificados o baseProfile e a versao que os
receptores procuram, o viewBox quadrado (uma marca nao quadrada e cortada de
forma diferente por cada cliente), exactamente um title, e a ausencia de tudo
o que seja activo ou externo: script, handlers de evento, links, imagens
rasterizadas, animacoes e referencias para fora do documento.

O registo so e publicado quando ha mesmo uma marca guardada. Um registo que
aponta para um logotipo inexistente e pior do que nenhum, porque o receptor
para de procurar assim que encontra um.

O logotipo e servido aberto, porque quem o vai buscar sao os fornecedores de
correio a avaliar uma mensagem e esses nunca terao sessao aqui.

Fica escrito no README que o Gmail e o Outlook so desenham a marca com um VMC,
e o painel avisa quando nao ha nenhum configurado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:12:11 -03:00
Lucas RayanandClaude Opus 5 fcfee6a3f9 feat(avatar): foto de perfil com editor de recorte circular
Carregar, enquadrar num circulo com zoom e arrasto, e guardar. A foto aparece
na barra lateral, na lista de mensagens e no leitor, caindo para as iniciais
quando nao ha nenhuma.

A geometria vai em funcoes puras com testes, porque e onde os erros moram: a
escala minima cobre o quadrado em vez de caber nele (caber deixa um canto
vazio e o ficheiro exportado leva uma cunha transparente), e o deslocamento e
preso a saliencia da imagem para que nenhum arrasto consiga trazer uma borda
para dentro do circulo. Uma dimensao NaN -- o que uma imagem que nao
descodificou reporta -- passava pelo guarda `<= 0`, porque NaN falha qualquer
comparacao, e propagava-se ate apagar o editor.

O recorte e exportado a partir dos pixeis da origem e nao da pre-visualizacao,
por isso a foto guardada e tao nitida quanto o original permite em vez de
ficar limitada ao tamanho do editor no ecra.

Do lado do servidor o tipo e decidido pelos bytes, nunca pelo Content-Type nem
pelo nome do ficheiro, que sao ambos escolhidos por quem carrega. SVG e
recusado apesar de ser imagem: e um documento que pode levar script e seria
servido de volta a partir da nossa propria origem.

A leitura exige sessao, verificada na propria rota porque o guarda de prefixo
so cobre /user/ e /admin/. Um endpoint aberto indexado por endereco responde
"esta caixa existe" a quem quer que pergunte, para todos os enderecos que
quiser experimentar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:04:12 -03:00
Lucas RayanandClaude Opus 5 82b4c8ce17 docs: como ligar DANE, e o que tem de ser verdade antes
O README dizia apenas que DANE estava desligado a menos que TLS_MODE=acme.
Passa a explicar o que a instalacao ensinou: um registo TLSA que nao
corresponde a chave servida nao degrada a entrega, para-a em qualquer
remetente que valide.

As duas pre-condicoes ficam escritas -- DNSSEC com o DS publicado no
registrador, e TLS_MODE=acme para que o servidor seja dono da chave e possa
publicar a associacao da seguinte antes de trocar -- e a variavel tem o nome
correcto, OUTBOUND_DANE_ENABLED.

Fica tambem registado que o digest mostrado em /admin/security e o registo
TLSA no DNS tem de ser o mesmo numero, porque quando divergem o correio e
recusado e mais nada diz porque.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:30:56 -03:00
Lucas RayanandClaude Opus 5 712885c091 feat(tls): o painel diz o digest que o registo TLSA tem de carregar
Uma associacao DANE e o SHA-256 sobre o SubjectPublicKeyInfo do certificado
que esta a ser servido. Se o que esta no DNS e o que o servidor apresenta se
afastarem, qualquer remetente que valide DANE recusa o correio -- e nada no
painel dizia porque.

Passa a mostrar o numero, para poder ser comparado com o registo publicado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:08:25 -03:00
Lucas RayanandClaude Opus 5 c06691a056 fix(mail): relatorios DMARC nao mostravam o clipe de anexo
has_attachments so era definido para mensagens multipart. Um relatorio
agregado DMARC do Google e de parte unica: o corpo E o anexo, um
application/zip sem qualquer parte de texto. Ficava guardado como nao tendo
anexos, ao lado dos relatorios TLS-RPT do mesmo remetente e na mesma pasta,
que trazem uma linha de texto e por isso sao multipart/mixed e apareciam
marcados.

A regra passa a olhar tambem para a parte de topo: uma disposicao explicita
decide (um .txt enviado como a mensagem inteira e anexo, uma parte inline nao
e), e sem ela conta como anexo tudo o que nao seja texto nem multipart -- um
zip, um PDF, uma mensagem reencaminhada.

As mensagens ja guardadas mantinham a marca antiga, e sao justamente aquelas
que o utilizador esta a olhar. Ha um backfill que as re-le uma vez, em segundo
plano, sem atrasar o arranque. So examina as que estao marcadas como sem
anexos: a regra antiga nunca inventou um anexo que nao existisse, portanto uma
marca verdadeira nao precisa de segunda opiniao e a varredura fica do tamanho
do defeito. A tabela backfills guarda o marcador para nao repetir a cada
arranque.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:28:37 -03:00
Lucas RayanandClaude Opus 5 7f8d9b7ff1 fix(tls): sem watcher nao ha veredito, e o README passa a explicar os modos
Com TLS_MODE=acme o certificado e emitido por este servico, nao vigiado em
disco, portanto nao ha watcher para estar saudavel. O painel reportava false e
mostrava "indisponivel" em permanencia, ao lado de um certificado perfeitamente
valido. Passa a ser null, e a pagina nao mostra veredito nenhum.

O README ganha uma seccao sobre os dois modos de certificado. O modo traefik
falha de duas maneiras silenciosas -- sem router para o PRIMARY_MAIL_HOST o
nome nunca chega ao acme.json, e o acme.json fica em 600 do root, ilegivel para
o utilizador nao-root do contentor. Nos dois casos o HTTPS continua a funcionar
porque quem o serve e o proxy, enquanto as portas de correio caem no
autoassinado e sao recusadas por qualquer cliente que verifique. Foi
exactamente o que aconteceu nesta instalacao.

Actualizado tambem o que ja nao esta por acabar: a resposta de ausencia, a
assinatura e as regras Sieve sao consumidas, e a reconciliacao de DNS conduz o
estado em vez de o relatar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 11:59:11 -03:00
Lucas RayanandClaude Opus 5 1e7c6a118c feat(admin): repor palavra-passe e remover segundo factor no modal
Duas operacoes que so existiam pela linha de comandos, e que sao precisamente
as que alguem pede ao telefone: esqueci-me da palavra-passe, perdi o
autenticador.

A reposicao termina todas as sessoes daquele utilizador, incluindo a que esta
aberta nesse momento. Um administrador a repor uma palavra-passe esta a fazer
uma passagem de conta ou a responder a um comprometimento, e nos dois casos as
sessoes abertas com a palavra-passe anterior nao podem sobreviver. Exige os
mesmos 12 caracteres que os outros caminhos, para a consola nao ser a maneira
de contornar a regra.

Remover o segundo factor apaga tambem os codigos de recuperacao: foram
gerados contra o segredo antigo, nao servem contra um novo, e deixa-los para
tras e um segundo conjunto de credenciais que ninguem esta a seguir.

A rota do /totp teve de ficar acima do DELETE generico de /mailboxes/. O
generico apanha qualquer caminho por baixo desse prefixo, por isso carregar em
"remover segundo factor" era encaminhado para apagar a caixa do utilizador --
so nao a apagou porque o id saia malformado e o Postgres recusou o uuid. Ha um
teste so para essa ordem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 11:43:53 -03:00
Lucas RayanandClaude Opus 5 bb12e603a3 fix(admin): dominio trocado ao criar, apelidos escondidos e quota a zero
Criar "lucas@cienciaembarcada.com.br" criava "lucas@lucasrguerra.dev.br". O
formulario partia o endereco e deitava fora a metade do dominio, e nao enviava
domain_id nenhum -- o servidor caia entao no dominio do admin com sessao
iniciada. Passa a ser parte local mais um dominio escolhido de uma lista: a
metade que decide o endereco deixa de poder ser descartada em silencio.

Apelidos de outros dominios estavam escondidos, e a culpa era minha: procurava
so no dominio onde a morada do proprio utilizador vive. Uma caixa recebe
postmaster, abuse, dmarc e tlsrpt de varios dominios ao mesmo tempo, por isso
o painel mostrava quatro onde entregavam oito. Ha um teste que falha com a
consulta antiga.

Os apelidos passam a poder ser criados e removidos dentro do modal. O dominio
sai do endereco escrito, para nao repetir a armadilha do formulario de cima.

USER, DOMAIN_ADMIN e SUPER_ADMIN eram constantes impressas no ecra, logo
ficavam em ingles em qualquer idioma. Um papel desconhecido continua a ser
mostrado tal como esta guardado, porque quem o ve precisa de saber o que a
base de dados tem.

A quota estava certa e mal mostrada. used_bytes vale 140771 -- os 137,5 KB
reais -- mas a pagina arredondava para megabytes inteiros, portanto qualquer
coisa abaixo de 512 KB lia-se "0 / 2 MB" e a barra ficava vazia, calculada
sobre os megabytes ja arredondados. Passa a formatar a partir dos bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 11:02:02 -03:00
Lucas RayanandClaude Opus 5 f801894f53 fix(admin): travessao literal na pagina de testes quebrava o lint de idioma
O lang-lint exige fonte ASCII e o CI apanhou-o. Passa a ser um escape
Unicode, como os restantes casos em que o caractere e o ponto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:43:19 -03:00
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 RayanandClaude Opus 5 136f9f339a fix(tls): o painel de seguranca estava vazio em qualquer modo menos um
/admin/security mostrava "Nao foi possivel carregar os dados" porque
/api/v1/admin/tls respondia 503. A rota so era ligada quando o certificado
vinha do watcher do acme.json do Traefik, o unico provider que tinha metodos
de estado proprios.

Nesta instalacao o TRAEFIK_ACME_DIR nao esta definido e o STATIC_CERT_PATH
tambem nao, portanto o servidor cai no certificado autoassinado efemero -- ou
seja, o painel calava-se exactamente no estado em que o operador mais precisa
de ser avisado.

- ProviderStatus deriva o estado de qualquer port.CertProvider, lendo a folha
  do certificado que ele realmente serve
- o painel passa a ser ligado sempre que ha JWT_SECRET
- passa a dizer quem emitiu e se e autoassinado, com um aviso proprio: nas
  portas de correio um autoassinado e recusado por qualquer cliente que
  verifique, o que pesa mais do que uma validade ainda longe

CheckSignatureFrom nao serve para detectar isto: tambem exige que o emissor
seja uma CA, coisa que uma folha descartavel nunca declara, e por isso dava a
fallback degradada como correctamente emitida.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:11:49 -03:00
Lucas RayanandClaude Opus 5 9689f26dda fix(dns): a lista dizia PENDING, nunca verificado, com tudo verificado
O crocha na lista de dominios lia-se da coluna dns_status, e nada lhe escrevia
depois do onboarding. A verificacao corre no servico de protocolos, que tem o
resolvedor, e a resposta ia directa para o browser sem ficar registada -- por
isso um dominio com 13 de 13 registos publicados e a resolver continuava
listado como PENDING, ultima verificacao "Nunca", ao lado da pagina que dizia
o contrario.

Quem verificou passa a escrever o que encontrou. Falhar a gravar nao
transforma uma verificacao boa num erro para o operador: fica no log.

A coluna aceita apenas PENDING, VERIFIED, PARTIAL, DRIFT e ERROR, e o
verificador tambem diz MISSING e UNKNOWN. Escrever qualquer uma dessas violava
a check constraint, a actualizacao era rejeitada e o crocha ficava velo na
mesma -- exactamente o sintoma que isto vem corrigir. Ha um teste so para essa
traducao.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 08:47:38 -03:00
Lucas RayanandClaude Opus 5 47c3700380 fix(dns): reconciliar reescrevia os mesmos registos a cada passagem
Duas coisas apanhadas ao correr o reconcile contra a zona real depois de a
rota passar a funcionar.

1) Nao era idempotente: 0 criados, 4 corrigidos, 11 iguais -- e outra vez 4
   corrigidos na passagem seguinte, para sempre, com o temporizador a fazer o
   mesmo em ciclo. Os 4 eram os SRV. A Cloudflare devolve o content de um SRV
   SEM a prioridade ("1 993 host" para um registo guardado como "0 1 993
   host") e guarda-a em data. O adaptador so recorria a data quando o content
   vinha vazio, portanto lia sempre um valor diferente do da spec.
   Para SRV e TLSA a data passa a ser a autoridade.

2) A spec de cada dominio incluia o A e o AAAA do mail host. Um segundo
   dominio servido pelo mesmo servidor aponta o MX para um host que nao lhe
   pertence, por isso reconciliar o cienciaembarcada.com.br tentava escrever
   mail.lucasrguerra.dev.br -- zona alheia. A Cloudflare respondia "an
   identical record already exists" e a consola mostrava dois erros para um
   dominio que estava correcto. O mesmo valia para o TLSA, que tambem tem o
   nome do mail host.
   Os registos de endereco passam a ser publicados so pela zona que contem o
   host; o MX e os SRV, que apontam para ele, continuam a ser de cada dominio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 08:34:14 -03:00
Lucas RayanandClaude Opus 5 9ce5fcafaa fix(dns): a consola dizia que faltava o token da Cloudflare, e nao faltava
O painel respondia "DNS reconciliation needs a provider token to be
configured" enquanto o token estava configurado e a funcionar: a varredura de
fundo estava a publicar registos com ele nesse preciso momento.

SetDnsReconciler so atribuia quando r.dns ja existia, e o main.go chama-o
antes de SetAdminDnsAPI. Escrevia num struct que ainda nao existia, sem erro
nem aviso, e a API era depois construida sem reconciliador nenhum.

Os testes anteriores nao apanharam isto porque construiam o adminDnsAPI
directamente -- justamente a fiacao que estava partida. O teste novo passa
pelos setters do Router nas duas ordens possiveis.

- o reconciliador passa a ser guardado no Router, logo a ordem deixa de contar
- so e ligado quando ha token, para a rota poder dizer que esta indisponivel
  em vez de falar com a Cloudflare com um token vazio e reportar uma recusa
- a mensagem deixa de culpar o token, que nao era a causa

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 08:15:45 -03:00
Lucas RayanandClaude Opus 5 9519be889d fix(admin): o botao de escolher dominio dizia "Adicionar"
Na lista de dominios, o botao de cada linha dizia "Adicionar" para um dominio
que ja estava la, e "Status" depois de clicado. Nao faz nem uma coisa nem
outra: escolhe sobre qual dominio as accoes de baixo actuam. Passa a dizer
Selecionar / Selecionado.

Removida tambem a string reconcileNotImplemented, que afirmava que a
verificacao de registos individuais ainda nao existia. Existe desde que o
endpoint de verify passou a consultar resolvedores publicos, e ja nao estava
a ser usada em lado nenhum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 16:01:11 -03:00
Lucas RayanandClaude Opus 5 2b369ab373 fix(dns): o botao de reconciliar nao criava registo nenhum
O painel listava os registos em falta e oferecia um botao para os criar, mas
esse botao chegava a um endpoint que so escrevia uma linha de auditoria e
respondia "REQUESTED". Nunca tocou no DNS. Quem tinha o token da Cloudflare
era o servico de protocolos, e a unica coisa capaz de o usar corria num
temporizador -- para um dominio so, o de MAIL_DOMAIN.

Por isso um dominio adicionado pela consola, como o cienciaembarcada.com.br,
nunca era reconciliado: os registos nao apareciam e o unico sintoma visivel
era a consola dizer que faltavam, para sempre.

- POST /api/v1/admin/dns/reconcile publica os registos de um dominio, agora,
  e devolve o que foi criado, corrigido e o que ficou em conflito
- o temporizador passa a percorrer todos os dominios ativos
- conflitos sao reportados, nao resolvidos: um registo do mesmo tipo e nome
  com outro valor pode ser correio da zona ou um servico de que ninguem se
  lembra, e nao e deste servidor decidir sobrepo-lo
- o erro do fornecedor fica no log, nao na resposta: pode nomear a conta ou
  a zona

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:52:27 -03:00
Lucas RayanandClaude Opus 5 8227fe2b97 fix(dns): o painel dizia que faltavam registos que estavam publicados
Tres defeitos na comparacao, todos encontrados contra a zona real. Cada
um fazia a consola declarar em falta um registo que existia e estava a
funcionar - o que e pior do que nao dizer nada, porque manda o operador
procurar um problema que nao ha.

MX. A especificacao guarda a prioridade num campo proprio, portanto o
valor desejado e so o host. A comparacao era feita contra "10 mail.x."
como texto, e por isso o MX de um dominio que recebe correio todos os
dias aparecia como inexistente.

TXT longo. Um valor acima de 255 bytes e publicado em varias strings, e
o resolver devolve-as entre aspas. Tirar as aspas e juntar tudo com
espacos punha um espaco no meio de uma chave DKIM: todos os registos
DKIM RSA da zona eram dados como em falta. As strings sao agora unidas
ponta a ponta, e os espacos dentro de cada uma sobrevivem.

Registos atras do proxy. Um CNAME proxied nao e publicado como CNAME -
o proxy responde A. Perguntar pelo CNAME devolvia resposta vazia, e o
registo era dado como em falta apesar de estar la a servir trafego. A
pergunta passa a ser do tipo que o proxy responde de facto.

A comparacao passa a ser por tipo em vez de uma normalizacao unica para
tudo, que era a origem comum dos tres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:40:54 -03:00
Lucas RayanandClaude Opus 5 64f1a2e145 fix(webmail): a barra lateral mostra so o que esta por ler
Mostrava tambem o tamanho de cada pasta. Sao numeros que nao mudam e
que ninguem esta a espera: enchiam a barra sem dizer nada de util.
Agora aparece um numero apenas onde ha correio por ler, e nada nas
restantes.

Consequencia a saber: Rascunhos nunca tem mensagens por ler - nada
marca um rascunho como lido - portanto passa a nao ter numero nenhum.
Quem quiser saber quantos rascunhos tem, ve-o no cabecalho da pasta.

O tamanho continua no tooltip do link, e o cabecalho da lista continua
a dize-lo por extenso, portanto a informacao nao se perdeu - deixou
apenas de estar sempre no campo de visao.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:30:17 -03:00
Lucas RayanandClaude Opus 5 e4491479ef fix(vacation): descodifica o assunto e responde a cada pessoa uma vez
O assunto que chegou a uma caixa real: "Fora do escritorio: Re:
=?UTF-8?Q?Re=3A_Fora_do_escrit=C3=B3rio?=". Duas coisas erradas, e a
segunda maior do que parece.

Um assunto com acento viaja como palavra codificada (RFC 2047). Colei o
cabecalho em bruto, portanto aquilo foi lido tal e qual por uma pessoa.
E foi tambem o que derrotou a verificacao do "Re:" ja existente:
codificado, todo o assunto comeca por "=?", nunca por "Re:", e por isso
foi acrescentado outro por cima. Descodificar corrige os dois sintomas
de uma vez. Os prefixos acumulados ("Re: RE: Re:") passam a ser
reduzidos a um.

O que estava por tras: aquele assunto era "Re: Fora do escritorio" -
alguem tinha respondido a propria mensagem automatica, e ela respondeu
de novo. Faltava a supressao por remetente. O RFC 5230 seccao 4.1 pede
exactamente isto e propoe sete dias: sem ela, uma conversa de cinco
mensagens produzia cinco copias do mesmo aviso, e quem respondesse ao
aviso recebia outro - que e como uma pessoa e um respondedor automatico
acabam a falar um para o outro indefinidamente.

A supressao falha aberta: um registo que nao se consegue ler deixa a
resposta sair. Ser avisado duas vezes e uma falha menor do que nunca
saber que a caixa esta ausente, que e o proposito da funcionalidade. O
envio so e registado depois de a resposta estar mesmo na fila, para que
um envio falhado nao silencie a mensagem seguinte da mesma pessoa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:15:58 -03:00
Lucas RayanandClaude Opus 5 866bd21846 fix(vacation): a resposta de ausencia passa a ser uma resposta
Chegava como conversa nova. Ia sem qualquer cabecalho de encadeamento,
por isso todos os clientes de correio a mostravam solta, sem relacao
com a mensagem que a tinha desencadeado - e quem escreveu tinha de
descobrir sozinho a qual dos seus emails aquilo respondia.

In-Reply-To nomeia a mensagem que esta a ser respondida e References
leva a cadeia inteira, que e o que permite a um cliente colocar a
resposta no sitio certo mesmo quando nunca viu as mensagens pelo meio
(RFC 5322 seccao 3.6.4). A cadeia e limitada em comprimento: uma
conversa longa faria este cabecalho crescer sem fim.

O assunto passa a ecoar o original - "Fora do escritorio: Re: Proposta
comercial" - porque e o que a pessoa le na sua lista, e porque ha
clientes que agrupam pelo assunto e nao apenas pelos cabecalhos. O
texto configurado fica como prefixo em vez de ser substituido, para
continuar a dizer o que a mensagem e. Um assunto que ja anuncia ser
resposta nao recebe um segundo "Re:".

Sem Message-ID no original nao se escreve nada: apontar para uma
mensagem inexistente faria os clientes mostrarem a resposta agarrada a
nada. Um Message-ID com quebra de linha e recusado por inteiro - viria
de um remetente remoto, e uma quebra ali terminaria o cabecalho e
deixaria o resto ser lido como mais cabecalhos.

Os cabecalhos que impedem dois respondedores automaticos de se
responderem um ao outro continuam todos la, com teste que o garante.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:43:52 -03:00
Lucas RayanandClaude Opus 5 508cc7c944 fix(webmail): a lista de destinos deixava de fora as pastas do utilizador
Encontrado a validar na produção, depois do deploy: o selector de pasta
de destino na tela de regras mostrava apenas INBOX, Archive, Junk e
Trash - exactamente sem as pastas que o utilizador tinha criado, que
são aquelas para que uma regra de arquivo costuma ser escrita.

A tela de regras escolhe um destino sem nenhuma mensagem aberta, por
isso não tem "pasta actual" para excluir e passava uma string vazia.
Uma pasta criada pelo utilizador não tem papel special-use, portanto
"role === current" era verdadeiro para todas elas de uma vez.

A exclusão passa a só se aplicar quando há de facto uma pasta actual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:12:27 -03:00
Lucas RayanandClaude Opus 5 be933d241c chore: mantém ASCII o código dos testes novos
O lang-lint exige que a fonte deste repositório seja ASCII, e os testes
que escrevi usavam acentos em texto de exemplo. Isto teria feito o CI
vermelho.

Onde o acento era só texto de exemplo, foi substituído. Nos dois testes
onde o acento é o que está a ser testado - que um nome de pasta acentuado
sobrevive ao parser, e que uma pasta pode chamar-se "Relatorios de
Marco" - fica escrito como escape Unicode, que mantém o teste a
verificar exactamente a mesma coisa sem pôr o caractere na fonte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:59:34 -03:00
Lucas RayanandClaude Opus 5 caec501ec5 fix(sieve): reconhece endereços automáticos com sufixo
Encontrado a correr o script guardado na caixa real antes de o pôr no
ar. O Google envia os relatórios DMARC de
noreply-dmarc-support@google.com, e a verificação só reconhecia a
palavra exacta ou um sufixo com "+": a caixa teria respondido
automaticamente a todos os relatórios diários que recebe.

O prefixo passa a ser comparado com os separadores que os remetentes
usam de facto - "+", "-", "." e "_" - e a lista cresce com os nomes
correntes deste tipo de endereço. Continua a responder a uma pessoa
cujo endereço apenas começa por uma dessas palavras, o que também tem
teste: "bouncer" é gente, "bounces-123" não é.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:49:33 -03:00
Lucas RayanandClaude Opus 5 d403c47a71 feat(webmail): pastas próprias na barra lateral e regras em linguagem comum
Regras. A tela pedia um tipo de correspondência do Sieve pelo nome -
:contains, :is, :matches (regex) - e colava o valor escrito
diretamente num script. Punha uma linguagem de padrões à frente de
quem só queria dizer "o que vem do banco vai para Faturas", e bastava
uma aspa no valor para o script deixar de fazer parse, desativando
silenciosamente todas as regras de uma vez.

O vocabulário passa a ser o de quem escreve: um campo, uma condição em
português, um valor e uma ação. Os curingas são gerados a partir de
"começa com" e "termina com" - ninguém os escreve, e um asterisco
escrito por acaso continua a ser um asterisco. A pasta de destino é
escolhida de uma lista das pastas que existem mesmo, em vez de ser
escrita à mão: antes uma regra podia nomear uma pasta inexistente e
nunca disparar, sem dizer porquê.

A ordem passa a ser visível e alterável, porque a primeira regra que
arquiva uma mensagem decide para onde ela vai.

Um script que esta tela não consegue exprimir - escrito à mão, ou por
um cliente de secretária sobre ManageSieve - é mostrado tal como está,
em vez de ser reescrito na coisa mais parecida que este formulário sabe
dizer, o que mudaria em silêncio o que o correio de alguém faz.

Pastas próprias. Existiam na base de dados e as regras podiam arquivar
nelas, mas a barra lateral listava só as seis fixas, portanto não havia
como ver uma - nem como criar. Criar fica onde a vontade aparece, na
própria barra; apagar avisa que leva as mensagens junto, porque leva.

Encontrado a escrever isto: o motor tratava "\\*" como barra seguida de
curinga em vez de um asterisco literal. Como é esta tela que gera os
curingas, uma regra sobre um assunto com "50% *" teria correspondido a
todos os assuntos começados por "50% ". Corrigido no motor, com testes
dos dois lados.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:48:10 -03:00
Lucas RayanandClaude Opus 5 42420a7de2 feat(regras): aplica as regras na entrega e envia a resposta de ausência
A metade que faltava. O motor já existia e nada o chamava; agora o
caminho de entrega consulta as regras da caixa que está a receber, e a
resposta de ausência é mesmo enviada.

Cada destinatário tem as suas próprias regras, avaliadas sobre a sua
própria cópia: duas caixas neste servidor que recebem a mesma mensagem
podem arquivá-la em sítios diferentes.

Nada disto pode impedir o correio de chegar. Um script que não faz
parse, ou uma base de dados que não responde, deixa a mensagem ser
entregue sem filtro em vez de a recusar - recusar diria ao remetente
que a mensagem falhou, e o servidor dele voltaria a tentar para sempre.
discard é aceitar e depois deitar fora, pela mesma razão: uma regra de
arquivo não é uma rejeição.

A resposta de ausência sai pela mesma fila, assinatura e limites de
envio que qualquer outra mensagem deste servidor - não é um caminho
especial - e só depois de a mensagem que a desencadeou estar guardada,
para não responder a correio que a caixa afinal não recebeu. Leva
Auto-Submitted: auto-replied, que é o que impede duas caixas ambas de
férias de se responderem uma à outra até uma esgotar a quota.

Pastas próprias. Só existiam as seis padrão, portanto uma regra que
dizia "arquiva isto em Faturas" nomeava uma pasta que não havia como
criar. Criar, renomear e apagar, com as reservadas protegidas: cada uma
delas significa alguma coisa para o protocolo ou para este servidor, e
deixar renomear a INBOX partia caminhos que nenhuma mensagem de erro
explica. Renomear não mexe nas mensagens nem nos UIDs, senão um cliente
IMAP perdia de vista tudo o que lá está dentro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:35:17 -03:00
Lucas RayanandClaude Opus 5 b9ebf606e1 feat(sieve): motor que avalia as regras na entrega
Primeira metade do que falta para a ausência e os filtros funcionarem:
o motor em si, como domínio puro e testável, ainda sem estar ligado à
entrega.

É um subconjunto deliberado do RFC 5228 mais o vacation do RFC 5230:
os testes e as ações que a interface gera e que um script escrito à
mão provavelmente usa - header com :is, :contains e :matches, anyof,
allof, not, if/elsif/else, fileinto, discard, addflag, keep, stop e
vacation. O que não entende é recusado em vez de ignorado: uma regra
que o utilizador escreveu e que silenciosamente não faz nada é pior do
que uma que diz que não consegue correr.

:matches é correspondência com curingas, não expressão regular. Em
Sieve todos os outros caracteres são literais, portanto "a.b@x.test"
tem de casar apenas com um ponto verdadeiro; compilá-lo como regex
faria casar também "axb@x.test" e entregaria a um autor de scripts toda
a gramática das expressões regulares, incluindo padrões que levam tempo
exponencial numa linha de assunto construída para isso. O algoritmo faz
backtracking iterativo, linear no caso comum, e há um teste com limite
de tempo que falha se voltar a ser exponencial.

A parte da ausência que realmente importa é quando não responder. O
RFC 3834 é a regra: responde-se à mensagem de uma pessoa e a mais nada.
Remetente de envelope vazio, auto-submitted, precedence bulk, qualquer
cabeçalho de lista, x-auto-response-suppress, endereços reservados como
mailer-daemon, e a própria caixa - nenhum recebe resposta. É assim que
uma caixa evita responder a uma lista à frente de toda a gente, ou
entrar num ciclo com outro respondedor automático.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:24:20 -03:00
Lucas RayanandClaude Opus 5 f179cb1f27 fix(webmail): acertos de interface e mover mensagens entre pastas
Cc/Cco. Os dois rótulos do compositor eram o último texto em inglês
que restava, e "Bcc" tem nome próprio em português - quem não conhece
a abreviatura inglesa não adivinha o que o campo faz.

Larguras. A barra lateral tinha 244px fixos e nela não cabia "Console
de administração", que transbordava no telemóvel e no computador: o
botão passa a mostrar um rótulo curto e guarda o nome completo no
tooltip. O endereço da conta usava break-words e partia-se a meio do
domínio, deixando um "br" sozinho na linha de baixo; passa a truncar
com o valor inteiro no tooltip.

A lista tinha 404px fixos e o leitor 760px centrados, portanto num ecrã
largo os assuntos eram espremidos numa coluna estreita enquanto o
leitor ficava entre duas margens enormes. Ambos crescem agora com a
janela. O assunto ganha overflow-wrap: os Message-ID dos relatórios do
Google são uma única "palavra" sem espaços, mais longa que a coluna, e
sem sítio onde quebrar saíam da linha.

Imagens remotas. A escolha era reposta sempre que uma mensagem era
aberta, por isso a mesma newsletter tinha de ser desbloqueada em cada
leitura. A decisão passa a ser lembrada por remetente, com um segundo
botão para a retirar. Bloqueado continua a ser o que acontece por
omissão: uma imagem remota é um aviso de leitura que o remetente não
pediu licença para receber.

Assinatura. Era escrita num textarea - texto simples com quebras de
linha reais - e injetada no compositor com innerHTML, onde uma quebra
de linha é espaço em branco: todas desapareciam. Passa a ser um campo
rico, com negrito, itálico, links e imagens, guardado como HTML e
sanitizado nos dois sentidos, já que este valor é injetado como
marcação na origem do webmail. Assinaturas guardadas pelo campo antigo
continuam a funcionar: as suas quebras de linha são convertidas.

Mover entre pastas. Faltava a única ação corrente que o leitor não
tinha: arquivar uma mensagem para fora da caixa de entrada era
impossível. Enviados e Rascunhos nunca são destino - descrevem como a
mensagem veio a existir, não onde o leitor a arrumou - mas sair deles é
permitido. Pastas criadas pelo utilizador, que não têm papel
special-use, são destino como qualquer outra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:18:08 -03:00
Lucas RayanandClaude Opus 5 ac0521cb9b fix(deploy): puxa a imagem publicada em vez da que está em cache
Um deploy recriava os contentores mas continuava a correr o código
antigo. "latest" é uma tag móvel, e o Docker considera satisfeita
qualquer tag que já tenha localmente: o compose pedia
protocols:latest, o host já tinha uma imagem com esse nome, e a que o
Release acabara de publicar nunca era descarregada. O deploy terminava
com sucesso, os contentores reiniciavam saudáveis, e nada do que
estava a correr mudava.

Foi assim que várias correções que passaram no CI nunca chegaram ao
site: a rota /api/v1/mail/delete respondia 404 em produção enquanto
existia no código e nos testes, e a pasta Reports existia na base de
dados - a migração corre a partir da imagem certa - sem existir em
nenhuma das interfaces.

pull_policy: always nos seis serviços que consomem imagens
publicadas. Os que usam imagens de terceiros com versão fixa
(postgres:16-alpine, redis:7-alpine, clamav) ficam como estão: aí a
tag identifica um conteúdo imutável e voltar a puxar não acrescenta
nada.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:35:53 -03:00
Lucas RayanandClaude Opus 5 203316f2c3 fix(webmail): corrige os contadores das pastas e as ações do leitor
Todos estes foram reproduzidos na interface real, com a conta em
mail.lucasrguerra.dev.br, e não em teste.

Contadores das pastas. O badge caía para o total sempre que não havia
nada por ler, no mesmo lugar e no mesmo estilo do contador de não
lidas: uma caixa de entrada com catorze mensagens lidas mostrava "14",
e ler mais uma nunca movia o número - ele nunca esteve contando o que
o leitor pensava. Os dois números agora são visualmente distintos e o
de não lidas só aparece quando existe mensagem por ler.

Em Rascunhos o badge e o total são o mesmo número por definição, então
o sidebar imprimia-o duas vezes: "Rascunhos 1 1". A regra passou para
folderBadge, onde é testada, e o tamanho da pasta é renderizado num
único sítio.

Ações do leitor. Todas as mensagens ofereciam os mesmos quatro botões,
onde a pasta devia decidir. Um e-mail que o próprio utilizador enviou
oferecia "marcar como não lida" - uma flag que nada lê numa pasta onde
nada é entregue - e um rascunho por acabar oferecia Responder e
Encaminhar, mas nenhuma forma de o terminar. Abrir um rascunho agora
abre o compositor com o que estava escrito e adota o seu UID, para que
continuar a escrever substitua esse rascunho em vez de deixar o antigo
para trás. Em Lixeira o botão de apagar diz que apaga em definitivo,
porque é o que faz.

Anexos. t("mail.attachments") era chamado sem o parâmetro que a string
declara, e a interface mostrava literalmente "Anexos ({count})" nos
três idiomas. Na lista, que sabe que há anexos mas não quantos, passa a
nomear o facto em vez de imprimir uma contagem que não tem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:18:45 -03:00
Lucas Rayan 1dc87a06c8 feat: add test database setup and teardown for end-to-end tests, ensuring unique and non-ephemeral ports 2026-08-22 09:02:26 -03:00
Lucas Rayan 184769d60f chore: update dependencies and add trivy ignore file for assessed findings 2026-08-22 08:22:27 -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 Rayan 880dced9e4 feat: implement message deletion functionality and improve draft handling
- Added `MoveToTrash` method in `WebmailRepository` to move messages to the Trash folder instead of deleting them permanently.
- Introduced `handleDelete` method in `mailAPI` to handle message deletion requests from the client.
- Updated router to include a new route for message deletion.
- Enhanced the compose page to manage draft integrity, ensuring drafts are correctly associated with sent messages.
- Implemented UI changes to allow users to delete messages from the mail folder view.
- Added tests to verify the correct behavior of message deletion and draft handling.
- Updated database migration scripts to reconcile folder counters and ensure accurate unread counts.
- Added tests for TLS-RPT report parsing and webmail repository functionalities.
2026-08-20 15:32:14 -03:00
Lucas RayanandClaude Opus 5 4f1b9c5217 #35: keep the redesign's source ASCII
The previous commit put four literal non-ASCII characters into source, which
lang-lint rejects (PLAN.md 21.1: all code in English, ASCII-only, enforced by
CI rather than review). Nine lines failed.

The brand mark in the four rails and sign-in cards was a literal lambda, and
the domains screen's meta line joined DMARC, MTA-STS and DANE with a literal
middot. Both are now \u escapes, so the source is ASCII and the rendered page
is unchanged - the served sign-in page emits ce bb for the mark, and the
domains chunk carries the middot as \xb7.

globals.css goes with them. The linter does not scan .css today, so this was
passing, but the stylesheet was the only file in the tree using box-drawing
section rules and em dashes, and there is no reason for it to be the
exception - ASCII hyphens read the same in a comment.

Verified: lang-lint clean, 98 tests green, tsc clean, production build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:32:22 -03:00
Lucas RayanandClaude Opus 5 b33fc12f08 #35: replace both web surfaces with the Nocturne redesign
The webmail was indigo over glass and the console was green over glass, so the
two halves of one product did not look related, and neither followed a system
anything could be checked against. This replaces the interface with the
Nocturne design system from Claude Design: a near-neutral blue-grey ground,
Inter at weight 500, solid surfaces with a 1px inset edge instead of blur, and
a single blurple accent used as a line, a mark and a glow rather than a flood.

The palette is remapped at the source rather than renamed at every call site.
tailwind.config.js points slate, indigo, emerald, teal and the rest at the
Nocturne ramps, and globals.css carries the same values as variables, so a
utility class and a token cannot drift apart and no colour can enter the
interface from outside the system. The console's green is gone because it
resolves to the one accent; that was the point.

Structurally, both surfaces now share one rail - the same brand mark, the same
3px accent mark on the active item, the same account tile - with a segmented
control for crossing between them. Crossing into the console still goes to the
step-up rather than the admin sign-in, and leaving it is still a plain link.
Settings moves from eight stacked panels to a tab strip, so enabling a second
factor and writing a filter are no longer separated by everything else.

Nothing is sized by the length of its text any more. Labels wrap, tab strips
wrap instead of scrolling, subjects and queue diagnostics wrap instead of
truncating, and the message list's snippet is clamped to two lines rather than
one. That is what broke when the interface was read in Portuguese or Spanish.

Defects found while doing it:

  - The queue and DMARC screens styled their status pills with "badge-danger",
    "badge-warning" and "badge-verified" - three class names no stylesheet in
    this project defines - so every verdict rendered as identical unstyled
    text. They are Badge variants now, which exist.
  - The DMARC screen printed the literal string "quarantine" as the
    enforcement level whatever the domains publish. The report endpoint returns
    no policy at all, so it was asserting a configuration nobody had made. It
    shows the DMARC pass ratio it genuinely has.
  - Half the mailboxes screen and two lines of the audit trace were hardcoded
    strings, some Portuguese and some English, so they stayed in one language
    whatever the interface was set to. They come from the bundle now, with the
    new keys added to all three locales.
  - Avatars printed email[0], so every account at one domain collapsed onto the
    same letter. initialsFor takes two letters from the local part's own word
    boundaries, behind tests.

Trivy reported one HIGH: nanoid 3.3.16, pulled in transitively by postcss,
vulnerable to a denial of service via an infinite loop in ID generation
(CVE-2026-67213). Pinned to ^3.3.18 through the overrides block the package
already uses for sharp and postcss.

Verified: 98 webmail tests green, tsc clean, production build clean, and the
compiled stylesheet serves the Nocturne values with no stock Tailwind indigo,
emerald or slate hex left in it. Trivy fs with the CI scanners, severities and
skip-dirs exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:25:24 -03:00
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