package checker import ( "fmt" "strings" "time" "github.com/intodns/backend/internal/resolver" "github.com/miekg/dns" ) // checkMail runs SPF, DKIM, and DMARC checks. func checkMail(domain string, r *resolver.Resolver) Category { cat := Category{Name: "mail-auth", Title: "Mail Authentication (SPF/DKIM/DMARC)"} domain = dns.Fqdn(domain) // 1. SPF cat.Checks = append(cat.Checks, checkSPF(domain, r)) // 2. SPF syntax cat.Checks = append(cat.Checks, checkSPFSyntax(domain, r)) // 3. DMARC cat.Checks = append(cat.Checks, checkDMARC(domain, r)) // 4. DMARC policy cat.Checks = append(cat.Checks, checkDMARCPolicy(domain, r)) // 5. DKIM (common selectors) cat.Checks = append(cat.Checks, checkDKIM(domain, r)) return cat } func getTXTRecords(name string, r *resolver.Resolver) []string { resp, err := r.Query(name, "8.8.8.8", dns.TypeTXT) if err != nil || resp == nil { return nil } var records []string for _, rr := range resp.Answer { if txt, ok := rr.(*dns.TXT); ok { records = append(records, strings.Join(txt.Txt, "")) } } return records } func findSPF(records []string) string { for _, r := range records { if strings.HasPrefix(strings.ToLower(strings.TrimSpace(r)), "v=spf1") { return r } } return "" } func checkSPF(domain string, r *resolver.Resolver) CheckResult { start := time.Now() res := CheckResult{ID: "spf-present", Title: "SPF Record"} defer func() { res.DurationMs = measureDuration(start) }() txtRecords := getTXTRecords(domain, r) spf := findSPF(txtRecords) if spf == "" { res.Status = StatusFail res.Message = "No SPF record found" if len(txtRecords) > 0 { res.Details = append(res.Details, fmt.Sprintf("%d TXT records found, but none is SPF", len(txtRecords))) } return res } res.Status = StatusPass res.Message = "SPF record found" res.Details = append(res.Details, spf) return res } func checkSPFSyntax(domain string, r *resolver.Resolver) CheckResult { start := time.Now() res := CheckResult{ID: "spf-syntax", Title: "SPF Syntax"} defer func() { res.DurationMs = measureDuration(start) }() txtRecords := getTXTRecords(domain, r) spf := findSPF(txtRecords) if spf == "" { res.Status = StatusInfo res.Message = "No SPF record to validate" return res } // Count SPF records — there should be exactly one. spfCount := 0 for _, rec := range txtRecords { if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=spf1") { spfCount++ } } if spfCount > 1 { res.Status = StatusFail res.Message = fmt.Sprintf("Multiple SPF records found (%d) — only one is allowed per RFC 7208", spfCount) return res } // Basic syntax checks. var warnings []string lower := strings.ToLower(spf) if !strings.HasPrefix(lower, "v=spf1 ") && lower != "v=spf1" { warnings = append(warnings, "SPF version tag should be followed by a space") } // Check for common mechanisms. mechanisms := []string{"all", "include:", "a", "mx", "ip4:", "ip6:", "redirect=", "exists:"} hasMechanism := false for _, m := range mechanisms { if strings.Contains(lower, m) { hasMechanism = true break } } if !hasMechanism { warnings = append(warnings, "SPF record has no recognizable mechanisms") } // Check for +all (too permissive). if strings.Contains(lower, "+all") { warnings = append(warnings, "SPF uses +all which allows anyone to send mail (too permissive)") } // Check for ?all (neutral — not recommended). if strings.Contains(lower, "?all") { warnings = append(warnings, "SPF uses ?all (neutral) — consider using ~all or -all") } // Count DNS lookups (include, a, mx, ptr, exists, redirect — max 10). lookups := 0 parts := strings.Fields(lower) for _, p := range parts { p = strings.TrimLeft(p, "+-~?") if strings.HasPrefix(p, "include:") || strings.HasPrefix(p, "redirect=") || p == "a" || strings.HasPrefix(p, "a:") || p == "mx" || strings.HasPrefix(p, "mx:") || strings.HasPrefix(p, "ptr") || strings.HasPrefix(p, "exists:") { lookups++ } } if lookups > 10 { warnings = append(warnings, fmt.Sprintf("SPF record has %d DNS lookups (max 10 allowed per RFC 7208)", lookups)) } res.Details = append(res.Details, spf) res.Details = append(res.Details, fmt.Sprintf("DNS lookup mechanisms: %d/10", lookups)) if len(warnings) > 0 { res.Status = StatusWarn res.Message = "SPF record has potential issues" res.Details = append(res.Details, warnings...) } else { res.Status = StatusPass res.Message = "SPF record syntax looks valid" } return res } func checkDMARC(domain string, r *resolver.Resolver) CheckResult { start := time.Now() res := CheckResult{ID: "dmarc-present", Title: "DMARC Record"} defer func() { res.DurationMs = measureDuration(start) }() dmarcName := "_dmarc." + domain txtRecords := getTXTRecords(dmarcName, r) var dmarcRecord string for _, rec := range txtRecords { if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") { dmarcRecord = rec break } } if dmarcRecord == "" { res.Status = StatusFail res.Message = fmt.Sprintf("No DMARC record found at %s", dmarcName) return res } res.Status = StatusPass res.Message = "DMARC record found" res.Details = append(res.Details, dmarcRecord) return res } func checkDMARCPolicy(domain string, r *resolver.Resolver) CheckResult { start := time.Now() res := CheckResult{ID: "dmarc-policy", Title: "DMARC Policy"} defer func() { res.DurationMs = measureDuration(start) }() dmarcName := "_dmarc." + domain txtRecords := getTXTRecords(dmarcName, r) var dmarcRecord string for _, rec := range txtRecords { if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") { dmarcRecord = rec break } } if dmarcRecord == "" { res.Status = StatusInfo res.Message = "No DMARC record to evaluate" return res } // Parse key=value tags. tags := parseDMARCTags(dmarcRecord) res.Details = append(res.Details, dmarcRecord) policy := strings.ToLower(tags["p"]) switch policy { case "reject": res.Status = StatusPass res.Message = "DMARC policy is 'reject' (strongest protection)" case "quarantine": res.Status = StatusPass res.Message = "DMARC policy is 'quarantine' (good protection)" case "none": res.Status = StatusWarn res.Message = "DMARC policy is 'none' (monitoring only, no enforcement)" default: res.Status = StatusWarn res.Message = fmt.Sprintf("DMARC policy tag missing or unrecognized: '%s'", policy) } // Check subdomain policy. if sp, ok := tags["sp"]; ok { res.Details = append(res.Details, fmt.Sprintf("Subdomain policy (sp): %s", sp)) } // Check reporting. if rua, ok := tags["rua"]; ok { res.Details = append(res.Details, fmt.Sprintf("Aggregate reports (rua): %s", rua)) } else { res.Details = append(res.Details, "No aggregate report address (rua) — consider adding one") } if ruf, ok := tags["ruf"]; ok { res.Details = append(res.Details, fmt.Sprintf("Forensic reports (ruf): %s", ruf)) } // Check percentage. if pct, ok := tags["pct"]; ok && pct != "100" { res.Details = append(res.Details, fmt.Sprintf("pct=%s — not all messages are subject to policy", pct)) } return res } func parseDMARCTags(record string) map[string]string { tags := make(map[string]string) // Remove v=DMARC1 prefix. parts := strings.Split(record, ";") for _, part := range parts { part = strings.TrimSpace(part) if eq := strings.IndexByte(part, '='); eq > 0 { key := strings.TrimSpace(part[:eq]) val := strings.TrimSpace(part[eq+1:]) tags[strings.ToLower(key)] = val } } return tags } func checkDKIM(domain string, r *resolver.Resolver) CheckResult { start := time.Now() res := CheckResult{ID: "dkim-present", Title: "DKIM Records"} defer func() { res.DurationMs = measureDuration(start) }() // DKIM selectors are not discoverable — check common ones. commonSelectors := []string{ "default", "google", "selector1", // Microsoft 365 "selector2", // Microsoft 365 "k1", // Mailchimp "s1", "s2", "dkim", "mail", "smtp", "mandrill", // Mailchimp Transactional "cm", // Campaign Monitor "sig1", "amazonses", // Amazon SES } var found []string for _, sel := range commonSelectors { dkimName := sel + "._domainkey." + domain txtRecords := getTXTRecords(dkimName, r) for _, rec := range txtRecords { lower := strings.ToLower(rec) if strings.Contains(lower, "v=dkim1") || strings.Contains(lower, "p=") { found = append(found, fmt.Sprintf("%s: %s", sel, truncate(rec, 80))) } } } if len(found) > 0 { res.Status = StatusPass res.Message = fmt.Sprintf("DKIM record(s) found for %d selector(s)", len(found)) res.Details = found } else { res.Status = StatusInfo res.Message = "No DKIM records found for common selectors (may use custom selector)" res.Details = append(res.Details, "Checked selectors: "+strings.Join(commonSelectors, ", ")) } return res } func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." }