package hammerhead import ( "fmt" "sort" "strings" "git.veillax.com/Veillax/reef/model" ) // A Finding is one line of a mail egress's deliverability checklist: what was // checked, how it came out, and what to do about it. The edge produces the raw // results; the findings put old and new checks in one shape for the dashboard // and the alerts. type Finding struct { ID string `json:"id"` // stable, e.g. "ptr", "spf:example.com" Label string `json:"label"` // "Reverse DNS", "SPF" Domain string `json:"domain,omitempty"` // the sending domain, for per-domain checks Status string `json:"status"` // pass, warn, fail or none (nothing published) Detail string `json:"detail"` Hint string `json:"hint,omitempty"` } // alertWorthy says whether a finding should raise an alert: failures do, and so // does a missing SPF or DMARC record (bulk senders are required to publish both). // Warnings and a missing MTA-STS policy are advice, not problems. func (f Finding) alertWorthy() bool { if f.Status == model.FindingFail { return true } return f.Status == model.FindingNone && (strings.HasPrefix(f.ID, "spf:") || strings.HasPrefix(f.ID, "dmarc:")) } // minOutcomeSample is how many messages a window needs before its failure rate says anything. const minOutcomeSample = 10 func deliverabilityFindings(x *model.MailEgress, chk model.MailCheck, keys []*model.DKIMKey) []Finding { var out []Finding add := func(f Finding) { out = append(out, f) } if chk.PTRMatchesHelo { add(Finding{ID: "ptr", Label: "Reverse DNS", Status: model.FindingPass, Detail: x.SourceIP + " resolves back to " + x.HeloName}) } else { found := "no reverse DNS record" if len(chk.PTR) > 0 { found = "reverse DNS is " + strings.Join(chk.PTR, ", ") } add(Finding{ID: "ptr", Label: "Reverse DNS", Status: model.FindingFail, Detail: found + ", not " + x.HeloName, Hint: fmt.Sprintf("Reverse DNS is set at your hosting provider, not in your DNS zone: set the PTR record of %s to %s.", x.SourceIP, x.HeloName)}) } if chk.HeloResolves { add(Finding{ID: "helo", Label: "HELO name", Status: model.FindingPass, Detail: x.HeloName + " resolves to " + x.SourceIP}) } else { add(Finding{ID: "helo", Label: "HELO name", Status: model.FindingFail, Detail: x.HeloName + " doesn't resolve to " + x.SourceIP, Hint: fmt.Sprintf("Add an A record for %s pointing at %s (an AAAA record for an IPv6 address).", x.HeloName, x.SourceIP)}) } if chk.Port25 == "open" { add(Finding{ID: "port25", Label: "Outgoing port 25", Status: model.FindingPass, Detail: "connections to other mail servers work"}) } else if chk.Port25 != "" { add(Finding{ID: "port25", Label: "Outgoing port 25", Status: model.FindingFail, Detail: chk.Port25, Hint: "Ask your hosting provider to unblock outgoing port 25 for this address; many VPS hosts block it until you ask."}) } for _, k := range keys { if k.OrgID != x.OrgID { continue } name := k.Selector + "._domainkey." + k.Domain switch chk.DKIM[k.ID] { case "published": add(Finding{ID: "dkim:" + k.ID, Label: "DKIM", Domain: k.Domain, Status: model.FindingPass, Detail: name + " is published"}) case "missing": add(Finding{ID: "dkim:" + k.ID, Label: "DKIM", Domain: k.Domain, Status: model.FindingFail, Detail: name + " isn't in DNS", Hint: "Publish the TXT record shown under DKIM signing; signed mail fails verification until it exists."}) case "mismatch": add(Finding{ID: "dkim:" + k.ID, Label: "DKIM", Domain: k.Domain, Status: model.FindingFail, Detail: name + " holds a different key", Hint: "Replace the TXT record's value with the one shown under DKIM signing; it was probably left over from an older key."}) } } doms := make([]string, 0, len(chk.Domains)) for d := range chk.Domains { doms = append(doms, d) } sort.Strings(doms) for _, d := range doms { c := chk.Domains[d] add(Finding{ID: "spf:" + d, Label: "SPF", Domain: d, Status: c.SPF.Status, Detail: c.SPF.Detail, Hint: c.SPF.Hint}) add(Finding{ID: "dmarc:" + d, Label: "DMARC", Domain: d, Status: c.DMARC.Status, Detail: c.DMARC.Detail, Hint: c.DMARC.Hint}) add(Finding{ID: "mtasts:" + d, Label: "MTA-STS", Domain: d, Status: c.MTASTS.Status, Detail: c.MTASTS.Detail, Hint: c.MTASTS.Hint}) } switch { case len(chk.Blocklists) > 0: add(Finding{ID: "blocklist", Label: "Blocklists", Status: model.FindingFail, Detail: x.SourceIP + " is listed on " + strings.Join(chk.Blocklists, ", "), Hint: "Look the address up on each list's website to see why it was listed and request removal. Until then expect mail to be rejected or sent to spam; check that no one is relaying spam through this server."}) case chk.Listable > 0: add(Finding{ID: "blocklist", Label: "Blocklists", Status: model.FindingPass, Detail: fmt.Sprintf("%s isn't on any of the %d blocklists checked", x.SourceIP, chk.Listable)}) case chk.Port25 != "" || chk.PTR != nil: add(Finding{ID: "blocklist", Label: "Blocklists", Status: model.FindingWarn, Detail: "no blocklist answered, so the address couldn't be checked", Hint: "Blocklists often refuse queries from public DNS resolvers; the edge needs a resolver of its own, or its provider's, to check them."}) } if o := chk.Outcomes; o != nil { if total := o.Delivered + o.Deferred + o.Failed; total >= minOutcomeSample { bad := float64(o.Deferred+o.Failed) / float64(total) detail := fmt.Sprintf("%d of %d messages in the last day were delivered (%d deferred, %d refused)", o.Delivered, total, o.Deferred, o.Failed) hint := outcomeHint(o) switch { case bad > 0.3: add(Finding{ID: "outcomes", Label: "Delivery", Status: model.FindingFail, Detail: detail, Hint: hint}) case bad > 0.1: add(Finding{ID: "outcomes", Label: "Delivery", Status: model.FindingWarn, Detail: detail, Hint: hint}) default: add(Finding{ID: "outcomes", Label: "Delivery", Status: model.FindingPass, Detail: detail}) } } } return out } // outcomeHint explains the latest refusal in terms of what to fix. func outcomeHint(o *model.MailOutcomes) string { if len(o.Recent) == 0 { return "" } r := o.Recent[0] low := strings.ToLower(r.Text) advice := "Read the message, it usually says what the receiver objected to." switch { case strings.Contains(low, "spf") || strings.Contains(low, "dmarc") || strings.Contains(low, "dkim") || strings.Contains(low, "authenticat"): advice = "The receiver wants authentication to pass: check the SPF, DKIM and DMARC lines above." case strings.Contains(low, "blocklist") || strings.Contains(low, "blacklist") || strings.Contains(low, "rbl") || strings.Contains(low, "listed") || strings.Contains(low, "spamhaus"): advice = "The receiver rejected the address as listed on a blocklist: see the Blocklists line and request delisting." case strings.Contains(low, "greylist") || strings.Contains(low, "try again") || strings.Contains(low, "temporar"): advice = "That receiver is delaying unfamiliar senders; these retry and normally get through." case strings.Contains(low, "rate") || strings.Contains(low, "too many") || strings.Contains(low, "throttl"): advice = "The receiver is limiting how fast you send to it; slow the sending or lower the relay's per-minute limit." case strings.Contains(low, "ptr") || strings.Contains(low, "reverse"): advice = "The receiver objects to the reverse DNS: see the Reverse DNS line." } return fmt.Sprintf("Latest from %s: %d %s. %s", r.Domain, r.Code, r.Text, advice) }