35 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 e5edee096c #32: file composed mail, and verify DNS records for real
Three things the console and the mail client were getting wrong.

**Sent stayed empty and drafts would not save.** Both write through the same
persistence path, and both failed on the same constraint: spf_result,
dkim_result and dmarc_result are restricted to the RFC verdict vocabulary and
are nullable, so "not evaluated" has to be NULL. The compose path left them at
Go's zero value, which sends an empty string - satisfying neither the CHECK nor
the meaning - and the insert was rejected. Filing the Sent copy is deliberately
non-fatal, so it failed silently; saving a draft reported the error. Inbound
mail was unaffected because it always supplies a real verdict, which is also
why the existing test never caught it.

**DNS reconciliation could not verify anything.** The console posted to the
auth service, which has no resolver, so the endpoint recorded the request and
re-read the status already in the database. The machinery to do it properly
already existed - the background reconciler resolves every expected record
through public resolvers - it just was not reachable from the console.

Adds GET /api/v1/admin/dns/verify to the protocols service, which owns both the
resolver and the record spec, and wires the domains screen to it: every
expected record, its expected value, and whether it actually resolves. A
proxied record is reported as present rather than matching, because behind a
proxy the published answer is the provider's address and comparing values would
always disagree. Reconciliation still runs afterwards so the stored status and
the audit trail stay in step.

The spec is read, never provisioned: opening a screen must not create DKIM keys
as a side effect.

Verified: the persistence test fails without the fix with the exact constraint
violation (email_messages_dkim_result_check), 22 Go packages and 36 webmail
tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:35:09 -03:00
Lucas RayanandClaude Opus 5 7c3a2d165a #26: let the relay's SPF mechanism be configured, not guessed
SpfInclude derived the mechanism by reducing the relay hostname to its
organisational domain. That is unsound, and Brevo is the case that proves it:
the relay is smtp-relay.brevo.com, so the reduction yields
"include:brevo.com" - but brevo.com publishes the SPF of Brevo's own
corporate mail (Google Workspace and Zendesk). The sending ranges live at
spf.brevo.com.

Publishing the wrong include is worse than publishing none. It authorises
Google to send for the domain while leaving the relay unauthorised, so every
message the relay forwards fails SPF - and against the "-all" policy this
reconciler generates, is rejected rather than merely marked.

RELAY_SPF_INCLUDE now carries whatever the provider documents. A bare domain
gets the "include:" prefix; any other SPF term (ip4:, a:, mx:) passes through
untouched, since not every provider publishes an include. The old reduction
stays as a fallback for relays whose hostname does happen to match, because
publishing something plausible beats publishing nothing at all.

Caught before configuring a relay for real, by checking what brevo.com and
spf.brevo.com actually serve rather than trusting the heuristic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:24:11 -03:00
Lucas RayanandClaude Opus 5 d8e2de2e11 #24: deliver what the composer wrote, keep a copy, and store drafts
Four faults, all in the path between the compose screen and the mailbox.

**Every message sent from the webmail had an empty body.** The HTTP handler
read a field named "body"; the composer has always sent "html". The two never
matched, so the payload handed to the queue contained headers and nothing
else. The handler now reads html and text, and still accepts body so an older
client is not broken. Sending was otherwise working, which is why this went
unnoticed: the mail left, it just said nothing.

**Bcc was discarded.** The field was absent from the request struct and from
ComposeInput, so a blind copy typed into the webmail reached nobody. It now
joins the envelope recipients and is deliberately kept out of the headers,
which is what makes it blind.

**HTML was labelled text/plain.** Even once the body was carried, the composer
produces markup and the message declared text/plain, so a recipient would see
raw tags. Messages with HTML now go out as multipart/alternative with a
plain-text part derived from the markup and ordered first per RFC 2046 5.1.4.
An empty or missing text alternative is itself a spam signal.

**Sent mail left no trace.** Nothing wrote to the Sent folder, so a delivered
message vanished from the sender's view entirely. A copy is now filed after
the message is durably queued - not before, or a send refused by the rate
limit would still leave a copy - reusing the inbound persistence path so it
gets a real IMAP UID and appears over IMAP too. A failure to file is logged
and swallowed: the mail is already on its way, and reporting failure would
invite the user to send it twice.

**Drafts were never saved.** There was no endpoint at all. The composer showed
"draft saved automatically" from a setTimeout with no request behind it, so
closing the tab lost the message the screen had just promised was safe. Adds
POST /api/v1/mail/draft, storing the draft as a whole RFC 5322 message in the
Drafts folder, and debounces the composer onto it.

The composer also navigated to Sent whether or not the send succeeded, which
is how a rejected message disappears silently. It now reports the error and
stays put.

**Delay notifications were being filed as spam.** A DSN this server writes is
injected through the same inbound path as mail from the internet, but without
a client IP - so SPF can only be "none" - and its bounce-shaped content scores
highly. The warning telling you your mail was delayed landed in your own Junk
folder. Messages the server generates itself now skip content scanning; the
exemption is narrow, and a test covers that ordinary mail is still scanned so
this cannot become a way past the filter. The DSN also declared
Auto-Submitted: auto-replied, which belongs to vacation responders; a delivery
report is auto-generated (RFC 3834 section 5), and filters read that header.

Not fixed here, because it is not a code fault: outbound port 25 is blocked by
the provider, which is what the delay notification was reporting
("i/o timeout" to Google's MX - a timeout, not a refusal, is the signature of
a block). Receiving is unaffected and queued mail keeps retrying. Documented
with the two ways out, including the relay path, which is verified working
code rather than another stub.

Verified: 22 Go packages pass, 5 new compose tests and 3 for the DSN handling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:56:59 -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 93d21b4752 #21: push new mail to the browser over WebSocket
F4 lists real-time updates and the interface had none: an open inbox only
changed when the user reloaded it.

Events come from the transactional outbox rather than from an in-process
callback, which is what the outbox is for (PLAN.md section 9.4): the row
is written in the same transaction as the message, so an event can never
describe a delivery that rolled back, and a crash between commit and
publish loses nothing because the row is still there on restart. The relay
claims rows with SKIP LOCKED so a second instance can help without
publishing anything twice.

The hub fans events out per mailbox, keyed by the mailbox the session
resolved to, so one account's arrivals are never visible to another even
though they share the process. A subscriber that stops reading is dropped
rather than allowed to stall the relay; it reconciles on reconnect.

The client reconnects with capped exponential backoff and treats every
successful connect as a resync, refetching the folder. Without that, a
socket that dropped for a minute would leave the list quietly stale, which
is worse than having no real-time updates at all.

The upgrade goes through a Next rewrite, not the API route handler: a
route handler sees a finished request and cannot upgrade a connection.
Verified against the running stack - the handshake returns 101 through the
Next server, and a message delivered on port 25 arrives as a frame on an
already-open socket without the page asking for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:26:54 -03:00
Lucas RayanandClaude Opus 5 48fe3c68f3 #21: serve the webmail mail API from the protocols service
The mail screens had no backend at all: they rendered a hardcoded array,
and the storage service that ADR-001 assigned this to is still a health
stub. The API lands here instead, because the folder, UID, MODSEQ, flag
and blob logic already exists and is tested in this service for IMAP.
Reimplementing it in TypeScript would mean two implementations of the
same semantics and two chances to disagree about what a UID means.

Reads use a projected read model rather than hydrated aggregates, which
is the CQRS exception PLAN.md section 2 carves out for webmail queries.
Every query is scoped to the mailbox resolved from the verified session,
never to an identifier taken from the request, so one account cannot read
another's mail by guessing a UID - covered by a test that tries exactly
that and expects 404.

Sending reuses ProcessOutboundEmailUseCase, so mail written in the browser
gets the same treatment as mail submitted over 587: send limits, DKIM
signing, and the durable queue. The From address comes from the session
and never from the request body; letting the body choose it is how a
webmail becomes a relay for spoofed mail.

MIME is parsed server-side because this service already depends on a MIME
library. A message that will not parse still returns its raw text - an
unreadable message beats an inbox that refuses to open one.

Marking a message read updates the flag and the folder counter in one
transaction, and only on a real transition, so re-opening a message cannot
drive unread_count negative.

Verified against the running stack: a message delivered over port 25
appears in the list with its decoded subject and sender, opens, marks
itself read, is found by search, and a message composed in the webmail
reaches outbound_jobs with the session's address as the envelope sender.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:03:25 -03:00
Lucas RayanandClaude Opus 5 33d91fa247 #19: fix F1-F3 defects found auditing the phases against PLAN.md
Every fix here is a case where the code and the plan disagreed, and the
code was wrong.

Certificates (section 8.2, 8.4). The parser documented that it skipped
expired entries but never checked, so an expired certificate would be
served until every handshake failed. LastReload() only advanced when
acme.json changed - and it does not change for sixty days - which made
the timestamp risk R2 monitors indistinguishable from a watcher gone
blind. Expiry is now checked on every pass, LastReload records the check
and LastChange the swap, and the <7d/<24h alerts of section 8.4 exist.

ARC (section 5). buildArcSealer resolved the DKIM key at startup, before
runDnsReconciler had provisioned one. On a first boot ARC was disabled
until someone restarted the process, which the log even admitted while
offering no way for it to start. A LazySealer binds on first use instead.

Health (section 8.4). There was no degraded state at all. /health now
reports one - still 200, because restarting cannot fix a certificate
Traefik never issued.

Bounces. DSNs were sent From postmaster@lambdamail.local, a domain that
does not resolve, so the notification was itself undeliverable. They now
carry the configured mail host, a Message-ID, and Auto-Submitted so a
vacation responder cannot loop against them.

Fan-out atomicity (section 6.1). Persist ran one transaction per
recipient, so a failure halfway through an alias fan-out left earlier
recipients with a copy while the sender was told to retry - and the retry
delivered to them twice. PersistAll commits the whole delivery or none of
it. Verified against Postgres by reverting the repository to the old
behaviour and confirming the new test fails on it.

SMTP replies. The use case encoded replies in error text ("550 5.7.1 ...")
which the adapter re-parsed by prefix, so rewording a message could turn a
permanent refusal into a retry. SmtpRejection carries the decision and the
adapter renders it.

Repository tests. These only run with TEST_DATABASE_URL, which CI does
set - so the job was red: copy and expunge seeded a 68-character digest
into a CHAR(64) column, and three others used fixed digests that collide
with the UNIQUE index on any re-run. A shared testBlobDigest() helper
fixes both; the suite now passes twice in a row against one database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 07:57:22 -03:00
Lucas Rayan d56f29d084 #18: implement SMTP Submission (587/465), SASL PLAIN/LOGIN auth and preflight diagnostics 2026-08-01 22:49:02 -03:00
Lucas Rayan af35211094 #13: implement Outbound Queue, Retry Backoff Engine & DSN Bounce Generator (RFC 3464) 2026-07-25 10:33:07 -03:00
Lucas Rayan 54d1ea5810 #12: implement Antispam & Antivirus scanning pipeline (Rspamd & ClamAV) 2026-07-25 10:25:52 -03:00
Lucas Rayan 314eb67dda #11: implement MTA-STS, TLS-RPT & Autoconfig HTTP services 2026-07-25 10:18:03 -03:00
Lucas Rayan 0e6fc27bc1 #8: implement ManageSieve protocol server (RFC 5804) 2026-07-25 04:20:21 -03:00
Lucas Rayan 9ca97298bd #7: implement POP3 protocol server (RFC 1939, RFC 2449, STLS) 2026-07-24 11:51:27 -03:00
Lucas Rayan 6b1e4dc00a #6: implement IMAP IDLE (RFC 2177) and CONDSTORE (RFC 7162) 2026-07-24 11:43:37 -03:00
Lucas RayanandClaude Sonnet 5 0ec0698079 #5: F2 sub-project 2 - IMAP SEARCH, EXPUNGE, COPY, MOVE
Adds the second F2 sub-project on top of the merged IMAP core: full
SEARCH criteria matching (flags, dates, size, headers, body/text,
NOT/OR), EXPUNGE (soft-delete via expunged_at), COPY, and MOVE (RFC
6851, advertised via CapMove/CapUIDPlus). Verified end-to-end with a
real go-imap/v2 client against embedded Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 09:40:47 -03:00
Lucas Rayan 9fa19291d4 #4: implement F2 sub-project 1 - IMAP core (auth, LIST, SELECT, FETCH, STORE)
Adds a real :143 IMAP listener to the Go protocols service: a mailbox
owner authenticates over IMAP (Argon2id against mailboxes.password_hash,
enumeration-resistant per PLAN.md section 14.6), lists folders, selects
INBOX, fetches messages F1's SMTP path already persisted - including real
ENVELOPE/BODY[] built from the actual stored MIME bytes via go-imap/v2's
own ExtractEnvelope/ExtractBodySection helpers, not reconstructed from
denormalized columns - and sets/reads flags via STORE.

- ImapSessionUseCase orchestrates auth, folder listing/selection, message
  querying, and flag mutation against new Postgres repositories (auth,
  folders, message query, flags) and a local-disk blob reader.
- A github.com/emersion/go-imap/v2 (pinned v2.0.0-beta.8) Backend/Session
  implements 7 real methods (Login, List, Select, Unselect, Status, Fetch,
  Store) plus a correct no-op Poll; the other 10 of the interface's 18
  methods (Create/Delete/Rename/Subscribe/Unsubscribe/Append/Idle/Expunge/
  Search/Copy) are explicit stubs returning a clear "not yet implemented"
  IMAP error - SEARCH/EXPUNGE/COPY/MOVE, IDLE/CONDSTORE, mailbox
  management, and APPEND are separate follow-up sub-projects per the
  design doc's agreed ordering with the user.

Verified end-to-end with a real go-imap/v2 imapclient (embedded Postgres,
no Docker needed) driving Login -> Select -> Fetch -> Store -> List
against a message seeded through F1's real SMTP use case, proving true
SMTP-write/IMAP-read interop, not just isolated unit tests.

Built via 8 sequential tasks under subagent-driven development, each
independently reviewed, plus a final whole-branch review. Two real bugs
were found and fixed before merge, both exactly the kind unit tests with
fakes cannot catch:
- Store's UID-set resolution silently no-op'd on dynamic ranges like
  "UID STORE 1:*" (the common "mark all as read" operation) after an
  earlier draft bypassed the folder's real message list; restored to
  resolve against real messages via the same pattern Fetch already used
  correctly, with tests proving both dynamic ranges and non-UID STORE.
- go-imap/v2's server calls Session.Poll() implicitly before the status
  response of nearly every authenticated command (FETCH/STORE/SEARCH/
  APPEND/COPY), not just IDLE as assumed - a hard-stubbed Poll broke every
  real command through this path; fixed to a correct no-op.
- List had zero test coverage despite being committed scope; added unit
  tests for wildcard/delimiter matching plus a real E2E LIST assertion.

Known follow-up, not blocking: a pre-existing test-fixture hygiene gap
(hardcoded content_sha256 blob hashes with no cleanup, shared across
several Postgres integration test files including one new to this
branch) causes local-dev test collisions on repeated runs against a
persistent database - invisible to CI (fresh Postgres per run) but worth
a cleanup pass.
2026-07-23 20:34:48 -03:00
Lucas Rayan e501cdc69e #2: implement F1 sub-project 1 - inbound SMTP listener with EML storage
Adds a real :25 SMTP listener to the Go protocols service, giving F1's
"Nucleo SMTP" milestone its first working sub-project: application,
infrastructure, and presentation layers on top of F0's domain-only
Clean Architecture.

- ProcessInboundEmailUseCase resolves recipients against Postgres and
  persists accepted mail (blob dedup by content hash, transactional
  UID allocation + outbox event, never sending 250 OK before durable
  commit per PLAN.md section 6.1).
- Postgres repositories via pgx/v5 (replacing lib/pq entirely).
- Local-disk blob storage with content-hash dedup, rename-before-commit
  ordering so a DB row can never reference a file that isn't actually
  on disk.
- A local-file TLS cert provider for STARTTLS, with an ephemeral
  self-signed fallback so a missing/misconfigured cert degrades
  gracefully instead of crash-looping the service (PLAN.md section 8.4).
- A github.com/emersion/go-smtp Backend/Session adapting real SMTP
  traffic to the use case: unknown recipients get 550 5.1.1 before
  DATA, duplicate RCPT TO for the same address is deduplicated, TLS is
  hardened to the project's required PFS-only cipher/curve policy.
- A dev-seed script and Makefile target for local testing.

Explicitly out of scope (separate follow-up sub-projects): alias
resolution, SPF/DKIM/DMARC/ARC verification, Rspamd/ClamAV, Submission
ports with SASL auth, DKIM signing, outbound delivery, and the real
Traefik acme.json cert watcher.

Verified live end-to-end via WSL Docker: the full compose stack (db,
redis, migrate, auth, storage, protocols, web) reaches healthy, and a
real swaks SMTP session is accepted and durably persisted with correct
sequential UIDs, while an unknown recipient is correctly rejected with
550 5.1.1 and zero rows persisted. Built via 10 sequential tasks under
subagent-driven development, each independently reviewed, plus a final
whole-branch review that caught and fixed a TLS-crash bug, a blob
commit-ordering bug, missing TLS hardening, and missing CI integration
coverage before merge.

Known follow-up, not blocking: email_messages' partition only covers
July 2026 and needs a partition-management job before 2026-08-01.
2026-07-23 08:41:31 -03:00
Lucas Rayan eb09fa0ced #1: implement F0 foundation - monorepo, migrations, Go domain layer, service skeletons, CI
Stands up the LambdaMail monorepo per PLAN.md's F0 milestone: local dev
scaffolding (.env.example, Makefile, .gitignore excluding PLAN.md/docs/
.superpowers/), the full initial Postgres schema as a golang-migrate
migration, the complete Go domain layer for services/protocols (value
objects, entities, domain events - zero external imports beyond
github.com/google/uuid) with unit tests, health-checked service skeletons
for auth/storage (TypeScript) and webmail (Next.js), a local
docker-compose.yaml wiring all services together, a language-enforcement
lint script, and a GitHub Actions CI workflow.

Built via 11 sequential tasks under subagent-driven development, each with
an independent implementer + reviewer cycle. Fixes applied during review:
DomainName.IsPunycode() added to satisfy the promised interface; ioredis
default-import typing under NodeNext; next.js bumped 15.0.3->15.5.21 to
resolve high/critical CVEs; lang-lint.mjs corrected to not flag its own
banned-terms list; web service healthcheck added so compose --wait
actually waits on it; webmail Dockerfile's missing public/ directory:
protocols go.mod version aligned with CI's Go 1.22 pin.

Known follow-ups for later phases (not blocking F0): go-arch-lint isn't
wired into CI yet; live docker compose up and migration apply have not
been exercised in this dev environment (no Docker/psql available here)
and will get their first real run in CI, which does have Docker.
2026-07-23 05:05:46 -03:00