This commit is contained in:
2026-08-10 21:15:19 +01:00
parent d7ca591b76
commit 4da942786e
97 changed files with 105039 additions and 3370 deletions
+131 -3
View File
@@ -4,7 +4,9 @@
package queue
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log/slog"
"net"
@@ -12,9 +14,11 @@ import (
"strings"
"time"
"gomail/internal/dane"
"gomail/internal/db"
"gomail/internal/dkim"
"gomail/internal/mailstore"
"gomail/internal/mtasts"
)
const (
@@ -49,7 +53,7 @@ func NewWorker(database *db.DB, store *mailstore.Store) *Worker {
return &Worker{
database: database,
store: store,
deliverer: &MXDeliverer{Hostname: "gomail"},
deliverer: &MXDeliverer{Hostname: "gomail", Database: database},
stopCh: make(chan struct{}),
}
}
@@ -251,6 +255,7 @@ func isPermanentError(err error) bool {
// uniformly regardless of which Deliverer implementation is in use.
type MXDeliverer struct {
Hostname string // EHLO identity
Database *db.DB // caches MTA-STS policies (RFC 8461 requires honoring max_age); nil just disables caching, not the feature — policy is re-fetched every delivery instead
}
func (d *MXDeliverer) Deliver(from, to string, raw []byte) error {
@@ -271,7 +276,119 @@ func (d *MXDeliverer) Deliver(from, to string, raw []byte) error {
return lastErr
}
// tlsPolicy is what resolveTLSPolicy decides for one delivery attempt.
// mandatory distinguishes "TLS must succeed or this attempt fails" (DANE,
// MTA-STS enforce) from today's original opportunistic behavior (try,
// log and continue in plaintext on failure).
type tlsPolicy struct {
config *tls.Config
mandatory bool
source string // for logging: "dane" | "mta-sts:enforce" | "opportunistic"
}
// resolveTLSPolicy decides what TLS behavior this delivery attempt must
// follow, most-specific first: DANE (per-host) over MTA-STS (per-domain)
// over plain opportunistic STARTTLS — today's original, unchanged default.
// A non-nil error means delivery to this host must not proceed at all
// (e.g. an MTA-STS enforce policy that doesn't list this host as valid —
// returned as a 550 so it bounces via the existing permanent-failure path
// instead of retrying forever against a host the domain's own policy
// disowns). See internal/dane and internal/mtasts package docs for the
// respective security models and caveats (DANE's DNSSEC dependency, in
// particular).
func (d *MXDeliverer) resolveTLSPolicy(ctx context.Context, host, domain string) (tlsPolicy, error) {
opportunistic := tlsPolicy{
config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12},
mandatory: false,
source: "opportunistic",
}
if records, err := dane.Lookup(ctx, host, 25); err != nil {
slog.Warn("DANE lookup failed, falling back to MTA-STS/opportunistic TLS", "host", host, "err", err)
} else if len(records) > 0 {
return tlsPolicy{
config: &tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
// Not disabling verification — replacing it. VerifyPeerCertificate
// is DANE's own check (RFC 6698 usage 2/3), see internal/dane.
InsecureSkipVerify: true,
VerifyPeerCertificate: dane.VerifyPeerCertificate(records),
},
mandatory: true,
source: "dane",
}, nil
}
policy, err := d.mtaSTSPolicyFor(ctx, domain)
if err != nil {
slog.Warn("MTA-STS policy lookup failed, falling back to opportunistic TLS", "domain", domain, "err", err)
return opportunistic, nil
}
if policy == nil || policy.Mode == "none" {
return opportunistic, nil
}
hostMatches := false
for _, pattern := range policy.MXPatterns {
if mtasts.Matches(pattern, host) {
hostMatches = true
break
}
}
switch policy.Mode {
case "enforce":
if !hostMatches {
return tlsPolicy{}, fmt.Errorf("550 5.7.5 host %s is not listed in %s's MTA-STS policy (enforce mode)", host, domain)
}
return tlsPolicy{config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}, mandatory: true, source: "mta-sts:enforce"}, nil
case "testing":
if !hostMatches {
slog.Warn("MTA-STS testing mode: host is not in the domain's policy (would fail under enforce)", "host", host, "domain", domain)
}
return opportunistic, nil
default:
return opportunistic, nil
}
}
// mtaSTSPolicyFor returns domain's MTA-STS policy, using the DB cache when
// present and unexpired (RFC 8461 requires honoring the policy's own
// max_age) and fetching fresh otherwise. Returns (nil, nil) if the domain
// has no MTA-STS policy at all.
func (d *MXDeliverer) mtaSTSPolicyFor(ctx context.Context, domain string) (*mtasts.Policy, error) {
if d.Database != nil {
if cached, err := d.Database.GetMTASTSPolicy(domain); err == nil && time.Now().UTC().Before(cached.ExpiresAt) {
var patterns []string
if err := json.Unmarshal([]byte(cached.MXPatterns), &patterns); err == nil {
return &mtasts.Policy{ID: cached.PolicyID, Mode: cached.Mode, MXPatterns: patterns, MaxAge: time.Duration(cached.MaxAge) * time.Second}, nil
}
}
}
policy, err := mtasts.Discover(ctx, domain)
if err != nil || policy == nil {
return policy, err
}
if d.Database != nil {
patternsJSON, _ := json.Marshal(policy.MXPatterns)
now := time.Now().UTC()
if err := d.Database.UpsertMTASTSPolicy(&db.MTASTSPolicy{
Domain: domain, PolicyID: policy.ID, Mode: policy.Mode,
MXPatterns: string(patternsJSON), MaxAge: int(policy.MaxAge.Seconds()),
FetchedAt: now, ExpiresAt: now.Add(policy.MaxAge),
}); err != nil {
slog.Warn("failed to cache MTA-STS policy", "domain", domain, "err", err)
}
}
return policy, nil
}
func (d *MXDeliverer) deliverToHost(host, from, to string, raw []byte) error {
domain := domainOf(to)
conn, err := net.DialTimeout("tcp", host+":25", deliveryTimeout)
if err != nil {
return fmt.Errorf("421 4.4.1 connect to %s failed: %w", host, err)
@@ -289,11 +406,22 @@ func (d *MXDeliverer) deliverToHost(host, from, to string, raw []byte) error {
return fmt.Errorf("EHLO to %s failed: %w", host, err)
}
policyCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
policy, err := d.resolveTLSPolicy(policyCtx, host, domain)
cancel()
if err != nil {
return err
}
if ok, _ := client.Extension("STARTTLS"); ok {
tlsConf := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
if err := client.StartTLS(tlsConf); err != nil {
if err := client.StartTLS(policy.config); err != nil {
if policy.mandatory {
return fmt.Errorf("450 4.7.5 mandatory TLS (%s) failed for %s: %w", policy.source, host, err)
}
slog.Warn("STARTTLS failed, continuing without encryption", "host", host, "err", err)
}
} else if policy.mandatory {
return fmt.Errorf("450 4.7.5 mandatory TLS (%s) required for %s but server does not offer STARTTLS", policy.source, host)
}
if err := client.Mail(from); err != nil {