SPF check return-path

This commit is contained in:
nemunaire 2025-10-22 18:38:24 +07:00
commit 8ca4bed875
7 changed files with 210 additions and 83 deletions

View file

@ -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:

View file

@ -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 {

View file

@ -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)
}
})
}

View file

@ -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

View file

@ -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}
<p class="text-muted mb-0">No DNS results available</p>
{:else}
<div class="mb-3">
<strong>Domain:</strong> <code>{dnsResults.domain}</code>
</div>
{#if dnsResults.errors && dnsResults.errors.length > 0}
<div class="alert alert-warning mb-3">
<strong>Errors:</strong>
@ -50,50 +47,36 @@
</div>
{/if}
<!-- MX Records -->
{#if dnsResults.mx_records && dnsResults.mx_records.length > 0}
<div class="mb-4">
<h5 class="text-muted mb-2">
<span class="badge bg-secondary">MX</span> Mail Exchange Records
</h5>
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>Priority</th>
<th>Host</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each dnsResults.mx_records as mx}
<tr>
<td>{mx.priority}</td>
<td><code>{mx.host}</code></td>
<td>
{#if mx.valid}
<span class="badge bg-success">Valid</span>
{:else}
<span class="badge bg-danger">Invalid</span>
{#if mx.error}
<br><small class="text-danger">{mx.error}</small>
{/if}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Return-Path Domain Section -->
<div class="mb-3">
<strong>Return-Path Domain:</strong> <code>{dnsResults.rp_domain || dnsResults.from_domain}</code>
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
<span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Different from From domain</span>
<small>
<i class="bi bi-chevron-right"></i>
<a href="#domain-alignment">See domain alignment</a>
</small>
{:else}
<span class="badge bg-success ms-2">Same as From domain</span>
{/if}
</div>
<!-- MX Records for Return-Path Domain -->
{#if dnsResults.rp_mx_records && dnsResults.rp_mx_records.length > 0}
<MxRecordsDisplay
mxRecords={dnsResults.rp_mx_records}
title="Mail Exchange Records for Return-Path Domain"
description="These MX records handle bounce messages and non-delivery reports."
/>
{/if}
<!-- SPF Records -->
<!-- SPF Records (for Return-Path Domain) -->
{#if dnsResults.spf_records && dnsResults.spf_records.length > 0}
<div class="mb-4">
<h5 class="text-muted mb-2">
<span class="badge bg-secondary">SPF</span> Sender Policy Framework
</h5>
<p class="small text-muted mb-2">SPF validates the Return-Path (envelope sender) domain.</p>
{#each dnsResults.spf_records as spf, index}
<div class="card mb-2">
<div class="card-body">
@ -131,6 +114,25 @@
</div>
{/if}
<hr>
<!-- From Domain Section -->
<div class="mb-3">
<strong>From Domain:</strong> <code>{dnsResults.from_domain}</code>
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
<span class="badge bg-danger ms-2"><i class="bi bi-exclamation-triangle-fill"></i> Different from Return-Path domain</span>
{/if}
</div>
<!-- MX Records for From Domain -->
{#if dnsResults.from_mx_records && dnsResults.from_mx_records.length > 0}
<MxRecordsDisplay
mxRecords={dnsResults.from_mx_records}
title="Mail Exchange Records for From Domain"
description="These MX records handle replies to emails sent from this domain."
/>
{/if}
<!-- DKIM Records -->
{#if dnsResults.dkim_records && dnsResults.dkim_records.length > 0}
<div class="mb-4">

View file

@ -56,11 +56,18 @@
{/if}
{#if headerAnalysis.domain_alignment}
<div class="mb-3">
<div class="mb-3" id="domain-alignment">
<h6>Domain Alignment</h6>
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-4">
<small class="text-muted">Aligned</small>
<div>
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle text-success' : 'bi-x-circle text-danger'} me-1"></i>
<strong>{headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'}</strong>
</div>
</div>
<div class="col-md-4">
<small class="text-muted">From Domain</small>
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
@ -69,13 +76,6 @@
<small class="text-muted">Return-Path Domain</small>
<div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></div>
</div>
<div class="col-md-4">
<small class="text-muted">Aligned</small>
<div>
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle text-success' : 'bi-x-circle text-danger'} me-1"></i>
{headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'}
</div>
</div>
</div>
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
<div class="mt-2">

View file

@ -0,0 +1,49 @@
<script lang="ts">
import type { MXRecord } from "$lib/api/types.gen";
interface Props {
mxRecords: MXRecord[];
title: string;
description?: string;
}
let { mxRecords, title, description }: Props = $props();
</script>
<div class="mb-4">
<h5 class="text-muted mb-2">
<span class="badge bg-secondary">MX</span> {title}
</h5>
{#if description}
<p class="small text-muted mb-2">{description}</p>
{/if}
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>Priority</th>
<th>Host</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each mxRecords as mx}
<tr>
<td>{mx.priority}</td>
<td><code>{mx.host}</code></td>
<td>
{#if mx.valid}
<span class="badge bg-success">Valid</span>
{:else}
<span class="badge bg-danger">Invalid</span>
{#if mx.error}
<br><small class="text-danger">{mx.error}</small>
{/if}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>