#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

@@ -6,10 +6,15 @@ import (
"github.com/google/uuid"
)
// MailboxAuth is the subset of mailbox data needed to verify a login.
// MailboxAuth is the subset of mailbox data needed to verify a login and,
// once authenticated, to apply the sender policy of PLAN.md section 5.2.
type MailboxAuth struct {
ID uuid.UUID
PasswordHash string
EmailAddress string
DomainName string
// MaxRecipientsPerHour caps a compromised account's blast radius.
MaxRecipientsPerHour int
}
// AuthRepository resolves a login address to its stored credential.
@@ -4,11 +4,11 @@ import "context"
// ImapFolderRecord is the IMAP-relevant subset of a folder row.
type ImapFolderRecord struct {
ID string
Name string
UIDNext uint32
UIDValidity uint32
NumMessages uint32
ID string
Name string
UIDNext uint32
UIDValidity uint32
NumMessages uint32
HighestModSeq uint64
}
@@ -2,6 +2,9 @@ package port
import (
"context"
"time"
"github.com/google/uuid"
"lambdamail/protocols/internal/domain/entity"
)
@@ -10,4 +13,9 @@ type OutboundRepository interface {
Enqueue(ctx context.Context, job *entity.OutboundJob) error
FetchNextReady(ctx context.Context, workerID string, limit int) ([]*entity.OutboundJob, error)
UpdateJob(ctx context.Context, job *entity.OutboundJob) error
// CountRecipientsSince reports how many recipients a mailbox has queued
// since a point in time. It backs the per-mailbox send limit that keeps a
// compromised account from becoming a spam source (PLAN.md section 5.2).
CountRecipientsSince(ctx context.Context, mailboxID uuid.UUID, since time.Time) (int, error)
}
@@ -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)
}
}
@@ -10,12 +10,12 @@ import (
)
type DmarcRecord struct {
SourceIP string
Count int
Disposition string
DKIMResult string
SPFResult string
HeaderFrom string
SourceIP string
Count int
Disposition string
DKIMResult string
SPFResult string
HeaderFrom string
}
type DmarcReport struct {
@@ -41,8 +41,8 @@ type dmarcXmlReport struct {
} `xml:"policy_published"`
Record []struct {
Row struct {
SourceIP string `xml:"source_ip"`
Count int `xml:"count"`
SourceIP string `xml:"source_ip"`
Count int `xml:"count"`
PolicyEvaluated struct {
Disposition string `xml:"disposition"`
DKIM string `xml:"dkim"`
@@ -1,9 +1,10 @@
package entity
import (
"crypto/sha256"
"encoding/binary"
"fmt"
"strings"
"time"
)
type DnsRecord struct {
@@ -17,8 +18,34 @@ type DnsRecord struct {
Comment string
}
// Build13DnsRecordSpecs generates the 13 mandatory DNS record specifications per PLAN.md section 7.
func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkimPubKey, edDkimPubKey, tlsaHash string, daneEnabled bool) []DnsRecord {
// DnsRecordSpec describes the zone the reconciler should converge on.
type DnsRecordSpec struct {
DomainName string
MailHost string
ServerIPv4 string
ServerIPv6 string
RsaDkimPubKey string
EdDkimPubKey string
// TlsaHashes are the DANE associations to publish. Two are normally
// present during a rollover: the current key and the next one
// (RFC 7671 section 8.1).
TlsaHashes []string
DaneEnabled bool
// RelaySpfInclude is the mechanism authorising a smarthost to send for
// this domain. Without it every message the relay forwards fails SPF
// (PLAN.md section 10.4).
RelaySpfInclude string
}
// BuildDnsRecordSpecs generates the DNS records of PLAN.md section 7: the 13
// numbered ones of section 7.1 plus the client-autoconfiguration records of
// section 7.2.
func BuildDnsRecordSpecs(spec DnsRecordSpec) []DnsRecord {
domainName := spec.DomainName
mailHost := spec.MailHost
serverIPv4, serverIPv6 := spec.ServerIPv4, spec.ServerIPv6
rsaDkimPubKey, edDkimPubKey := spec.RsaDkimPubKey, spec.EdDkimPubKey
if mailHost == "" {
mailHost = fmt.Sprintf("mail.%s", domainName)
}
@@ -46,7 +73,7 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim
{
Type: "TXT",
Name: domainName,
Value: fmt.Sprintf("v=spf1 mx a:%s -all", mailHost),
Value: buildSpfRecord(mailHost, spec.RelaySpfInclude),
TTL: 1,
Comment: "LambdaMail SPF Record",
},
@@ -78,7 +105,7 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim
{
Type: "TXT",
Name: fmt.Sprintf("_mta-sts.%s", domainName),
Value: fmt.Sprintf("v=STSv1; id=%d", time.Now().Unix()),
Value: fmt.Sprintf("v=STSv1; id=%s", mtaStsPolicyID(domainName, mailHost)),
TTL: 1,
Comment: "LambdaMail MTA-STS Policy Version",
},
@@ -117,6 +144,33 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim
Priority: intPtr(0),
Comment: "LambdaMail Submissions Autoconfig",
},
// The remaining client-autoconfiguration records of PLAN.md section
// 7.2. They are unnumbered there because they are conveniences, but
// the HTTPS endpoints of section 7.3 are unreachable without them.
{
Type: "SRV",
Name: fmt.Sprintf("_pop3s._tcp.%s", domainName),
Value: fmt.Sprintf("0 0 995 %s", mailHost),
TTL: 1,
Priority: intPtr(0),
Comment: "LambdaMail POP3S Autoconfig",
},
{
Type: "CNAME",
Name: fmt.Sprintf("autoconfig.%s", domainName),
Value: mailHost,
TTL: 1,
Proxied: true,
Comment: "LambdaMail Thunderbird Autoconfig Endpoint",
},
{
Type: "SRV",
Name: fmt.Sprintf("_autodiscover._tcp.%s", domainName),
Value: fmt.Sprintf("0 0 443 %s", mailHost),
TTL: 1,
Priority: intPtr(0),
Comment: "LambdaMail Outlook Autodiscover",
},
}
// 2. Conditional AAAA record if IPv6 is supplied
@@ -131,24 +185,53 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim
})
}
// 11. Conditional TLSA record if DANE is enabled
if daneEnabled && tlsaHash != "" {
records = append(records, DnsRecord{
Type: "TLSA",
Name: fmt.Sprintf("_25._tcp.%s", mailHost),
Value: fmt.Sprintf("3 1 1 %s", tlsaHash),
TTL: 1,
Comment: "LambdaMail DANE TLSA Record",
})
// 11. Conditional TLSA records if DANE is enabled. More than one is normal
// during a rollover: the current association and the next one are both
// published so the certificate can change without a gap (RFC 7671 8.1).
if spec.DaneEnabled {
for _, hash := range spec.TlsaHashes {
if hash == "" {
continue
}
records = append(records, DnsRecord{
Type: "TLSA",
Name: fmt.Sprintf("_25._tcp.%s", mailHost),
Value: fmt.Sprintf("3 1 1 %s", hash),
TTL: 1,
Comment: "LambdaMail DANE TLSA Record",
})
}
}
return records
}
// buildSpfRecord assembles the sender policy. PLAN.md section 5 uses a hard
// fail, which is only safe once every legitimate sending path is listed - so
// the relay, when there is one, has to be included here.
func buildSpfRecord(mailHost, relayInclude string) string {
mechanisms := []string{"v=spf1", "mx", "a:" + mailHost}
if relayInclude != "" {
mechanisms = append(mechanisms, relayInclude)
}
return strings.Join(append(mechanisms, "-all"), " ")
}
func intPtr(v int) *int {
return &v
}
// mtaStsPolicyID derives the MTA-STS policy id from the policy's own content
// rather than from the wall clock. RFC 8461 section 3.1 requires the id to
// change whenever the served policy changes; PLAN.md section 7.5 requires the
// reconciler to be fully idempotent. A timestamp satisfies the first and
// violates the second - every sync would rewrite the record and force every
// sender to re-fetch. Hashing the inputs that define the policy satisfies both.
func mtaStsPolicyID(domainName, mailHost string) string {
sum := sha256.Sum256([]byte(domainName + "\x00" + mailHost))
return fmt.Sprintf("%016x", binary.BigEndian.Uint64(sum[:8]))
}
// EqualsNormalized compares two DNS records ignoring subtle formatting whitespace/case differences.
func (r DnsRecord) EqualsNormalized(other DnsRecord) bool {
if r.Type != other.Type {
@@ -163,5 +246,13 @@ func (r DnsRecord) EqualsNormalized(other DnsRecord) bool {
if r.Proxied != other.Proxied {
return false
}
// MX and SRV priority drift routes mail to the wrong host, so it counts as
// a difference even though the record value itself matches.
if (r.Priority == nil) != (other.Priority == nil) {
return false
}
if r.Priority != nil && *r.Priority != *other.Priority {
return false
}
return true
}
@@ -4,21 +4,43 @@ import (
"testing"
)
func TestBuild13DnsRecordSpecs_GeneratesMandatoryRecords(t *testing.T) {
specs := Build13DnsRecordSpecs(
"example.test",
"mail.example.test",
"192.0.2.1",
"2001:db8::1",
"rsaPubKeyBase64",
"edPubKeyBase64",
"hash123",
true,
)
func TestBuildDnsRecordSpecs_GeneratesMandatoryRecords(t *testing.T) {
specs := BuildDnsRecordSpecs(DnsRecordSpec{
DomainName: "example.test",
MailHost: "mail.example.test",
ServerIPv4: "192.0.2.1",
ServerIPv6: "2001:db8::1",
RsaDkimPubKey: "rsaPubKeyBase64",
EdDkimPubKey: "edPubKeyBase64",
TlsaHashes: []string{"hash123"},
DaneEnabled: true,
})
// Should contain 13 records (11 mandatory + 1 AAAA + 1 TLSA)
if len(specs) != 13 {
t.Fatalf("expected 13 records, got %d", len(specs))
// PLAN.md section 7.1 numbers 13 records (11 always-on, plus AAAA when
// IPv6 is configured and TLSA when DANE is on). Section 7.2 adds three
// unnumbered client-autoconfiguration records: the _pop3s SRV, the
// autoconfig CNAME and the _autodiscover SRV.
const numberedRecords, convenienceRecords = 13, 3
if len(specs) != numberedRecords+convenienceRecords {
t.Fatalf("expected %d records, got %d", numberedRecords+convenienceRecords, len(specs))
}
// The HTTPS endpoints of section 7.3 are unreachable without the names
// that point at them.
for _, required := range []struct{ recType, name string }{
{"CNAME", "autoconfig.example.test"},
{"SRV", "_pop3s._tcp.example.test"},
{"SRV", "_autodiscover._tcp.example.test"},
} {
found := false
for _, r := range specs {
if r.Type == required.recType && r.Name == required.name {
found = true
}
}
if !found {
t.Errorf("%s %s not found in specs", required.recType, required.name)
}
}
// Verify MX
@@ -24,7 +24,7 @@ func NewMailbox(domainID uuid.UUID, address valueobject.EmailAddress, hash value
func (m *Mailbox) Address() valueobject.EmailAddress { return m.address }
func (m *Mailbox) UsedBytes() int64 { return m.usedBytes }
func (m *Mailbox) Quota() valueobject.QuotaLimit { return m.quota }
func (m *Mailbox) Quota() valueobject.QuotaLimit { return m.quota }
// RecordUsage sets used_bytes, enforcing "used_bytes <= quota_bytes" (PLAN.md section 3).
func (m *Mailbox) RecordUsage(bytes int64) error {
@@ -36,15 +36,23 @@ type OutboundJob struct {
CreatedAt time.Time
}
// CalculateNextBackoff computes the next retry time and DSN requirements based on attempt count.
func CalculateNextBackoff(now time.Time, attempt int, expiresAt time.Time) (nextAttemptAt time.Time, sendDelayDsn bool, permanentFailure bool) {
// CalculateNextBackoff computes the next retry time and DSN requirements from
// the attempt that has just failed. The delays follow PLAN.md section 6.3,
// which tabulates the wait *before* each attempt: attempt 2 runs 5 min after
// attempt 1 failed, attempt 4 an hour later and also emits the delay DSN, and
// from attempt 5 on the wait carries +/-20% jitter to avoid a thundering herd.
func CalculateNextBackoff(now time.Time, failedAttempt int, expiresAt time.Time) (nextAttemptAt time.Time, sendDelayDsn bool, permanentFailure bool) {
if !expiresAt.IsZero() && (now.After(expiresAt) || now.Equal(expiresAt)) {
return now, false, true
}
// The delay to apply is the one the retry table associates with the
// attempt about to be scheduled, not with the one that just failed.
nextAttempt := failedAttempt + 1
var baseDelay time.Duration
switch attempt {
switch nextAttempt {
case 1:
baseDelay = 0
case 2:
@@ -64,8 +72,8 @@ func CalculateNextBackoff(now time.Time, attempt int, expiresAt time.Time) (next
baseDelay = 12 * time.Hour
}
if baseDelay > 0 && attempt >= 5 {
// Apply ±20% jitter
if baseDelay > 0 && nextAttempt >= 5 {
// Apply +/-20% jitter
jitterFactor := 0.8 + rand.Float64()*0.4
baseDelay = time.Duration(float64(baseDelay) * jitterFactor)
}
@@ -5,26 +5,50 @@ import (
"time"
)
func TestCalculateNextBackoff_SchedulesCorrectDelays(t *testing.T) {
// PLAN.md section 6.3 defines the wait *before the next* attempt: after
// attempt 1 fails the next try is +5 min, after attempt 3 it is +1 h together
// with the delay DSN, and from the fifth try on the delay carries +/-20%
// jitter. A zero delay after the first failure would hammer the destination in
// a tight loop.
func TestCalculateNextBackoff_FollowsRetryTable(t *testing.T) {
now := time.Now()
expiresAt := now.Add(5 * 24 * time.Hour)
// Attempt 2 -> +5 min
next, delayDsn, perm := CalculateNextBackoff(now, 2, expiresAt)
if perm || delayDsn || next.Sub(now) < 4*time.Minute {
t.Errorf("attempt 2 backoff mismatch: next=%v, delayDsn=%v, perm=%v", next, delayDsn, perm)
cases := []struct {
failedAttempt int
wantMin time.Duration
wantMax time.Duration
wantDelayDsn bool
}{
{1, 5 * time.Minute, 5 * time.Minute, false},
{2, 15 * time.Minute, 15 * time.Minute, false},
{3, time.Hour, time.Hour, true},
{4, 96 * time.Minute, 144 * time.Minute, false}, // 2 h +/- 20%
{5, 192 * time.Minute, 288 * time.Minute, false}, // 4 h +/- 20%
{6, 384 * time.Minute, 576 * time.Minute, false}, // 8 h +/- 20%
{7, 576 * time.Minute, 864 * time.Minute, false}, // 12 h +/- 20%
{20, 576 * time.Minute, 864 * time.Minute, false}, // steady 12 h
}
// Attempt 4 -> +1 hr + delayDsn=true
next, delayDsn, perm = CalculateNextBackoff(now, 4, expiresAt)
if perm || !delayDsn || next.Sub(now) < 55*time.Minute {
t.Errorf("attempt 4 backoff mismatch: next=%v, delayDsn=%v, perm=%v", next, delayDsn, perm)
for _, tc := range cases {
next, delayDsn, perm := CalculateNextBackoff(now, tc.failedAttempt, expiresAt)
if perm {
t.Errorf("attempt %d: unexpected permanent failure", tc.failedAttempt)
continue
}
got := next.Sub(now)
if got < tc.wantMin || got > tc.wantMax {
t.Errorf("attempt %d: delay = %v, want between %v and %v", tc.failedAttempt, got, tc.wantMin, tc.wantMax)
}
if delayDsn != tc.wantDelayDsn {
t.Errorf("attempt %d: delayDsn = %v, want %v", tc.failedAttempt, delayDsn, tc.wantDelayDsn)
}
}
}
// Expired -> permanent failure
expiredTime := now.Add(-1 * time.Hour)
_, _, perm = CalculateNextBackoff(now, 5, expiredTime)
if !perm {
t.Errorf("expected permanent failure for expired job")
func TestCalculateNextBackoff_ExpiredJobIsPermanent(t *testing.T) {
now := time.Now()
if _, _, perm := CalculateNextBackoff(now, 5, now.Add(-time.Hour)); !perm {
t.Error("expected permanent failure for an expired job")
}
}
@@ -7,10 +7,10 @@ import (
)
var (
ErrEmptyScript = errors.New("sieve script cannot be empty")
ErrUnmatchedBrace = errors.New("unmatched curly brace in sieve script")
ErrUnmatchedBracket = errors.New("unmatched square bracket in sieve script")
ErrUnterminatedQuote = errors.New("unterminated string literal in sieve script")
ErrEmptyScript = errors.New("sieve script cannot be empty")
ErrUnmatchedBrace = errors.New("unmatched curly brace in sieve script")
ErrUnmatchedBracket = errors.New("unmatched square bracket in sieve script")
ErrUnterminatedQuote = errors.New("unterminated string literal in sieve script")
ErrUnterminatedComment = errors.New("unterminated comment block in sieve script")
)
@@ -32,8 +32,8 @@ if size :over 1M {
func TestValidateSieveScript_InvalidScripts(t *testing.T) {
tests := []struct {
name string
script string
name string
script string
wantErr error
}{
{"empty", "", ErrEmptyScript},
@@ -1,25 +1,155 @@
package entity
// TLSPolicy is the effective transport security policy for an outbound destination
// (PLAN.md section 3: DANE has precedence over MTA-STS, RFC 8461 section 2).
import (
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"strings"
"lambdamail/protocols/internal/domain/valueobject"
)
// Transport security modes, in the precedence order of RFC 8461 section 2:
// DANE wins when TLSA records exist, MTA-STS applies otherwise, and a
// destination with neither gets opportunistic TLS (RFC 7435).
const (
TLSModeDane = "dane"
TLSModeMtaSts = "mta-sts"
TLSModeOpportunistic = "opportunistic"
// TLSModeRelay records that the message left through a smarthost, which
// owns transport security from that point on (PLAN.md section 10.4).
TLSModeRelay = "relay"
)
// TLSPolicy is the effective transport security policy for one outbound
// destination (PLAN.md sections 5 and 6.2).
type TLSPolicy struct {
daneAvailable bool
mtaSTSAvailable bool
// enforce is true when a validation failure must defer the message
// instead of falling back to an unvalidated connection. It is false for
// MTA-STS "testing", whose whole purpose is to report without breaking.
enforce bool
// mxPatterns are the "mx:" entries of the MTA-STS policy. A host that
// matches none of them is not covered by the policy.
mxPatterns []string
// tlsaRecords are the DANE-EE associations published for the target host.
tlsaRecords []valueobject.TLSARecord
}
// NewTLSPolicy builds an opportunistic-or-simple policy. It is kept for
// callers that only need the precedence decision.
func NewTLSPolicy(daneAvailable, mtaSTSAvailable bool) TLSPolicy {
return TLSPolicy{daneAvailable: daneAvailable, mtaSTSAvailable: mtaSTSAvailable}
}
// Effective returns "dane", "mta-sts" or "opportunistic" following the precedence
// rule in PLAN.md section 5 / RFC 8461 section 2.
// NewDanePolicy builds a policy backed by TLSA records. DANE is always
// enforcing: the records are DNSSEC-signed, so a mismatch is an attack or a
// misconfiguration, never an acceptable downgrade (RFC 7672 section 2.2).
func NewDanePolicy(records []valueobject.TLSARecord) TLSPolicy {
return TLSPolicy{daneAvailable: true, enforce: true, tlsaRecords: records}
}
// NewMtaStsPolicy builds a policy from a fetched MTA-STS policy file.
func NewMtaStsPolicy(mxPatterns []string, enforce bool) TLSPolicy {
return TLSPolicy{mtaSTSAvailable: true, enforce: enforce, mxPatterns: mxPatterns}
}
// Effective returns "dane", "mta-sts" or "opportunistic" following the
// precedence rule in PLAN.md section 5 / RFC 8461 section 2.
func (p TLSPolicy) Effective() string {
switch {
case p.daneAvailable:
return "dane"
return TLSModeDane
case p.mtaSTSAvailable:
return "mta-sts"
return TLSModeMtaSts
default:
return "opportunistic"
return TLSModeOpportunistic
}
}
// RequiresValidation reports whether the connection's certificate has to be
// checked before any mail is handed over.
func (p TLSPolicy) RequiresValidation() bool {
return p.enforce && (p.daneAvailable || p.mtaSTSAvailable)
}
// CoversHost reports whether a candidate MX is inside the policy's scope. An
// MTA-STS policy that does not list the host means the host must not be used
// under that policy (RFC 8461 section 4.1).
func (p TLSPolicy) CoversHost(host string) bool {
if !p.mtaSTSAvailable {
return true
}
for _, pattern := range p.mxPatterns {
if matchMxPattern(pattern, host) {
return true
}
}
return false
}
// matchMxPattern implements the single-label wildcard of RFC 8461 section 4.1:
// "*.example.com" matches "mx1.example.com" but not "a.mx1.example.com".
func matchMxPattern(pattern, host string) bool {
pattern = strings.ToLower(strings.TrimSuffix(pattern, "."))
host = strings.ToLower(strings.TrimSuffix(host, "."))
if !strings.HasPrefix(pattern, "*.") {
return pattern == host
}
suffix := pattern[1:] // ".example.com"
if !strings.HasSuffix(host, suffix) {
return false
}
label := strings.TrimSuffix(host, suffix)
return label != "" && !strings.Contains(label, ".")
}
// MatchesCertificate checks a presented certificate chain against the DANE
// associations. Only usage 3 (DANE-EE) is supported by explicit decision
// (PLAN.md section 5.1), so the end-entity certificate is what gets matched
// and the PKIX chain is irrelevant.
func (p TLSPolicy) MatchesCertificate(chain []*x509.Certificate) bool {
if len(p.tlsaRecords) == 0 || len(chain) == 0 {
return false
}
leaf := chain[0]
for _, record := range p.tlsaRecords {
if digestFor(leaf, record) == strings.ToLower(record.Data()) {
return true
}
}
return false
}
// digestFor renders the certificate the way a TLSA record's selector and
// matching type describe it (RFC 6698 section 2.1.2).
func digestFor(cert *x509.Certificate, record valueobject.TLSARecord) string {
var material []byte
switch record.Selector() {
case 0: // full certificate
material = cert.Raw
case 1: // SubjectPublicKeyInfo
material = cert.RawSubjectPublicKeyInfo
default:
return ""
}
switch record.MatchingType() {
case 0: // exact match on the material itself
return strings.ToLower(hex.EncodeToString(material))
case 1: // SHA-256
sum := sha256.Sum256(material)
return hex.EncodeToString(sum[:])
default:
// SHA-512 (type 2) is deliberately unsupported: the reconciler only
// ever publishes "3 1 1" records (PLAN.md section 7.1).
return ""
}
}
@@ -13,75 +13,75 @@ type DomainEvent interface {
type EmailReceived struct{ MessageAggregateID uuid.UUID }
func (e EmailReceived) Type() string { return "EmailReceived" }
func (e EmailReceived) Type() string { return "EmailReceived" }
func (e EmailReceived) AggregateID() uuid.UUID { return e.MessageAggregateID }
type EmailAccepted struct{ MessageAggregateID uuid.UUID }
func (e EmailAccepted) Type() string { return "EmailAccepted" }
func (e EmailAccepted) Type() string { return "EmailAccepted" }
func (e EmailAccepted) AggregateID() uuid.UUID { return e.MessageAggregateID }
type EmailDelivered struct{ JobAggregateID uuid.UUID }
func (e EmailDelivered) Type() string { return "EmailDelivered" }
func (e EmailDelivered) Type() string { return "EmailDelivered" }
func (e EmailDelivered) AggregateID() uuid.UUID { return e.JobAggregateID }
type EmailDeferred struct{ JobAggregateID uuid.UUID }
func (e EmailDeferred) Type() string { return "EmailDeferred" }
func (e EmailDeferred) Type() string { return "EmailDeferred" }
func (e EmailDeferred) AggregateID() uuid.UUID { return e.JobAggregateID }
type EmailBounced struct{ JobAggregateID uuid.UUID }
func (e EmailBounced) Type() string { return "EmailBounced" }
func (e EmailBounced) Type() string { return "EmailBounced" }
func (e EmailBounced) AggregateID() uuid.UUID { return e.JobAggregateID }
type MailboxQuotaWarning struct{ MailboxAggregateID uuid.UUID }
func (e MailboxQuotaWarning) Type() string { return "MailboxQuotaWarning" }
func (e MailboxQuotaWarning) Type() string { return "MailboxQuotaWarning" }
func (e MailboxQuotaWarning) AggregateID() uuid.UUID { return e.MailboxAggregateID }
type MailboxQuotaExceeded struct{ MailboxAggregateID uuid.UUID }
func (e MailboxQuotaExceeded) Type() string { return "MailboxQuotaExceeded" }
func (e MailboxQuotaExceeded) Type() string { return "MailboxQuotaExceeded" }
func (e MailboxQuotaExceeded) AggregateID() uuid.UUID { return e.MailboxAggregateID }
type SpamDetected struct{ MessageAggregateID uuid.UUID }
func (e SpamDetected) Type() string { return "SpamDetected" }
func (e SpamDetected) Type() string { return "SpamDetected" }
func (e SpamDetected) AggregateID() uuid.UUID { return e.MessageAggregateID }
type VirusDetected struct{ MessageAggregateID uuid.UUID }
func (e VirusDetected) Type() string { return "VirusDetected" }
func (e VirusDetected) Type() string { return "VirusDetected" }
func (e VirusDetected) AggregateID() uuid.UUID { return e.MessageAggregateID }
type DkimRotationStarted struct{ DomainAggregateID uuid.UUID }
func (e DkimRotationStarted) Type() string { return "DkimRotationStarted" }
func (e DkimRotationStarted) Type() string { return "DkimRotationStarted" }
func (e DkimRotationStarted) AggregateID() uuid.UUID { return e.DomainAggregateID }
type DkimRotationCompleted struct{ DomainAggregateID uuid.UUID }
func (e DkimRotationCompleted) Type() string { return "DkimRotationCompleted" }
func (e DkimRotationCompleted) Type() string { return "DkimRotationCompleted" }
func (e DkimRotationCompleted) AggregateID() uuid.UUID { return e.DomainAggregateID }
type DnsRecordDrifted struct{ DomainAggregateID uuid.UUID }
func (e DnsRecordDrifted) Type() string { return "DnsRecordDrifted" }
func (e DnsRecordDrifted) Type() string { return "DnsRecordDrifted" }
func (e DnsRecordDrifted) AggregateID() uuid.UUID { return e.DomainAggregateID }
type CertificateRotated struct{ HostAggregateID uuid.UUID }
func (e CertificateRotated) Type() string { return "CertificateRotated" }
func (e CertificateRotated) Type() string { return "CertificateRotated" }
func (e CertificateRotated) AggregateID() uuid.UUID { return e.HostAggregateID }
type AuthenticationFailed struct{ MailboxAggregateID uuid.UUID }
func (e AuthenticationFailed) Type() string { return "AuthenticationFailed" }
func (e AuthenticationFailed) Type() string { return "AuthenticationFailed" }
func (e AuthenticationFailed) AggregateID() uuid.UUID { return e.MailboxAggregateID }
type RateLimitTriggered struct{ SubjectAggregateID uuid.UUID }
func (e RateLimitTriggered) Type() string { return "RateLimitTriggered" }
func (e RateLimitTriggered) Type() string { return "RateLimitTriggered" }
func (e RateLimitTriggered) AggregateID() uuid.UUID { return e.SubjectAggregateID }
@@ -11,9 +11,9 @@ type DomainName struct {
}
var (
ErrDomainNameEmpty = errors.New("domain name: must not be empty")
ErrDomainNameNotFQDN = errors.New("domain name: must contain at least one dot (not a FQDN)")
ErrDomainNameInvalidLabel = errors.New("domain name: label must not start or end with a hyphen")
ErrDomainNameEmpty = errors.New("domain name: must not be empty")
ErrDomainNameNotFQDN = errors.New("domain name: must contain at least one dot (not a FQDN)")
ErrDomainNameInvalidLabel = errors.New("domain name: label must not start or end with a hyphen")
)
// NewDomainName validates and normalizes raw into a DomainName.
@@ -14,9 +14,9 @@ type EmailAddress struct {
}
var (
ErrEmailMissingAtSign = errors.New("email address: missing '@' separator")
ErrEmailEmptyLocalPart = errors.New("email address: local part must not be empty")
ErrEmailEmptyDomain = errors.New("email address: domain must not be empty")
ErrEmailMissingAtSign = errors.New("email address: missing '@' separator")
ErrEmailEmptyLocalPart = errors.New("email address: local part must not be empty")
ErrEmailEmptyDomain = errors.New("email address: domain must not be empty")
ErrEmailLocalPartTooLong = errors.New("email address: local part exceeds 64 octets (RFC 5321 4.5.3.1.1)")
)
@@ -11,7 +11,7 @@ type MessageID struct {
}
var (
ErrMessageIDEmpty = errors.New("message id: must not be empty")
ErrMessageIDEmpty = errors.New("message id: must not be empty")
ErrMessageIDNotBracketed = errors.New("message id: must be wrapped in angle brackets")
)
@@ -12,7 +12,7 @@ func TestSpamVerdictFromScore_MatchesThresholds(t *testing.T) {
{3.9, SpamHam},
{4, SpamProbable},
{8.9, SpamProbable},
{9, SpamReject}, // section 10.1: 9-15 greylists/defers, modeled here as reject-eligible
{9, SpamReject}, // section 10.1: 9-15 greylists/defers, modeled here as reject-eligible
{15.1, SpamReject},
}
for _, c := range cases {
@@ -6,14 +6,12 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
gosmtp "github.com/emersion/go-smtp"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/google/uuid"
appusecase "lambdamail/protocols/internal/application/usecase"
@@ -47,22 +45,7 @@ func TestAntispamAntivirusEndToEnd(t *testing.T) {
}
defer pool.Close()
root := repoRoot(t)
migrations := []string{
"0001_init_schema.up.sql",
"0002_add_is_system_to_aliases.up.sql",
"0003_create_report_tables.up.sql",
}
for _, m := range migrations {
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
if err != nil {
t.Fatalf("read migration %s: %v", m, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", m, err)
}
}
applyMigrations(t, ctx, pool)
// Seed domain, mailbox, INBOX and Junk folders
domainID := uuid.New()
@@ -2,13 +2,11 @@ package e2e
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/google/uuid"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/domain/entity"
@@ -38,21 +36,7 @@ func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) {
}
defer pool.Close()
root := repoRoot(t)
migrations := []string{
"0001_init_schema.up.sql",
"0002_add_is_system_to_aliases.up.sql",
"0003_create_report_tables.up.sql",
}
for _, m := range migrations {
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
if err != nil {
t.Fatalf("read migration %s: %v", m, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", m, err)
}
}
applyMigrations(t, ctx, pool)
// Seed domain & admin mailbox
domainID := uuid.New()
@@ -86,8 +70,11 @@ func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) {
t.Fatalf("SyncDnsRecords execute: %v", err)
}
if out.CreatedCount != 13 {
t.Errorf("CreatedCount = %d, want 13", out.CreatedCount)
// The 13 numbered records of PLAN.md section 7.1 plus the three
// client-autoconfiguration records of section 7.2.
const expectedRecords = 16
if out.CreatedCount != expectedRecords {
t.Errorf("CreatedCount = %d, want %d", out.CreatedCount, expectedRecords)
}
// Verify 4 system aliases in PostgreSQL (postmaster@, abuse@, dmarc@, tlsrpt@)
@@ -7,8 +7,6 @@ import (
"encoding/base64"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -52,21 +50,7 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) {
}
defer pool.Close()
root := repoRoot(t)
migrations := []string{
"0001_init_schema.up.sql",
"0002_add_is_system_to_aliases.up.sql",
"0003_create_report_tables.up.sql",
}
for _, m := range migrations {
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
if err != nil {
t.Fatalf("read migration %s: %v", m, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", m, err)
}
}
applyMigrations(t, ctx, pool)
// 2. Seed domain, mailbox, INBOX, Archive
password := "correct horse battery staple"
@@ -443,7 +427,7 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) {
t.Fatalf("SETACTIVE = %q", activeResp)
}
// Verify RFC 5804 §2.7: DELETESCRIPT on active script MUST fail
// Verify RFC 5804 section 2.7: DELETESCRIPT on active script MUST fail
sendCmdSieve("DELETESCRIPT \"auto_rules\"")
if delActiveResp := readLineSieve(); !strings.HasPrefix(delActiveResp, "NO") {
t.Fatalf("expected NO for deleting active script, got %q", delActiveResp)
@@ -8,8 +8,6 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -19,12 +17,14 @@ import (
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/domain/entity"
"lambdamail/protocols/internal/domain/valueobject"
"lambdamail/protocols/internal/infrastructure/clamav"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/postgres"
"lambdamail/protocols/internal/infrastructure/rspamd"
httppresentation "lambdamail/protocols/internal/presentation/http"
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
gosmtp "github.com/emersion/go-smtp"
)
@@ -96,22 +96,7 @@ func TestPhase3CompleteEndToEnd(t *testing.T) {
defer pool.Close()
// Apply Database Migrations
root := repoRoot(t)
migrations := []string{
"0001_init_schema.up.sql",
"0002_add_is_system_to_aliases.up.sql",
"0003_create_report_tables.up.sql",
}
for _, m := range migrations {
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
if err != nil {
t.Fatalf("read migration %s: %v", m, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", m, err)
}
}
applyMigrations(t, ctx, pool)
// -------------------------------------------------------------------
// 1. Sub-project 1: Cloudflare DNS Automation Engine & System Aliases
@@ -156,8 +141,14 @@ func TestPhase3CompleteEndToEnd(t *testing.T) {
if err != nil {
t.Fatalf("sync dns records: %v", err)
}
if dnsOut.CreatedCount != 13 {
t.Fatalf("expected 13 created DNS records, got %d", dnsOut.CreatedCount)
// The 13 numbered records of PLAN.md section 7.1 plus the three
// client-autoconfiguration records of section 7.2.
const expectedDnsRecords = 16
if dnsOut.CreatedCount != expectedDnsRecords {
t.Fatalf("expected %d created DNS records, got %d", expectedDnsRecords, dnsOut.CreatedCount)
}
if dnsOut.Status != appusecase.SyncStatusVerified {
t.Errorf("sync status = %s, want VERIFIED", dnsOut.Status)
}
// Verify system aliases created
@@ -174,14 +165,26 @@ func TestPhase3CompleteEndToEnd(t *testing.T) {
reportUC := appusecase.NewIngestReportsUseCase(reportRepo)
httpRouter := httppresentation.NewRouter(reportUC, func() error { return pool.Ping(ctx) })
// Test MTA-STS endpoint
// Test MTA-STS endpoint. The default is the safe end of the ramp
// (PLAN.md section 5): senders honour the policy but do not hard-fail on
// it until the operator promotes the domain to enforce.
reqSts := httptest.NewRequest("GET", "/.well-known/mta-sts.txt", nil)
reqSts.Host = "mta-sts.f3e2e.test"
recSts := httptest.NewRecorder()
httpRouter.ServeHTTP(recSts, reqSts)
if recSts.Code != http.StatusOK || !strings.Contains(recSts.Body.String(), "mode: enforce") {
if recSts.Code != http.StatusOK || !strings.Contains(recSts.Body.String(), "mode: testing") {
t.Errorf("MTA-STS endpoint error: code=%d body=%s", recSts.Code, recSts.Body.String())
}
if !strings.Contains(recSts.Body.String(), "mx: mail.f3e2e.test") {
t.Errorf("MTA-STS policy does not advertise the mail host: %s", recSts.Body.String())
}
httpRouter.SetMtaStsMode(valueobject.MtaStsModeEnforce)
recEnforced := httptest.NewRecorder()
httpRouter.ServeHTTP(recEnforced, httptest.NewRequest("GET", "/.well-known/mta-sts.txt", nil))
if !strings.Contains(recEnforced.Body.String(), "mode: enforce") {
t.Errorf("promoting the domain to enforce had no effect: %s", recEnforced.Body.String())
}
// Test Autoconfig endpoint
reqAuto := httptest.NewRequest("GET", "/.well-known/autoconfig/mail/config-v1.1.xml", nil)
@@ -6,8 +6,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -42,22 +40,7 @@ func TestHttpServicesEndToEnd(t *testing.T) {
}
defer pool.Close()
root := repoRoot(t)
migrations := []string{
"0001_init_schema.up.sql",
"0002_add_is_system_to_aliases.up.sql",
"0003_create_report_tables.up.sql",
}
for _, m := range migrations {
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
if err != nil {
t.Fatalf("read migration %s: %v", m, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", m, err)
}
}
applyMigrations(t, ctx, pool)
reportRepo := postgres.NewReportRepository(pool)
reportUseCase := appusecase.NewIngestReportsUseCase(reportRepo)
@@ -10,13 +10,13 @@ import (
"time"
"github.com/alexedwards/argon2id"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-imap/v2/imapserver"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/application/port"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/postgres"
imappresentation "lambdamail/protocols/internal/presentation/imap"
@@ -10,13 +10,13 @@ import (
"time"
"github.com/alexedwards/argon2id"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-imap/v2/imapserver"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/application/port"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/postgres"
imappresentation "lambdamail/protocols/internal/presentation/imap"
@@ -0,0 +1,79 @@
package e2e
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// applyMigrations brings a fresh database up to the current schema by running
// every up-migration in order. It reads the directory instead of taking a
// hand-written list so that adding a migration cannot leave a test silently
// running against a stale schema.
func applyMigrations(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
t.Helper()
dir := filepath.Join(repoRoot(t), "migrations")
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read migrations dir: %v", err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
names = append(names, e.Name())
}
}
if len(names) == 0 {
t.Fatalf("no up-migrations found in %s", dir)
}
sort.Strings(names)
for _, name := range names {
sql, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("read migration %s: %v", name, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", name, err)
}
}
}
// selfSignedTLSCert issues an in-memory certificate for the STARTTLS handshake
// in tests. Certificate validation is not what these tests exercise.
func selfSignedTLSCert(t *testing.T) tls.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "mail.test"},
DNSNames: []string{"mail.test", "localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}
@@ -4,8 +4,6 @@ import (
"bufio"
"context"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -50,22 +48,7 @@ func TestOutboundQueueEndToEnd(t *testing.T) {
}
defer pool.Close()
root := repoRoot(t)
migrations := []string{
"0001_init_schema.up.sql",
"0002_add_is_system_to_aliases.up.sql",
"0003_create_report_tables.up.sql",
}
for _, m := range migrations {
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
if err != nil {
t.Fatalf("read migration %s: %v", m, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", m, err)
}
}
applyMigrations(t, ctx, pool)
// Seed domain, sender mailbox, INBOX
domainID := uuid.New()
@@ -14,8 +14,8 @@ import (
"github.com/alexedwards/argon2id"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/application/port"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/postgres"
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
@@ -19,8 +19,8 @@ import (
"testing"
"time"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
gosmtp "github.com/emersion/go-smtp"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/diskstorage"
@@ -0,0 +1,360 @@
package e2e
import (
"bufio"
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"net"
"strings"
"testing"
"time"
"github.com/alexedwards/argon2id"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/google/uuid"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/dkim"
"lambdamail/protocols/internal/infrastructure/postgres"
"lambdamail/protocols/internal/infrastructure/vault"
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
gosmtp "github.com/emersion/go-smtp"
)
// TestSubmissionEndToEnd covers the outbound half of PLAN.md section 6.2:
// an authenticated client submits on the submission port, the message is
// signed with both DKIM keys, queued, and then delivered by the worker to a
// remote MX.
func TestSubmissionEndToEnd(t *testing.T) {
if testing.Short() {
t.Skip("e2e test skipped in -short mode")
}
ctx := context.Background()
runtimeDir := t.TempDir()
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
Port(54344).
RuntimePath(runtimeDir).
StartTimeout(120 * time.Second))
if err := pg.Start(); err != nil {
t.Fatalf("start embedded postgres: %v", err)
}
defer pg.Stop()
dbURL := "postgres://postgres:postgres@localhost:54344/postgres?sslmode=disable"
pool, err := postgres.NewPool(ctx, dbURL)
if err != nil {
t.Fatalf("connect pool: %v", err)
}
defer pool.Close()
applyMigrations(t, ctx, pool)
// ---------------------------------------------------------------- seed
const (
senderAddress = "sender@submission.test"
password = "correct horse battery staple"
)
domainID, mailboxID, inboxID := uuid.New(), uuid.New(), uuid.New()
hash, err := argon2id.CreateHash(password, argon2id.DefaultParams)
if err != nil {
t.Fatalf("hash password: %v", err)
}
pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'submission.test', 'submission.test')`, domainID)
pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, max_recipients_per_hour)
VALUES ($1, $2, 'sender', $3, $4, 5)`, mailboxID, domainID, senderAddress, hash)
pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`, inboxID, mailboxID)
// ----------------------------------------------------------- provision
secretVault, err := vault.New("e2e-master-key")
if err != nil {
t.Fatalf("vault: %v", err)
}
dkimRepo := postgres.NewDkimRepository(pool, secretVault)
provisioner := appusecase.NewProvisionDkimKeysUseCase(dkimRepo, func(algorithm string) ([]byte, string, error) {
generated, err := dkim.Generate(algorithm)
if err != nil {
return nil, "", err
}
return generated.PrivateKeyPEM, generated.PublicKeyBase64, nil
})
keys, err := provisioner.Execute(ctx, "submission.test")
if err != nil {
t.Fatalf("provision DKIM keys: %v", err)
}
if keys.RsaPublicKey == "" || keys.Ed25519PublicKey == "" {
t.Fatal("provisioning produced no public keys for the DNS records")
}
// The private halves must not be readable from the table without the
// master key (PLAN.md section 9).
var storedCiphertext []byte
pool.QueryRow(ctx, `SELECT private_key_enc FROM dkim_keys WHERE algorithm = 'rsa2048'`).Scan(&storedCiphertext)
if strings.Contains(string(storedCiphertext), "PRIVATE KEY") {
t.Error("the DKIM private key was stored in the clear")
}
// ------------------------------------------------------------- wiring
blobs := diskstorage.NewLocalDiskBlobStorage(pool, t.TempDir())
blobReader := diskstorage.NewLocalDiskBlobReader(pool)
authRepo := postgres.NewAuthRepository(pool)
outboundRepo := postgres.NewOutboundRepository(pool)
submissionUC := appusecase.NewProcessOutboundEmailUseCase(authRepo, outboundRepo, blobs, dkim.NewSigner(dkimRepo))
certificate := selfSignedTLSCert(t)
server := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC))
server.Domain = "mail.submission.test"
server.MaxMessageBytes = 10 << 20
server.MaxRecipients = 10
server.AllowInsecureAuth = false
server.TLSConfig = &tls.Config{Certificates: []tls.Certificate{certificate}}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go server.Serve(ln)
// -------------------------------------------------- submit over STARTTLS
submitMessage(t, ln.Addr().String(), senderAddress, password, "rcpt@remote.test")
// The job must be queued against the sending mailbox.
var queued int
var destinationDomain string
pool.QueryRow(ctx, `SELECT COUNT(*), COALESCE(MAX(destination_domain), '') FROM outbound_jobs WHERE mailbox_id = $1`, mailboxID).
Scan(&queued, &destinationDomain)
if queued != 1 {
t.Fatalf("queued %d jobs, want 1", queued)
}
if destinationDomain != "remote.test" {
t.Errorf("destination domain = %q, want remote.test", destinationDomain)
}
// The stored message must carry both signatures.
var blobID uuid.UUID
pool.QueryRow(ctx, `SELECT blob_id FROM outbound_jobs WHERE mailbox_id = $1`, mailboxID).Scan(&blobID)
stored, err := blobReader.ReadByID(ctx, blobID)
if err != nil {
t.Fatalf("read stored message: %v", err)
}
if count := strings.Count(string(stored), "DKIM-Signature:"); count != 2 {
t.Errorf("stored message carries %d DKIM signatures, want 2 (RSA and Ed25519)", count)
}
if !strings.Contains(string(stored), "d=submission.test") {
t.Errorf("signatures do not claim the sending domain:\n%s", stored)
}
// --------------------------------------------------------- delivery
received := make(chan string, 1)
mxAddr := startCapturingMX(t, received)
worker := appusecase.NewOutboundWorkerUseCase(
outboundRepo, &staticMXResolver{addr: mxAddr}, blobReader, nil, nil, "mail.submission.test",
)
if _, err := worker.ProcessBatch(ctx, "worker1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
var status, tlsPolicy string
pool.QueryRow(ctx, `SELECT status, COALESCE(tls_policy_used, '') FROM outbound_jobs WHERE mailbox_id = $1`, mailboxID).
Scan(&status, &tlsPolicy)
if status != "DELIVERED" {
var lastError string
pool.QueryRow(ctx, `SELECT COALESCE(last_error, '') FROM outbound_jobs WHERE mailbox_id = $1`, mailboxID).Scan(&lastError)
t.Fatalf("status = %s, want DELIVERED (last error: %s)", status, lastError)
}
if tlsPolicy != "opportunistic" {
t.Errorf("tls_policy_used = %q, want opportunistic for a destination with no published policy", tlsPolicy)
}
select {
case delivered := <-received:
if !strings.Contains(delivered, "DKIM-Signature:") {
t.Error("the delivered message lost its signatures")
}
case <-time.After(5 * time.Second):
t.Fatal("the remote MX never received the message")
}
// ------------------------------------------------ per-mailbox send limit
// The mailbox allows five recipients an hour and has used one.
assertSubmissionRefused(t, ln.Addr().String(), senderAddress, password,
[]string{"a@remote.test", "b@remote.test", "c@remote.test", "d@remote.test", "e@remote.test"})
// An account may not send as somebody else.
assertSenderRefused(t, ln.Addr().String(), senderAddress, password, "ceo@submission.test")
}
// submitMessage performs a full EHLO/STARTTLS/AUTH/MAIL/RCPT/DATA exchange.
func submitMessage(t *testing.T, addr, username, password, recipient string) {
t.Helper()
client, err := gosmtp.DialStartTLS(addr, &tls.Config{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("dial submission over STARTTLS: %v", err)
}
defer client.Close()
if err := client.Auth(plainAuth(username, password)); err != nil {
t.Fatalf("AUTH: %v", err)
}
if err := client.Mail(username, nil); err != nil {
t.Fatalf("MAIL FROM: %v", err)
}
if err := client.Rcpt(recipient, nil); err != nil {
t.Fatalf("RCPT TO: %v", err)
}
w, err := client.Data()
if err != nil {
t.Fatalf("DATA: %v", err)
}
message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: e2e\r\nDate: Mon, 20 Jul 2026 10:00:00 +0000\r\nMessage-ID: <e2e@submission.test>\r\n\r\nhello\r\n", username, recipient)
if _, err := w.Write([]byte(message)); err != nil {
t.Fatalf("write DATA: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close DATA: %v", err)
}
client.Quit()
}
// assertSubmissionRefused checks that going past the hourly recipient budget
// is refused with a temporary error rather than silently accepted.
func assertSubmissionRefused(t *testing.T, addr, username, password string, recipients []string) {
t.Helper()
client, err := gosmtp.DialStartTLS(addr, &tls.Config{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer client.Close()
client.Auth(plainAuth(username, password))
client.Mail(username, nil)
for _, r := range recipients {
client.Rcpt(r, nil)
}
w, err := client.Data()
if err != nil {
// Some servers refuse at DATA issue time; either point is acceptable.
return
}
w.Write([]byte("From: x\r\n\r\nbody\r\n"))
if err := w.Close(); err == nil {
t.Error("submission past the hourly recipient limit was accepted")
} else if !strings.Contains(err.Error(), "451") {
t.Errorf("error = %v, want a 451 temporary refusal", err)
}
}
func assertSenderRefused(t *testing.T, addr, username, password, forgedSender string) {
t.Helper()
client, err := gosmtp.DialStartTLS(addr, &tls.Config{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer client.Close()
client.Auth(plainAuth(username, password))
if err := client.Mail(forgedSender, nil); err == nil {
t.Errorf("MAIL FROM %s was accepted for an account that does not own it", forgedSender)
}
}
// startCapturingMX accepts one message and hands its body back on a channel.
func startCapturingMX(t *testing.T, received chan<- 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(10 * time.Second))
r := bufio.NewReader(c)
c.Write([]byte("220 capture.mx ESMTP\r\n"))
var body strings.Builder
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
switch cmd := strings.ToUpper(strings.TrimSpace(line)); {
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
c.Write([]byte("250 capture.mx\r\n"))
case cmd == "DATA":
c.Write([]byte("354 Send it\r\n"))
for {
dataLine, err := r.ReadString('\n')
if err != nil {
return
}
if strings.TrimSpace(dataLine) == "." {
break
}
body.WriteString(dataLine)
}
c.Write([]byte("250 2.0.0 Queued\r\n"))
select {
case received <- body.String():
default:
}
case cmd == "QUIT":
c.Write([]byte("221 Bye\r\n"))
return
default:
c.Write([]byte("250 Ok\r\n"))
}
}
}(conn)
}
}()
return ln.Addr().String()
}
// plainAuth builds a SASL PLAIN client without pulling in another dependency.
type plainAuthClient struct {
username, password string
done bool
}
func plainAuth(username, password string) *plainAuthClient {
return &plainAuthClient{username: username, password: password}
}
func (a *plainAuthClient) Start() (mech string, ir []byte, err error) {
return "PLAIN", []byte("\x00" + a.username + "\x00" + a.password), nil
}
func (a *plainAuthClient) Next(challenge []byte) ([]byte, error) {
if a.done {
return nil, fmt.Errorf("unexpected challenge %q", base64.StdEncoding.EncodeToString(challenge))
}
a.done = true
return nil, nil
}
@@ -5,240 +5,392 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/entity"
)
const (
// rateLimitMaxAttempts bounds the 429 retry loop so a throttled zone
// delays the reconcile instead of blocking it forever.
rateLimitMaxAttempts = 4
rateLimitMaxBackoff = 30 * time.Second
recordsPerPage = 100
)
type CloudflareAdapter struct {
apiToken string
baseURL string
apiToken string
baseURL string
httpClient *http.Client
}
func NewCloudflareAdapter(apiToken string) *CloudflareAdapter {
return &CloudflareAdapter{
apiToken: apiToken,
baseURL: "https://api.cloudflare.com/client/v4",
httpClient: &http.Client{},
apiToken: apiToken,
baseURL: "https://api.cloudflare.com/client/v4",
// Without a timeout a stalled API call would hold the reconcile - and
// on the periodic drift job, the goroutine - open indefinitely.
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
type cfError struct {
Message string `json:"message"`
}
type cfResultInfo struct {
Page int `json:"page"`
TotalPages int `json:"total_pages"`
}
type cfZoneResponse struct {
Success bool `json:"success"`
Result []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"result"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
Errors []cfError `json:"errors"`
}
type cfRecord struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
Priority *int `json:"priority,omitempty"`
Proxied bool `json:"proxied,omitempty"`
Comment string `json:"comment,omitempty"`
ID string `json:"id,omitempty"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content,omitempty"`
TTL int `json:"ttl"`
// Pointers so that "absent" and "explicitly false" stay distinguishable:
// Cloudflare applies its own default for an omitted proxied flag, and a
// proxied mail host silently breaks SMTP (PLAN.md section 7.1).
Priority *int `json:"priority,omitempty"`
Proxied *bool `json:"proxied,omitempty"`
// Data carries the structured form Cloudflare requires for SRV and TLSA;
// those types reject a flat content string.
Data map[string]any `json:"data,omitempty"`
Comment string `json:"comment,omitempty"`
}
type cfRecordResponse struct {
Success bool `json:"success"`
Result []cfRecord `json:"result"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
Success bool `json:"success"`
Result []cfRecord `json:"result"`
ResultInfo cfResultInfo `json:"result_info"`
Errors []cfError `json:"errors"`
}
type cfSingleRecordResponse struct {
Success bool `json:"success"`
Result cfRecord `json:"result"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
Success bool `json:"success"`
Result cfRecord `json:"result"`
Errors []cfError `json:"errors"`
}
func (a *CloudflareAdapter) GetZoneID(ctx context.Context, domainName string) (string, error) {
reqURL := fmt.Sprintf("%s/zones?name=%s", a.baseURL, url.QueryEscape(domainName))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return "", err
}
a.setHeaders(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var data cfZoneResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return "", fmt.Errorf("decode json: %w", err)
if err := a.doJSON(ctx, http.MethodGet, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
return "", err
}
if !data.Success || len(data.Result) == 0 {
errMsg := "zone not found"
if len(data.Errors) > 0 {
errMsg = data.Errors[0].Message
}
return "", fmt.Errorf("cloudflare API error: %s", errMsg)
return "", fmt.Errorf("cloudflare: zone %q not found or token lacks Zone:Read on it", domainName)
}
return data.Result[0].ID, nil
}
func (a *CloudflareAdapter) ListRecords(ctx context.Context, zoneID string) ([]entity.DnsRecord, error) {
reqURL := fmt.Sprintf("%s/zones/%s/dns_records?per_page=100", a.baseURL, zoneID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
a.setHeaders(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var data cfRecordResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("decode json: %w", err)
}
if !data.Success {
return nil, fmt.Errorf("cloudflare API error listing records")
}
var out []entity.DnsRecord
for _, r := range data.Result {
out = append(out, entity.DnsRecord{
ID: r.ID,
Type: r.Type,
Name: r.Name,
Value: r.Content,
TTL: r.TTL,
Priority: r.Priority,
Proxied: r.Proxied,
Comment: r.Comment,
})
for page := 1; ; page++ {
reqURL := fmt.Sprintf("%s/zones/%s/dns_records?per_page=%d&page=%d", a.baseURL, zoneID, recordsPerPage, page)
var data cfRecordResponse
if err := a.doJSON(ctx, http.MethodGet, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
return nil, err
}
if !data.Success {
return nil, fmt.Errorf("cloudflare: listing records for zone %s failed", zoneID)
}
for _, r := range data.Result {
rec := entity.DnsRecord{
ID: r.ID,
Type: r.Type,
Name: r.Name,
Value: r.Content,
TTL: r.TTL,
Priority: r.Priority,
Comment: r.Comment,
}
if r.Proxied != nil {
rec.Proxied = *r.Proxied
}
// Cloudflare returns SRV and TLSA with content already rendered,
// but older zones and some responses only populate data; fall back
// to it so a structured record is not seen as an empty value and
// rewritten on every reconcile.
if rec.Value == "" && len(r.Data) > 0 {
rec.Value = renderStructuredValue(r.Type, r.Data)
}
out = append(out, rec)
}
// Zones larger than one page would otherwise look like they are
// missing records, and the reconciler would try to re-create them.
if data.ResultInfo.TotalPages <= page || len(data.Result) == 0 {
break
}
}
return out, nil
}
func (a *CloudflareAdapter) CreateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error {
reqURL := fmt.Sprintf("%s/zones/%s/dns_records", a.baseURL, zoneID)
cfBody := cfRecord{
Type: record.Type,
Name: record.Name,
Content: record.Value,
TTL: record.TTL,
Priority: record.Priority,
Proxied: record.Proxied,
Comment: record.Comment,
}
payload, err := json.Marshal(cfBody)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewBuffer(payload))
if err != nil {
return err
}
a.setHeaders(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var data cfSingleRecordResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return err
if err := a.doJSON(ctx, http.MethodPost, reqURL, toCfRecord(record), &data, func() []cfError { return data.Errors }); err != nil {
return fmt.Errorf("create %s %s: %w", record.Type, record.Name, err)
}
if !data.Success {
errMsg := "create record failed"
if len(data.Errors) > 0 {
errMsg = data.Errors[0].Message
}
return fmt.Errorf("cloudflare API error: %s", errMsg)
return fmt.Errorf("cloudflare: create %s %s failed", record.Type, record.Name)
}
return nil
}
func (a *CloudflareAdapter) UpdateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error {
reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", a.baseURL, zoneID, record.ID)
cfBody := cfRecord{
Type: record.Type,
Name: record.Name,
Content: record.Value,
TTL: record.TTL,
Priority: record.Priority,
Proxied: record.Proxied,
Comment: record.Comment,
}
payload, err := json.Marshal(cfBody)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewBuffer(payload))
if err != nil {
return err
}
a.setHeaders(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var data cfSingleRecordResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return err
if err := a.doJSON(ctx, http.MethodPut, reqURL, toCfRecord(record), &data, func() []cfError { return data.Errors }); err != nil {
return fmt.Errorf("update %s %s: %w", record.Type, record.Name, err)
}
if !data.Success {
return fmt.Errorf("cloudflare API error updating record")
return fmt.Errorf("cloudflare: update %s %s failed", record.Type, record.Name)
}
return nil
}
func (a *CloudflareAdapter) DeleteRecord(ctx context.Context, zoneID string, recordID string) error {
reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", a.baseURL, zoneID, recordID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil)
if err != nil {
return err
}
a.setHeaders(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return err
var data cfSingleRecordResponse
if err := a.doJSON(ctx, http.MethodDelete, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
return fmt.Errorf("delete record %s: %w", recordID, err)
}
if !data.Success {
return fmt.Errorf("cloudflare: delete record %s failed", recordID)
}
return nil
}
// toCfRecord maps a desired record onto the API payload. The proxied flag is
// only meaningful for the record types Cloudflare can proxy; sending it for a
// TXT or MX record is rejected.
func toCfRecord(record entity.DnsRecord) cfRecord {
body := cfRecord{
ID: record.ID,
Type: record.Type,
Name: record.Name,
Content: record.Value,
TTL: record.TTL,
Priority: record.Priority,
Comment: record.Comment,
}
switch strings.ToUpper(record.Type) {
case "A", "AAAA", "CNAME":
proxied := record.Proxied
body.Proxied = &proxied
case "SRV", "TLSA":
// These types are rejected with a flat content string; the API wants
// the components broken out.
if data := structuredData(record); data != nil {
body.Data = data
body.Content = ""
body.Priority = nil
}
}
return body
}
// structuredData splits an SRV or TLSA record value into the field map the
// Cloudflare API expects. It returns nil when the value is not in the
// canonical presentation form, letting the caller fall back to content.
func structuredData(record entity.DnsRecord) map[string]any {
fields := strings.Fields(record.Value)
switch strings.ToUpper(record.Type) {
case "SRV":
// Presentation form: "<priority> <weight> <port> <target>".
if len(fields) != 4 {
return nil
}
priority, err1 := strconv.Atoi(fields[0])
weight, err2 := strconv.Atoi(fields[1])
port, err3 := strconv.Atoi(fields[2])
if err1 != nil || err2 != nil || err3 != nil {
return nil
}
return map[string]any{
"priority": priority,
"weight": weight,
"port": port,
"target": strings.TrimSuffix(fields[3], "."),
}
case "TLSA":
// Presentation form: "<usage> <selector> <matching type> <data>".
if len(fields) != 4 {
return nil
}
usage, err1 := strconv.Atoi(fields[0])
selector, err2 := strconv.Atoi(fields[1])
matchingType, err3 := strconv.Atoi(fields[2])
if err1 != nil || err2 != nil || err3 != nil {
return nil
}
return map[string]any{
"usage": usage,
"selector": selector,
"matching_type": matchingType,
"certificate": fields[3],
}
}
defer resp.Body.Close()
return nil
}
func (a *CloudflareAdapter) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+a.apiToken)
req.Header.Set("Content-Type", "application/json")
// renderStructuredValue is the inverse of structuredData: it rebuilds the
// presentation form so the reconciler can compare a published record against
// the desired one.
func renderStructuredValue(recordType string, data map[string]any) string {
number := func(key string) string {
switch v := data[key].(type) {
case float64: // JSON numbers decode as float64
return strconv.Itoa(int(v))
case string:
return v
default:
return ""
}
}
text := func(key string) string {
if v, ok := data[key].(string); ok {
return strings.TrimSuffix(v, ".")
}
return ""
}
switch strings.ToUpper(recordType) {
case "SRV":
return strings.Join([]string{number("priority"), number("weight"), number("port"), text("target")}, " ")
case "TLSA":
return strings.Join([]string{number("usage"), number("selector"), number("matching_type"), text("certificate")}, " ")
default:
return ""
}
}
// doJSON performs one API call, retrying while Cloudflare reports 429 and
// honouring Retry-After (PLAN.md section 7.5). It fails on any non-2xx status
// rather than handing a decoder an error page.
func (a *CloudflareAdapter) doJSON(ctx context.Context, method, reqURL string, body any, out any, errsOf func() []cfError) error {
var payload []byte
if body != nil {
var err error
if payload, err = json.Marshal(body); err != nil {
return fmt.Errorf("encode request body: %w", err)
}
}
backoff := time.Second
for attempt := 1; ; attempt++ {
var reader io.Reader
if payload != nil {
reader = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, reader)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+a.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return err
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < rateLimitMaxAttempts {
wait := retryAfter(resp.Header.Get("Retry-After"), backoff)
resp.Body.Close()
backoff = min(backoff*2, rateLimitMaxBackoff)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
continue
}
respBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return fmt.Errorf("read response body: %w", readErr)
}
// Decode first so an error envelope can be quoted back to the operator,
// then judge the status. Cloudflare returns its envelope on 4xx too.
decodeErr := json.Unmarshal(respBody, out)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("cloudflare API returned HTTP %d: %s", resp.StatusCode, describeErrors(errsOf(), respBody))
}
if decodeErr != nil {
return fmt.Errorf("decode json: %w", decodeErr)
}
return nil
}
}
func describeErrors(errs []cfError, raw []byte) string {
if len(errs) > 0 {
messages := make([]string, 0, len(errs))
for _, e := range errs {
messages = append(messages, e.Message)
}
return strings.Join(messages, "; ")
}
const maxRaw = 200
if len(raw) > maxRaw {
raw = raw[:maxRaw]
}
return strings.TrimSpace(string(raw))
}
// retryAfter reads the header as delta-seconds (RFC 9110 section 10.2.3),
// falling back to the caller's exponential backoff when it is absent or
// unparseable.
func retryAfter(header string, fallback time.Duration) time.Duration {
if header == "" {
return fallback
}
seconds, err := strconv.Atoi(strings.TrimSpace(header))
if err != nil || seconds < 0 {
return fallback
}
return time.Duration(seconds) * time.Second
}
// Ensure port interface compliance
@@ -0,0 +1,237 @@
package cloudflare
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"lambdamail/protocols/internal/domain/entity"
)
// A proxied A record for the mail host makes Cloudflare answer with its own
// anycast addresses, which do not speak SMTP - the domain stops receiving mail.
// PLAN.md section 7.1 requires proxied=false, so the flag must be sent
// explicitly rather than left to the zone default.
func TestCloudflareAdapter_CreateRecord_SendsProxiedFalseExplicitly(t *testing.T) {
var body map[string]interface{}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&body)
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "result": map[string]interface{}{"id": "rec_1"}})
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
err := adapter.CreateRecord(context.Background(), "zone1", entity.DnsRecord{
Type: "A", Name: "mail.example.test", Value: "192.0.2.1", TTL: 1, Proxied: false,
})
if err != nil {
t.Fatalf("CreateRecord: %v", err)
}
proxied, present := body["proxied"]
if !present {
t.Fatal("proxied flag was omitted from the request; Cloudflare would fall back to the zone default")
}
if proxied != false {
t.Errorf("proxied = %v, want false", proxied)
}
}
// An HTTP-level failure carries no Cloudflare envelope, so a decoder-only
// check reports a confusing JSON error - or worse, silently succeeds.
func TestCloudflareAdapter_ReportsHttpErrorStatus(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"success":false,"errors":[{"message":"Insufficient permissions"}]}`))
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
_, err := adapter.GetZoneID(context.Background(), "example.test")
if err == nil {
t.Fatal("expected an error for HTTP 403")
}
if !strings.Contains(err.Error(), "Insufficient permissions") {
t.Errorf("error should surface the API message, got: %v", err)
}
}
// PLAN.md section 7.5 lists the record listing as paginated. Stopping at the
// first page makes the reconciler believe records are missing and try to
// re-create them, which the API then rejects.
func TestCloudflareAdapter_ListRecords_FollowsPagination(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := r.URL.Query().Get("page")
if page == "" || page == "1" {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"result": []map[string]interface{}{
{"id": "rec_1", "type": "A", "name": "a.example.test", "content": "192.0.2.1"},
},
"result_info": map[string]interface{}{"page": 1, "total_pages": 2},
})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"result": []map[string]interface{}{
{"id": "rec_2", "type": "A", "name": "b.example.test", "content": "192.0.2.2"},
},
"result_info": map[string]interface{}{"page": 2, "total_pages": 2},
})
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
records, err := adapter.ListRecords(context.Background(), "zone1")
if err != nil {
t.Fatalf("ListRecords: %v", err)
}
if len(records) != 2 {
t.Fatalf("got %d records, want 2 across both pages", len(records))
}
}
// PLAN.md section 7.5 requires retrying 429 while honouring Retry-After.
func TestCloudflareAdapter_RetriesOnRateLimit(t *testing.T) {
var calls int
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"result": []map[string]interface{}{{"id": "zone_1", "name": "example.test"}},
})
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
zoneID, err := adapter.GetZoneID(context.Background(), "example.test")
if err != nil {
t.Fatalf("GetZoneID: %v", err)
}
if zoneID != "zone_1" {
t.Errorf("zoneID = %q, want zone_1", zoneID)
}
if calls != 2 {
t.Errorf("made %d calls, want 2 (one rate limited, one retried)", calls)
}
}
// A failed delete that reports success would silently leave stale records in
// the zone.
func TestCloudflareAdapter_DeleteRecord_ReportsFailure(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"success":false,"errors":[{"message":"Record not found"}]}`))
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
if err := adapter.DeleteRecord(context.Background(), "zone1", "rec_missing"); err == nil {
t.Fatal("expected an error when the delete fails")
}
}
// Cloudflare rejects SRV and TLSA records submitted as a flat content string;
// they must be broken into the structured data object.
func TestCloudflareAdapter_SendsStructuredDataForSrvAndTlsa(t *testing.T) {
cases := []struct {
name string
record entity.DnsRecord
expected map[string]interface{}
}{
{
name: "SRV",
record: entity.DnsRecord{Type: "SRV", Name: "_imaps._tcp.example.test", Value: "0 1 993 mail.example.test", TTL: 1},
expected: map[string]interface{}{
"priority": float64(0), "weight": float64(1), "port": float64(993), "target": "mail.example.test",
},
},
{
name: "TLSA",
record: entity.DnsRecord{Type: "TLSA", Name: "_25._tcp.mail.example.test", Value: "3 1 1 " + strings.Repeat("ab", 32), TTL: 1},
expected: map[string]interface{}{
"usage": float64(3), "selector": float64(1), "matching_type": float64(1), "certificate": strings.Repeat("ab", 32),
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var body map[string]interface{}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&body)
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "result": map[string]interface{}{"id": "rec_1"}})
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
if err := adapter.CreateRecord(context.Background(), "zone1", tc.record); err != nil {
t.Fatalf("CreateRecord: %v", err)
}
data, ok := body["data"].(map[string]interface{})
if !ok {
t.Fatalf("no structured data object sent; body = %v", body)
}
for key, want := range tc.expected {
if data[key] != want {
t.Errorf("data[%q] = %v, want %v", key, data[key], want)
}
}
if content, present := body["content"]; present && content != "" {
t.Errorf("content was sent alongside data: %v", content)
}
})
}
}
// A record returned with only a data object must round-trip back to the
// presentation form, or the reconciler sees an empty value and rewrites it on
// every run.
func TestCloudflareAdapter_ListRecords_RendersStructuredValues(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"result": []map[string]interface{}{{
"id": "rec_1", "type": "SRV", "name": "_imaps._tcp.example.test",
"data": map[string]interface{}{"priority": 0, "weight": 1, "port": 993, "target": "mail.example.test"},
}},
"result_info": map[string]interface{}{"page": 1, "total_pages": 1},
})
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
records, err := adapter.ListRecords(context.Background(), "zone1")
if err != nil {
t.Fatalf("ListRecords: %v", err)
}
if len(records) != 1 || records[0].Value != "0 1 993 mail.example.test" {
t.Errorf("value = %q, want the rendered presentation form", records[0].Value)
}
}
@@ -21,7 +21,7 @@ func NewAuthRepository(pool *pgxpool.Pool) *AuthRepository {
func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*port.MailboxAuth, error) {
row := r.pool.QueryRow(ctx, `
SELECT m.id, m.password_hash
SELECT m.id, m.password_hash, m.email_address, d.name, m.max_recipients_per_hour
FROM mailboxes m
JOIN domains d ON d.id = m.domain_id
WHERE m.email_address = $1
@@ -31,7 +31,7 @@ func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*po
var rec port.MailboxAuth
var id uuid.UUID
if err := row.Scan(&id, &rec.PasswordHash); err != nil {
if err := row.Scan(&id, &rec.PasswordHash, &rec.EmailAddress, &rec.DomainName, &rec.MaxRecipientsPerHour); err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
@@ -3,6 +3,9 @@ package postgres
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
@@ -37,8 +40,13 @@ func (r *OutboundRepository) FetchNextReady(ctx context.Context, workerID string
rows, err := r.pool.Query(ctx, `
WITH ready_jobs AS (
SELECT id FROM outbound_jobs
WHERE status IN ('QUEUED', 'DEFERRED')
AND next_attempt_at <= NOW()
WHERE (
(status IN ('QUEUED', 'DEFERRED') AND next_attempt_at <= NOW())
-- Reclaim jobs whose worker died mid-delivery. Without this
-- they stay SENDING forever and the message is silently
-- lost, which contradicts ADR-002's durability guarantee.
OR (status = 'SENDING' AND locked_until IS NOT NULL AND locked_until <= NOW())
)
ORDER BY next_attempt_at ASC
LIMIT $2
FOR UPDATE SKIP LOCKED
@@ -90,6 +98,22 @@ func (r *OutboundRepository) FetchNextReady(ctx context.Context, workerID string
return jobs, nil
}
// CountRecipientsSince counts queued recipients, not messages: one message to
// fifty addresses is fifty recipients against the limit, which is the number
// that matters for abuse.
func (r *OutboundRepository) CountRecipientsSince(ctx context.Context, mailboxID uuid.UUID, since time.Time) (int, error) {
var count int
err := r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM outbound_jobs
WHERE mailbox_id = $1 AND created_at >= $2
`, mailboxID, since).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count recent recipients for mailbox %s: %w", mailboxID, err)
}
return count, nil
}
func (r *OutboundRepository) UpdateJob(ctx context.Context, job *entity.OutboundJob) error {
_, err := r.pool.Exec(ctx, `
UPDATE outbound_jobs
@@ -7,15 +7,16 @@ import (
"fmt"
"net/http"
"strings"
"time"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/valueobject"
)
type RspamdResponse struct {
Action string `json:"action"`
Score float64 `json:"score"`
RequiredScore float64 `json:"required_score"`
Action string `json:"action"`
Score float64 `json:"score"`
RequiredScore float64 `json:"required_score"`
Symbols map[string]map[string]interface{} `json:"symbols"`
}
@@ -29,8 +30,11 @@ func NewRspamdAdapter(baseURL string) *RspamdAdapter {
baseURL = "http://localhost:11333"
}
return &RspamdAdapter{
baseURL: strings.TrimSuffix(baseURL, "/"),
httpClient: &http.Client{},
baseURL: strings.TrimSuffix(baseURL, "/"),
// A scan sits in the middle of an open SMTP transaction, so an
// unbounded request would pin the session (and the sender's socket)
// until the peer gives up.
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
@@ -86,7 +90,9 @@ func (a *RspamdAdapter) Scan(ctx context.Context, input port.ScanInput) (*valueo
switch parsed.Action {
case "reject":
res.Verdict = valueobject.ScanVerdictSpamReject
case "greylist":
// "soft reject" is Rspamd asking the sender to retry later (rate limit or
// transient policy); like a greylist it maps to a 4xx, never to acceptance.
case "greylist", "soft reject":
res.Verdict = valueobject.ScanVerdictGreylist
case "add header", "rewrite subject":
res.Verdict = valueobject.ScanVerdictSpamJunk
@@ -18,7 +18,12 @@ func TestRspamdAdapter_Scan_MapsActions(t *testing.T) {
}{
{"no action", valueobject.ScanVerdictClean},
{"add header", valueobject.ScanVerdictSpamJunk},
{"rewrite subject", valueobject.ScanVerdictSpamJunk},
{"greylist", valueobject.ScanVerdictGreylist},
// Rspamd's "soft reject" asks the sender to come back later (rate
// limit, transient policy). Treating it as clean would accept mail
// the filter explicitly refused to accept yet.
{"soft reject", valueobject.ScanVerdictGreylist},
{"reject", valueobject.ScanVerdictSpamReject},
}
@@ -3,6 +3,7 @@ package httppresentation
import (
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
@@ -11,10 +12,16 @@ import (
"lambdamail/protocols/internal/domain/valueobject"
)
// mtaStsMaxAge is the policy lifetime advertised to senders. RFC 8461
// recommends a value in the order of weeks so that an attacker cannot strip
// the policy by making the endpoint unreachable.
const mtaStsMaxAge = 604800 // 7 days
type Router struct {
mux *http.ServeMux
reportUseCase *appusecase.IngestReportsUseCase
pingFunc func() error
mtaStsMode valueobject.MtaStsMode
}
func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() error) *Router {
@@ -22,11 +29,24 @@ func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() e
mux: http.NewServeMux(),
reportUseCase: reportUseCase,
pingFunc: pingFunc,
// PLAN.md section 5 ramps MTA-STS from testing to enforce. Starting at
// enforce means any TLS misconfiguration silently blackholes inbound
// mail from every sender that honours the policy, with no way to tell
// from this side - so the safe mode is the default and the operator
// promotes it once TLS-RPT reports come back clean.
mtaStsMode: valueobject.MtaStsModeTesting,
}
r.registerRoutes()
return r
}
// SetMtaStsMode selects the mode advertised in the served policy. Callers
// should promote to enforce only after TLS-RPT confirms senders can negotiate
// TLS against the published MX.
func (r *Router) SetMtaStsMode(mode valueobject.MtaStsMode) {
r.mtaStsMode = mode
}
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mux.ServeHTTP(w, req)
}
@@ -57,19 +77,23 @@ func (r *Router) handleMtaSts(w http.ResponseWriter, req *http.Request) {
host := extractHost(req.Host)
domainName := strings.TrimPrefix(host, "mta-sts.")
policy := valueobject.NewMtaStsPolicy(valueobject.MtaStsModeEnforce, []string{fmt.Sprintf("mail.%s", domainName)}, 604800)
policy := valueobject.NewMtaStsPolicy(r.mtaStsMode, []string{fmt.Sprintf("mail.%s", domainName)}, mtaStsMaxAge)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
// Kept in step with max_age so an HTTP cache cannot outlive the policy it
// is caching.
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", mtaStsMaxAge))
w.WriteHeader(http.StatusOK)
w.Write([]byte(policy.Format()))
}
func (r *Router) handleSecurityTxt(w http.ResponseWriter, req *http.Request) {
host := extractHost(req.Host)
// The contact must be a real mailbox on the mail domain, not on whichever
// service hostname the request happened to arrive at.
domainName := baseDomain(extractHost(req.Host))
expires := time.Now().AddDate(1, 0, 0).Format(time.RFC3339)
content := fmt.Sprintf("Contact: mailto:security@%s\r\nExpires: %s\r\nPreferred-Languages: en, pt-BR, es\r\n", host, expires)
content := fmt.Sprintf("Contact: mailto:security@%s\r\nExpires: %s\r\nPreferred-Languages: en, pt-BR, es\r\n", domainName, expires)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
@@ -144,12 +168,27 @@ func (r *Router) handleTlsRptIngest(w http.ResponseWriter, req *http.Request) {
w.Write([]byte(`{"status":"ok"}`))
}
// extractHost strips the optional port from a Host header, handling the
// bracketed IPv6 literal form as well.
func extractHost(hostHeader string) string {
if h, _, err := strings.Cut(hostHeader, ":"); err && h != "" {
return h
}
if hostHeader == "" {
return "example.test"
}
return hostHeader
if host, _, err := net.SplitHostPort(hostHeader); err == nil && host != "" {
return host
}
return strings.Trim(hostHeader, "[]")
}
// serviceHostPrefixes are the per-service names published under a mail domain
// (PLAN.md section 7.3); stripping one yields the mail domain itself.
var serviceHostPrefixes = []string{"mta-sts.", "autoconfig.", "autodiscover.", "mail."}
func baseDomain(host string) string {
for _, prefix := range serviceHostPrefixes {
if strings.HasPrefix(host, prefix) {
return strings.TrimPrefix(host, prefix)
}
}
return host
}
@@ -24,4 +24,3 @@ func (s *session) Unsubscribe(_ string) error { return errN
func (s *session) Append(_ string, _ imap.LiteralReader, _ *imap.AppendOptions) (*imap.AppendData, error) {
return nil, errNotYetImplemented
}
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"io"
"net"
"strings"
gosmtp "github.com/emersion/go-smtp"
@@ -24,19 +25,44 @@ func NewBackend(uc *usecase.ProcessInboundEmailUseCase) *Backend {
return &Backend{useCase: uc}
}
func (b *Backend) NewSession(_ *gosmtp.Conn) (gosmtp.Session, error) {
return &session{useCase: b.useCase}, nil
func (b *Backend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) {
return &session{useCase: b.useCase, conn: c}, nil
}
// session holds one SMTP transaction's accumulated state (RFC 5321 section
// 4.1.1: MAIL, then one or more RCPT, then DATA, then reset).
type session struct {
useCase *usecase.ProcessInboundEmailUseCase
conn *gosmtp.Conn
from string
recipients []port.MailboxRecord
recipientAddresses []string
}
// clientIP is the peer address SPF is evaluated against. It returns nil when
// the connection is not available, which downgrades SPF to "none" rather than
// producing a wrong verdict.
func (s *session) clientIP() net.IP {
if s.conn == nil || s.conn.Conn() == nil {
return nil
}
if addr, ok := s.conn.Conn().RemoteAddr().(*net.TCPAddr); ok {
return addr.IP
}
host, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())
if err != nil {
return nil
}
return net.ParseIP(host)
}
func (s *session) heloDomain() string {
if s.conn == nil {
return ""
}
return s.conn.Hostname()
}
func (s *session) Reset() {
s.from = ""
s.recipients = nil
@@ -107,6 +133,8 @@ func (s *session) Data(r io.Reader) error {
Recipients: s.recipients,
RecipientAddresses: s.recipientAddresses,
Body: r,
ClientIP: s.clientIP(),
HeloDomain: s.heloDomain(),
})
if err != nil {
var smtpErr *gosmtp.SMTPError
@@ -114,6 +142,13 @@ func (s *session) Data(r io.Reader) error {
return smtpErr
}
errMsg := err.Error()
if strings.HasPrefix(errMsg, "550 ") {
return &gosmtp.SMTPError{
Code: 550,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 1},
Message: strings.TrimPrefix(errMsg, "550 5.7.1 "),
}
}
if strings.HasPrefix(errMsg, "554 ") {
return &gosmtp.SMTPError{
Code: 554,
@@ -0,0 +1,67 @@
package smtppresentation
import (
"errors"
"github.com/emersion/go-sasl"
)
// loginMechanism is the SASL mechanism name for the legacy LOGIN exchange.
// PLAN.md section 4 requires it alongside PLAIN because several desktop and
// mobile clients still offer nothing else. Neither go-sasl nor go-smtp ships a
// server side for it, so it is implemented here.
const loginMechanism = "LOGIN"
// LOGIN is not specified by an RFC; it is a de facto mechanism where the
// server prompts for the username, then for the password, each as a bare
// challenge string.
var (
loginUsernameChallenge = []byte("Username:")
loginPasswordChallenge = []byte("Password:")
)
// loginAuthenticator verifies a username and password pair.
type loginAuthenticator func(username, password string) error
// loginServer implements sasl.Server for the LOGIN mechanism.
type loginServer struct {
authenticate loginAuthenticator
username string
// state advances from awaiting the username, to awaiting the password,
// to finished.
state int
}
const (
loginStateAwaitingUsername = iota
loginStateAwaitingPassword
loginStateDone
)
func newLoginServer(authenticate loginAuthenticator) sasl.Server {
return &loginServer{authenticate: authenticate}
}
func (s *loginServer) Next(response []byte) (challenge []byte, done bool, err error) {
switch s.state {
case loginStateAwaitingUsername:
// A client may send the username as the initial response, in which
// case there is nothing to prompt for yet.
if response == nil {
return loginUsernameChallenge, false, nil
}
s.username = string(response)
s.state = loginStateAwaitingPassword
return loginPasswordChallenge, false, nil
case loginStateAwaitingPassword:
s.state = loginStateDone
if err := s.authenticate(s.username, string(response)); err != nil {
return nil, false, err
}
return nil, true, nil
default:
return nil, false, errors.New("sasl: LOGIN exchange already finished")
}
}
@@ -0,0 +1,67 @@
package smtppresentation
import (
"errors"
"testing"
)
// The LOGIN exchange prompts for the username, then the password, and only
// then authenticates.
func TestLoginServer_PromptsThenAuthenticates(t *testing.T) {
var gotUser, gotPass string
server := newLoginServer(func(username, password string) error {
gotUser, gotPass = username, password
return nil
})
challenge, done, err := server.Next(nil)
if err != nil || done || string(challenge) != "Username:" {
t.Fatalf("first step: challenge=%q done=%v err=%v", challenge, done, err)
}
challenge, done, err = server.Next([]byte("user@example.test"))
if err != nil || done || string(challenge) != "Password:" {
t.Fatalf("second step: challenge=%q done=%v err=%v", challenge, done, err)
}
_, done, err = server.Next([]byte("secret"))
if err != nil || !done {
t.Fatalf("third step: done=%v err=%v", done, err)
}
if gotUser != "user@example.test" || gotPass != "secret" {
t.Errorf("credentials = %q/%q", gotUser, gotPass)
}
}
// A client that sends the username as its initial response skips the first
// prompt.
func TestLoginServer_AcceptsInitialResponse(t *testing.T) {
server := newLoginServer(func(string, string) error { return nil })
challenge, done, err := server.Next([]byte("user@example.test"))
if err != nil || done || string(challenge) != "Password:" {
t.Fatalf("challenge=%q done=%v err=%v", challenge, done, err)
}
}
func TestLoginServer_PropagatesAuthFailure(t *testing.T) {
authErr := errors.New("bad credentials")
server := newLoginServer(func(string, string) error { return authErr })
server.Next(nil)
server.Next([]byte("user@example.test"))
if _, done, err := server.Next([]byte("wrong")); err == nil || done {
t.Fatalf("expected the failure to surface: done=%v err=%v", done, err)
}
}
func TestLoginServer_RefusesExtraSteps(t *testing.T) {
server := newLoginServer(func(string, string) error { return nil })
server.Next(nil)
server.Next([]byte("user@example.test"))
server.Next([]byte("secret"))
if _, _, err := server.Next([]byte("extra")); err == nil {
t.Fatal("expected an error after the exchange finished")
}
}
@@ -0,0 +1,159 @@
package smtppresentation
import (
"context"
"errors"
"io"
"github.com/emersion/go-sasl"
gosmtp "github.com/emersion/go-smtp"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
)
// SubmissionBackend serves the submission ports (587 and 465). It is a
// separate backend from the MX one on purpose: submission always requires
// authentication and always relays outward, while port 25 never authenticates
// and only accepts mail for local mailboxes (PLAN.md section 4).
type SubmissionBackend struct {
useCase *usecase.ProcessOutboundEmailUseCase
}
func NewSubmissionBackend(uc *usecase.ProcessOutboundEmailUseCase) *SubmissionBackend {
return &SubmissionBackend{useCase: uc}
}
func (b *SubmissionBackend) NewSession(_ *gosmtp.Conn) (gosmtp.Session, error) {
return &submissionSession{useCase: b.useCase}, nil
}
type submissionSession struct {
useCase *usecase.ProcessOutboundEmailUseCase
// authenticated is nil until AUTH succeeds; every other command checks it.
authenticated *port.MailboxAuth
from string
recipients []string
}
// AuthMechanisms advertises PLAIN and LOGIN. The server only offers them once
// the connection is encrypted - go-smtp withholds AUTH before TLS unless
// AllowInsecureAuth is set, which this deployment never sets (PLAN.md
// section 4: credentials never travel in the clear).
func (s *submissionSession) AuthMechanisms() []string {
return []string{sasl.Plain, loginMechanism}
}
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
switch mech {
case sasl.Plain:
return sasl.NewPlainServer(func(identity, username, password string) error {
return s.authenticate(username, password)
}), nil
case loginMechanism:
return newLoginServer(s.authenticate), nil
default:
return nil, gosmtp.ErrAuthUnsupported
}
}
func (s *submissionSession) authenticate(username, password string) error {
rec, err := s.useCase.Authenticate(context.Background(), username, password)
if err != nil {
return &gosmtp.SMTPError{
Code: 535,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 8},
Message: "Authentication credentials invalid",
}
}
s.authenticated = rec
return nil
}
func (s *submissionSession) Reset() {
s.from = ""
s.recipients = nil
}
func (s *submissionSession) Logout() error {
s.authenticated = nil
return nil
}
func (s *submissionSession) Mail(from string, _ *gosmtp.MailOptions) error {
if s.authenticated == nil {
return errAuthRequired()
}
if err := usecase.AuthorizeSender(s.authenticated, from); err != nil {
return &gosmtp.SMTPError{
Code: 550,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 1},
Message: "Sender address not owned by the authenticated account",
}
}
s.from = from
return nil
}
func (s *submissionSession) Rcpt(to string, _ *gosmtp.RcptOptions) error {
if s.authenticated == nil {
return errAuthRequired()
}
s.recipients = append(s.recipients, to)
return nil
}
func (s *submissionSession) Data(r io.Reader) error {
if s.authenticated == nil {
return errAuthRequired()
}
if len(s.recipients) == 0 {
return &gosmtp.SMTPError{
Code: 503,
EnhancedCode: gosmtp.EnhancedCode{5, 5, 1},
Message: "RCPT TO required before DATA",
}
}
err := s.useCase.Submit(context.Background(), usecase.ProcessOutboundEmailInput{
MailboxID: s.authenticated.ID,
SenderAddr: s.from,
DomainName: s.authenticated.DomainName,
Recipients: s.recipients,
Body: r,
MaxRecipientsPerHour: s.authenticated.MaxRecipientsPerHour,
})
if err == nil {
return nil
}
// A rate-limited sender gets a 4xx: the message is legitimate, the
// account has simply run out of budget for this hour and may retry.
if errors.Is(err, usecase.ErrSendLimitExceeded) {
return &gosmtp.SMTPError{
Code: 451,
EnhancedCode: gosmtp.EnhancedCode{4, 7, 0},
Message: "Hourly recipient limit reached, try again later",
}
}
return &gosmtp.SMTPError{
Code: 451,
EnhancedCode: gosmtp.EnhancedCode{4, 3, 0},
Message: "Temporary error accepting message for delivery",
}
}
func errAuthRequired() error {
return &gosmtp.SMTPError{
Code: 530,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 0},
Message: "Authentication required",
}
}
// Ensure the session satisfies both the base and the auth add-on interface.
var (
_ gosmtp.Session = (*submissionSession)(nil)
_ gosmtp.AuthSession = (*submissionSession)(nil)
)
@@ -0,0 +1,94 @@
package smtppresentation
import (
"strings"
"testing"
gosmtp "github.com/emersion/go-smtp"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
)
func smtpCode(t *testing.T, err error) int {
t.Helper()
if err == nil {
return 0
}
var smtpErr *gosmtp.SMTPError
if !ok(err, &smtpErr) {
t.Fatalf("error is not an SMTPError: %v", err)
}
return smtpErr.Code
}
func ok(err error, target **gosmtp.SMTPError) bool {
e, is := err.(*gosmtp.SMTPError)
if is {
*target = e
}
return is
}
// Every command before AUTH must be refused: submission that accepted an
// unauthenticated MAIL FROM would be an open relay.
func TestSubmissionSession_RefusesCommandsBeforeAuth(t *testing.T) {
s := &submissionSession{}
if got := smtpCode(t, s.Mail("user@example.test", nil)); got != 530 {
t.Errorf("MAIL FROM before AUTH = %d, want 530", got)
}
if got := smtpCode(t, s.Rcpt("rcpt@remote.test", nil)); got != 530 {
t.Errorf("RCPT TO before AUTH = %d, want 530", got)
}
if got := smtpCode(t, s.Data(strings.NewReader("body"))); got != 530 {
t.Errorf("DATA before AUTH = %d, want 530", got)
}
}
// An authenticated account must not be able to send as anybody else.
func TestSubmissionSession_RefusesForeignSender(t *testing.T) {
s := &submissionSession{authenticated: &port.MailboxAuth{EmailAddress: "user@example.test"}}
if err := s.Mail("user@example.test", nil); err != nil {
t.Fatalf("sending as itself was refused: %v", err)
}
if got := smtpCode(t, s.Mail("ceo@example.test", nil)); got != 550 {
t.Errorf("MAIL FROM a foreign address = %d, want 550", got)
}
}
func TestSubmissionSession_AdvertisesPlainAndLogin(t *testing.T) {
mechs := (&submissionSession{}).AuthMechanisms()
joined := strings.Join(mechs, " ")
if !strings.Contains(joined, "PLAIN") || !strings.Contains(joined, "LOGIN") {
t.Errorf("AuthMechanisms = %v, want PLAIN and LOGIN", mechs)
}
}
func TestSubmissionSession_RejectsUnsupportedMechanism(t *testing.T) {
if _, err := (&submissionSession{}).Auth("SCRAM-SHA-256"); err == nil {
t.Fatal("expected an unsupported-mechanism error")
}
}
// Reset must clear the envelope but keep the session authenticated: RSET
// starts a new transaction, it does not log the user out.
func TestSubmissionSession_ResetKeepsAuthentication(t *testing.T) {
s := &submissionSession{authenticated: &port.MailboxAuth{EmailAddress: "user@example.test"}}
s.Mail("user@example.test", nil)
s.Rcpt("rcpt@remote.test", nil)
s.Reset()
if s.from != "" || len(s.recipients) != 0 {
t.Errorf("envelope survived RSET: from=%q recipients=%v", s.from, s.recipients)
}
if s.authenticated == nil {
t.Error("RSET logged the session out")
}
}
// Compile-time proof that the use case wiring matches what the session needs.
var _ = usecase.NewProcessOutboundEmailUseCase