Every message was resolved through MX and delivered over SMTP, including one
addressed to a mailbox on this very host. Two users on the same server could
not write to each other without a working outbound port 25 - which the
provider blocks, so on this deployment they could not write to each other at
all. It also made internal mail take a pointless round trip through the public
internet.
The worker now checks whether the recipient resolves to a local mailbox and,
if so, hands the message to the inbound path directly. The check runs ahead of
the relay branch on purpose: sending local mail out to a smarthost only to
have it delivered back is slower and has more ways to fail.
The shortcut is narrow. It relies on ResolveDeliveryTargets, which only
resolves addresses on an active local domain, and a test covers that a remote
recipient still goes out over SMTP - otherwise local delivery would swallow
mail meant for the internet. A resolver error is treated as retry rather than
as not-local: falling through to SMTP on an unknown answer would send to the
internet a message that may belong here. Local delivery is still
content-scanned; a virus between two accounts on one host is no less a virus.
This also surfaced a permissive test double: fakeAliasFanOutRepository claimed
every address was local, which was harmless while nothing consulted it while
routing and wrong the moment something did. It now hosts one address, as the
real repository does, and the DSN fan-out test names it explicitly.
Verified: the local-delivery test fails without the change (the recipient
triggers an MX lookup) and passes with it; 22 Go packages green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every fix here is a case where the code and the plan disagreed, and the
code was wrong.
Certificates (section 8.2, 8.4). The parser documented that it skipped
expired entries but never checked, so an expired certificate would be
served until every handshake failed. LastReload() only advanced when
acme.json changed - and it does not change for sixty days - which made
the timestamp risk R2 monitors indistinguishable from a watcher gone
blind. Expiry is now checked on every pass, LastReload records the check
and LastChange the swap, and the <7d/<24h alerts of section 8.4 exist.
ARC (section 5). buildArcSealer resolved the DKIM key at startup, before
runDnsReconciler had provisioned one. On a first boot ARC was disabled
until someone restarted the process, which the log even admitted while
offering no way for it to start. A LazySealer binds on first use instead.
Health (section 8.4). There was no degraded state at all. /health now
reports one - still 200, because restarting cannot fix a certificate
Traefik never issued.
Bounces. DSNs were sent From postmaster@lambdamail.local, a domain that
does not resolve, so the notification was itself undeliverable. They now
carry the configured mail host, a Message-ID, and Auto-Submitted so a
vacation responder cannot loop against them.
Fan-out atomicity (section 6.1). Persist ran one transaction per
recipient, so a failure halfway through an alias fan-out left earlier
recipients with a copy while the sender was told to retry - and the retry
delivered to them twice. PersistAll commits the whole delivery or none of
it. Verified against Postgres by reverting the repository to the old
behaviour and confirming the new test fails on it.
SMTP replies. The use case encoded replies in error text ("550 5.7.1 ...")
which the adapter re-parsed by prefix, so rewording a message could turn a
permanent refusal into a retry. SmtpRejection carries the decision and the
adapter renders it.
Repository tests. These only run with TEST_DATABASE_URL, which CI does
set - so the job was red: copy and expunge seeded a 68-character digest
into a CHAR(64) column, and three others used fixed digests that collide
with the UNIQUE index on any re-run. A shared testBlobDigest() helper
fixes both; the suite now passes twice in a row against one database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per feature/3-f0-f1-validation's own gap analysis (commit 097fa37):
- InboundMessageRepository.Persist now updates folders.unread_count,
folders.total_count, and mailboxes.used_bytes in the same transaction
as the message insert (PLAN.md section 6.1 "++counters" was a no-op
before this).
- MailboxRecord carries QuotaBytes/UsedBytes; ResolveRecipient rejects
RCPT with 452 4.2.2 when a target mailbox has already reached its
quota (used_bytes >= quota_bytes), before DATA - never silently
accepting mail into a full mailbox.
- MailboxRepository.ResolveDeliveryTargets tries a direct mailbox match
first, then falls back to the aliases table (exact source_address
match takes precedence over a domain catch-all). One RCPT TO can now
fan out to multiple mailboxes; alias destinations that don't resolve
to an internal active mailbox are silently skipped, since outbound/
external forwarding doesn't exist yet. postmaster@/abuse@/dmarc@-style
aliases now actually deliver.
The E2E test's counters_updated subtest flips from a documented KNOWN
GAP (t.Skipf) to a hard assertion now that it passes, and gains two new
subtests (quota_rejected_when_mailbox_full, alias_resolves_to_destination_mailbox)
covering the other two fixes end-to-end. New unit and Postgres
integration test coverage added for all three fixes at every layer.
Verified: full suite green on Windows (unit + E2E via embedded
Postgres, no Docker needed) and via WSL Docker against the real
docker-compose Postgres (all infrastructure/postgres and diskstorage
integration tests, not just E2E). lang-lint clean.
Adds a real :25 SMTP listener to the Go protocols service, giving F1's
"Nucleo SMTP" milestone its first working sub-project: application,
infrastructure, and presentation layers on top of F0's domain-only
Clean Architecture.
- ProcessInboundEmailUseCase resolves recipients against Postgres and
persists accepted mail (blob dedup by content hash, transactional
UID allocation + outbox event, never sending 250 OK before durable
commit per PLAN.md section 6.1).
- Postgres repositories via pgx/v5 (replacing lib/pq entirely).
- Local-disk blob storage with content-hash dedup, rename-before-commit
ordering so a DB row can never reference a file that isn't actually
on disk.
- A local-file TLS cert provider for STARTTLS, with an ephemeral
self-signed fallback so a missing/misconfigured cert degrades
gracefully instead of crash-looping the service (PLAN.md section 8.4).
- A github.com/emersion/go-smtp Backend/Session adapting real SMTP
traffic to the use case: unknown recipients get 550 5.1.1 before
DATA, duplicate RCPT TO for the same address is deduplicated, TLS is
hardened to the project's required PFS-only cipher/curve policy.
- A dev-seed script and Makefile target for local testing.
Explicitly out of scope (separate follow-up sub-projects): alias
resolution, SPF/DKIM/DMARC/ARC verification, Rspamd/ClamAV, Submission
ports with SASL auth, DKIM signing, outbound delivery, and the real
Traefik acme.json cert watcher.
Verified live end-to-end via WSL Docker: the full compose stack (db,
redis, migrate, auth, storage, protocols, web) reaches healthy, and a
real swaks SMTP session is accepted and durably persisted with correct
sequential UIDs, while an unknown recipient is correctly rejected with
550 5.1.1 and zero rows persisted. Built via 10 sequential tasks under
subagent-driven development, each independently reviewed, plus a final
whole-branch review that caught and fixed a TLS-crash bug, a blob
commit-ordering bug, missing TLS hardening, and missing CI integration
coverage before merge.
Known follow-up, not blocking: email_messages' partition only covers
July 2026 and needs a partition-management job before 2026-08-01.