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>
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>
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>
A fresh deployment published "v=DKIM1; k=rsa; p=" for its own domain. An empty
p= is not an absent key, it is a revoked one (RFC 6376 section 3.6.1): every
verifier reading it treats all signatures from that selector as invalid. On a
domain whose DMARC policy is p=quarantine with strict alignment - which this
reconciler generates - that quarantines all of its own outbound mail. Worse
than publishing nothing.
The chain: DkimRepository.Insert is an INSERT ... SELECT conditional on the
domain existing in the domains table, so on an empty database it inserts no
rows and returns no error. FindPublicKey then finds nothing, ensure() returns
an empty string with no error, and the reconciler faithfully published the
empty value. Every field along that path was behaving as written; nothing
checked that the result meant anything.
Two changes:
- The record builder omits a DKIM record when its key is empty, so the
revocation cannot be published regardless of why the key is missing.
- Provisioning logs when it produced no key, naming the likely cause. The
insert being conditional on an onboarded domain is exactly the case a new
operator hits, and "no key, domain probably not onboarded" is the difference
between a diagnosis and unsigned mail with no explanation. It stays
non-fatal: erroring here would abort the whole reconcile and leave a new
install with no A, MX or SPF records either.
Found while migrating to a new host: the fresh database had no domain row, so
the first reconcile revoked the domain's DKIM. The two published records have
been deleted from the zone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first deployments died building on the target: four images, and the
webmail build alone was enough to sink it on a small arm64 host - the
build step was killed with no BuildKit error, which is what an exhausted
or overloaded machine looks like from the outside.
The images already exist. CI publishes all four to GHCR for amd64 and
arm64 on every merge, and they are the ones the suite ran against, so
rebuilding them on the deploy target is slower, heavier and less
trustworthy than pulling them.
docker-compose.yaml therefore carries no build definitions, and
docker-compose.build.yaml is the overlay that adds them for local
development and for CI - which does have to exercise the branch rather
than the published images. Both paths were run before committing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things stood between this and a real deployment.
A missing JWT_SECRET fell back to a constant that is published in this
repository, so any deployment that forgot the variable would have accepted
session tokens anyone could mint, including an admin one. The auth service
now refuses to start without a secret of at least 32 characters. The test
suites set it through vitest config, because ESM hoists imports and an
assignment inside a test file runs too late.
Session cookies were issued without the Secure attribute: it is driven by
NODE_ENV, which compose never set. Behind a TLS terminator that is a
downgrade waiting to happen.
Nothing created the first mailbox. A fresh install has no domain and no
account, and every route into the console needs one - so there was no
first login. There is now a create-admin command, which also provisions
the standard folders and the four system aliases. It is a deliberate
command rather than a bootstrap on boot: creating an administrator as a
side effect of starting a container is how a deleted account quietly comes
back.
Also: the production images were shipping compiled test files, since the
build compiled everything under src. A separate build config excludes
them while lint still type-checks them.
Documentation. README.md covers what this is, how it is put together, and
what is not finished - stated plainly, because a mail server that half
works loses mail. docs/DEPLOYMENT.md is the Coolify path in the order it
has to happen, starting with the three prerequisites no code can satisfy:
outbound port 25, the PTR record, and the DNSSEC delegation.
docs/OPERATIONS.md is the runbook - health states, certificates, backups,
reading a delivery failure, DKIM rotation, and an explicit list of what is
not automated. .env.production.example replaces guessing which of the dev
defaults are safe: none of them are.
docs/ was gitignored as a place for internal notes; only docs/internal/ is
now, so these guides ship with the code.
Every command the documentation tells an operator to run was executed
against a running stack: create-admin, preflight, the health endpoint, the
published mail ports, and the Secure cookie.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>