Release now pushes to git.lucasrguerra.dev.br instead of ghcr.io, tags each
architecture separately (no artifact upload, no gha cache) and joins them with
imagetools. GITHUB_OUTPUT and GITHUB_STEP_SUMMARY stay: they are the runner's
own interface and the GITEA_* equivalents are not set.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A fresh deployment published "v=DKIM1; k=rsa; p=" for its own domain. An empty
p= is not an absent key, it is a revoked one (RFC 6376 section 3.6.1): every
verifier reading it treats all signatures from that selector as invalid. On a
domain whose DMARC policy is p=quarantine with strict alignment - which this
reconciler generates - that quarantines all of its own outbound mail. Worse
than publishing nothing.
The chain: DkimRepository.Insert is an INSERT ... SELECT conditional on the
domain existing in the domains table, so on an empty database it inserts no
rows and returns no error. FindPublicKey then finds nothing, ensure() returns
an empty string with no error, and the reconciler faithfully published the
empty value. Every field along that path was behaving as written; nothing
checked that the result meant anything.
Two changes:
- The record builder omits a DKIM record when its key is empty, so the
revocation cannot be published regardless of why the key is missing.
- Provisioning logs when it produced no key, naming the likely cause. The
insert being conditional on an onboarded domain is exactly the case a new
operator hits, and "no key, domain probably not onboarded" is the difference
between a diagnosis and unsigned mail with no explanation. It stays
non-fatal: erroring here would abort the whole reconcile and leave a new
install with no A, MX or SPF records either.
Found while migrating to a new host: the fresh database had no domain row, so
the first reconcile revoked the domain's DKIM. The two published records have
been deleted from the zone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SpfInclude derived the mechanism by reducing the relay hostname to its
organisational domain. That is unsound, and Brevo is the case that proves it:
the relay is smtp-relay.brevo.com, so the reduction yields
"include:brevo.com" - but brevo.com publishes the SPF of Brevo's own
corporate mail (Google Workspace and Zendesk). The sending ranges live at
spf.brevo.com.
Publishing the wrong include is worse than publishing none. It authorises
Google to send for the domain while leaving the relay unauthorised, so every
message the relay forwards fails SPF - and against the "-all" policy this
reconciler generates, is rejected rather than merely marked.
RELAY_SPF_INCLUDE now carries whatever the provider documents. A bare domain
gets the "include:" prefix; any other SPF term (ip4:, a:, mx:) passes through
untouched, since not every provider publishes an include. The old reduction
stays as a fallback for relays whose hostname does happen to match, because
publishing something plausible beats publishing nothing at all.
Caught before configuring a relay for real, by checking what brevo.com and
spf.brevo.com actually serve rather than trusting the heuristic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>