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.
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).
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.
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.
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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
/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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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.
- 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.
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>
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>
A fresh deployment published "v=DKIM1; k=rsa; p=" for its own domain. An empty
p= is not an absent key, it is a revoked one (RFC 6376 section 3.6.1): every
verifier reading it treats all signatures from that selector as invalid. On a
domain whose DMARC policy is p=quarantine with strict alignment - which this
reconciler generates - that quarantines all of its own outbound mail. Worse
than publishing nothing.
The chain: DkimRepository.Insert is an INSERT ... SELECT conditional on the
domain existing in the domains table, so on an empty database it inserts no
rows and returns no error. FindPublicKey then finds nothing, ensure() returns
an empty string with no error, and the reconciler faithfully published the
empty value. Every field along that path was behaving as written; nothing
checked that the result meant anything.
Two changes:
- The record builder omits a DKIM record when its key is empty, so the
revocation cannot be published regardless of why the key is missing.
- Provisioning logs when it produced no key, naming the likely cause. The
insert being conditional on an onboarded domain is exactly the case a new
operator hits, and "no key, domain probably not onboarded" is the difference
between a diagnosis and unsigned mail with no explanation. It stays
non-fatal: erroring here would abort the whole reconcile and leave a new
install with no A, MX or SPF records either.
Found while migrating to a new host: the fresh database had no domain row, so
the first reconcile revoked the domain's DKIM. The two published records have
been deleted from the zone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Every message was resolved through MX and delivered over SMTP, including one
addressed to a mailbox on this very host. Two users on the same server could
not write to each other without a working outbound port 25 - which the
provider blocks, so on this deployment they could not write to each other at
all. It also made internal mail take a pointless round trip through the public
internet.
The worker now checks whether the recipient resolves to a local mailbox and,
if so, hands the message to the inbound path directly. The check runs ahead of
the relay branch on purpose: sending local mail out to a smarthost only to
have it delivered back is slower and has more ways to fail.
The shortcut is narrow. It relies on ResolveDeliveryTargets, which only
resolves addresses on an active local domain, and a test covers that a remote
recipient still goes out over SMTP - otherwise local delivery would swallow
mail meant for the internet. A resolver error is treated as retry rather than
as not-local: falling through to SMTP on an unknown answer would send to the
internet a message that may belong here. Local delivery is still
content-scanned; a virus between two accounts on one host is no less a virus.
This also surfaced a permissive test double: fakeAliasFanOutRepository claimed
every address was local, which was harmless while nothing consulted it while
routing and wrong the moment something did. It now hosts one address, as the
real repository does, and the DSN fan-out test names it explicitly.
Verified: the local-delivery test fails without the change (the recipient
triggers an MX lookup) and passes with it; 22 Go packages green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The autosave I had just added appended a new message on every debounce, so a
few minutes of typing would have left the Drafts folder holding a dozen copies
of the same unfinished mail. Caught before deploying.
The composer now returns the stored UID and sends it back as replace_uid on
the next save; the use case expunges the superseded draft, but only after the
replacement has committed, so a failure leaves the older draft in place rather
than losing both. Expunge is a soft delete matching the expunged_at column the
schema already uses, and it locates the message by mailbox as well as UID so a
caller cannot delete someone else's mail by guessing a number.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The console demanded a second factor and refused to open without one, but
enrolment lived on the other surface - so an operator whose only account
was the administrator had no way in. The password was already verified at
that point, so the refusal now carries a short-lived grant that permits
enrolling a factor and nothing else, and the admin login screen walks
through it: secret, confirmation, recovery codes, sign in. Two tests cover
it, including that the grant opens no other endpoint.
The rewrite also undid work that was already in place:
- The postcss override was dropped and the advisory came back, along
with a critical one in vitest. postcss is a direct dependency now, so
the fix is the version plus an override scoped to Next's own copy;
vitest moves to 3.2.7. The lockfile needed regenerating for either to
take effect. Zero advisories now.
- 100 language-lint violations: the admin console had been rewritten
with its strings in Portuguese, hardcoded. That fails CI, and it also
puts those strings beyond the reach of the translation bundles - an
English or Spanish user would have read Portuguese. They are English
in the source again.
- The mail screen put a fixed 320px list beside the reader with no
breakpoint, so on a phone the viewport overflowed and the message was
a few pixels wide. One pane at a time below md, with a way back.
- The icon-only navigation toggles had nothing for a screen reader to
announce.
The layouts' own mobile navigation and every previously fixed behaviour
survived the rewrite - checked rather than assumed.
Also fixes a marker collision I introduced: the E2E helper locates the
repository by a migrations directory, and services/migrations satisfied
that one directory too early. It now requires the SQL to actually be
there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
F4's acceptance criterion asks for the three translations to be 100%
complete with the language lint green. The bundles had parity but the
admin screens and the account area were still hardcoded English: 63
visible strings that no locale could reach.
They now come from a shared ui namespace, so the three bundles cannot
drift apart, and all three carry 126 keys.
The language lint also caught this branch's own Go tests: the RFC 2047
cases need accented output to be worth anything, and the delivery test
had Portuguese fixture text. The expectations are written as Go escapes
instead, which keeps the assertions and leaves the source ASCII, and the
fixture text is English.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Two problems the mail screens exposed, both in the delivery path.
The subject, snippet, sender name and message-id columns have existed
since the first migration, but nothing ever wrote them: every row went in
with those fields NULL. A message list built on the database would have
shown blank subjects for every message. Delivery now parses them once per
message and stores them, decoding RFC 2047 encoded words so an accented
subject is readable rather than raw. Parsing failures are non-fatal: a
message with unreadable headers is still delivered, just with less
metadata, because refusing it would lose mail over a malformed Subject.
Worse, a message the spam filter routed to Junk failed delivery outright
when the mailbox had no Junk folder - the sender got 451 and retried
forever against a mailbox that would never grow one on its own. Delivery
now falls back to INBOX and says so in the log. Found by sending a real
message through port 25 against the running stack.
The SMTP handler also swallowed the cause of any unexpected failure: the
peer saw "temporary error" and the operator got nothing. It is logged now,
which is how the missing folder was diagnosed at all.
Also adds the JWT verifier the mail API needs. The tokens are minted by
the auth service and verified here under the same secret, so the test
fixtures are real tokens produced by that service: if the two
implementations drift, this fails before the webmail does. Challenge
tokens, wrong secrets, tampered payloads, expired tokens and the other
surface's audience are all rejected.
The dev seed gained the standard folder set, which is what a provisioned
mailbox has and what the fallback above should not have to compensate for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The E2E suite has never run in CI. repoRoot() walked up looking for
PLAN.md, but .gitignore excludes PLAN.md on purpose ("Internal planning
docs - never published with the code"), so it is absent from every fresh
checkout and the helper always failed. All twelve E2E tests died on it,
in this run and identically in #18 before any of these changes.
The marker is now the migrations directory, which is committed and is
what every caller of repoRoot is ultimately looking for.
Reproduced by exporting the tree with git archive - which yields exactly
what actions/checkout puts on the runner - and running the suite there:
it fails with the old helper and passes with the new one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI gains a security job (Trivy fs: vuln, secret and misconfig) and an
image job that builds each of the four services and scans the result. The
secret scanner earns its place here: a DKIM key or Cloudflare token
committed by accident is a full compromise of every domain served.
release.yml publishes on a merge into main and on v* tags. Four services
on two architectures is eight build jobs pushing by digest, then four
publish jobs joining each pair into one manifest list, tagged latest, sha
and semver. github.repository is lowercased first because GHCR rejects an
uppercase path and this repository is lucasrguerra/LambdaMail.
Both scanners failed on first run, so this commit also fixes what they
found rather than shipping a workflow that is red on arrival:
- All four images ran as root (DS-0002), which section 17 risk R5
explicitly argues against. They now run unprivileged; protocols keeps
its eight ports below 1024 through cap_net_bind_service on the binary
rather than through being root.
- A HIGH advisory in postcss, reached only through Next. "npm audit fix
--force" would have resolved it by downgrading Next from 15 to 9, so
it is pinned with an override instead.
- The Node images copied the build stage's node_modules wholesale,
shipping typescript, eslint and vitest into production, and the base
image's own bundled npm accounted for the rest of the CVEs. Runtime
dependencies now come from npm ci --omit=dev and npm is removed from
the final layer.
- compose-up could never have passed: the proxy network is external
because Coolify owns it on the deploy host, and nothing creates it in
CI, so compose refused to start the stack at all.
Verified locally: actionlint clean, all ten action refs confirmed to
exist, Trivy fs and all four image scans exit 0, the full stack reaches
Healthy on every container, and the containers run as uid 10001/1000 with
all eight privileged ports bound and SMTP answering on 25. The arm64
build itself is unverified - this machine has no buildx - and will first
be exercised by release.yml.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>