package checker import ( "fmt" "strings" ) // deriveIssues distils observation data into a sorted list of findings // that the rule reduces to a CheckState and the HTML report renders as // the "What to fix" panel. // // The function is pure: it reads data and returns a slice, so it is // trivially testable and stable across runs. func deriveIssues(data *SMTPData) []Issue { var issues []Issue // 1. MX / DNS scope. switch { case data.MX.NullMX: issues = append(issues, Issue{ Code: CodeNullMX, Severity: SeverityInfo, Message: "Domain advertises a null MX (RFC 7505): it explicitly refuses all email.", Fix: "If this is intentional (the domain sends but does not receive mail), no action needed. Otherwise, remove the '.' MX record and publish real mail exchangers.", }) return issues case data.MX.Error != "": issues = append(issues, Issue{ Code: CodeMXLookupFailed, Severity: SeverityCrit, Message: "MX lookup failed: " + data.MX.Error, Fix: "Check the authoritative DNS servers for this domain.", }) case data.MX.ImplicitMX: issues = append(issues, Issue{ Code: CodeImplicitMX, Severity: SeverityWarn, Message: "No MX record published; senders will fall back to the A/AAAA of the bare domain (implicit MX).", Fix: "Publish explicit MX records so you can separate web and mail servers and so anti-spam signals apply correctly.", }) case len(data.MX.Records) == 0: issues = append(issues, Issue{ Code: CodeNoMX, Severity: SeverityCrit, Message: "No MX record found for " + data.Domain + ".", Fix: "Publish at least one MX record pointing to a reachable mail server, or a null MX ('.' with preference 0) if the domain must not receive mail.", }) } for _, rec := range data.MX.Records { if rec.IsIPLiteral { issues = append(issues, Issue{ Code: CodeMXIPLiteral, Severity: SeverityCrit, Message: fmt.Sprintf("MX target %q is an IP address; RFC 5321 § 5.1 requires a hostname.", rec.Target), Fix: "Publish an A/AAAA record under a hostname (e.g. mail." + data.Domain + ") and point the MX at that name.", Target: rec.Target, }) } if rec.IsCNAME { issues = append(issues, Issue{ Code: CodeMXCNAME, Severity: SeverityWarn, Message: fmt.Sprintf("MX target %q is a CNAME (chain: %s). RFC 5321 § 5.1 forbids this.", rec.Target, strings.Join(rec.CNAMEChain, " → ")), Fix: "Replace the CNAME with an A/AAAA record directly on the MX target, or point the MX at the CNAME's canonical name.", Target: rec.Target, }) } if rec.ResolveError != "" { issues = append(issues, Issue{ Code: CodeMXResolveFailed, Severity: SeverityCrit, Message: fmt.Sprintf("Failed to resolve MX target %q: %s", rec.Target, rec.ResolveError), Fix: "Check that " + rec.Target + " has valid A/AAAA records.", Target: rec.Target, }) } if !rec.IsIPLiteral && rec.ResolveError == "" && len(rec.IPv4) == 0 && len(rec.IPv6) == 0 { issues = append(issues, Issue{ Code: CodeNoAddresses, Severity: SeverityCrit, Message: fmt.Sprintf("MX target %q has no A or AAAA records.", rec.Target), Fix: "Add at least one A or AAAA record for " + rec.Target + ".", Target: rec.Target, }) } } // 2. Endpoint-level issues. anyConnected := false anySTARTTLS := false for _, ep := range data.Endpoints { if !ep.TCPConnected { issues = append(issues, Issue{ Code: CodeTCPUnreachable, Severity: SeverityCrit, Message: fmt.Sprintf("Cannot reach %s (%s): %s.", ep.Address, ep.Target, ep.Error), Fix: "Verify firewall rules and that an SMTP service listens on port 25 of " + ep.IP + ".", Endpoint: ep.Address, Target: ep.Target, }) continue } anyConnected = true if !ep.BannerReceived { issues = append(issues, Issue{ Code: CodeBannerMissing, Severity: SeverityCrit, Message: fmt.Sprintf("No SMTP banner received on %s (%s).", ep.Address, ep.Target), Fix: "Confirm that the service on port 25 is actually SMTP and is not rate-limiting or blackholing the probe.", Endpoint: ep.Address, Target: ep.Target, }) } else if ep.BannerCode != 220 { issues = append(issues, Issue{ Code: CodeBannerInvalid, Severity: SeverityCrit, Message: fmt.Sprintf("Banner on %s returned code %d (expected 220).", ep.Address, ep.BannerCode), Fix: "A non-220 greeting means the MTA refuses the connection. Check logs for tarpit/rate-limit rules triggered by our EHLO hostname.", Endpoint: ep.Address, Target: ep.Target, }) } if ep.BannerReceived && !ep.EHLOReceived { issues = append(issues, Issue{ Code: CodeEHLOFailed, Severity: SeverityCrit, Message: fmt.Sprintf("EHLO rejected on %s: %s.", ep.Address, ep.Error), Fix: "Check the MTA's HELO access rules. Most servers require the EHLO name to resolve; run the checker with a working EHLO hostname.", Endpoint: ep.Address, Target: ep.Target, }) } else if ep.EHLOFallbackHELO { issues = append(issues, Issue{ Code: CodeEHLOFallback, Severity: SeverityWarn, Message: fmt.Sprintf("Server on %s only accepts HELO, not EHLO.", ep.Address), Fix: "Upgrade to an ESMTP-capable configuration. EHLO is mandatory for STARTTLS, PIPELINING, SIZE, DSN, 8BITMIME, SMTPUTF8…", Endpoint: ep.Address, Target: ep.Target, }) } // STARTTLS posture: MX SMTP is opportunistic, but "no TLS at all" // is a strong signal that the operator forgot to configure it. if ep.EHLOReceived && !ep.STARTTLSOffered { issues = append(issues, Issue{ Code: CodeSTARTTLSMissing, Severity: SeverityCrit, Message: fmt.Sprintf("STARTTLS not advertised on %s; inbound mail will be delivered in cleartext.", ep.Address), Fix: "Enable STARTTLS in your MTA (Postfix: smtpd_tls_security_level=may and a valid cert; Exim: tls_advertise_hosts=*). This is a prerequisite for DANE / MTA-STS.", Endpoint: ep.Address, Target: ep.Target, }) } if ep.STARTTLSOffered && !ep.STARTTLSUpgraded { issues = append(issues, Issue{ Code: CodeSTARTTLSFailed, Severity: SeverityCrit, Message: fmt.Sprintf("STARTTLS advertised but the TLS handshake failed on %s: %s.", ep.Address, ep.Error), Fix: "Check the server certificate and protocol versions with the TLS checker. A common cause is an expired certificate or disabled TLS 1.0/1.1 on a client that only speaks older versions.", Endpoint: ep.Address, Target: ep.Target, }) } if ep.STARTTLSUpgraded { anySTARTTLS = true } // AUTH offered without TLS is a classic misconfiguration: // a client can send credentials in cleartext. if len(ep.AUTHPreTLS) > 0 { issues = append(issues, Issue{ Code: CodeAUTHOverPlain, Severity: SeverityCrit, Message: fmt.Sprintf("AUTH (%s) advertised on %s *before* STARTTLS; credentials can be observed on the wire.", strings.Join(ep.AUTHPreTLS, ","), ep.Address), Fix: "Disable SMTP AUTH on port 25 entirely (it's not supposed to be used for submission) or gate it behind smtpd_tls_auth_only=yes. Submission belongs on port 587.", Endpoint: ep.Address, Target: ep.Target, }) } // PTR / FCrDNS, strongly weighted by anti-spam. if ep.PTR == "" { issues = append(issues, Issue{ Code: CodePTRMissing, Severity: SeverityWarn, Message: fmt.Sprintf("No PTR record for %s. Many receivers (Gmail, Outlook, Yahoo) reject mail from IPs without reverse DNS.", ep.IP), Fix: "Set a PTR record on " + ep.IP + " at your hosting provider. It should match the EHLO name announced by the MTA.", Endpoint: ep.Address, Target: ep.Target, }) } else if !ep.FCrDNSPass { issues = append(issues, Issue{ Code: CodeFCrDNSMismatch, Severity: SeverityWarn, Message: fmt.Sprintf("FCrDNS fails on %s: PTR %q does not resolve back to this IP.", ep.IP, ep.PTR), Fix: "Either fix the PTR to point at a hostname whose A/AAAA resolves to " + ep.IP + ", or add the missing A/AAAA on the existing PTR target.", Endpoint: ep.Address, Target: ep.Target, }) } // Null sender / postmaster / open relay. if ep.NullSenderAccepted != nil && !*ep.NullSenderAccepted { issues = append(issues, Issue{ Code: CodeNullSenderReject, Severity: SeverityCrit, Message: fmt.Sprintf("Server on %s rejects MAIL FROM:<> (response: %s).", ep.Address, ep.NullSenderResponse), Fix: "RFC 5321 mandates that bounces (DSNs) use the null sender. Refusing it means you cannot receive bounce reports, and any legitimate DSN will be lost.", Endpoint: ep.Address, Target: ep.Target, }) } if ep.PostmasterAccepted != nil && !*ep.PostmasterAccepted { issues = append(issues, Issue{ Code: CodePostmasterReject, Severity: SeverityCrit, Message: fmt.Sprintf("Server on %s rejects RCPT TO: (response: %s).", ep.Address, data.Domain, ep.PostmasterResponse), Fix: "RFC 5321 § 4.5.1 requires every SMTP receiver to accept mail for postmaster. Create the mailbox (or an alias to the team's inbox).", Endpoint: ep.Address, Target: ep.Target, }) } if ep.OpenRelay != nil && *ep.OpenRelay { issues = append(issues, Issue{ Code: CodeOpenRelay, Severity: SeverityCrit, Message: fmt.Sprintf("OPEN RELAY: %s accepted RCPT TO:<%s> from the probe. Spammers can use this server to send arbitrary mail.", ep.Address, ep.OpenRelayRecipient), Fix: "Restrict relaying to authenticated users only. In Postfix set smtpd_relay_restrictions accordingly; in Exim, require `acl_smtp_rcpt` to check local domains first.", Endpoint: ep.Address, Target: ep.Target, }) } // Minor posture issues. if ep.EHLOReceived && !ep.HasPipelining { issues = append(issues, Issue{ Code: CodeNoPipelining, Severity: SeverityInfo, Message: fmt.Sprintf("PIPELINING not advertised on %s.", ep.Address), Fix: "Enable ESMTP PIPELINING; it materially reduces the number of network round-trips for each delivery.", Endpoint: ep.Address, Target: ep.Target, }) } if ep.EHLOReceived && !ep.Has8BITMIME { issues = append(issues, Issue{ Code: CodeNo8BITMIME, Severity: SeverityInfo, Message: fmt.Sprintf("8BITMIME not advertised on %s.", ep.Address), Fix: "Enable 8BITMIME support; without it, senders must MIME-encode non-ASCII bodies or risk rewrites.", Endpoint: ep.Address, Target: ep.Target, }) } } if len(data.Endpoints) > 0 && !anyConnected { issues = append(issues, Issue{ Code: CodeAllEndpointsDown, Severity: SeverityCrit, Message: "None of the MX targets accepted a TCP connection on port 25.", Fix: "Confirm the mail servers are running and that their network path is open to the internet on port 25.", }) } if anyConnected && !anySTARTTLS { issues = append(issues, Issue{ Code: CodeAllNoSTARTTLS, Severity: SeverityCrit, Message: "No MX endpoint advertises a working STARTTLS. All inbound mail is delivered in cleartext.", Fix: "Enable STARTTLS on every MX endpoint (a valid certificate is needed). Once this is done, consider publishing MTA-STS and TLSA/DANE records for strict enforcement.", }) } // IPv6 coverage (info only, since IPv4 is still the dominant path). if data.Coverage.HasIPv4 && !data.Coverage.HasIPv6 { issues = append(issues, Issue{ Code: CodeNoIPv6, Severity: SeverityInfo, Message: "No MX endpoint reachable over IPv6.", Fix: "Publish AAAA records for your MX targets; Gmail, Outlook and Yahoo prefer IPv6-capable receivers.", }) } return issues }