diff --git a/api/openapi.yaml b/api/openapi.yaml index 9c682dc..6012ba0 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -777,17 +777,26 @@ components: DNSResults: type: object required: - - domain + - from_domain properties: - domain: + from_domain: type: string - description: Domain name + description: From Domain name example: "example.com" - mx_records: + rp_domain: + type: string + description: Return Path Domain name + example: "example.com" + from_mx_records: type: array items: $ref: '#/components/schemas/MXRecord' - description: MX records for the domain + description: MX records for the From domain + rp_mx_records: + type: array + items: + $ref: '#/components/schemas/MXRecord' + description: MX records for the Return-Path domain spf_records: type: array items: diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 303c095..9dc12fa 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -54,24 +54,40 @@ func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer { // AnalyzeDNS performs DNS validation for the email's domain func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *api.DNSResults { // Extract domain from From address - domain := d.extractDomain(email) - if domain == "" { + fromDomain := d.extractFromDomain(email) + if fromDomain == "" { return &api.DNSResults{ Errors: &[]string{"Unable to extract domain from email"}, } } results := &api.DNSResults{ - Domain: domain, + FromDomain: fromDomain, + RpDomain: d.extractRPDomain(email), } - // Check MX records - results.MxRecords = d.checkMXRecords(domain) + // Determine which domain to check SPF for (Return-Path domain) + // SPF validates the envelope sender (Return-Path), not the From header + spfDomain := fromDomain + if results.RpDomain != nil { + spfDomain = *results.RpDomain + } - // Check SPF records (including includes) - results.SpfRecords = d.checkSPFRecords(domain) + // Check MX records for From domain (where replies would go) + results.FromMxRecords = d.checkMXRecords(fromDomain) + + // Check MX records for Return-Path domain (where bounces would go) + // Only check if Return-Path domain is different from From domain + if results.RpDomain != nil && *results.RpDomain != fromDomain { + results.RpMxRecords = d.checkMXRecords(*results.RpDomain) + } + + // Check SPF records (for Return-Path domain - this is the envelope sender) + // SPF validates the MAIL FROM command, which corresponds to Return-Path + results.SpfRecords = d.checkSPFRecords(spfDomain) // Check DKIM records (from authentication results) + // DKIM can be for any domain, but typically the From domain if authResults != nil && authResults.Dkim != nil { for _, dkim := range *authResults.Dkim { if dkim.Domain != nil && dkim.Selector != nil { @@ -86,17 +102,18 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic } } - // Check DMARC record - results.DmarcRecord = d.checkDMARCRecord(domain) + // Check DMARC record (for From domain - DMARC protects the visible sender) + // DMARC validates alignment between SPF/DKIM and the From domain + results.DmarcRecord = d.checkDMARCRecord(fromDomain) - // Check BIMI record (using default selector) - results.BimiRecord = d.checkBIMIRecord(domain, "default") + // Check BIMI record (for From domain - branding is based on visible sender) + results.BimiRecord = d.checkBIMIRecord(fromDomain, "default") return results } -// extractDomain extracts the domain from the email's From address -func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string { +// extractFromDomain extracts the domain from the email's From address +func (d *DNSAnalyzer) extractFromDomain(email *EmailMessage) string { if email.From != nil && email.From.Address != "" { parts := strings.Split(email.From.Address, "@") if len(parts) == 2 { @@ -106,6 +123,17 @@ func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string { return "" } +// extractRPDomain extracts the domain from the email's Return-Path address +func (d *DNSAnalyzer) extractRPDomain(email *EmailMessage) *string { + if email.ReturnPath != "" { + parts := strings.Split(email.ReturnPath, "@") + if len(parts) == 2 { + return api.PtrTo(strings.TrimSuffix(strings.ToLower(strings.TrimSpace(parts[1])), ">")) + } + } + return nil +} + // checkMXRecords looks up MX records for a domain func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord { ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) @@ -529,18 +557,50 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) { // TODO: 20 points for correct PTR and A/AAAA - // MX Records: 20 points + // MX Records: 20 points (10 for From domain, 10 for Return-Path domain) // Having valid MX records is critical for email deliverability - if results.MxRecords != nil && len(*results.MxRecords) > 0 { - hasValidMX := false - for _, mx := range *results.MxRecords { + // From domain MX records (10 points) - needed for replies + if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { + hasValidFromMX := false + for _, mx := range *results.FromMxRecords { if mx.Valid { - hasValidMX = true + hasValidFromMX = true break } } - if hasValidMX { - score += 20 + if hasValidFromMX { + score += 10 + } + } + + // Return-Path domain MX records (10 points) - needed for bounces + if results.RpMxRecords != nil && len(*results.RpMxRecords) > 0 { + hasValidRpMX := false + for _, mx := range *results.RpMxRecords { + if mx.Valid { + hasValidRpMX = true + break + } + } + if hasValidRpMX { + score += 10 + } + } else if results.RpDomain != nil && *results.RpDomain != results.FromDomain { + // If Return-Path domain is different but has no MX records, it's a problem + // Don't deduct points if RP domain is same as From domain (already checked) + } else { + // If Return-Path is same as From domain, give full 10 points for RP MX + if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 { + hasValidFromMX := false + for _, mx := range *results.FromMxRecords { + if mx.Valid { + hasValidFromMX = true + break + } + } + if hasValidFromMX { + score += 10 + } } } @@ -560,8 +620,8 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) { // Softfail - moderate penalty score -= 5 } else if strings.HasSuffix(*mainSPF.Record, " +all") || - strings.HasSuffix(*mainSPF.Record, " ?all") || - strings.HasSuffix(*mainSPF.Record, " all") { + strings.HasSuffix(*mainSPF.Record, " ?all") || + strings.HasSuffix(*mainSPF.Record, " all") { // Pass/neutral - severe penalty score -= 10 } else { diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go index 7859523..664ae5e 100644 --- a/pkg/analyzer/dns_test.go +++ b/pkg/analyzer/dns_test.go @@ -104,9 +104,9 @@ func TestExtractDomain(t *testing.T) { } } - domain := analyzer.extractDomain(email) + domain := analyzer.extractFromDomain(email) if domain != tt.expectedDomain { - t.Errorf("extractDomain() = %q, want %q", domain, tt.expectedDomain) + t.Errorf("extractFromDomain() = %q, want %q", domain, tt.expectedDomain) } }) } diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 1fc18dd..4364218 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -46,7 +46,14 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int maxGrade := 6 headers := *analysis.Headers - // Check required headers (RFC 5322) - 40 points + // RP and From alignment (20 points) + if analysis.DomainAlignment.Aligned != nil && *analysis.DomainAlignment.Aligned { + score += 20 + } else { + maxGrade -= 2 + } + + // Check required headers (RFC 5322) - 30 points requiredHeaders := []string{"from", "date", "message-id"} requiredCount := len(requiredHeaders) presentRequired := 0 @@ -58,13 +65,13 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int } if presentRequired == requiredCount { - score += 40 + score += 30 } else { - score += int(40 * (float32(presentRequired) / float32(requiredCount))) + score += int(30 * (float32(presentRequired) / float32(requiredCount))) maxGrade = 1 } - // Check recommended headers (30 points) + // Check recommended headers (20 points) recommendedHeaders := []string{"subject", "to"} // Add reply-to when from is a no-reply address @@ -80,7 +87,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int presentRecommended++ } } - score += presentRecommended * 30 / recommendedCount + score += presentRecommended * 20 / recommendedCount if presentRecommended < recommendedCount { maxGrade -= 1 diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index f4ff358..be2dd1d 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -2,6 +2,7 @@ import type { DNSResults } from "$lib/api/types.gen"; import { getScoreColorClass } from "$lib/score"; import GradeDisplay from "./GradeDisplay.svelte"; + import MxRecordsDisplay from "./MxRecordsDisplay.svelte"; interface Props { dnsResults?: DNSResults; @@ -35,10 +36,6 @@ {#if !dnsResults}
No DNS results available
{:else} -{dnsResults.domain}
- | Priority | -Host | -Status | -
|---|---|---|
| {mx.priority} | -{mx.host} |
-
- {#if mx.valid}
- Valid
- {:else}
- Invalid
- {#if mx.error}
- {mx.error} - {/if} - {/if} - |
-
{dnsResults.rp_domain || dnsResults.from_domain}
+ {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
+ Different from From domain
+
+
+ See domain alignment
+
+ {:else}
+ Same as From domain
+ {/if}
+ SPF validates the Return-Path (envelope sender) domain.
{#each dnsResults.spf_records as spf, index}{dnsResults.from_domain}
+ {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
+ Different from Return-Path domain
+ {/if}
+ {headerAnalysis.domain_alignment.from_domain || '-'}{headerAnalysis.domain_alignment.return_path_domain || '-'}{description}
+ {/if} +| Priority | +Host | +Status | +
|---|---|---|
| {mx.priority} | +{mx.host} |
+
+ {#if mx.valid}
+ Valid
+ {:else}
+ Invalid
+ {#if mx.error}
+ {mx.error} + {/if} + {/if} + |
+