#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,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])
}