#19: fix F1-F3 defects found auditing the phases against PLAN.md

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>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 07:57:22 -03:00
1 parent 2e0e65b167
commit 33d91fa247
23 files changed
+906 -139

No files matched your search

@@ -19,12 +19,22 @@ type PersistInboundMessageInput struct {
DMARCResult string
}
// InboundMessageRepository durably records an accepted inbound message for
// one recipient: allocates the next folder UID, inserts the email_messages
// InboundMessageRepository durably records an accepted inbound message: for
// each recipient it allocates the next folder UID, inserts the email_messages
// row, increments the blob's ref_count, and writes the transactional outbox
// event - all in a single transaction (PLAN.md section 6.1, section 9.4).
// event (PLAN.md section 6.1, section 9.4).
type InboundMessageRepository interface {
// Persist returns the allocated IMAP UID on success. The caller may only
// report success to the SMTP client after this returns without error.
Persist(ctx context.Context, input PersistInboundMessageInput) (uid int64, err error)
// PersistAll records every recipient's copy in a single transaction and
// returns the allocated IMAP UIDs, in the order the inputs were given.
//
// One transaction for the whole delivery is a correctness requirement,
// not an optimisation. An alias fanning out to several mailboxes is one
// SMTP transaction with one reply: if the second insert failed after the
// first had committed, the sender would be told to retry and the first
// recipient would receive the message twice. Either all recipients get
// it, or the sender is asked to try again.
//
// The caller may only report success to the SMTP client after this
// returns without error.
PersistAll(ctx context.Context, inputs []PersistInboundMessageInput) (uids []int64, err error)
}
@@ -229,7 +229,7 @@ func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject.
return
}
dsnBytes, isLoop := valueobject.BuildDsnReport(action, job.EnvelopeFrom, job.EnvelopeTo, job.ID.String(), reason)
dsnBytes, isLoop := valueobject.BuildDsnReport(action, w.localHost, job.EnvelopeFrom, job.EnvelopeTo, job.ID.String(), reason)
if isLoop || dsnBytes == nil {
return
}
@@ -252,7 +252,7 @@ func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject.
}
_ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
Sender: "postmaster@lambdamail.local",
Sender: "postmaster@" + w.localHost,
Recipients: targets,
RecipientAddresses: addresses,
Body: bytes.NewReader(dsnBytes),
@@ -171,7 +171,7 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
// having published it. Quarantine is left to the spam filter, which
// files rather than refuses.
if authResult.DMARC == port.AuthResultFail && authResult.DmarcPolicy == "reject" {
return errors.New("550 5.7.1 DMARC policy rejects this message")
return rejectDmarc()
}
if authResult.AuthenticationResults != "" {
@@ -212,11 +212,11 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
if scanRes != nil {
switch scanRes.Verdict {
case valueobject.ScanVerdictVirusReject:
return errors.New("554 5.7.1 Virus detected: " + scanRes.VirusName)
return rejectVirus(scanRes.VirusName)
case valueobject.ScanVerdictSpamReject:
return errors.New("554 5.7.1 Spam threshold exceeded")
return rejectSpam()
case valueobject.ScanVerdictGreylist:
return errors.New("451 4.7.1 Greylisted, please try again later")
return deferGreylisted()
case valueobject.ScanVerdictSpamJunk:
targetFolder = "Junk"
}
@@ -229,8 +229,14 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
return err
}
// Every recipient is handed to the repository at once so the whole
// delivery commits or none of it does. Persisting them one call at a time
// would mean a failure halfway through leaves the earlier recipients with
// a copy while the sender is told to retry - and the retry delivers to
// them a second time.
persistInputs := make([]port.PersistInboundMessageInput, len(input.Recipients))
for i, recipient := range input.Recipients {
_, err := uc.messages.Persist(ctx, port.PersistInboundMessageInput{
persistInputs[i] = port.PersistInboundMessageInput{
MailboxID: recipient.ID,
Blob: blob,
SenderAddress: input.Sender,
@@ -239,11 +245,16 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
SPFResult: authResult.SPF,
DKIMResult: authResult.DKIM,
DMARCResult: authResult.DMARC,
})
if err != nil {
return err
}
}
if _, err := uc.messages.PersistAll(ctx, persistInputs); err != nil {
return err
}
// Notifications happen only after the commit: an IDLE client told about a
// message that then rolled back would show one that does not exist.
for _, recipient := range input.Recipients {
if uc.trackerManager != nil && uc.folders != nil {
folderRec, err := uc.folders.FindByName(ctx, recipient.ID.String(), targetFolder)
if err == nil && folderRec != nil {
@@ -105,15 +105,31 @@ func (s *stubBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobRef, e
return s.ref, s.err
}
// errRepositoryFailure stands in for any persistence error.
var errRepositoryFailure = errors.New("repository is unavailable")
type recordingMessageRepository struct {
persisted []port.PersistInboundMessageInput
nextUID int64
// failAfter makes PersistAll reject a batch larger than this many
// recipients, standing in for a partial write in the real repository.
failAfter int
}
func (r *recordingMessageRepository) Persist(_ context.Context, input port.PersistInboundMessageInput) (int64, error) {
r.persisted = append(r.persisted, input)
r.nextUID++
return r.nextUID, nil
func (r *recordingMessageRepository) PersistAll(_ context.Context, inputs []port.PersistInboundMessageInput) ([]int64, error) {
// The real repository commits once for the whole batch, so a failure
// records nothing. Mirroring that here keeps the mock honest.
if r.failAfter > 0 && len(inputs) > r.failAfter {
return nil, errRepositoryFailure
}
uids := make([]int64, 0, len(inputs))
for _, input := range inputs {
r.persisted = append(r.persisted, input)
r.nextUID++
uids = append(uids, r.nextUID)
}
return uids, nil
}
func TestUseCase_Handle_StoresBlobOnceAndPersistsOncePerRecipient(t *testing.T) {
@@ -171,3 +187,31 @@ func TestUseCase_Handle_PropagatesBlobStorageError(t *testing.T) {
t.Fatal("expected error when blob storage fails, got nil")
}
}
// One SMTP transaction gets one reply, so a fan-out delivery has to be
// all-or-nothing: if the sender is told to retry, no recipient may already
// hold a copy, or the retry duplicates it for them.
func TestUseCase_Handle_DoesNotPartiallyDeliverOnFanOut(t *testing.T) {
blobs := &stubBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "abc123", SizeBytes: 42}}
messages := &recordingMessageRepository{failAfter: 1}
uc := NewProcessInboundEmailUseCase(nil, blobs, messages)
recipients := []port.MailboxRecord{
{ID: uuid.New()},
{ID: uuid.New()},
}
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Sender: "sender@remote.test",
Recipients: recipients,
RecipientAddresses: []string{"alias@example.test", "alias@example.test"},
Body: bytes.NewBufferString("Subject: hi\r\n\r\nbody\r\n"),
})
if err == nil {
t.Fatal("Handle reported success even though persistence failed")
}
if len(messages.persisted) != 0 {
t.Errorf("%d recipients were left with a copy after a failed delivery; the sender's retry would duplicate it", len(messages.persisted))
}
}
@@ -0,0 +1,55 @@
package usecase
import "fmt"
// SmtpRejection is a use case's decision to refuse a message, carrying the
// reply the SMTP layer must send back.
//
// The decision belongs to the application layer - only it knows that a virus
// is permanent and a greylist is temporary - but the wire format belongs to
// the presentation layer. This type is the seam: the use case names the
// outcome, the adapter renders it. Encoding the reply in the error *text* and
// re-parsing it downstream would make a reworded message silently change the
// code a remote MTA receives, turning a permanent refusal into a retry loop.
type SmtpRejection struct {
// Code is the three-digit reply code (RFC 5321 section 4.2.1).
Code int
// EnhancedCode is the class.subject.detail triplet of RFC 3463.
EnhancedCode [3]int
// Message is the human-readable text, without any code prefix.
Message string
}
func (e *SmtpRejection) Error() string {
return fmt.Sprintf("%d %d.%d.%d %s",
e.Code, e.EnhancedCode[0], e.EnhancedCode[1], e.EnhancedCode[2], e.Message)
}
// IsPermanent reports whether the sender must give up rather than retry.
func (e *SmtpRejection) IsPermanent() bool {
return e.Code >= 500 && e.Code < 600
}
// rejectDmarc refuses a message whose sender publishes p=reject and whose
// DMARC evaluation failed.
func rejectDmarc() *SmtpRejection {
return &SmtpRejection{Code: 550, EnhancedCode: [3]int{5, 7, 1}, Message: "DMARC policy rejects this message"}
}
// rejectVirus refuses an infected message. It is permanent: the same bytes
// will still be infected on a retry.
func rejectVirus(virusName string) *SmtpRejection {
return &SmtpRejection{Code: 554, EnhancedCode: [3]int{5, 7, 1}, Message: "Virus detected: " + virusName}
}
// rejectSpam refuses a message that scored above the reject threshold.
func rejectSpam() *SmtpRejection {
return &SmtpRejection{Code: 554, EnhancedCode: [3]int{5, 7, 1}, Message: "Spam threshold exceeded"}
}
// deferGreylisted asks the sender to try again. It is deliberately temporary:
// greylisting works precisely because a legitimate MTA retries and a spam
// cannon does not.
func deferGreylisted() *SmtpRejection {
return &SmtpRejection{Code: 451, EnhancedCode: [3]int{4, 7, 1}, Message: "Greylisted, please try again later"}
}