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} -
- Domain: {dnsResults.domain} -
- {#if dnsResults.errors && dnsResults.errors.length > 0}
Errors: @@ -50,50 +47,36 @@
{/if} - - {#if dnsResults.mx_records && dnsResults.mx_records.length > 0} -
-
- MX Mail Exchange Records -
-
- - - - - - - - - - {#each dnsResults.mx_records as mx} - - - - - - {/each} - -
PriorityHostStatus
{mx.priority}{mx.host} - {#if mx.valid} - Valid - {:else} - Invalid - {#if mx.error} -
{mx.error} - {/if} - {/if} -
-
-
+ +
+ Return-Path Domain: {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} +
+ + + {#if dnsResults.rp_mx_records && dnsResults.rp_mx_records.length > 0} + {/if} - + {#if dnsResults.spf_records && dnsResults.spf_records.length > 0}
SPF Sender Policy Framework
+

SPF validates the Return-Path (envelope sender) domain.

{#each dnsResults.spf_records as spf, index}
@@ -131,6 +114,25 @@
{/if} +
+ + +
+ From Domain: {dnsResults.from_domain} + {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} + Different from Return-Path domain + {/if} +
+ + + {#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0} + + {/if} + {#if dnsResults.dkim_records && dnsResults.dkim_records.length > 0}
diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 8dd074f..382da56 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -56,11 +56,18 @@ {/if} {#if headerAnalysis.domain_alignment} -
+
Domain Alignment
+
+ Aligned +
+ + {headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'} +
+
From Domain
{headerAnalysis.domain_alignment.from_domain || '-'}
@@ -69,13 +76,6 @@ Return-Path Domain
{headerAnalysis.domain_alignment.return_path_domain || '-'}
-
- Aligned -
- - {headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'} -
-
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
diff --git a/web/src/lib/components/MxRecordsDisplay.svelte b/web/src/lib/components/MxRecordsDisplay.svelte new file mode 100644 index 0000000..55fd7df --- /dev/null +++ b/web/src/lib/components/MxRecordsDisplay.svelte @@ -0,0 +1,49 @@ + + +
+
+ MX {title} +
+ {#if description} +

{description}

+ {/if} +
+ + + + + + + + + + {#each mxRecords as mx} + + + + + + {/each} + +
PriorityHostStatus
{mx.priority}{mx.host} + {#if mx.valid} + Valid + {:else} + Invalid + {#if mx.error} +
{mx.error} + {/if} + {/if} +
+
+