#18: implement SMTP Submission (587/465), SASL PLAIN/LOGIN auth and preflight diagnostics

This commit is contained in:
Lucas Rayan committed 2026-08-01 22:49:02 -03:00
1 parent 7249419efc
commit d56f29d084
70 files changed
+5163 -659

No files matched your search

@@ -0,0 +1,140 @@
package usecase
import (
"bytes"
"context"
"net"
"strings"
"testing"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
)
type stubAuthenticator struct {
result port.InboundAuthResult
seen port.InboundAuthInput
}
func (s *stubAuthenticator) Authenticate(_ context.Context, input port.InboundAuthInput) port.InboundAuthResult {
s.seen = input
return s.result
}
func inboundInput(recipients int) ProcessInboundEmailInput {
in := ProcessInboundEmailInput{
Sender: "sender@sender.test",
Body: bytes.NewBufferString("From: sender@sender.test\r\n\r\nbody"),
}
for i := 0; i < recipients; i++ {
in.Recipients = append(in.Recipients, port.MailboxRecord{ID: uuid.New()})
in.RecipientAddresses = append(in.RecipientAddresses, "rcpt@example.test")
}
return in
}
// The verdicts must reach the database, otherwise the webmail and the DMARC
// dashboard have nothing to show (PLAN.md section 6.1).
func TestUseCase_Handle_PersistsAuthenticationVerdicts(t *testing.T) {
messages := &recordingMessageRepository{}
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
uc.SetAuthenticator(&stubAuthenticator{result: port.InboundAuthResult{
SPF: port.AuthResultPass, DKIM: port.AuthResultPass, DMARC: port.AuthResultPass,
}})
if err := uc.Handle(context.Background(), inboundInput(1)); err != nil {
t.Fatalf("Handle: %v", err)
}
got := messages.persisted[0]
if got.SPFResult != "pass" || got.DKIMResult != "pass" || got.DMARCResult != "pass" {
t.Errorf("stored verdicts = %q/%q/%q, want pass/pass/pass", got.SPFResult, got.DKIMResult, got.DMARCResult)
}
}
// RFC 8601: our own checks must be recorded in the delivered message so the
// spam filter and the user can see them.
func TestUseCase_Handle_PrependsAuthenticationResultsHeader(t *testing.T) {
blobs := &capturingBlobStorage{}
uc := NewProcessInboundEmailUseCase(nil, blobs, &recordingMessageRepository{})
uc.SetAuthenticator(&stubAuthenticator{result: port.InboundAuthResult{
SPF: port.AuthResultPass, DKIM: port.AuthResultNone, DMARC: port.AuthResultPass,
AuthenticationResults: "mail.lambdamail.test; spf=pass; dmarc=pass",
}})
if err := uc.Handle(context.Background(), inboundInput(1)); err != nil {
t.Fatalf("Handle: %v", err)
}
if !strings.HasPrefix(string(blobs.stored), "Authentication-Results: mail.lambdamail.test") {
t.Errorf("stored message does not lead with Authentication-Results:\n%s", blobs.stored)
}
}
// A sender that published p=reject asked for exactly this. Accepting the
// message instead would defeat the policy the sender is paying for.
func TestUseCase_Handle_RejectsOnDmarcFailWithRejectPolicy(t *testing.T) {
messages := &recordingMessageRepository{}
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
uc.SetAuthenticator(&stubAuthenticator{result: port.InboundAuthResult{
SPF: port.AuthResultFail, DKIM: port.AuthResultNone,
DMARC: port.AuthResultFail, DmarcPolicy: "reject",
}})
err := uc.Handle(context.Background(), inboundInput(1))
if err == nil {
t.Fatal("expected the message to be refused")
}
if !strings.HasPrefix(err.Error(), "550 ") {
t.Errorf("error = %q, want a permanent 550 rejection", err)
}
if len(messages.persisted) != 0 {
t.Error("a DMARC-rejected message was stored anyway")
}
}
// Quarantine is a filing decision, not a refusal: the message is accepted and
// the spam filter decides where it lands.
func TestUseCase_Handle_AcceptsOnDmarcFailWithQuarantinePolicy(t *testing.T) {
messages := &recordingMessageRepository{}
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
uc.SetAuthenticator(&stubAuthenticator{result: port.InboundAuthResult{
SPF: port.AuthResultFail, DMARC: port.AuthResultFail, DmarcPolicy: "quarantine",
}})
if err := uc.Handle(context.Background(), inboundInput(1)); err != nil {
t.Fatalf("Handle: %v", err)
}
if len(messages.persisted) != 1 {
t.Errorf("persisted %d messages, want 1", len(messages.persisted))
}
}
// SPF is defined over the peer address and the HELO name, so both have to
// reach the authenticator.
func TestUseCase_Handle_PassesPeerIdentityToAuthenticator(t *testing.T) {
auth := &stubAuthenticator{result: port.InboundAuthResult{SPF: port.AuthResultNone}}
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, &recordingMessageRepository{})
uc.SetAuthenticator(auth)
in := inboundInput(1)
in.ClientIP = parseTestIP("192.0.2.10")
in.HeloDomain = "mx.sender.test"
if err := uc.Handle(context.Background(), in); err != nil {
t.Fatalf("Handle: %v", err)
}
if auth.seen.ClientIP.String() != "192.0.2.10" {
t.Errorf("ClientIP = %v, want 192.0.2.10", auth.seen.ClientIP)
}
if auth.seen.HeloDomain != "mx.sender.test" {
t.Errorf("HeloDomain = %q, want mx.sender.test", auth.seen.HeloDomain)
}
if auth.seen.EnvelopeFrom != "sender@sender.test" {
t.Errorf("EnvelopeFrom = %q", auth.seen.EnvelopeFrom)
}
}
func parseTestIP(s string) net.IP { return net.ParseIP(s) }
@@ -0,0 +1,99 @@
package usecase
import (
"bytes"
"context"
"io"
"strings"
"testing"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/valueobject"
)
type capturingBlobStorage struct {
stored []byte
}
func (c *capturingBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobRef, error) {
b, err := io.ReadAll(r)
c.stored = b
return port.BlobRef{ID: uuid.New(), SizeBytes: int64(len(b))}, err
}
type stubScanner struct {
result *valueobject.ScanResult
}
func (s *stubScanner) Scan(_ context.Context, _ port.ScanInput) (*valueobject.ScanResult, error) {
return s.result, nil
}
// The scan verdict is only actionable by the user - and by their Sieve rules -
// if it reaches the stored message. PLAN.md section 6.1 persists the EML
// together with the verdicts, so the spam headers the scanner produced must be
// present in the delivered message.
func TestUseCase_Handle_WritesScanHeadersIntoStoredMessage(t *testing.T) {
blobs := &capturingBlobStorage{}
scanner := &stubScanner{result: &valueobject.ScanResult{
Verdict: valueobject.ScanVerdictSpamJunk,
Score: 9.5,
HeadersToAdd: map[string]string{
"X-Spam-Flag": "YES",
"X-Spam-Score": "9.50",
},
}}
uc := NewProcessInboundEmailUseCase(nil, blobs, &recordingMessageRepository{})
uc.SetScanner(scanner)
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Sender: "spammer@example.test",
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
RecipientAddresses: []string{"victim@example.test"},
Body: bytes.NewBufferString("Subject: buy now\r\n\r\nbody"),
})
if err != nil {
t.Fatalf("Handle: %v", err)
}
stored := string(blobs.stored)
for _, want := range []string{"X-Spam-Flag: YES", "X-Spam-Score: 9.50"} {
if !strings.Contains(stored, want) {
t.Errorf("stored message is missing %q:\n%s", want, stored)
}
}
if !strings.Contains(stored, "Subject: buy now") {
t.Errorf("original headers were lost:\n%s", stored)
}
// Added headers must precede the original ones and stay inside the header
// block, i.e. before the first empty line.
headerBlock, _, found := strings.Cut(stored, "\r\n\r\n")
if !found {
t.Fatalf("stored message has no header/body separator:\n%s", stored)
}
if !strings.Contains(headerBlock, "X-Spam-Flag: YES") {
t.Errorf("scan headers leaked into the body:\n%s", stored)
}
}
func TestUseCase_Handle_LeavesMessageUntouchedWithoutScanHeaders(t *testing.T) {
blobs := &capturingBlobStorage{}
uc := NewProcessInboundEmailUseCase(nil, blobs, &recordingMessageRepository{})
original := "Subject: hi\r\n\r\nbody"
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
RecipientAddresses: []string{"a@example.test"},
Body: bytes.NewBufferString(original),
})
if err != nil {
t.Fatalf("Handle: %v", err)
}
if string(blobs.stored) != original {
t.Errorf("message was rewritten without a scanner: got %q, want %q", blobs.stored, original)
}
}
@@ -12,10 +12,10 @@ import (
)
var (
ErrScriptNotFound = errors.New("script does not exist")
ErrActiveScriptDelete = errors.New("cannot delete active script")
ErrScriptNotFound = errors.New("script does not exist")
ErrActiveScriptDelete = errors.New("cannot delete active script")
ErrScriptAlreadyExists = errors.New("script with new name already exists")
ErrScriptQuotaExceeded = errors.New("script size exceeds storage limit")
ErrScriptQuotaExceeded = errors.New("script size exceeds storage limit")
)
const MaxScriptSizeBytes = 1024 * 1024 // 1 MB limit per Sieve script
@@ -135,7 +135,7 @@ func TestManageSieveSessionUseCase_Put_Get_List_SetActive_Delete(t *testing.T) {
t.Fatalf("SetActive failed: %v", err)
}
// 6. Delete active script fails per RFC 5804 §2.7
// 6. Delete active script fails per RFC 5804 section 2.7
if err := uc.DeleteScript(ctx, gotID, "main_rules"); !errors.Is(err, ErrActiveScriptDelete) {
t.Fatalf("expected ErrActiveScriptDelete, got %v", err)
}
@@ -0,0 +1,168 @@
package usecase
import (
"bufio"
"context"
"net"
"strings"
"testing"
"time"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/entity"
)
// startFakeMX serves one SMTP conversation, answering MAIL FROM with the given
// reply so the worker can be driven through a real permanent/temporary failure.
func startFakeMX(t *testing.T, mailReply string) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
c.SetDeadline(time.Now().Add(5 * time.Second))
r := bufio.NewReader(c)
c.Write([]byte("220 fake.mx ESMTP\r\n"))
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
c.Write([]byte("250 fake.mx\r\n"))
case strings.HasPrefix(cmd, "MAIL FROM"):
c.Write([]byte(mailReply + "\r\n"))
case strings.HasPrefix(cmd, "QUIT"):
c.Write([]byte("221 Bye\r\n"))
return
default:
c.Write([]byte("250 Ok\r\n"))
}
}
}(conn)
}
}()
return ln.Addr().String()
}
func newTestJob() *entity.OutboundJob {
mbID := uuid.New()
return &entity.OutboundJob{
ID: uuid.New(),
MailboxID: &mbID,
BlobID: uuid.New(),
EnvelopeFrom: "user@domain.test",
EnvelopeTo: "rcpt@remote.test",
DestinationDomain: "remote.test",
Status: entity.OutboundJobStatusQueued,
ExpiresAt: time.Now().Add(5 * 24 * time.Hour),
}
}
// PLAN.md section 6.3: a 5xx is a permanent failure and must bounce
// immediately with a DSN, not sit in the queue retrying for five days.
func TestOutboundWorker_PermanentSmtpFailureBouncesImmediately(t *testing.T) {
addr := startFakeMX(t, "550 5.1.1 User unknown")
repo := &fakeOutboundRepo{}
repo.Enqueue(context.Background(), newTestJob())
worker := NewOutboundWorkerUseCase(
repo,
&fakeMXResolver{hosts: []string{addr}},
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
nil, nil, "mail.local",
)
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
got := repo.jobs[0]
if got.Status != entity.OutboundJobStatusBounced {
t.Errorf("status = %s, want BOUNCED for a 5xx reply", got.Status)
}
if got.LastSmtpCode != "550" {
t.Errorf("LastSmtpCode = %q, want %q", got.LastSmtpCode, "550")
}
}
// A 4xx is temporary and must be deferred for a later retry.
func TestOutboundWorker_TemporarySmtpFailureIsDeferred(t *testing.T) {
addr := startFakeMX(t, "451 4.3.0 Try later")
repo := &fakeOutboundRepo{}
repo.Enqueue(context.Background(), newTestJob())
worker := NewOutboundWorkerUseCase(
repo,
&fakeMXResolver{hosts: []string{addr}},
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
nil, nil, "mail.local",
)
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
got := repo.jobs[0]
if got.Status != entity.OutboundJobStatusDeferred {
t.Errorf("status = %s, want DEFERRED for a 4xx reply", got.Status)
}
if got.LastSmtpCode != "451" {
t.Errorf("LastSmtpCode = %q, want %q", got.LastSmtpCode, "451")
}
}
// The DSN is delivered back to the original sender. When that sender address is
// an alias fanning out to several mailboxes, the recipient records and their
// envelope addresses must stay aligned - otherwise the delivery indexes past
// the end of the address list and takes the worker down.
func TestOutboundWorker_DsnToFanOutSenderDeliversToEveryTarget(t *testing.T) {
addr := startFakeMX(t, "550 5.1.1 User unknown")
repo := &fakeOutboundRepo{}
repo.Enqueue(context.Background(), newTestJob())
mailboxes := &fakeAliasFanOutRepository{targets: []port.MailboxRecord{
{ID: uuid.New(), QuotaBytes: 1000},
{ID: uuid.New(), QuotaBytes: 1000},
}}
messages := &recordingMessageRepository{}
inbound := NewProcessInboundEmailUseCase(mailboxes, &stubBlobStorage{ref: port.BlobRef{ID: uuid.New()}}, messages)
worker := NewOutboundWorkerUseCase(
repo,
&fakeMXResolver{hosts: []string{addr}},
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
inbound, mailboxes, "mail.local",
)
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
if len(messages.persisted) != 2 {
t.Fatalf("DSN persisted to %d mailboxes, want 2 (one per fan-out target)", len(messages.persisted))
}
for i, p := range messages.persisted {
if p.RecipientAddress != "user@domain.test" {
t.Errorf("persisted[%d].RecipientAddress = %q, want the original sender", i, p.RecipientAddress)
}
}
}
@@ -0,0 +1,190 @@
package usecase
import (
"bufio"
"context"
"fmt"
"net"
"strings"
"testing"
"time"
"lambdamail/protocols/internal/domain/entity"
)
// startPlaintextMX serves an SMTP conversation that never offers STARTTLS,
// which is what a downgrade attack looks like from our side.
func startPlaintextMX(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
c.SetDeadline(time.Now().Add(5 * time.Second))
r := bufio.NewReader(c)
c.Write([]byte("220 plain.mx ESMTP\r\n"))
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(cmd, "EHLO"):
// Deliberately no STARTTLS in the extension list.
c.Write([]byte("250-plain.mx\r\n250 SIZE 10240000\r\n"))
case cmd == "DATA":
c.Write([]byte("354 End data with <CR><LF>.<CR><LF>\r\n"))
for {
body, err := r.ReadString('\n')
if err != nil {
return
}
if strings.TrimSpace(body) == "." {
break
}
}
c.Write([]byte("250 2.0.0 Ok: queued\r\n"))
case strings.HasPrefix(cmd, "QUIT"):
c.Write([]byte("221 Bye\r\n"))
return
default:
c.Write([]byte("250 Ok\r\n"))
}
}
}(conn)
}
}()
return ln.Addr().String()
}
type stubPolicyResolver struct {
policy entity.TLSPolicy
err error
}
func (s stubPolicyResolver) Resolve(_ context.Context, _ string, _ string) (entity.TLSPolicy, error) {
return s.policy, s.err
}
func runWorkerWithPolicy(t *testing.T, mxAddr string, resolver stubPolicyResolver) *entity.OutboundJob {
t.Helper()
repo := &fakeOutboundRepo{}
repo.Enqueue(context.Background(), newTestJob())
worker := NewOutboundWorkerUseCase(
repo,
&fakeMXResolver{hosts: []string{mxAddr}},
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
nil, nil, "mail.local",
)
worker.SetPolicyResolver(resolver)
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
return repo.jobs[0]
}
// PLAN.md section 6.2: when the destination's policy demands validated TLS
// and it cannot be had, the message is deferred - never delivered in the
// clear. This is the anti-downgrade guarantee.
func TestOutboundWorker_DefersRatherThanDowngradeUnderEnforcePolicy(t *testing.T) {
addr := startPlaintextMX(t)
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
policy: entity.NewMtaStsPolicy([]string{"*"}, true),
})
if got.Status != entity.OutboundJobStatusDeferred {
t.Errorf("status = %s, want DEFERRED", got.Status)
}
if got.LastSmtpCode != "451" {
t.Errorf("LastSmtpCode = %q, want 451", got.LastSmtpCode)
}
if !strings.Contains(got.LastError, "4.7.5") {
t.Errorf("LastError should carry the 4.7.5 status: %q", got.LastError)
}
if got.TlsPolicyUsed != "none" {
t.Errorf("TlsPolicyUsed = %q, want none", got.TlsPolicyUsed)
}
}
// The same MX, with no policy published, is delivered to over plaintext:
// refusing here would lose legitimate mail (RFC 7435).
func TestOutboundWorker_DeliversOpportunisticallyWithoutPolicy(t *testing.T) {
addr := startPlaintextMX(t)
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
policy: entity.NewTLSPolicy(false, false),
})
if got.Status != entity.OutboundJobStatusDelivered {
t.Errorf("status = %s, want DELIVERED (last error: %s)", got.Status, got.LastError)
}
if got.TlsPolicyUsed != entity.TLSModeOpportunistic {
t.Errorf("TlsPolicyUsed = %q, want opportunistic", got.TlsPolicyUsed)
}
}
// An MX outside the policy's mx set must not be used at all
// (RFC 8461 section 4.1).
func TestOutboundWorker_RefusesMxOutsidePolicy(t *testing.T) {
addr := startPlaintextMX(t)
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
policy: entity.NewMtaStsPolicy([]string{"only.allowed.test"}, true),
})
if got.Status != entity.OutboundJobStatusDeferred {
t.Errorf("status = %s, want DEFERRED", got.Status)
}
if !strings.Contains(got.LastError, "not listed in the MTA-STS policy") {
t.Errorf("LastError = %q, want it to name the policy mismatch", got.LastError)
}
}
// A policy that exists but cannot be evaluated is a reason to wait, not to
// downgrade.
func TestOutboundWorker_DefersWhenPolicyCannotBeResolved(t *testing.T) {
addr := startPlaintextMX(t)
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
err: fmt.Errorf("TLSA records are not DNSSEC-validated"),
})
if got.Status != entity.OutboundJobStatusDeferred {
t.Errorf("status = %s, want DEFERRED", got.Status)
}
if !strings.Contains(got.LastError, "DNSSEC") {
t.Errorf("LastError = %q, want the resolution failure to be recorded", got.LastError)
}
}
// A testing-mode policy reports but must not block delivery.
func TestOutboundWorker_TestingModePolicyStillDelivers(t *testing.T) {
addr := startPlaintextMX(t)
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
policy: entity.NewMtaStsPolicy([]string{"only.allowed.test"}, false),
})
if got.Status != entity.OutboundJobStatusDelivered {
t.Errorf("status = %s, want DELIVERED under mode: testing (last error: %s)", got.Status, got.LastError)
}
if got.TlsPolicyUsed != entity.TLSModeMtaSts {
t.Errorf("TlsPolicyUsed = %q, want mta-sts", got.TlsPolicyUsed)
}
}
@@ -4,9 +4,13 @@ import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/smtp"
"net/textproto"
"strconv"
"strings"
"time"
@@ -15,13 +19,35 @@ import (
"lambdamail/protocols/internal/domain/valueobject"
)
// ErrPolicyDowngradeRefused is returned when a destination's published TLS
// policy could not be satisfied. PLAN.md section 6.2 is explicit: the message
// is deferred with 4.7.5, never delivered over an unvalidated channel.
var ErrPolicyDowngradeRefused = errors.New("outbound: refusing to deliver without the TLS validation the destination requires")
type OutboundWorkerUseCase struct {
outboundRepo port.OutboundRepository
mxResolver port.MXResolver
blobReader port.BlobReader
inboundUC *ProcessInboundEmailUseCase
mailboxes port.MailboxRepository
localHost string
outboundRepo port.OutboundRepository
mxResolver port.MXResolver
blobReader port.BlobReader
inboundUC *ProcessInboundEmailUseCase
mailboxes port.MailboxRepository
policyResolver port.TLSPolicyResolver
relay RelayConfig
localHost string
}
// SetRelay routes delivery through a smarthost (PLAN.md section 10.4). This is
// the fallback for hosts whose provider blocks outbound port 25, and it
// replaces MX resolution entirely: every message goes to the relay.
func (w *OutboundWorkerUseCase) SetRelay(relay RelayConfig) {
w.relay = relay
}
// SetPolicyResolver enables DANE and MTA-STS on the delivery path. Without
// one the worker falls back to opportunistic TLS, which is correct for a
// destination that publishes no policy but must never be the choice for one
// that does.
func (w *OutboundWorkerUseCase) SetPolicyResolver(resolver port.TLSPolicyResolver) {
w.policyResolver = resolver
}
func NewOutboundWorkerUseCase(
@@ -72,6 +98,21 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit
return
}
// With a smarthost every message goes to the relay regardless of
// destination, and the relay owns transport security from there on.
if w.relay.Configured() {
if err := w.deliverViaRelay(job.EnvelopeFrom, job.EnvelopeTo, payload); err != nil {
w.finishFailed(ctx, job, err)
return
}
job.Status = entity.OutboundJobStatusDelivered
job.LastError = ""
job.LastSmtpCode = ""
job.TlsPolicyUsed = entity.TLSModeRelay
_ = w.outboundRepo.UpdateJob(ctx, job)
return
}
mxHosts, err := w.mxResolver.LookupMX(ctx, job.DestinationDomain)
if err != nil || len(mxHosts) == 0 {
mxHosts = []string{job.DestinationDomain}
@@ -79,10 +120,28 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit
var lastErr error
delivered := false
policyUsed := entity.TLSModeOpportunistic
for _, mxHost := range mxHosts {
if err := w.deliverToMX(ctx, mxHost, job.EnvelopeFrom, job.EnvelopeTo, payload); err == nil {
policy, err := w.resolvePolicy(ctx, job.DestinationDomain, mxHost)
if err != nil {
// A published policy we cannot evaluate is a reason to wait, not
// a reason to send in the clear.
lastErr = err
continue
}
// RFC 8461 section 4.1: a host outside the policy's mx set must not
// be used while that policy is in force.
if policy.RequiresValidation() && !policy.CoversHost(mxHost) {
lastErr = fmt.Errorf("%w: %s is not listed in the MTA-STS policy of %s",
ErrPolicyDowngradeRefused, mxHost, job.DestinationDomain)
continue
}
if err := w.deliverToMX(ctx, mxHost, job.EnvelopeFrom, job.EnvelopeTo, payload, policy); err == nil {
delivered = true
policyUsed = policy.Effective()
break
} else {
lastErr = err
@@ -92,18 +151,52 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit
if delivered {
job.Status = entity.OutboundJobStatusDelivered
job.LastError = ""
job.TlsPolicyUsed = "opportunistic"
job.LastSmtpCode = ""
job.TlsPolicyUsed = policyUsed
_ = w.outboundRepo.UpdateJob(ctx, job)
return
}
w.finishFailed(ctx, job, lastErr)
}
// finishFailed classifies a delivery failure and records the outcome.
func (w *OutboundWorkerUseCase) finishFailed(ctx context.Context, job *entity.OutboundJob, lastErr error) {
errStr := "delivery failed"
if lastErr != nil {
errStr = lastErr.Error()
}
isPermanent := strings.HasPrefix(errStr, "5")
w.handleFailure(ctx, job, errStr, isPermanent)
// The remote reply code decides the outcome (PLAN.md section 6.3): 5xx is
// a permanent failure that bounces now, anything else - including a dial
// or TLS error, which carries no reply code at all - is retried.
code := smtpReplyCode(lastErr)
if code > 0 {
job.LastSmtpCode = strconv.Itoa(code)
}
// A refused downgrade is always temporary: the destination may fix its
// certificate, and bouncing would hand the attacker the outcome they
// wanted (PLAN.md section 6.2).
if errors.Is(lastErr, ErrPolicyDowngradeRefused) {
job.LastSmtpCode = "451"
job.TlsPolicyUsed = "none"
w.handleFailure(ctx, job, "4.7.5 "+errStr, false)
return
}
w.handleFailure(ctx, job, errStr, code >= 500 && code < 600)
}
// smtpReplyCode extracts the reply code from an error returned by net/smtp,
// which wraps remote replies in *textproto.Error. It returns 0 when the
// failure happened before any reply was read.
func smtpReplyCode(err error) int {
var protoErr *textproto.Error
if errors.As(err, &protoErr) {
return protoErr.Code
}
return 0
}
func (w *OutboundWorkerUseCase) handleFailure(ctx context.Context, job *entity.OutboundJob, errStr string, isPermanent bool) {
@@ -141,20 +234,87 @@ func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject.
return
}
if w.mailboxes != nil {
targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeFrom)
if err == nil && len(targets) > 0 {
_ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
Sender: "postmaster@lambdamail.local",
Recipients: targets,
RecipientAddresses: []string{job.EnvelopeFrom},
Body: bytes.NewReader(dsnBytes),
})
}
if w.mailboxes == nil {
return
}
targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeFrom)
if err != nil || len(targets) == 0 {
return
}
// Handle indexes the two slices in lockstep, so the envelope address is
// repeated once per resolved mailbox. An alias sender fans out to several
// mailboxes under the same address.
addresses := make([]string, len(targets))
for i := range targets {
addresses[i] = job.EnvelopeFrom
}
_ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
Sender: "postmaster@lambdamail.local",
Recipients: targets,
RecipientAddresses: addresses,
Body: bytes.NewReader(dsnBytes),
})
}
func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string, from string, to string, payload []byte) error {
// resolvePolicy asks the resolver what transport security the destination
// requires. With no resolver configured every destination is opportunistic.
func (w *OutboundWorkerUseCase) resolvePolicy(ctx context.Context, destinationDomain, mxHost string) (entity.TLSPolicy, error) {
if w.policyResolver == nil {
return entity.NewTLSPolicy(false, false), nil
}
return w.policyResolver.Resolve(ctx, destinationDomain, mxHost)
}
// tlsConfigFor turns a policy into a TLS configuration.
//
// Opportunistic delivery deliberately skips verification: RFC 7435 prefers an
// unauthenticated encrypted channel to a cleartext one, and the alternative -
// refusing every destination with a self-signed certificate - would lose
// legitimate mail. When a policy is in force the verification is real, and a
// failure surfaces as a refusal to deliver.
func tlsConfigFor(mxHost string, policy entity.TLSPolicy) *tls.Config {
config := &tls.Config{
ServerName: strings.TrimSuffix(mxHost, "."),
MinVersion: tls.VersionTLS12,
}
if !policy.RequiresValidation() {
config.InsecureSkipVerify = true
return config
}
if policy.Effective() == entity.TLSModeDane {
// DANE authenticates the certificate directly against the
// DNSSEC-signed association, so the PKIX chain and the name are not
// what is being trusted (RFC 7672 section 3.1). Verification is done
// by hand below instead of by the standard chain builder.
config.InsecureSkipVerify = true
config.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
chain := make([]*x509.Certificate, 0, len(rawCerts))
for _, raw := range rawCerts {
cert, err := x509.ParseCertificate(raw)
if err != nil {
return fmt.Errorf("parse peer certificate: %w", err)
}
chain = append(chain, cert)
}
if !policy.MatchesCertificate(chain) {
return fmt.Errorf("%w: certificate of %s matches no TLSA record", ErrPolicyDowngradeRefused, mxHost)
}
return nil
}
return config
}
// MTA-STS in enforce mode requires a normal, publicly trusted PKIX
// validation against the MX name (RFC 8461 section 4.1).
return config
}
func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string, from string, to string, payload []byte, policy entity.TLSPolicy) error {
addr := mxHost
if _, _, err := net.SplitHostPort(mxHost); err != nil {
addr = net.JoinHostPort(mxHost, "25")
@@ -177,11 +337,20 @@ func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string,
return fmt.Errorf("EHLO failed: %w", err)
}
if ok, _ := client.Extension("STARTTLS"); ok {
config := &tls.Config{InsecureSkipVerify: true, ServerName: mxHost}
if err := client.StartTLS(config); err != nil {
starttlsOffered, _ := client.Extension("STARTTLS")
if starttlsOffered {
if err := client.StartTLS(tlsConfigFor(mxHost, policy)); err != nil {
if policy.RequiresValidation() {
return fmt.Errorf("%w: STARTTLS to %s failed validation: %v", ErrPolicyDowngradeRefused, mxHost, err)
}
return fmt.Errorf("STARTTLS failed: %w", err)
}
} else if policy.RequiresValidation() {
// The destination's own published policy says it speaks TLS. A peer
// that does not offer STARTTLS is either broken or being stripped,
// and handing it the message either way is exactly the downgrade the
// policy exists to prevent.
return fmt.Errorf("%w: %s did not offer STARTTLS", ErrPolicyDowngradeRefused, mxHost)
}
if err := client.Mail(from); err != nil {
@@ -30,6 +30,10 @@ func (f *fakeOutboundRepo) FetchNextReady(_ context.Context, _ string, _ int) ([
return ready, nil
}
func (f *fakeOutboundRepo) CountRecipientsSince(_ context.Context, _ uuid.UUID, _ time.Time) (int, error) {
return 0, nil
}
func (f *fakeOutboundRepo) UpdateJob(_ context.Context, job *entity.OutboundJob) error {
for i, j := range f.jobs {
if j.ID == job.ID {
@@ -0,0 +1,404 @@
package usecase
import (
"context"
"fmt"
"net"
"sort"
"strings"
"time"
)
// Severity mirrors the table in PLAN.md section 15.
type Severity string
const (
SeverityCritical Severity = "CRITICAL"
SeverityHigh Severity = "HIGH"
SeverityMedium Severity = "MEDIUM"
)
// CheckStatus is the outcome of one preflight check.
type CheckStatus string
const (
CheckPassed CheckStatus = "PASS"
CheckFailed CheckStatus = "FAIL"
CheckSkipped CheckStatus = "SKIP"
)
// CheckResult is one line of the preflight report.
type CheckResult struct {
Name string
Status CheckStatus
Severity Severity
Detail string
// Remedy is the operator action that fixes it. Several of these checks
// cover steps PLAN.md section 7.4 lists as not automatable, so telling
// the operator exactly what to do is the point of the check.
Remedy string
}
// PreflightReport aggregates the checks.
type PreflightReport struct {
Results []CheckResult
}
// Blocking reports whether any critical check failed. PLAN.md section 15 uses
// this to keep a domain out of the VERIFIED state.
func (r PreflightReport) Blocking() bool {
for _, result := range r.Results {
if result.Status == CheckFailed && result.Severity == SeverityCritical {
return true
}
}
return false
}
func (r PreflightReport) String() string {
var sb strings.Builder
for _, result := range r.Results {
fmt.Fprintf(&sb, "[%-4s] %-10s %s", result.Status, result.Severity, result.Name)
if result.Detail != "" {
fmt.Fprintf(&sb, "\n %s", result.Detail)
}
if result.Status == CheckFailed && result.Remedy != "" {
fmt.Fprintf(&sb, "\n remedy: %s", result.Remedy)
}
sb.WriteString("\n")
}
return sb.String()
}
// PreflightDeps are the environment probes the checks need. They are injected
// so the whole preflight is testable without touching the network.
type PreflightDeps struct {
// DialPort25 reports whether outbound port 25 reaches a known MX.
DialPort25 func(ctx context.Context, address string) error
// LookupAddr resolves the PTR of an IP.
LookupAddr func(ctx context.Context, ip string) ([]string, error)
// LookupHost resolves a name to addresses.
LookupHost func(ctx context.Context, host string) ([]string, error)
// LookupTXT resolves TXT records, used for the DNSBL checks.
LookupTXT func(ctx context.Context, name string) ([]string, error)
// HasCertificateFor reports whether a certificate is loaded for a host.
HasCertificateFor func(host string) bool
// FetchMtaSts fetches the served MTA-STS policy over HTTPS.
FetchMtaSts func(ctx context.Context, domain string) error
// FreeDiskPercent reports the free space on the mail spool.
FreeDiskPercent func() (float64, error)
// Now is the local clock; drift against NTP breaks DKIM and TOTP.
Now func() time.Time
// NetworkTime returns a trusted time to compare the local clock against.
NetworkTime func(ctx context.Context) (time.Time, error)
}
// PreflightInput describes the deployment being checked.
type PreflightInput struct {
PrimaryMailHost string
MailDomain string
PublicIPv4 string
PublicIPv6 string
DaneEnabled bool
TLSMode string
SmarthostSet bool
}
// PreflightUseCase implements PLAN.md section 15.
type PreflightUseCase struct {
deps PreflightDeps
}
func NewPreflightUseCase(deps PreflightDeps) *PreflightUseCase {
return &PreflightUseCase{deps: deps}
}
// wellKnownMX is the probe target of PLAN.md section 15. Google is used
// because it is the destination most likely to matter and least likely to be
// down.
const wellKnownMX = "gmail-smtp-in.l.google.com:25"
// dnsblZones are the reputation lists PLAN.md section 5.2 names.
var dnsblZones = map[string]string{
"zen.spamhaus.org": "Spamhaus ZEN",
"b.barracudacentral.org": "Barracuda",
"dnsbl.sorbs.net": "SORBS",
}
func (uc *PreflightUseCase) Execute(ctx context.Context, input PreflightInput) PreflightReport {
report := PreflightReport{}
add := func(r CheckResult) { report.Results = append(report.Results, r) }
add(uc.checkPort25(ctx, input))
add(uc.checkReverseDNS(ctx, input))
add(uc.checkForwardConfirmedReverseDNS(ctx, input))
add(uc.checkBlocklists(ctx, input))
add(uc.checkCertificate(input))
add(uc.checkDaneRequiresAcmeMode(input))
add(uc.checkMtaStsEndpoint(ctx, input))
add(uc.checkClockDrift(ctx))
add(uc.checkDiskSpace())
add(uc.checkIPv6Coherence(ctx, input))
return report
}
func (uc *PreflightUseCase) checkPort25(ctx context.Context, input PreflightInput) CheckResult {
const name = "Outbound port 25 reaches a public MX"
if input.SmarthostSet {
return CheckResult{
Name: name, Status: CheckSkipped, Severity: SeverityCritical,
Detail: "a smarthost is configured, so direct delivery on port 25 is not used",
}
}
if uc.deps.DialPort25 == nil {
return skipped(name, SeverityCritical)
}
if err := uc.deps.DialPort25(ctx, wellKnownMX); err != nil {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("could not reach %s: %v", wellKnownMX, err),
Remedy: "most VPS providers block egress on port 25 by default; ask the provider to unblock it, or configure RELAY_HOST to deliver through a smarthost",
}
}
return passed(name, SeverityCritical, "")
}
func (uc *PreflightUseCase) checkReverseDNS(ctx context.Context, input PreflightInput) CheckResult {
const name = "PTR of the public IP resolves to the mail host"
if uc.deps.LookupAddr == nil || input.PublicIPv4 == "" {
return skipped(name, SeverityCritical)
}
names, err := uc.deps.LookupAddr(ctx, input.PublicIPv4)
if err != nil || len(names) == 0 {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("no PTR record for %s", input.PublicIPv4),
Remedy: "set the PTR in the hosting provider's panel; reverse delegation belongs to the owner of the IP block, not to the DNS provider",
}
}
for _, candidate := range names {
if strings.EqualFold(strings.TrimSuffix(candidate, "."), input.PrimaryMailHost) {
return passed(name, SeverityCritical, "")
}
}
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("PTR of %s is %v, want %s", input.PublicIPv4, names, input.PrimaryMailHost),
Remedy: "point the PTR at " + input.PrimaryMailHost + " in the hosting provider's panel",
}
}
func (uc *PreflightUseCase) checkForwardConfirmedReverseDNS(ctx context.Context, input PreflightInput) CheckResult {
const name = "FCrDNS: the mail host resolves back to the public IP"
if uc.deps.LookupHost == nil || input.PublicIPv4 == "" {
return skipped(name, SeverityCritical)
}
addresses, err := uc.deps.LookupHost(ctx, input.PrimaryMailHost)
if err != nil {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("cannot resolve %s: %v", input.PrimaryMailHost, err),
Remedy: "publish the A record for " + input.PrimaryMailHost,
}
}
for _, address := range addresses {
if address == input.PublicIPv4 {
return passed(name, SeverityCritical, "")
}
}
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("%s resolves to %v, not to %s", input.PrimaryMailHost, addresses, input.PublicIPv4),
Remedy: "make the A record and the PTR agree; Gmail and Outlook reject or spam-file mail without forward-confirmed reverse DNS",
}
}
func (uc *PreflightUseCase) checkBlocklists(ctx context.Context, input PreflightInput) CheckResult {
const name = "Public IP is not on a major blocklist"
if uc.deps.LookupTXT == nil || input.PublicIPv4 == "" {
return skipped(name, SeverityCritical)
}
reversed := reverseIPv4(input.PublicIPv4)
if reversed == "" {
return skipped(name, SeverityCritical)
}
var listed []string
// Sorted so the report is stable between runs.
zones := make([]string, 0, len(dnsblZones))
for zone := range dnsblZones {
zones = append(zones, zone)
}
sort.Strings(zones)
for _, zone := range zones {
if records, err := uc.deps.LookupTXT(ctx, reversed+"."+zone); err == nil && len(records) > 0 {
listed = append(listed, dnsblZones[zone])
}
}
if len(listed) > 0 {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("%s is listed on: %s", input.PublicIPv4, strings.Join(listed, ", ")),
Remedy: "request delisting with each provider before sending, or move to an IP with clean reputation",
}
}
return passed(name, SeverityCritical, "")
}
func (uc *PreflightUseCase) checkCertificate(input PreflightInput) CheckResult {
const name = "A TLS certificate is loaded for the mail host"
if uc.deps.HasCertificateFor == nil {
return skipped(name, SeverityCritical)
}
if uc.deps.HasCertificateFor(input.PrimaryMailHost) {
return passed(name, SeverityCritical, "")
}
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("no certificate for %s", input.PrimaryMailHost),
Remedy: "Traefik only requests certificates for hosts it routes; declare an HTTP router with Host(`" + input.PrimaryMailHost + "`) so ACME issues one",
}
}
func (uc *PreflightUseCase) checkDaneRequiresAcmeMode(input PreflightInput) CheckResult {
const name = "DANE is only enabled in self-managed ACME mode"
if !input.DaneEnabled {
return CheckResult{
Name: name, Status: CheckSkipped, Severity: SeverityCritical,
Detail: "DANE is disabled",
}
}
if strings.EqualFold(input.TLSMode, "acme") {
return passed(name, SeverityCritical, "")
}
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: "DANE is enabled while certificates come from Traefik",
Remedy: "Traefik cannot sign a CSR with a pre-generated key, so the TLSA record would stop matching at the next renewal and every validating MTA would reject mail; set TLS_MODE=acme or disable DANE",
}
}
func (uc *PreflightUseCase) checkMtaStsEndpoint(ctx context.Context, input PreflightInput) CheckResult {
const name = "MTA-STS policy is served over HTTPS with a valid certificate"
if uc.deps.FetchMtaSts == nil || input.MailDomain == "" {
return skipped(name, SeverityHigh)
}
if err := uc.deps.FetchMtaSts(ctx, input.MailDomain); err != nil {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityHigh,
Detail: fmt.Sprintf("cannot fetch the policy for %s: %v", input.MailDomain, err),
Remedy: "publish the mta-sts host and route it through the proxy with a valid certificate; senders that cannot fetch the policy fall back to unprotected delivery",
}
}
return passed(name, SeverityHigh, "")
}
func (uc *PreflightUseCase) checkClockDrift(ctx context.Context) CheckResult {
const name = "System clock is synchronised"
const maxDrift = 5 * time.Second
if uc.deps.NetworkTime == nil || uc.deps.Now == nil {
return skipped(name, SeverityHigh)
}
networkTime, err := uc.deps.NetworkTime(ctx)
if err != nil {
return skipped(name, SeverityHigh)
}
drift := uc.deps.Now().Sub(networkTime)
if drift < 0 {
drift = -drift
}
if drift > maxDrift {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityHigh,
Detail: fmt.Sprintf("local clock is off by %v", drift.Round(time.Millisecond)),
Remedy: "enable NTP on the host; DKIM signature timestamps and TOTP codes both depend on an accurate clock",
}
}
return passed(name, SeverityHigh, fmt.Sprintf("drift %v", drift.Round(time.Millisecond)))
}
func (uc *PreflightUseCase) checkDiskSpace() CheckResult {
const name = "Mail spool has more than 20% free space"
if uc.deps.FreeDiskPercent == nil {
return skipped(name, SeverityMedium)
}
free, err := uc.deps.FreeDiskPercent()
if err != nil {
return skipped(name, SeverityMedium)
}
if free < 20 {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityMedium,
Detail: fmt.Sprintf("%.1f%% free", free),
Remedy: "free space or grow the volume; a full spool means accepted mail cannot be written and has to be refused",
}
}
return passed(name, SeverityMedium, fmt.Sprintf("%.1f%% free", free))
}
func (uc *PreflightUseCase) checkIPv6Coherence(ctx context.Context, input PreflightInput) CheckResult {
const name = "IPv6 is either fully configured or disabled"
if input.PublicIPv6 == "" {
return passed(name, SeverityMedium, "IPv6 is disabled")
}
if uc.deps.LookupAddr == nil {
return skipped(name, SeverityMedium)
}
names, err := uc.deps.LookupAddr(ctx, input.PublicIPv6)
if err != nil || len(names) == 0 {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityMedium,
Detail: fmt.Sprintf("no PTR for %s", input.PublicIPv6),
Remedy: "publish a PTR for the IPv6 address or unset PUBLIC_IPV6; half-configured IPv6 is a common cause of rejection",
}
}
return passed(name, SeverityMedium, "")
}
func passed(name string, severity Severity, detail string) CheckResult {
return CheckResult{Name: name, Status: CheckPassed, Severity: severity, Detail: detail}
}
func skipped(name string, severity Severity) CheckResult {
return CheckResult{
Name: name, Status: CheckSkipped, Severity: severity,
Detail: "not enough configuration to run this check",
}
}
// reverseIPv4 turns "192.0.2.10" into "10.2.0.192", the form DNSBL queries
// take. It returns an empty string for anything that is not an IPv4 address.
func reverseIPv4(address string) string {
ip := net.ParseIP(address).To4()
if ip == nil {
return ""
}
return fmt.Sprintf("%d.%d.%d.%d", ip[3], ip[2], ip[1], ip[0])
}
@@ -0,0 +1,266 @@
package usecase
import (
"context"
"errors"
"strings"
"testing"
"time"
)
// healthyDeps is a deployment where every probe succeeds; individual tests
// break exactly one thing so the failure being asserted is unambiguous.
func healthyDeps() PreflightDeps {
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
return PreflightDeps{
DialPort25: func(context.Context, string) error { return nil },
LookupAddr: func(_ context.Context, _ string) ([]string, error) { return []string{"mail.example.test."}, nil },
LookupHost: func(_ context.Context, _ string) ([]string, error) { return []string{"192.0.2.10"}, nil },
LookupTXT: func(_ context.Context, _ string) ([]string, error) { return nil, errors.New("NXDOMAIN") },
HasCertificateFor: func(string) bool { return true },
FetchMtaSts: func(context.Context, string) error { return nil },
FreeDiskPercent: func() (float64, error) { return 65, nil },
Now: func() time.Time { return now },
NetworkTime: func(context.Context) (time.Time, error) { return now, nil },
}
}
func healthyInput() PreflightInput {
return PreflightInput{
PrimaryMailHost: "mail.example.test",
MailDomain: "example.test",
PublicIPv4: "192.0.2.10",
TLSMode: "traefik",
}
}
func findCheck(t *testing.T, report PreflightReport, substring string) CheckResult {
t.Helper()
for _, result := range report.Results {
if strings.Contains(result.Name, substring) {
return result
}
}
t.Fatalf("no check matching %q in report:\n%s", substring, report)
return CheckResult{}
}
func TestPreflight_HealthyDeploymentDoesNotBlock(t *testing.T) {
report := NewPreflightUseCase(healthyDeps()).Execute(context.Background(), healthyInput())
if report.Blocking() {
t.Errorf("a healthy deployment was reported as blocking:\n%s", report)
}
}
// PLAN.md premise 1: without outbound port 25 there is no direct delivery.
func TestPreflight_BlockedPort25IsCriticalAndSuggestsSmarthost(t *testing.T) {
deps := healthyDeps()
deps.DialPort25 = func(context.Context, string) error { return errors.New("connection timed out") }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "port 25")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !strings.Contains(check.Remedy, "smarthost") {
t.Errorf("remedy should point at the smarthost fallback: %q", check.Remedy)
}
if !report.Blocking() {
t.Error("a blocked port 25 must block")
}
}
// With a smarthost configured, direct port 25 delivery is not used, so the
// check must not block the deployment.
func TestPreflight_SmarthostSkipsPort25Check(t *testing.T) {
deps := healthyDeps()
deps.DialPort25 = func(context.Context, string) error { return errors.New("blocked") }
input := healthyInput()
input.SmarthostSet = true
report := NewPreflightUseCase(deps).Execute(context.Background(), input)
if findCheck(t, report, "port 25").Status != CheckSkipped {
t.Error("the port 25 check should be skipped when a smarthost is configured")
}
if report.Blocking() {
t.Errorf("a smarthost deployment was blocked:\n%s", report)
}
}
// PLAN.md premise 2: PTR delegation belongs to the hosting provider, so the
// remedy has to say so.
func TestPreflight_MissingPtrIsCriticalAndNamesTheProvider(t *testing.T) {
deps := healthyDeps()
deps.LookupAddr = func(context.Context, string) ([]string, error) { return nil, errors.New("NXDOMAIN") }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "PTR")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !strings.Contains(check.Remedy, "hosting provider") {
t.Errorf("remedy should name the hosting provider: %q", check.Remedy)
}
}
func TestPreflight_ForwardConfirmedReverseDnsMismatchIsCritical(t *testing.T) {
deps := healthyDeps()
deps.LookupHost = func(context.Context, string) ([]string, error) { return []string{"198.51.100.1"}, nil }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
if check := findCheck(t, report, "FCrDNS"); check.Status != CheckFailed {
t.Errorf("check = %+v, want a failure when the A record and the PTR disagree", check)
}
}
func TestPreflight_ListedIpIsCriticalAndNamesTheList(t *testing.T) {
deps := healthyDeps()
deps.LookupTXT = func(_ context.Context, name string) ([]string, error) {
if strings.Contains(name, "zen.spamhaus.org") {
return []string{"https://www.spamhaus.org/query/ip/192.0.2.10"}, nil
}
return nil, errors.New("NXDOMAIN")
}
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "blocklist")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !strings.Contains(check.Detail, "Spamhaus") {
t.Errorf("detail should name the list: %q", check.Detail)
}
}
// PLAN.md section 8.2 trap 2: Traefik only holds certificates for hosts it
// routes, so the remedy has to explain the missing router.
func TestPreflight_MissingCertificateExplainsTheTraefikRouter(t *testing.T) {
deps := healthyDeps()
deps.HasCertificateFor = func(string) bool { return false }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "certificate is loaded")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !strings.Contains(check.Remedy, "router") {
t.Errorf("remedy should explain the missing router: %q", check.Remedy)
}
}
// PLAN.md section 5.1: enabling DANE under Traefik-managed certificates
// guarantees breakage at the next renewal, so the preflight must refuse it.
func TestPreflight_DaneUnderTraefikModeIsRefused(t *testing.T) {
input := healthyInput()
input.DaneEnabled = true
input.TLSMode = "traefik"
report := NewPreflightUseCase(healthyDeps()).Execute(context.Background(), input)
check := findCheck(t, report, "DANE")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !report.Blocking() {
t.Error("DANE under Traefik mode must block the deployment")
}
}
func TestPreflight_DaneUnderAcmeModePasses(t *testing.T) {
input := healthyInput()
input.DaneEnabled = true
input.TLSMode = "acme"
report := NewPreflightUseCase(healthyDeps()).Execute(context.Background(), input)
if check := findCheck(t, report, "DANE"); check.Status != CheckPassed {
t.Errorf("check = %+v, want a pass under TLS_MODE=acme", check)
}
}
// DKIM signatures and TOTP codes both break on a drifting clock, but neither
// loses mail outright, so this is high rather than critical.
func TestPreflight_ClockDriftIsHighNotCritical(t *testing.T) {
deps := healthyDeps()
local := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
deps.Now = func() time.Time { return local.Add(90 * time.Second) }
deps.NetworkTime = func(context.Context) (time.Time, error) { return local, nil }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "clock")
if check.Status != CheckFailed || check.Severity != SeverityHigh {
t.Errorf("check = %+v, want a high-severity failure", check)
}
if report.Blocking() {
t.Error("clock drift alone must not block the deployment")
}
}
func TestPreflight_LowDiskIsMediumSeverity(t *testing.T) {
deps := healthyDeps()
deps.FreeDiskPercent = func() (float64, error) { return 4, nil }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "free space")
if check.Status != CheckFailed || check.Severity != SeverityMedium {
t.Errorf("check = %+v, want a medium-severity failure", check)
}
}
// PLAN.md premise 5: half-configured IPv6 is a common cause of rejection, and
// disabling it is a valid answer.
func TestPreflight_IPv6WithoutPtrFailsButDisabledIPv6Passes(t *testing.T) {
deps := healthyDeps()
deps.LookupAddr = func(_ context.Context, ip string) ([]string, error) {
if strings.Contains(ip, ":") {
return nil, errors.New("NXDOMAIN")
}
return []string{"mail.example.test."}, nil
}
withIPv6 := healthyInput()
withIPv6.PublicIPv6 = "2001:db8::1"
report := NewPreflightUseCase(deps).Execute(context.Background(), withIPv6)
if check := findCheck(t, report, "IPv6"); check.Status != CheckFailed {
t.Errorf("check = %+v, want a failure for IPv6 without a PTR", check)
}
report = NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
if check := findCheck(t, report, "IPv6"); check.Status != CheckPassed {
t.Errorf("check = %+v, want a pass when IPv6 is disabled", check)
}
}
// A probe that cannot run is reported as skipped, never silently as a pass.
func TestPreflight_MissingProbesAreSkippedNotPassed(t *testing.T) {
report := NewPreflightUseCase(PreflightDeps{}).Execute(context.Background(), healthyInput())
for _, result := range report.Results {
if result.Status == CheckPassed && result.Name != "IPv6 is either fully configured or disabled" {
t.Errorf("check %q passed with no probe configured", result.Name)
}
}
if report.Blocking() {
t.Error("skipped checks must not block")
}
}
func TestReverseIPv4(t *testing.T) {
if got := reverseIPv4("192.0.2.10"); got != "10.2.0.192" {
t.Errorf("reverseIPv4 = %q, want 10.2.0.192", got)
}
if got := reverseIPv4("2001:db8::1"); got != "" {
t.Errorf("reverseIPv4 of an IPv6 address = %q, want empty", got)
}
}
@@ -6,7 +6,11 @@ import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net"
"sort"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/valueobject"
@@ -31,6 +35,11 @@ type ProcessInboundEmailInput struct {
Recipients []port.MailboxRecord
RecipientAddresses []string
Body io.Reader
// ClientIP and HeloDomain identify the peer. SPF is defined over them
// (RFC 7208 section 2.3), so a delivery without them can only ever
// produce a "none" verdict.
ClientIP net.IP
HeloDomain string
}
// ProcessInboundEmailUseCase implements the inbound half of PLAN.md section
@@ -39,6 +48,8 @@ type ProcessInboundEmailInput struct {
// later sub-projects).
type ProcessInboundEmailUseCase struct {
scanner port.ContentScanner
authenticator port.MailAuthenticator
arcSealer port.ArcSealer
mailboxes port.MailboxRepository
blobs port.BlobStorage
messages port.InboundMessageRepository
@@ -54,11 +65,49 @@ func (uc *ProcessInboundEmailUseCase) SetScanner(scanner port.ContentScanner) {
uc.scanner = scanner
}
func (uc *ProcessInboundEmailUseCase) SetAuthenticator(authenticator port.MailAuthenticator) {
uc.authenticator = authenticator
}
// SetArcSealer enables ARC sealing of accepted messages. It is only worth
// enabling on a host that forwards mail onward (PLAN.md section 5).
func (uc *ProcessInboundEmailUseCase) SetArcSealer(sealer port.ArcSealer) {
uc.arcSealer = sealer
}
func (uc *ProcessInboundEmailUseCase) SetTrackerManager(tm *MailboxTrackerManager, folders port.ImapFolderRepository) {
uc.trackerManager = tm
uc.folders = folders
}
// prependHeaders inserts the scanner's verdict headers at the top of the
// message. A receiving MTA adds its trace headers above the existing ones
// (RFC 5321 section 4.4), which also keeps the original bytes - and therefore
// any DKIM signature over them - untouched.
func prependHeaders(payload []byte, headers map[string]string) []byte {
if len(headers) == 0 {
return payload
}
// Sorted so the delivered message is byte-for-byte reproducible instead of
// depending on Go's randomized map iteration order.
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
sort.Strings(names)
var buf bytes.Buffer
for _, name := range names {
buf.WriteString(name)
buf.WriteString(": ")
buf.WriteString(headers[name])
buf.WriteString("\r\n")
}
buf.Write(payload)
return buf.Bytes()
}
// ResolveRecipient is called from the SMTP session's RCPT TO handler. It
// returns every mailbox address should be delivered to (more than one for
// an alias that fans out), or ErrRecipientNotFound / ErrMailboxQuotaExceeded.
@@ -84,12 +133,68 @@ func (uc *ProcessInboundEmailUseCase) ResolveRecipient(ctx context.Context, addr
// the same blob (PLAN.md section 9's dedup design: 1 email to 50
// recipients = 1 file).
func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessInboundEmailInput) error {
// The two slices are indexed in lockstep below: one envelope address per
// resolved mailbox, repeated when an alias fans out. Refusing a mismatch
// turns a caller's bug into an error instead of an out-of-range panic that
// would take down the calling worker.
if len(input.Recipients) != len(input.RecipientAddresses) {
return fmt.Errorf(
"process inbound email: %d recipients but %d envelope addresses",
len(input.Recipients), len(input.RecipientAddresses),
)
}
payload, err := io.ReadAll(input.Body)
if err != nil {
return err
}
targetFolder := "INBOX"
authResult := port.InboundAuthResult{
SPF: port.AuthResultNone,
DKIM: port.AuthResultNone,
DMARC: port.AuthResultNone,
}
// Authentication runs before the content scanners so that the spam filter
// sees our Authentication-Results header and can score on it
// (PLAN.md section 6.1).
if uc.authenticator != nil {
authResult = uc.authenticator.Authenticate(ctx, port.InboundAuthInput{
ClientIP: input.ClientIP,
HeloDomain: input.HeloDomain,
EnvelopeFrom: input.Sender,
Message: payload,
})
// Honouring a published "p=reject" is the whole point of the sender
// 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")
}
if authResult.AuthenticationResults != "" {
payload = prependHeaders(payload, map[string]string{
"Authentication-Results": authResult.AuthenticationResults,
})
}
// The ARC set has to be built over the message as it arrived, with
// this hop's verdict already known, so it is sealed here rather than
// after the content scanners rewrite headers.
if uc.arcSealer != nil {
sealed, err := uc.arcSealer.Seal(ctx, payload, authResult)
if err != nil {
// A sealing failure must not lose the message: it is delivered
// without the ARC set, which is exactly the state it would
// have been in had sealing not been enabled.
log.Printf("arc: could not seal message from %s: %v", input.Sender, err)
} else {
payload = sealed
}
}
}
if uc.scanner != nil {
recipientAddr := ""
@@ -115,6 +220,7 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
case valueobject.ScanVerdictSpamJunk:
targetFolder = "Junk"
}
payload = prependHeaders(payload, scanRes.HeadersToAdd)
}
}
@@ -130,9 +236,9 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
SenderAddress: input.Sender,
RecipientAddress: input.RecipientAddresses[i],
TargetFolderName: targetFolder,
SPFResult: "none",
DKIMResult: "none",
DMARCResult: "none",
SPFResult: authResult.SPF,
DKIMResult: authResult.DKIM,
DMARCResult: authResult.DMARC,
})
if err != nil {
return err
@@ -0,0 +1,179 @@
package usecase
import (
"context"
"strings"
"testing"
"lambdamail/protocols/internal/domain/entity"
)
func conflictTestInput() SyncDnsRecordsInput {
return SyncDnsRecordsInput{
DomainName: "example.test",
MailHost: "mail.example.test",
ServerIPv4: "192.0.2.1",
RsaDkimPubKey: "rsaKey",
EdDkimPubKey: "edKey",
}
}
// PLAN.md section 7.5: pre-existing records that are not ours are never
// touched, only reported as conflicts. A third-party verification TXT sits at
// the zone apex under the same type and name as our SPF record, so a reconciler
// keyed only on type+name would overwrite it and break the other service.
func TestSyncDnsRecords_ForeignApexTxtIsPreserved(t *testing.T) {
dns := newFakeDnsProvider()
dns.seed(entity.DnsRecord{
Type: "TXT",
Name: "example.test",
Value: "google-site-verification=abc123",
})
uc := NewSyncDnsRecordsUseCase(dns, nil)
out, err := uc.Execute(context.Background(), conflictTestInput())
if err != nil {
t.Fatalf("Execute: %v", err)
}
apexTxt := dns.find("TXT", "example.test")
if len(apexTxt) != 2 {
t.Fatalf("expected the foreign TXT and our SPF to coexist, got %d records", len(apexTxt))
}
var foundForeign, foundSPF bool
for _, r := range apexTxt {
if r.Value == "google-site-verification=abc123" {
foundForeign = true
}
if strings.HasPrefix(r.Value, "v=spf1") {
foundSPF = true
}
}
if !foundForeign {
t.Error("the foreign verification TXT was destroyed")
}
if !foundSPF {
t.Error("the SPF record was not created alongside the foreign apex TXT")
}
if out.ConflictCount != 0 {
t.Errorf("a coexisting foreign TXT is not a conflict, got ConflictCount=%d", out.ConflictCount)
}
}
// A name that admits only one value (the mail host A record) already pointing
// somewhere else is a genuine conflict: silently repointing it could take down
// an unrelated service.
func TestSyncDnsRecords_ForeignARecordIsReportedAsConflict(t *testing.T) {
dns := newFakeDnsProvider()
dns.seed(entity.DnsRecord{
Type: "A",
Name: "mail.example.test",
Value: "198.51.100.9",
Comment: "managed by something else",
})
uc := NewSyncDnsRecordsUseCase(dns, nil)
out, err := uc.Execute(context.Background(), conflictTestInput())
if err != nil {
t.Fatalf("Execute: %v", err)
}
records := dns.find("A", "mail.example.test")
if len(records) != 1 || records[0].Value != "198.51.100.9" {
t.Errorf("foreign A record was not left untouched: %+v", records)
}
if out.ConflictCount != 1 {
t.Fatalf("ConflictCount = %d, want 1", out.ConflictCount)
}
if out.Status != SyncStatusPartial {
t.Errorf("Status = %s, want %s", out.Status, SyncStatusPartial)
}
if !strings.Contains(out.Conflicts[0].String(), "198.51.100.9") {
t.Errorf("conflict report does not name the blocking value: %s", out.Conflicts[0])
}
}
// Our own records must still be updated in place when they drift.
func TestSyncDnsRecords_OwnRecordDriftIsCorrected(t *testing.T) {
dns := newFakeDnsProvider()
uc := NewSyncDnsRecordsUseCase(dns, nil)
ctx := context.Background()
if _, err := uc.Execute(ctx, conflictTestInput()); err != nil {
t.Fatalf("initial Execute: %v", err)
}
for i, r := range dns.records {
if r.Type == "A" && r.Name == "mail.example.test" {
dns.records[i].Value = "203.0.113.7"
}
}
out, err := uc.Execute(ctx, conflictTestInput())
if err != nil {
t.Fatalf("second Execute: %v", err)
}
if out.UpdatedCount != 1 || out.ConflictCount != 0 {
t.Errorf("updated=%d conflicts=%d, want updated=1 conflicts=0", out.UpdatedCount, out.ConflictCount)
}
if records := dns.find("A", "mail.example.test"); records[0].Value != "192.0.2.1" {
t.Errorf("drifted A record was not corrected: got %q", records[0].Value)
}
if out.Status != SyncStatusVerified {
t.Errorf("Status = %s, want %s", out.Status, SyncStatusVerified)
}
}
type stubDnsVerifier struct {
invisible map[string]bool
}
func (s stubDnsVerifier) VerifyRecord(_ context.Context, record entity.DnsRecord) (bool, string) {
if s.invisible[record.Name] {
return false, "no answer"
}
return true, ""
}
// PLAN.md section 7.5 verifies through public resolvers after writing. A
// record the provider accepted but nobody can resolve is drift, not success.
func TestSyncDnsRecords_UnresolvableRecordIsReportedAsDrift(t *testing.T) {
dns := newFakeDnsProvider()
uc := NewSyncDnsRecordsUseCase(dns, nil)
uc.SetVerifier(stubDnsVerifier{invisible: map[string]bool{"mail.example.test": true}})
out, err := uc.Execute(context.Background(), conflictTestInput())
if err != nil {
t.Fatalf("Execute: %v", err)
}
if out.Status != SyncStatusDrift {
t.Errorf("Status = %s, want DRIFT", out.Status)
}
if len(out.Unverified) == 0 {
t.Fatal("the unresolvable record was not reported")
}
if !strings.Contains(out.Unverified[0], "mail.example.test") {
t.Errorf("Unverified = %v, want it to name the record", out.Unverified)
}
}
func TestSyncDnsRecords_AllRecordsVisibleIsVerified(t *testing.T) {
dns := newFakeDnsProvider()
uc := NewSyncDnsRecordsUseCase(dns, nil)
uc.SetVerifier(stubDnsVerifier{})
out, err := uc.Execute(context.Background(), conflictTestInput())
if err != nil {
t.Fatalf("Execute: %v", err)
}
if out.Status != SyncStatusVerified {
t.Errorf("Status = %s, want VERIFIED", out.Status)
}
if len(out.Unverified) != 0 {
t.Errorf("Unverified = %v, want empty", out.Unverified)
}
}
@@ -9,28 +9,68 @@ import (
"lambdamail/protocols/internal/domain/entity"
)
// SyncStatus is the outcome reported to the admin console (PLAN.md section 7.5).
type SyncStatus string
const (
SyncStatusVerified SyncStatus = "VERIFIED"
SyncStatusPartial SyncStatus = "PARTIAL"
SyncStatusDrift SyncStatus = "DRIFT"
)
// ownedRecordCommentPrefix marks the records this reconciler manages. Anything
// in the zone without it that would collide with a desired record belongs to
// somebody else and is reported instead of overwritten.
const ownedRecordCommentPrefix = "LambdaMail"
type SyncDnsRecordsInput struct {
DomainName string
MailHost string
ServerIPv4 string
ServerIPv6 string
RsaDkimPubKey string
EdDkimPubKey string
DomainName string
MailHost string
ServerIPv4 string
ServerIPv6 string
RsaDkimPubKey string
EdDkimPubKey string
// TlsaHash publishes a single DANE association. TlsaHashes supersedes it
// and is what a rollover uses, since it can carry the current and the
// next key at once.
TlsaHash string
TlsaHashes []string
DaneEnabled bool
RelaySpfInclude string
AdminEmailAddress string
}
// SyncConflict describes a record that could not be reconciled because a
// foreign record already occupies its name.
type SyncConflict struct {
Type string
Name string
Existing string
Desired string
}
func (c SyncConflict) String() string {
return fmt.Sprintf("%s %s: existing %q blocks desired %q", c.Type, c.Name, c.Existing, c.Desired)
}
type SyncDnsRecordsOutput struct {
ZoneID string
Status SyncStatus
CreatedCount int
UpdatedCount int
UnchangedCount int
ConflictCount int
Conflicts []SyncConflict
Errors []string
// Unverified lists records that were written but could not be read back
// through the public resolvers.
Unverified []string
}
type SyncDnsRecordsUseCase struct {
dnsProvider port.DnsProvider
aliasRepo port.SystemAliasRepository
verifier port.DnsVerifier
}
func NewSyncDnsRecordsUseCase(dnsProvider port.DnsProvider, aliasRepo port.SystemAliasRepository) *SyncDnsRecordsUseCase {
@@ -40,21 +80,35 @@ func NewSyncDnsRecordsUseCase(dnsProvider port.DnsProvider, aliasRepo port.Syste
}
}
// SetVerifier enables the independent check of PLAN.md section 7.5: after
// writing, the records are read back through public resolvers. The provider
// accepting a write only proves the API accepted it, not that the record
// resolves for anyone else.
func (uc *SyncDnsRecordsUseCase) SetVerifier(verifier port.DnsVerifier) {
uc.verifier = verifier
}
func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecordsInput) (*SyncDnsRecordsOutput, error) {
if input.DomainName == "" {
return nil, fmt.Errorf("domain name is required")
}
desiredSpecs := entity.Build13DnsRecordSpecs(
input.DomainName,
input.MailHost,
input.ServerIPv4,
input.ServerIPv6,
input.RsaDkimPubKey,
input.EdDkimPubKey,
input.TlsaHash,
input.DaneEnabled,
)
tlsaHashes := input.TlsaHashes
if len(tlsaHashes) == 0 && input.TlsaHash != "" {
tlsaHashes = []string{input.TlsaHash}
}
desiredSpecs := entity.BuildDnsRecordSpecs(entity.DnsRecordSpec{
DomainName: input.DomainName,
MailHost: input.MailHost,
ServerIPv4: input.ServerIPv4,
ServerIPv6: input.ServerIPv6,
RsaDkimPubKey: input.RsaDkimPubKey,
EdDkimPubKey: input.EdDkimPubKey,
TlsaHashes: tlsaHashes,
DaneEnabled: input.DaneEnabled,
RelaySpfInclude: input.RelaySpfInclude,
})
zoneID, err := uc.dnsProvider.GetZoneID(ctx, input.DomainName)
if err != nil {
@@ -66,31 +120,48 @@ func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecor
return nil, fmt.Errorf("list dns records for zone %s: %w", zoneID, err)
}
existingMap := make(map[string]entity.DnsRecord)
// A single type+name can legitimately hold several records (multiple TXT at
// the zone apex is the common case), so the index maps to every candidate.
existingByKey := make(map[string][]entity.DnsRecord)
for _, rec := range existingRecords {
key := fmt.Sprintf("%s:%s", strings.ToUpper(rec.Type), strings.ToLower(rec.Name))
existingMap[key] = rec
key := recordKey(rec.Type, rec.Name)
existingByKey[key] = append(existingByKey[key], rec)
}
output := &SyncDnsRecordsOutput{ZoneID: zoneID}
for _, spec := range desiredSpecs {
key := fmt.Sprintf("%s:%s", strings.ToUpper(spec.Type), strings.ToLower(spec.Name))
existing, found := existingMap[key]
candidates := existingByKey[recordKey(spec.Type, spec.Name)]
match, foreign := classifyCandidates(spec, candidates)
if !found {
if err := uc.dnsProvider.CreateRecord(ctx, zoneID, spec); err != nil {
return nil, fmt.Errorf("create dns record %s %s: %w", spec.Type, spec.Name, err)
switch {
case match != nil:
if spec.EqualsNormalized(*match) {
output.UnchangedCount++
continue
}
output.CreatedCount++
} else if !spec.EqualsNormalized(existing) {
spec.ID = existing.ID
spec.ID = match.ID
if err := uc.dnsProvider.UpdateRecord(ctx, zoneID, spec); err != nil {
return nil, fmt.Errorf("update dns record %s %s: %w", spec.Type, spec.Name, err)
output.Errors = append(output.Errors, fmt.Sprintf("update %s %s: %v", spec.Type, spec.Name, err))
continue
}
output.UpdatedCount++
} else {
output.UnchangedCount++
case foreign != nil:
output.ConflictCount++
output.Conflicts = append(output.Conflicts, SyncConflict{
Type: spec.Type,
Name: spec.Name,
Existing: foreign.Value,
Desired: spec.Value,
})
default:
if err := uc.dnsProvider.CreateRecord(ctx, zoneID, spec); err != nil {
output.Errors = append(output.Errors, fmt.Sprintf("create %s %s: %v", spec.Type, spec.Name, err))
continue
}
output.CreatedCount++
}
}
@@ -100,5 +171,104 @@ func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecor
}
}
output.Unverified = uc.verifyPublished(ctx, desiredSpecs)
output.Status = SyncStatusVerified
switch {
case output.ConflictCount > 0 || len(output.Errors) > 0:
output.Status = SyncStatusPartial
case len(output.Unverified) > 0:
// The records were written but the world cannot see them yet. That is
// drift, not success: reporting VERIFIED here is what would let a
// broken zone look healthy.
output.Status = SyncStatusDrift
}
return output, nil
}
// verifyPublished re-reads every desired record through public resolvers and
// returns the ones that did not check out.
func (uc *SyncDnsRecordsUseCase) verifyPublished(ctx context.Context, specs []entity.DnsRecord) []string {
if uc.verifier == nil {
return nil
}
var unverified []string
for _, spec := range specs {
visible, detail := uc.verifier.VerifyRecord(ctx, spec)
if visible {
continue
}
entry := fmt.Sprintf("%s %s", spec.Type, spec.Name)
if detail != "" {
entry += ": " + detail
}
unverified = append(unverified, entry)
}
return unverified
}
func recordKey(recType, name string) string {
return strings.ToUpper(recType) + ":" + strings.ToLower(strings.TrimSuffix(name, "."))
}
// classifyCandidates picks, among the records already published under a
// desired record's type and name, the one this reconciler owns. It returns
// either that match, or - when no record is ours and the name admits only a
// single value - the foreign record blocking it.
func classifyCandidates(spec entity.DnsRecord, candidates []entity.DnsRecord) (match *entity.DnsRecord, blocking *entity.DnsRecord) {
for i := range candidates {
if isOurRecord(spec, candidates[i]) {
return &candidates[i], nil
}
}
if len(candidates) > 0 && !allowsCoexistence(spec.Type) {
return nil, &candidates[0]
}
return nil, nil
}
// isOurRecord decides ownership. The record comment is the primary marker, but
// it is not preserved by every provider or migration path, so TXT records fall
// back to their protocol tag: only one "v=spf1" record may exist per name, so a
// record carrying our tag is unambiguously the one we manage.
func isOurRecord(spec, existing entity.DnsRecord) bool {
if strings.HasPrefix(existing.Comment, ownedRecordCommentPrefix) {
return true
}
if strings.EqualFold(spec.Type, "TXT") {
tag := txtTag(spec.Value)
return tag != "" && strings.EqualFold(txtTag(existing.Value), tag)
}
// For single-valued names an identical value means the record is already
// what we want, whoever created it.
return strings.TrimSpace(spec.Value) == strings.TrimSpace(existing.Value)
}
// txtTag extracts the leading "v=<protocol>" tag that identifies which mail
// protocol a TXT record belongs to (RFC 7208 SPF, RFC 6376 DKIM, RFC 7489
// DMARC, RFC 8461 MTA-STS, RFC 8460 TLS-RPT).
func txtTag(value string) string {
trimmed := strings.TrimSpace(strings.Trim(value, `"`))
if !strings.HasPrefix(strings.ToLower(trimmed), "v=") {
return ""
}
rest := trimmed[2:]
if idx := strings.IndexAny(rest, "; \t"); idx >= 0 {
rest = rest[:idx]
}
return strings.ToLower(rest)
}
// allowsCoexistence reports whether several records may share a type and name.
// TXT is the mail-relevant case: verification tokens from unrelated services
// live alongside SPF at the zone apex and must not be disturbed.
func allowsCoexistence(recType string) bool {
switch strings.ToUpper(recType) {
case "TXT", "MX", "TLSA":
return true
default:
return false
}
}
@@ -9,12 +9,35 @@ import (
"lambdamail/protocols/internal/domain/entity"
)
// fakeDnsProvider models a real zone: several records may share a type and
// name (multiple TXT at the apex), so records are held in a list keyed by id.
type fakeDnsProvider struct {
records map[string]entity.DnsRecord // key -> record
records []entity.DnsRecord
nextID int
}
func newFakeDnsProvider() *fakeDnsProvider {
return &fakeDnsProvider{records: make(map[string]entity.DnsRecord)}
return &fakeDnsProvider{}
}
// seed publishes a pre-existing record that this reconciler did not create.
func (f *fakeDnsProvider) seed(record entity.DnsRecord) {
if record.ID == "" {
f.nextID++
record.ID = fmt.Sprintf("seed_%d", f.nextID)
}
f.records = append(f.records, record)
}
// find returns the records published under a type and name.
func (f *fakeDnsProvider) find(recType, name string) []entity.DnsRecord {
var out []entity.DnsRecord
for _, r := range f.records {
if strings.EqualFold(r.Type, recType) && strings.EqualFold(r.Name, name) {
out = append(out, r)
}
}
return out
}
func (f *fakeDnsProvider) GetZoneID(_ context.Context, domainName string) (string, error) {
@@ -22,31 +45,31 @@ func (f *fakeDnsProvider) GetZoneID(_ context.Context, domainName string) (strin
}
func (f *fakeDnsProvider) ListRecords(_ context.Context, _ string) ([]entity.DnsRecord, error) {
var out []entity.DnsRecord
for _, r := range f.records {
out = append(out, r)
}
return out, nil
return append([]entity.DnsRecord(nil), f.records...), nil
}
func (f *fakeDnsProvider) CreateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name))
record.ID = fmt.Sprintf("id_%s", key)
f.records[key] = record
f.nextID++
record.ID = fmt.Sprintf("id_%d", f.nextID)
f.records = append(f.records, record)
return nil
}
func (f *fakeDnsProvider) UpdateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name))
f.records[key] = record
return nil
for i, r := range f.records {
if r.ID == record.ID {
f.records[i] = record
return nil
}
}
return fmt.Errorf("record %s not found", record.ID)
}
func (f *fakeDnsProvider) DeleteRecord(_ context.Context, _ string, recordID string) error {
for k, r := range f.records {
for i, r := range f.records {
if r.ID == recordID {
delete(f.records, k)
break
f.records = append(f.records[:i], f.records[i+1:]...)
return nil
}
}
return nil
@@ -61,7 +84,11 @@ func (f *fakeSystemAliasRepository) EnsureSystemAliases(_ context.Context, _ str
return nil
}
func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) {
// The full desired state is the 13 numbered records of PLAN.md section 7.1
// plus the three client-autoconfiguration records of section 7.2.
const expectedRecordCount = 16
func TestSyncDnsRecordsUseCase_CreatesFullRecordSetAndAliases(t *testing.T) {
dns := newFakeDnsProvider()
alias := &fakeSystemAliasRepository{}
uc := NewSyncDnsRecordsUseCase(dns, alias)
@@ -85,8 +112,8 @@ func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) {
t.Fatalf("Execute failed: %v", err)
}
if out.CreatedCount != 13 {
t.Errorf("CreatedCount = %d, want 13", out.CreatedCount)
if out.CreatedCount != expectedRecordCount {
t.Errorf("CreatedCount = %d, want %d", out.CreatedCount, expectedRecordCount)
}
if !alias.ensured {
t.Error("expected system aliases to be ensured")
@@ -98,7 +125,7 @@ func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) {
t.Fatalf("Execute 2 failed: %v", err)
}
if out2.UnchangedCount != 13 || out2.CreatedCount != 0 || out2.UpdatedCount != 0 {
t.Errorf("expected 13 unchanged, got created=%d, updated=%d, unchanged=%d", out2.CreatedCount, out2.UpdatedCount, out2.UnchangedCount)
if out2.UnchangedCount != expectedRecordCount || out2.CreatedCount != 0 || out2.UpdatedCount != 0 {
t.Errorf("expected %d unchanged, got created=%d, updated=%d, unchanged=%d", expectedRecordCount, out2.CreatedCount, out2.UpdatedCount, out2.UnchangedCount)
}
}