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

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

No files matched your search

@@ -0,0 +1,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)
}
}