SPF check return-path
This commit is contained in:
parent
f6a1ea73a2
commit
8ca4bed875
7 changed files with 210 additions and 83 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
49
web/src/lib/components/MxRecordsDisplay.svelte
Normal file
49
web/src/lib/components/MxRecordsDisplay.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue