// This file is part of the happyDeliver (R) project. // Copyright (c) 2025 happyDomain // Authors: Pierre-Olivier Mercier, et al. // // This program is offered under a commercial and under the AGPL license. // For commercial licensing, contact us at . // // For AGPL licensing: // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package analyzer import ( "context" "fmt" "net" "regexp" "strings" "time" "git.happydns.org/happyDeliver/internal/api" ) // DNSAnalyzer analyzes DNS records for email domains type DNSAnalyzer struct { Timeout time.Duration resolver *net.Resolver } // NewDNSAnalyzer creates a new DNS analyzer with configurable timeout func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer { if timeout == 0 { timeout = 10 * time.Second // Default timeout } return &DNSAnalyzer{ Timeout: timeout, resolver: &net.Resolver{ PreferGo: true, }, } } // DNSResults represents DNS validation results for an email type DNSResults struct { Domain string MXRecords []MXRecord SPFRecord *SPFRecord DKIMRecords []DKIMRecord DMARCRecord *DMARCRecord BIMIRecord *BIMIRecord Errors []string } // MXRecord represents an MX record type MXRecord struct { Host string Priority uint16 Valid bool Error string } // SPFRecord represents an SPF record type SPFRecord struct { Record string Valid bool Error string } // DKIMRecord represents a DKIM record type DKIMRecord struct { Selector string Domain string Record string Valid bool Error string } // DMARCRecord represents a DMARC record type DMARCRecord struct { Record string Policy string // none, quarantine, reject Valid bool Error string } // BIMIRecord represents a BIMI record type BIMIRecord struct { Selector string Domain string Record string LogoURL string // URL to the brand logo (SVG) VMCURL string // URL to Verified Mark Certificate (optional) Valid bool Error string } // AnalyzeDNS performs DNS validation for the email's domain func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults { // Extract domain from From address domain := d.extractDomain(email) if domain == "" { return &DNSResults{ Errors: []string{"Unable to extract domain from email"}, } } results := &DNSResults{ Domain: domain, } // Check MX records results.MXRecords = d.checkMXRecords(domain) // Check SPF record results.SPFRecord = d.checkSPFRecord(domain) // Check DKIM records (from authentication results) if authResults != nil && authResults.Dkim != nil { for _, dkim := range *authResults.Dkim { if dkim.Domain != nil && dkim.Selector != nil { dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector) if dkimRecord != nil { results.DKIMRecords = append(results.DKIMRecords, *dkimRecord) } } } } // Check DMARC record results.DMARCRecord = d.checkDMARCRecord(domain) // Check BIMI record (using default selector) results.BIMIRecord = d.checkBIMIRecord(domain, "default") return results } // extractDomain extracts the domain from the email's From address func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string { if email.From != nil && email.From.Address != "" { parts := strings.Split(email.From.Address, "@") if len(parts) == 2 { return strings.ToLower(strings.TrimSpace(parts[1])) } } return "" } // checkMXRecords looks up MX records for a domain func (d *DNSAnalyzer) checkMXRecords(domain string) []MXRecord { ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() mxRecords, err := d.resolver.LookupMX(ctx, domain) if err != nil { return []MXRecord{ { Valid: false, Error: fmt.Sprintf("Failed to lookup MX records: %v", err), }, } } if len(mxRecords) == 0 { return []MXRecord{ { Valid: false, Error: "No MX records found", }, } } var results []MXRecord for _, mx := range mxRecords { results = append(results, MXRecord{ Host: mx.Host, Priority: mx.Pref, Valid: true, }) } return results } // checkSPFRecord looks up and validates SPF record for a domain func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord { ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() txtRecords, err := d.resolver.LookupTXT(ctx, domain) if err != nil { return &SPFRecord{ Valid: false, Error: fmt.Sprintf("Failed to lookup TXT records: %v", err), } } // Find SPF record (starts with "v=spf1") var spfRecord string spfCount := 0 for _, txt := range txtRecords { if strings.HasPrefix(txt, "v=spf1") { spfRecord = txt spfCount++ } } if spfCount == 0 { return &SPFRecord{ Valid: false, Error: "No SPF record found", } } if spfCount > 1 { return &SPFRecord{ Record: spfRecord, Valid: false, Error: "Multiple SPF records found (RFC violation)", } } // Basic validation if !d.validateSPF(spfRecord) { return &SPFRecord{ Record: spfRecord, Valid: false, Error: "SPF record appears malformed", } } return &SPFRecord{ Record: spfRecord, Valid: true, } } // validateSPF performs basic SPF record validation func (d *DNSAnalyzer) validateSPF(record string) bool { // Must start with v=spf1 if !strings.HasPrefix(record, "v=spf1") { return false } // Check for common syntax issues // Should have a final mechanism (all, +all, -all, ~all, ?all) validEndings := []string{" all", " +all", " -all", " ~all", " ?all"} hasValidEnding := false for _, ending := range validEndings { if strings.HasSuffix(record, ending) { hasValidEnding = true break } } return hasValidEnding } // checkDKIMRecord looks up and validates DKIM record for a domain and selector func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord { // DKIM records are at: selector._domainkey.domain dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) if err != nil { return &DKIMRecord{ Selector: selector, Domain: domain, Valid: false, Error: fmt.Sprintf("Failed to lookup DKIM record: %v", err), } } if len(txtRecords) == 0 { return &DKIMRecord{ Selector: selector, Domain: domain, Valid: false, Error: "No DKIM record found", } } // Concatenate all TXT record parts (DKIM can be split) dkimRecord := strings.Join(txtRecords, "") // Basic validation - should contain "v=DKIM1" and "p=" (public key) if !d.validateDKIM(dkimRecord) { return &DKIMRecord{ Selector: selector, Domain: domain, Record: dkimRecord, Valid: false, Error: "DKIM record appears malformed", } } return &DKIMRecord{ Selector: selector, Domain: domain, Record: dkimRecord, Valid: true, } } // validateDKIM performs basic DKIM record validation func (d *DNSAnalyzer) validateDKIM(record string) bool { // Should contain p= tag (public key) if !strings.Contains(record, "p=") { return false } // Often contains v=DKIM1 but not required // If v= is present, it should be DKIM1 if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { return false } return true } // checkDMARCRecord looks up and validates DMARC record for a domain func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord { // DMARC records are at: _dmarc.domain dmarcDomain := fmt.Sprintf("_dmarc.%s", domain) ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain) if err != nil { return &DMARCRecord{ Valid: false, Error: fmt.Sprintf("Failed to lookup DMARC record: %v", err), } } // Find DMARC record (starts with "v=DMARC1") var dmarcRecord string for _, txt := range txtRecords { if strings.HasPrefix(txt, "v=DMARC1") { dmarcRecord = txt break } } if dmarcRecord == "" { return &DMARCRecord{ Valid: false, Error: "No DMARC record found", } } // Extract policy policy := d.extractDMARCPolicy(dmarcRecord) // Basic validation if !d.validateDMARC(dmarcRecord) { return &DMARCRecord{ Record: dmarcRecord, Policy: policy, Valid: false, Error: "DMARC record appears malformed", } } return &DMARCRecord{ Record: dmarcRecord, Policy: policy, Valid: true, } } // extractDMARCPolicy extracts the policy from a DMARC record func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { // Look for p=none, p=quarantine, or p=reject re := regexp.MustCompile(`p=(none|quarantine|reject)`) matches := re.FindStringSubmatch(record) if len(matches) > 1 { return matches[1] } return "unknown" } // validateDMARC performs basic DMARC record validation func (d *DNSAnalyzer) validateDMARC(record string) bool { // Must start with v=DMARC1 if !strings.HasPrefix(record, "v=DMARC1") { return false } // Must have a policy tag if !strings.Contains(record, "p=") { return false } return true } // checkBIMIRecord looks up and validates BIMI record for a domain and selector func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord { // BIMI records are at: selector._bimi.domain bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) defer cancel() txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) if err != nil { return &BIMIRecord{ Selector: selector, Domain: domain, Valid: false, Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err), } } if len(txtRecords) == 0 { return &BIMIRecord{ Selector: selector, Domain: domain, Valid: false, Error: "No BIMI record found", } } // Concatenate all TXT record parts (BIMI can be split) bimiRecord := strings.Join(txtRecords, "") // Extract logo URL and VMC URL logoURL := d.extractBIMITag(bimiRecord, "l") vmcURL := d.extractBIMITag(bimiRecord, "a") // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) if !d.validateBIMI(bimiRecord) { return &BIMIRecord{ Selector: selector, Domain: domain, Record: bimiRecord, LogoURL: logoURL, VMCURL: vmcURL, Valid: false, Error: "BIMI record appears malformed", } } return &BIMIRecord{ Selector: selector, Domain: domain, Record: bimiRecord, LogoURL: logoURL, VMCURL: vmcURL, Valid: true, } } // extractBIMITag extracts a tag value from a BIMI record func (d *DNSAnalyzer) extractBIMITag(record, tag string) string { // Look for tag=value pattern re := regexp.MustCompile(tag + `=([^;]+)`) matches := re.FindStringSubmatch(record) if len(matches) > 1 { return strings.TrimSpace(matches[1]) } return "" } // validateBIMI performs basic BIMI record validation func (d *DNSAnalyzer) validateBIMI(record string) bool { // Must start with v=BIMI1 if !strings.HasPrefix(record, "v=BIMI1") { return false } // Must have a logo URL tag (l=) if !strings.Contains(record, "l=") { return false } return true } // GenerateDNSChecks generates check results for DNS validation func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check { var checks []api.Check if results == nil { return checks } // MX record check checks = append(checks, d.generateMXCheck(results)) // SPF record check if results.SPFRecord != nil { checks = append(checks, d.generateSPFCheck(results.SPFRecord)) } // DKIM record checks for _, dkim := range results.DKIMRecords { checks = append(checks, d.generateDKIMCheck(&dkim)) } // DMARC record check if results.DMARCRecord != nil { checks = append(checks, d.generateDMARCCheck(results.DMARCRecord)) } // BIMI record check (optional) if results.BIMIRecord != nil { checks = append(checks, d.generateBIMICheck(results.BIMIRecord)) } return checks } // generateMXCheck creates a check for MX records func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check { check := api.Check{ Category: api.Dns, Name: "MX Records", } if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid { check.Status = api.CheckStatusFail check.Score = 0.0 check.Severity = api.PtrTo(api.CheckSeverityCritical) if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" { check.Message = results.MXRecords[0].Error } else { check.Message = "No valid MX records found" } check.Advice = api.PtrTo("Configure MX records for your domain to receive email") } else { check.Status = api.CheckStatusPass check.Score = 1.0 check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords)) // Add details about MX records var mxList []string for _, mx := range results.MXRecords { mxList = append(mxList, fmt.Sprintf("%s (priority %d)", mx.Host, mx.Priority)) } details := strings.Join(mxList, ", ") check.Details = &details check.Advice = api.PtrTo("Your MX records are properly configured") } return check } // generateSPFCheck creates a check for SPF records func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check { check := api.Check{ Category: api.Dns, Name: "SPF Record", } if !spf.Valid { // If no record exists at all, it's a failure if spf.Record == "" { check.Status = api.CheckStatusFail check.Score = 0.0 check.Message = spf.Error check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability") } else { // If record exists but is invalid, it's a warning check.Status = api.CheckStatusWarn check.Score = 0.5 check.Message = "SPF record found but appears invalid" check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("Review and fix your SPF record syntax") check.Details = &spf.Record } } else { check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = "Valid SPF record found" check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Details = &spf.Record check.Advice = api.PtrTo("Your SPF record is properly configured") } return check } // generateDKIMCheck creates a check for DKIM records func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check { check := api.Check{ Category: api.Dns, Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector), } if !dkim.Valid { check.Status = api.CheckStatusFail check.Score = 0.0 check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error) check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used") details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) check.Details = &details } else { check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = "Valid DKIM record found" check.Severity = api.PtrTo(api.CheckSeverityInfo) details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) check.Details = &details check.Advice = api.PtrTo("Your DKIM record is properly published") } return check } // generateDMARCCheck creates a check for DMARC records func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { check := api.Check{ Category: api.Dns, Name: "DMARC Record", } if !dmarc.Valid { check.Status = api.CheckStatusFail check.Score = 0.0 check.Message = dmarc.Error check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing") } else { check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy) check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Details = &dmarc.Record // Provide advice based on policy switch dmarc.Policy { case "none": advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection" check.Advice = &advice case "quarantine": advice := "DMARC policy is set to 'quarantine'. This provides good protection" check.Advice = &advice case "reject": advice := "DMARC policy is set to 'reject'. This provides the strongest protection" check.Advice = &advice default: advice := "Your DMARC record is properly configured" check.Advice = &advice } } return check } // generateBIMICheck creates a check for BIMI records func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { check := api.Check{ Category: api.Dns, Name: "BIMI Record", } if !bimi.Valid { // BIMI is optional, so missing record is just informational if bimi.Record == "" { check.Status = api.CheckStatusInfo check.Score = 0.0 check.Message = "No BIMI record found (optional)" check.Severity = api.PtrTo(api.CheckSeverityLow) check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)") } else { // If record exists but is invalid check.Status = api.CheckStatusWarn check.Score = 0.0 check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error) check.Severity = api.PtrTo(api.CheckSeverityLow) check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)") check.Details = &bimi.Record } } else { check.Status = api.CheckStatusPass check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) check.Message = "Valid BIMI record found" check.Severity = api.PtrTo(api.CheckSeverityInfo) // Build details with logo and VMC URLs var detailsParts []string detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector)) if bimi.LogoURL != "" { detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL)) } if bimi.VMCURL != "" { detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL)) check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate") } else { check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust") } details := strings.Join(detailsParts, ", ") check.Details = &details } return check }