#18: implement SMTP Submission (587/465), SASL PLAIN/LOGIN auth and preflight diagnostics
This commit is contained in:
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 ""
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user