144 lines
7.3 KiB
Go
144 lines
7.3 KiB
Go
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)
|
|
}
|