#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

@@ -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 ""
}
}