The photo endpoint was cached for five minutes with no revalidation, and only
the settings page added a version to the URL, so the header, the message list
and the reader kept the old photo until it expired.
- every <Avatar> reads a per-address version (and refetches when it changes);
saving or removing a photo bumps it, in this tab and the other open tabs
- the server answers private, max-age=60, must-revalidate, so a stale copy
lives for a minute at most and then costs one 304
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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 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>
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>
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>
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>
/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>
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>
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>
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>
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>
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>
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>
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>
Enrolling a second factor could reach a state where the authenticator showed
perfectly valid codes and every one of them was rejected, with the only
feedback being "TOTP verification failed".
The browser holds the enrolment secret in component state alone. Reloading the
settings page - or leaving and coming back - brought the "enable" button back,
and pressing it called the enrol endpoint again, which deleted the pending row
and stored a brand new secret. The entry already saved in the authenticator
then belonged to a secret the server no longer had. Nothing about that is
visible from either side: the app generates codes correctly, the server
compares them correctly, and they are for different keys.
Enrolment now resumes. The endpoint returns the pending secret if there is
one, so the QR shown after a reload is the same one already scanned and
rescanning is harmless. Starting over is still possible - ?reset=1, exposed in
the settings screen - because someone who deleted the entry from their app
needs a secret they can actually generate codes for. A pending row that can no
longer be decrypted is replaced rather than resumed, since nobody could ever
confirm it.
Also: the submitted code now has every non-digit stripped rather than just
surrounding whitespace. Authenticators display the code grouped as "123 456",
and a value typed or pasted with that space failed the six-character length
check before it was ever compared - which, to someone reading the right number
off their phone, looks like the server being wrong. The inputs sanitise as you
type for the same reason.
Ruled out first, rather than guessed at: the algorithm passes all six RFC 6238
reference vectors, base32 round-trips, and the deployment's clock is exactly in
sync with real time, so neither the implementation nor drift explains it.
Verified: the resume test fails without the change and passes with it; 47 auth
tests green against a real Postgres.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 interface was reporting things that were not true. Several screens were
mockups wired to nothing, and a reader with the console in Portuguese was
served a mixture of two languages.
Fabricated data, removed:
- The outbound queue was two invented jobs held in component state. Retry and
Freeze only edited that local array, so the screen showed the same two rows
on every deployment and reported success for actions that never happened.
"Freeze" named an operation the backend has never implemented. It now reads
/api/v1/admin/queue, which was already serving the real thing, and offers
retry and cancel, which exist.
- Every dashboard KPI fell back to an invented constant (1420 received, 890
sent, 3 queued, 18.4% disk), so an unreachable API produced a dashboard of
plausible traffic for a server that might have delivered nothing. Two of
those fields were never returned by the endpoint at all, so the fake disk
figure was what everyone always saw. DMARC did the same with 1250/98%/97%.
- The domains screen showed thirteen DNS records for example.com, all marked
VERIFIED, and posted {domain_id: "default-domain"} to endpoints expecting a
real id. DKIM rotation sends "domain", not "domain_id", so its 400 was
swallowed and a made-up TXT record displayed as though a key had been
issued: rotation had never once worked from this screen.
- The TLS panel asserted a Let's Encrypt RSA 4096 certificate and verified
DANE from static markup, on a deployment currently serving a self-signed
one. It now reads the cert watcher via /api/v1/admin/tls.
- The readiness scorecard printed "10 / 10 all checks passed" regardless of
what preflight reported, including failures.
- A message trace that found nothing invented a five-step delivery - TLS 1.3
accepted, SPF pass, spam score 0.8 - for a message that does not exist.
- Both sidebars showed a fixed admin@lambdamail.local with a hardcoded
"SUPER_ADMIN (2FA)", claiming a second factor whether or not one existed.
- Settings seeded a "[Boletim]" Sieve rule. Saving posts the whole list, so
the first edit wrote that invented filter into the account's real script.
The reconcile endpoint itself answered {records_verified: 13, status:
"VERIFIED"} for any domain id, including one that does not exist, without
resolving a name. It now reports what it does, which is record the request.
Surfaces:
The root was a chooser listing webmail and the console side by side, which
asked visitors a question they should not have to answer and advertised cookie
names and audiences to anonymous readers. It now redirects into the mail
client. Accounts that may administer the server get a link to the console from
inside the app - a link to /admin/login, so crossing over still costs the
second factor the admin audience requires - and the console gets a plain link
back, since the webmail session was never given up to get there.
Enrolment:
Both enrolment screens printed the base32 secret as text and left the operator
to type 32 characters into their phone. The enrol endpoint already returned an
otpauth:// URI; it simply was not drawn. Added a shared component that renders
it as a QR locally - the seed is already in the page, and sending it to an
image service would put it in another request log for nothing.
The recovery-code step rendered its own "sign in" button while the form's
submit button stayed visible below it: two identical buttons, and no way to
copy the codes it insisted you save. It is now terminal, with copy and
continue.
Language:
translate() had no interpolation, so "Undo Send ({seconds}s remaining)" showed
the placeholder and a second hardcoded countdown was appended after it. It now
substitutes, leaving an unmatched token visible rather than blanking it.
The pt-BR bundle had lost its diacritics - "Destinatario", "Ultimo erro",
"Acoes" - from the ASCII-source rule being applied to the translations, which
are data. Restored, and the three bundles are back in key parity at 270 each,
with roughly 120 strings that were hardcoded in the source now keyed.
Healthchecks:
web had none, despite being the only service users reach directly: a wedged
Next process kept its container running and Traefik kept routing to it. Added,
using node's own fetch since the image ships no curl. rspamd and clamav gained
the timeouts and start_periods whose absence would fail them during startup.
migrate stays without one and says why: it is a one-shot job, and a probe
would report unhealthy exactly when it has succeeded.
Verified: 36 webmail tests and 41 auth tests against a real Postgres, both new
admin/me cases confirmed to fail without the route (404). lang-lint clean,
production build clean, 0 vulnerabilities, compose validates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The deployment sets NODE_ENV=production, and npm omits devDependencies
when it is set - so "npm ci" in the build stage installed no typescript
and "npm run build" failed with exit 127, command not found. The first
Coolify deployment died there.
The build stage now asks for them explicitly rather than depending on the
environment it happens to run in. Reproduced by building all three images
with NODE_ENV=production before and after.
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 list rendered two hardcoded messages and the reader showed their
canned HTML; compose ran a countdown and then navigated away without ever
submitting anything. Both now use the API, including search, the unread
badges in the sidebar (which were the literals "3" and "1", so an empty
inbox still advertised unread mail), and marking a message read on open.
The undo window now means what it says: the message is held in the browser
and only submitted when the countdown elapses, so cancelling it means
nothing was ever queued rather than trying to recall it afterwards.
Message bodies render in an iframe with an empty sandbox - no scripts, no
same-origin - and remote references are stripped until the reader asks for
images, so a tracking pixel cannot fire on open. The previous sandbox
allowed popups.
The admin page titles and the language switcher labels now come from the
bundles. The labels live there rather than in the source because PLAN.md
R9 keeps non-ASCII out of code, and each language should be named the way
that language names it.
ADR-001 assigned the mail API to the TypeScript storage service; the
reasoning for putting it in Go instead is recorded in PLAN.md, which
.gitignore deliberately keeps out of the repository, so that revision
lives only in the working copy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three separate gaps, all of which made the F4 interface non-functional or
unprotected:
The admin console rendered for anyone. There was no middleware and no
guard in the layout, so /admin/dashboard returned 200 unauthenticated -
only its data calls failed. middleware.ts now redirects both surfaces to
their login when the session cookie is missing, expired, carries the wrong
audience, or is a pre-MFA challenge token. It reads the claims without
verifying the signature on purpose: the signing key belongs to the auth
service and must not reach the browser bundle. That is sound because this
is not the boundary - every /api/v1 request is verified for real upstream.
Nothing served /api/v1/*. next.config.ts had no rewrites and there were no
route handlers, so every call the pages made 404'd against the Next
server, while AUTH_SERVICE_URL sat unused. A catch-all route now proxies
to the auth service, forwarding the client address and preserving multiple
Set-Cookie headers, which logout needs to clear both surfaces. Session
cookies also move to Path=/, since a cookie pinned to /user or /admin was
never sent with the /api/v1 requests that needed it.
i18n existed only as files. The three bundles had perfect key parity and
real translations, but no library, no imports and no switcher: the UI was
hardcoded English and i18n.test.ts compared keys while the feature was
absent. There is now a provider, a hook, a language switcher, and
server-side Accept-Language negotiation that honours q-values and matches
a bare "pt" to pt-BR. Logins, both layouts and the settings screen consume
it; the admin pages still have English titles.
The settings screen invented app passwords in the browser with Math.random
and never stored them. It now calls the API, which returns the password
once and keeps only its hash.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>