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>
O painel respondia "API endpoint not found" e o logotipo devolvia a pagina de
404 do Next. As rotas estavam registadas no servico protocols, mas o proxy do
webmail decide o upstream por uma lista propria, e eu nao acrescentei a "bimi"
-- exactamente o erro contra o qual o comentario dessa lista avisa.
Ficam ambas cobertas por testes: cada area administrativa que o protocols
possui, e o caminho well-known do logotipo. Esquecer isto nao volta a ser
silencioso.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 servico web nao tinha ports nem expose -- so a etiqueta do Traefik com
loadbalancer.server.port=3000. Essa etiqueta serve os routers escritos aqui,
mas o Coolify gera os seus proprios a partir dos dominios configurados, e para
esses precisa de descobrir a porta. Sem expose e sem porta fixada no dominio,
cai nos 80, onde nada escuta: ligacao recusada, 502 em todos os hostnames, com
o contentor perfeitamente saudavel e o Coolify a reportar running:healthy.
Aconteceu agora porque a migracao de servidor de 2026-09-02 recriou a
aplicacao e a afinacao anterior perdeu-se. O expose deixa a porta descoberta
sozinha, para nao depender de uma configuracao que vive fora do repositorio.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Carregar, enquadrar num circulo com zoom e arrasto, e guardar. A foto aparece
na barra lateral, na lista de mensagens e no leitor, caindo para as iniciais
quando nao ha nenhuma.
A geometria vai em funcoes puras com testes, porque e onde os erros moram: a
escala minima cobre o quadrado em vez de caber nele (caber deixa um canto
vazio e o ficheiro exportado leva uma cunha transparente), e o deslocamento e
preso a saliencia da imagem para que nenhum arrasto consiga trazer uma borda
para dentro do circulo. Uma dimensao NaN -- o que uma imagem que nao
descodificou reporta -- passava pelo guarda `<= 0`, porque NaN falha qualquer
comparacao, e propagava-se ate apagar o editor.
O recorte e exportado a partir dos pixeis da origem e nao da pre-visualizacao,
por isso a foto guardada e tao nitida quanto o original permite em vez de
ficar limitada ao tamanho do editor no ecra.
Do lado do servidor o tipo e decidido pelos bytes, nunca pelo Content-Type nem
pelo nome do ficheiro, que sao ambos escolhidos por quem carrega. SVG e
recusado apesar de ser imagem: e um documento que pode levar script e seria
servido de volta a partir da nossa propria origem.
A leitura exige sessao, verificada na propria rota porque o guarda de prefixo
so cobre /user/ e /admin/. Um endpoint aberto indexado por endereco responde
"esta caixa existe" a quem quer que pergunte, para todos os enderecos que
quiser experimentar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
O README dizia apenas que DANE estava desligado a menos que TLS_MODE=acme.
Passa a explicar o que a instalacao ensinou: um registo TLSA que nao
corresponde a chave servida nao degrada a entrega, para-a em qualquer
remetente que valide.
As duas pre-condicoes ficam escritas -- DNSSEC com o DS publicado no
registrador, e TLS_MODE=acme para que o servidor seja dono da chave e possa
publicar a associacao da seguinte antes de trocar -- e a variavel tem o nome
correcto, OUTBOUND_DANE_ENABLED.
Fica tambem registado que o digest mostrado em /admin/security e o registo
TLSA no DNS tem de ser o mesmo numero, porque quando divergem o correio e
recusado e mais nada diz porque.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Duas operacoes que so existiam pela linha de comandos, e que sao precisamente
as que alguem pede ao telefone: esqueci-me da palavra-passe, perdi o
autenticador.
A reposicao termina todas as sessoes daquele utilizador, incluindo a que esta
aberta nesse momento. Um administrador a repor uma palavra-passe esta a fazer
uma passagem de conta ou a responder a um comprometimento, e nos dois casos as
sessoes abertas com a palavra-passe anterior nao podem sobreviver. Exige os
mesmos 12 caracteres que os outros caminhos, para a consola nao ser a maneira
de contornar a regra.
Remover o segundo factor apaga tambem os codigos de recuperacao: foram
gerados contra o segredo antigo, nao servem contra um novo, e deixa-los para
tras e um segundo conjunto de credenciais que ninguem esta a seguir.
A rota do /totp teve de ficar acima do DELETE generico de /mailboxes/. O
generico apanha qualquer caminho por baixo desse prefixo, por isso carregar em
"remover segundo factor" era encaminhado para apagar a caixa do utilizador --
so nao a apagou porque o id saia malformado e o Postgres recusou o uuid. Ha um
teste so para essa ordem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Criar "lucas@cienciaembarcada.com.br" criava "lucas@lucasrguerra.dev.br". O
formulario partia o endereco e deitava fora a metade do dominio, e nao enviava
domain_id nenhum -- o servidor caia entao no dominio do admin com sessao
iniciada. Passa a ser parte local mais um dominio escolhido de uma lista: a
metade que decide o endereco deixa de poder ser descartada em silencio.
Apelidos de outros dominios estavam escondidos, e a culpa era minha: procurava
so no dominio onde a morada do proprio utilizador vive. Uma caixa recebe
postmaster, abuse, dmarc e tlsrpt de varios dominios ao mesmo tempo, por isso
o painel mostrava quatro onde entregavam oito. Ha um teste que falha com a
consulta antiga.
Os apelidos passam a poder ser criados e removidos dentro do modal. O dominio
sai do endereco escrito, para nao repetir a armadilha do formulario de cima.
USER, DOMAIN_ADMIN e SUPER_ADMIN eram constantes impressas no ecra, logo
ficavam em ingles em qualquer idioma. Um papel desconhecido continua a ser
mostrado tal como esta guardado, porque quem o ve precisa de saber o que a
base de dados tem.
A quota estava certa e mal mostrada. used_bytes vale 140771 -- os 137,5 KB
reais -- mas a pagina arredondava para megabytes inteiros, portanto qualquer
coisa abaixo de 512 KB lia-se "0 / 2 MB" e a barra ficava vazia, calculada
sobre os megabytes ja arredondados. Passa a formatar a partir dos bytes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
O lang-lint exige fonte ASCII e o CI apanhou-o. Passa a ser um escape
Unicode, como os restantes casos em que o caractere e o ponto.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- fila e utilizadores passam a pedir uma pagina de cada vez, com pesquisa e
filtros; nada e cortado no browser, porque o que faltava nunca chegou a ser
buscado. A pesquisa espera que se pare de escrever: cada tecla seria mais
uma consulta, e as respostas podem chegar fora de ordem
- pageWindow e pageRange tem testes proprios: uma pagina fora do intervalo vem
de um URL velho ou de uma lista que encolheu, e nao pode dar um paginador
vazio ou negativo, nem dizer "76 a 100 de 82"
- editar utilizador: papel, quota, idioma, com os apelidos que entregam
naquela caixa a mostra no mesmo painel
- "Caixas de e-mail" passa a chamar-se Utilizadores, que e o que sao
Pagina nova /admin/tests: verificacoes que o operador corre a mao, sobre o
servidor ou sobre um utilizador. Todas leem estado; nenhuma envia correio nem
altera definicoes -- uma pagina de diagnostico que mexe naquilo que
diagnostica e pior do que nao existir.
As regras de cada verificacao sao funcoes puras com testes, separadas da
recolha dos factos. Alguns casos que estes testes fixam: uma quota zero
significa sem limite, e um racio marcaria todas as caixas ilimitadas como
cheias; um bloqueio ja expirado nao bloqueia ninguem e nao deve mandar
ninguem investigar; e uma resposta de ausencia ligada com o periodo ja
passado esta ligada sem fazer nada, que e o que parece avariado a quem a pos.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
As listas do painel respondiam com um LIMIT fixo e nenhum filtro: 200 caixas,
50 trabalhos na fila. Numa instalacao maior nao havia forma de chegar ao
resto, e a fila so podia ser inspeccionada pela cabeca -- justamente quando
esta acumulada e que alguem precisa de olhar para ela.
- parsePageParams com testes proprios: pagina e tamanho tem de ser inteiros
positivos, o tamanho e limitado, e um valor invalido cai no seguro em vez de
chegar ao SQL como NaN, LIMIT negativo ou OFFSET fraccionario
- o termo de pesquisa vai como parametro ligado, nunca interpolado; ha um
teste que tenta terminar a instrucao e largar a tabela
- a contagem usa o mesmo filtro da pagina, senao o numero de paginas descreve
um conjunto de linhas diferente do que e mostrado
- PATCH /admin/mailboxes/:id edita papel, quota, idioma, estado e liberta um
bloqueio; so um super admin pode conceder super admin, ou um admin de
dominio promovia-se para fora do seu proprio dominio
Apelidos por utilizador: um apelido nao pertence a um utilizador, pertence ao
dominio e leva uma lista de destinos que pode incluir enderecos noutros
servidores, e um catch-all e do dominio inteiro. O que pode ser mostrado
dentro de um utilizador sao os apelidos que entregam na caixa dele, e e isso
que /admin/mailboxes/:id/aliases responde.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/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>
Na lista de dominios, o botao de cada linha dizia "Adicionar" para um dominio
que ja estava la, e "Status" depois de clicado. Nao faz nem uma coisa nem
outra: escolhe sobre qual dominio as accoes de baixo actuam. Passa a dizer
Selecionar / Selecionado.
Removida tambem a string reconcileNotImplemented, que afirmava que a
verificacao de registos individuais ainda nao existia. Existe desde que o
endpoint de verify passou a consultar resolvedores publicos, e ja nao estava
a ser usada em lado nenhum.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Mostrava tambem o tamanho de cada pasta. Sao numeros que nao mudam e
que ninguem esta a espera: enchiam a barra sem dizer nada de util.
Agora aparece um numero apenas onde ha correio por ler, e nada nas
restantes.
Consequencia a saber: Rascunhos nunca tem mensagens por ler - nada
marca um rascunho como lido - portanto passa a nao ter numero nenhum.
Quem quiser saber quantos rascunhos tem, ve-o no cabecalho da pasta.
O tamanho continua no tooltip do link, e o cabecalho da lista continua
a dize-lo por extenso, portanto a informacao nao se perdeu - deixou
apenas de estar sempre no campo de visao.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Encontrado a validar na produção, depois do deploy: o selector de pasta
de destino na tela de regras mostrava apenas INBOX, Archive, Junk e
Trash - exactamente sem as pastas que o utilizador tinha criado, que
são aquelas para que uma regra de arquivo costuma ser escrita.
A tela de regras escolhe um destino sem nenhuma mensagem aberta, por
isso não tem "pasta actual" para excluir e passava uma string vazia.
Uma pasta criada pelo utilizador não tem papel special-use, portanto
"role === current" era verdadeiro para todas elas de uma vez.
A exclusão passa a só se aplicar quando há de facto uma pasta actual.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Um deploy recriava os contentores mas continuava a correr o código
antigo. "latest" é uma tag móvel, e o Docker considera satisfeita
qualquer tag que já tenha localmente: o compose pedia
protocols:latest, o host já tinha uma imagem com esse nome, e a que o
Release acabara de publicar nunca era descarregada. O deploy terminava
com sucesso, os contentores reiniciavam saudáveis, e nada do que
estava a correr mudava.
Foi assim que várias correções que passaram no CI nunca chegaram ao
site: a rota /api/v1/mail/delete respondia 404 em produção enquanto
existia no código e nos testes, e a pasta Reports existia na base de
dados - a migração corre a partir da imagem certa - sem existir em
nenhuma das interfaces.
pull_policy: always nos seis serviços que consomem imagens
publicadas. Os que usam imagens de terceiros com versão fixa
(postgres:16-alpine, redis:7-alpine, clamav) ficam como estão: aí a
tag identifica um conteúdo imutável e voltar a puxar não acrescenta
nada.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Todos estes foram reproduzidos na interface real, com a conta em
mail.lucasrguerra.dev.br, e não em teste.
Contadores das pastas. O badge caía para o total sempre que não havia
nada por ler, no mesmo lugar e no mesmo estilo do contador de não
lidas: uma caixa de entrada com catorze mensagens lidas mostrava "14",
e ler mais uma nunca movia o número - ele nunca esteve contando o que
o leitor pensava. Os dois números agora são visualmente distintos e o
de não lidas só aparece quando existe mensagem por ler.
Em Rascunhos o badge e o total são o mesmo número por definição, então
o sidebar imprimia-o duas vezes: "Rascunhos 1 1". A regra passou para
folderBadge, onde é testada, e o tamanho da pasta é renderizado num
único sítio.
Ações do leitor. Todas as mensagens ofereciam os mesmos quatro botões,
onde a pasta devia decidir. Um e-mail que o próprio utilizador enviou
oferecia "marcar como não lida" - uma flag que nada lê numa pasta onde
nada é entregue - e um rascunho por acabar oferecia Responder e
Encaminhar, mas nenhuma forma de o terminar. Abrir um rascunho agora
abre o compositor com o que estava escrito e adota o seu UID, para que
continuar a escrever substitua esse rascunho em vez de deixar o antigo
para trás. Em Lixeira o botão de apagar diz que apaga em definitivo,
porque é o que faz.
Anexos. t("mail.attachments") era chamado sem o parâmetro que a string
declara, e a interface mostrava literalmente "Anexos ({count})" nos
três idiomas. Na lista, que sabe que há anexos mas não quantos, passa a
nomear o facto em vez de imprimir uma contagem que não tem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- 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 previous commit put four literal non-ASCII characters into source, which
lang-lint rejects (PLAN.md 21.1: all code in English, ASCII-only, enforced by
CI rather than review). Nine lines failed.
The brand mark in the four rails and sign-in cards was a literal lambda, and
the domains screen's meta line joined DMARC, MTA-STS and DANE with a literal
middot. Both are now \u escapes, so the source is ASCII and the rendered page
is unchanged - the served sign-in page emits ce bb for the mark, and the
domains chunk carries the middot as \xb7.
globals.css goes with them. The linter does not scan .css today, so this was
passing, but the stylesheet was the only file in the tree using box-drawing
section rules and em dashes, and there is no reason for it to be the
exception - ASCII hyphens read the same in a comment.
Verified: lang-lint clean, 98 tests green, tsc clean, production build clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The webmail was indigo over glass and the console was green over glass, so the
two halves of one product did not look related, and neither followed a system
anything could be checked against. This replaces the interface with the
Nocturne design system from Claude Design: a near-neutral blue-grey ground,
Inter at weight 500, solid surfaces with a 1px inset edge instead of blur, and
a single blurple accent used as a line, a mark and a glow rather than a flood.
The palette is remapped at the source rather than renamed at every call site.
tailwind.config.js points slate, indigo, emerald, teal and the rest at the
Nocturne ramps, and globals.css carries the same values as variables, so a
utility class and a token cannot drift apart and no colour can enter the
interface from outside the system. The console's green is gone because it
resolves to the one accent; that was the point.
Structurally, both surfaces now share one rail - the same brand mark, the same
3px accent mark on the active item, the same account tile - with a segmented
control for crossing between them. Crossing into the console still goes to the
step-up rather than the admin sign-in, and leaving it is still a plain link.
Settings moves from eight stacked panels to a tab strip, so enabling a second
factor and writing a filter are no longer separated by everything else.
Nothing is sized by the length of its text any more. Labels wrap, tab strips
wrap instead of scrolling, subjects and queue diagnostics wrap instead of
truncating, and the message list's snippet is clamped to two lines rather than
one. That is what broke when the interface was read in Portuguese or Spanish.
Defects found while doing it:
- The queue and DMARC screens styled their status pills with "badge-danger",
"badge-warning" and "badge-verified" - three class names no stylesheet in
this project defines - so every verdict rendered as identical unstyled
text. They are Badge variants now, which exist.
- The DMARC screen printed the literal string "quarantine" as the
enforcement level whatever the domains publish. The report endpoint returns
no policy at all, so it was asserting a configuration nobody had made. It
shows the DMARC pass ratio it genuinely has.
- Half the mailboxes screen and two lines of the audit trace were hardcoded
strings, some Portuguese and some English, so they stayed in one language
whatever the interface was set to. They come from the bundle now, with the
new keys added to all three locales.
- Avatars printed email[0], so every account at one domain collapsed onto the
same letter. initialsFor takes two letters from the local part's own word
boundaries, behind tests.
Trivy reported one HIGH: nanoid 3.3.16, pulled in transitively by postcss,
vulnerable to a denial of service via an infinite loop in ID generation
(CVE-2026-67213). Pinned to ^3.3.18 through the overrides block the package
already uses for sharp and postcss.
Verified: 98 webmail tests green, tsc clean, production build clean, and the
compiled stylesheet serves the Nocturne values with no stock Tailwind indigo,
emerald or slate hex left in it. Trivy fs with the CI scanners, severities and
skip-dirs exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The endpoint added in #32 lives on the protocols service, but the proxy only
forwarded admin/dkim and admin/tls there - so admin/dns went to the auth
service, which has no such route and answers 401 from its admin guard. The
console would have reported "not authorised" for a request that was simply
sent to the wrong process.
It survived my own check because an unauthenticated probe of a nonexistent
admin path returns 401 too: the guard runs before routing, so 401 proves
nothing about whether a route exists. Comparing against a path I knew was fake
is what exposed it.
The list is now named and commented, and a test asserts every protocols-owned
area appears in it, because the failure mode reads as a session problem rather
than a routing one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>