Domain alignment checks for DKIM

This commit is contained in:
nemunaire 2025-11-03 14:58:48 +07:00
commit 5b179e7b93
5 changed files with 408 additions and 81 deletions

View file

@ -664,6 +664,21 @@ components:
description: Reverse DNS (PTR record) for the IP address
example: "mail.example.com"
DKIMDomainInfo:
type: object
required:
- domain
- org_domain
properties:
domain:
type: string
description: DKIM signature domain
example: "mail.example.com"
org_domain:
type: string
description: Organizational domain extracted from DKIM domain (using Public Suffix List)
example: "example.com"
DomainAlignment:
type: object
properties:
@ -686,9 +701,8 @@ components:
dkim_domains:
type: array
items:
type: string
description: Domains from DKIM signatures
example: ["example.com"]
$ref: '#/components/schemas/DKIMDomainInfo'
description: Domains from DKIM signatures with their organizational domains
aligned:
type: boolean
description: Whether all domains align (strict alignment - exact match)

View file

@ -52,13 +52,14 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
maxGrade := 6
headers := *analysis.Headers
// RP and From alignment (20 points)
if analysis.DomainAlignment.Aligned != nil && *analysis.DomainAlignment.Aligned {
score += 20
} else if analysis.DomainAlignment.RelaxedAligned != nil && *analysis.DomainAlignment.RelaxedAligned {
score += 15
} else {
// RP and From alignment (25 points)
if analysis.DomainAlignment.Aligned == nil || !*analysis.DomainAlignment.RelaxedAligned {
// Bad domain alignment, cap grade to C
maxGrade -= 2
} else if *analysis.DomainAlignment.Aligned {
score += 25
} else if *analysis.DomainAlignment.RelaxedAligned {
score += 20
}
// Check required headers (RFC 5322) - 30 points
@ -79,7 +80,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
maxGrade = 1
}
// Check recommended headers (20 points)
// Check recommended headers (15 points)
recommendedHeaders := []string{"subject", "to"}
// Add reply-to when from is a no-reply address
@ -95,7 +96,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int
presentRecommended++
}
}
score += presentRecommended * 20 / recommendedCount
score += presentRecommended * 15 / recommendedCount
if presentRecommended < recommendedCount {
maxGrade -= 1
@ -235,7 +236,7 @@ func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string {
}
// GenerateHeaderAnalysis creates structured header analysis from email
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.HeaderAnalysis {
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *api.AuthenticationResults) *api.HeaderAnalysis {
if email == nil {
return nil
}
@ -281,7 +282,7 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header
}
// Domain alignment
domainAlignment := h.analyzeDomainAlignment(email)
domainAlignment := h.analyzeDomainAlignment(email, authResults)
if domainAlignment != nil {
analysis.DomainAlignment = domainAlignment
}
@ -352,8 +353,8 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
return check
}
// analyzeDomainAlignment checks domain alignment between headers
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment {
// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *api.AuthenticationResults) *api.DomainAlignment {
alignment := &api.DomainAlignment{
Aligned: api.PtrTo(true),
RelaxedAligned: api.PtrTo(true),
@ -383,14 +384,45 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain
}
}
// Extract DKIM domains from authentication results
var dkimDomains []api.DKIMDomainInfo
if authResults != nil && authResults.Dkim != nil {
for _, dkim := range *authResults.Dkim {
if dkim.Domain != nil && *dkim.Domain != "" {
domain := *dkim.Domain
orgDomain := h.getOrganizationalDomain(domain)
dkimDomains = append(dkimDomains, api.DKIMDomainInfo{
Domain: domain,
OrgDomain: orgDomain,
})
}
}
}
if len(dkimDomains) > 0 {
alignment.DkimDomains = &dkimDomains
}
// Check alignment (strict and relaxed)
issues := []string{}
if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil {
// hasReturnPath and hasDKIM track whether we have these fields to check
hasReturnPath := alignment.FromDomain != nil && alignment.ReturnPathDomain != nil
hasDKIM := alignment.FromDomain != nil && len(dkimDomains) > 0
// If neither Return-Path nor DKIM is present, keep default alignment (true)
// Otherwise, at least one must be aligned for overall alignment to be true
strictAligned := !hasReturnPath && !hasDKIM
relaxedAligned := !hasReturnPath && !hasDKIM
// Check Return-Path alignment
rpStrictAligned := false
rpRelaxedAligned := false
if hasReturnPath {
fromDomain := *alignment.FromDomain
rpDomain := *alignment.ReturnPathDomain
// Strict alignment: exact match (case-insensitive)
strictAligned := strings.EqualFold(fromDomain, rpDomain)
rpStrictAligned = strings.EqualFold(fromDomain, rpDomain)
// Relaxed alignment: organizational domain match
var fromOrgDomain, rpOrgDomain string
@ -400,20 +432,67 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain
if alignment.ReturnPathOrgDomain != nil {
rpOrgDomain = *alignment.ReturnPathOrgDomain
}
relaxedAligned := strings.EqualFold(fromOrgDomain, rpOrgDomain)
rpRelaxedAligned = strings.EqualFold(fromOrgDomain, rpOrgDomain)
*alignment.Aligned = strictAligned
*alignment.RelaxedAligned = relaxedAligned
if !strictAligned {
if relaxedAligned {
if !rpStrictAligned {
if rpRelaxedAligned {
issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not exactly match From domain (%s), but satisfies relaxed alignment (organizational domain: %s)", rpDomain, fromDomain, fromOrgDomain))
} else {
issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not match From domain (%s) - neither strict nor relaxed alignment", rpDomain, fromDomain))
}
}
strictAligned = rpStrictAligned
relaxedAligned = rpRelaxedAligned
}
// Check DKIM alignment
dkimStrictAligned := false
dkimRelaxedAligned := false
if hasDKIM {
fromDomain := *alignment.FromDomain
var fromOrgDomain string
if alignment.FromOrgDomain != nil {
fromOrgDomain = *alignment.FromOrgDomain
}
for _, dkimDomain := range dkimDomains {
// Check strict alignment for this DKIM signature
if strings.EqualFold(fromDomain, dkimDomain.Domain) {
dkimStrictAligned = true
}
// Check relaxed alignment for this DKIM signature
if strings.EqualFold(fromOrgDomain, dkimDomain.OrgDomain) {
dkimRelaxedAligned = true
}
}
if !dkimStrictAligned && !dkimRelaxedAligned {
// List all DKIM domains that failed alignment
dkimDomainsList := []string{}
for _, dkimDomain := range dkimDomains {
dkimDomainsList = append(dkimDomainsList, dkimDomain.Domain)
}
issues = append(issues, fmt.Sprintf("DKIM signature domains (%s) do not align with From domain (%s) - neither strict nor relaxed alignment", strings.Join(dkimDomainsList, ", "), fromDomain))
} else if !dkimStrictAligned && dkimRelaxedAligned {
// DKIM has relaxed alignment but not strict
issues = append(issues, fmt.Sprintf("DKIM signature domains satisfy relaxed alignment with From domain (%s) but not strict alignment (organizational domain: %s)", fromDomain, fromOrgDomain))
}
// Overall alignment requires at least one method (Return-Path OR DKIM) to be aligned
// For DMARC compliance, at least one of SPF or DKIM must be aligned
if dkimStrictAligned {
strictAligned = true
}
if dkimRelaxedAligned {
relaxedAligned = true
}
}
*alignment.Aligned = strictAligned
*alignment.RelaxedAligned = relaxedAligned
if len(issues) > 0 {
alignment.Issues = &issues
}

View file

@ -24,6 +24,7 @@ package analyzer
import (
"net/mail"
"net/textproto"
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/api"
@ -110,7 +111,7 @@ func TestCalculateHeaderScore(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Generate header analysis first
analysis := analyzer.GenerateHeaderAnalysis(tt.email)
analysis := analyzer.GenerateHeaderAnalysis(tt.email, nil)
score, _ := analyzer.CalculateHeaderScore(analysis)
if score < tt.minScore || score > tt.maxScore {
t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
@ -360,7 +361,7 @@ func TestAnalyzeDomainAlignment(t *testing.T) {
}),
}
alignment := analyzer.analyzeDomainAlignment(email)
alignment := analyzer.analyzeDomainAlignment(email, nil)
if alignment == nil {
t.Fatal("Expected non-nil alignment")
@ -698,7 +699,7 @@ func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
"from relay.example.com (relay.example.com [192.0.2.2]) by mail.example.com with SMTP id DEF456; Mon, 01 Jan 2024 11:59:00 +0000",
}
analysis := analyzer.GenerateHeaderAnalysis(email)
analysis := analyzer.GenerateHeaderAnalysis(email, nil)
if analysis == nil {
t.Fatal("GenerateHeaderAnalysis returned nil")
@ -923,3 +924,156 @@ func equalStrPtr(a, b *string) bool {
}
return *a == *b
}
func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) {
tests := []struct {
name string
fromHeader string
returnPath string
dkimDomains []string
expectStrictAligned bool
expectRelaxedAligned bool
expectIssuesContain string
}{
{
name: "DKIM strict alignment with From domain",
fromHeader: "sender@example.com",
returnPath: "",
dkimDomains: []string{"example.com"},
expectStrictAligned: true,
expectRelaxedAligned: true,
expectIssuesContain: "",
},
{
name: "DKIM relaxed alignment only",
fromHeader: "sender@mail.example.com",
returnPath: "",
dkimDomains: []string{"example.com"},
expectStrictAligned: false,
expectRelaxedAligned: true,
expectIssuesContain: "relaxed alignment",
},
{
name: "DKIM no alignment",
fromHeader: "sender@example.com",
returnPath: "",
dkimDomains: []string{"different.com"},
expectStrictAligned: false,
expectRelaxedAligned: false,
expectIssuesContain: "do not align",
},
{
name: "Multiple DKIM signatures - one aligns",
fromHeader: "sender@example.com",
returnPath: "",
dkimDomains: []string{"different.com", "example.com"},
expectStrictAligned: true,
expectRelaxedAligned: true,
expectIssuesContain: "",
},
{
name: "Return-Path misaligned but DKIM aligned",
fromHeader: "sender@example.com",
returnPath: "bounce@different.com",
dkimDomains: []string{"example.com"},
expectStrictAligned: true,
expectRelaxedAligned: true,
expectIssuesContain: "Return-Path",
},
{
name: "Return-Path aligned, no DKIM",
fromHeader: "sender@example.com",
returnPath: "bounce@example.com",
dkimDomains: []string{},
expectStrictAligned: true,
expectRelaxedAligned: true,
expectIssuesContain: "",
},
{
name: "Both Return-Path and DKIM misaligned",
fromHeader: "sender@example.com",
returnPath: "bounce@other.com",
dkimDomains: []string{"different.com"},
expectStrictAligned: false,
expectRelaxedAligned: false,
expectIssuesContain: "do not",
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": tt.fromHeader,
"Return-Path": tt.returnPath,
}),
}
// Create authentication results with DKIM signatures
var authResults *api.AuthenticationResults
if len(tt.dkimDomains) > 0 {
dkimResults := make([]api.AuthResult, 0, len(tt.dkimDomains))
for _, domain := range tt.dkimDomains {
dkimResults = append(dkimResults, api.AuthResult{
Result: api.AuthResultResultPass,
Domain: &domain,
})
}
authResults = &api.AuthenticationResults{
Dkim: &dkimResults,
}
}
alignment := analyzer.analyzeDomainAlignment(email, authResults)
if alignment == nil {
t.Fatal("Expected non-nil alignment")
}
if alignment.Aligned == nil {
t.Fatal("Expected non-nil Aligned field")
}
if *alignment.Aligned != tt.expectStrictAligned {
t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectStrictAligned)
}
if alignment.RelaxedAligned == nil {
t.Fatal("Expected non-nil RelaxedAligned field")
}
if *alignment.RelaxedAligned != tt.expectRelaxedAligned {
t.Errorf("RelaxedAligned = %v, want %v", *alignment.RelaxedAligned, tt.expectRelaxedAligned)
}
// Check DKIM domains are populated
if len(tt.dkimDomains) > 0 {
if alignment.DkimDomains == nil {
t.Error("Expected DkimDomains to be populated")
} else if len(*alignment.DkimDomains) != len(tt.dkimDomains) {
t.Errorf("Expected %d DKIM domains, got %d", len(tt.dkimDomains), len(*alignment.DkimDomains))
}
}
// Check issues contain expected string
if tt.expectIssuesContain != "" {
if alignment.Issues == nil || len(*alignment.Issues) == 0 {
t.Errorf("Expected issues to contain '%s', but no issues found", tt.expectIssuesContain)
} else {
found := false
for _, issue := range *alignment.Issues {
if strings.Contains(strings.ToLower(issue), strings.ToLower(tt.expectIssuesContain)) {
found = true
break
}
}
if !found {
t.Errorf("Expected issues to contain '%s', but found: %v", tt.expectIssuesContain, *alignment.Issues)
}
}
}
})
}
}

View file

@ -75,7 +75,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
// Run all analyzers
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email)
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
results.RBL = r.rblChecker.CheckEmail(email)
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)

View file

@ -66,68 +66,148 @@
</h5>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-3">
Domain alignment ensures that the visible "From" domain matches the domain used for authentication (Return-Path). Proper alignment is crucial for DMARC compliance and helps prevent email spoofing by verifying that the sender domain is consistent across all authentication layers.
<p class="card-text small text-muted">
Domain alignment ensures that the visible "From" domain matches the domain used for authentication (Return-Path or DKIM signature). Proper alignment is crucial for DMARC compliance, regardless of the policy. It helps prevent email spoofing by verifying that the sender domain is consistent across all authentication layers. Only one of the following lines needs to pass.
</p>
<div class="row">
<div class="col-md-3">
<small class="text-muted">Strict Alignment</small>
<div>
<span class="badge" class:bg-success={headerAnalysis.domain_alignment.aligned} class:bg-danger={!headerAnalysis.domain_alignment.aligned}>
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'}</strong>
</span>
</div>
<div class="small text-muted mt-1">Exact domain match</div>
</div>
<div class="list-group list-group-flush">
<div class="list-group-item d-flex ps-0">
<div class="d-flex align-items-center justify-content-center" style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;">
SPF
</div>
<div class="col-md-3">
<small class="text-muted">Relaxed Alignment</small>
<div>
<span class="badge" class:bg-success={headerAnalysis.domain_alignment.relaxed_aligned} class:bg-danger={!headerAnalysis.domain_alignment.relaxed_aligned}>
<i class="bi {headerAnalysis.domain_alignment.relaxed_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{headerAnalysis.domain_alignment.relaxed_aligned ? 'Pass' : 'Fail'}</strong>
</span>
</div>
<div class="small text-muted mt-1">Organizational domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">From Domain</small>
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
{/if}
</div>
<div class="col-md-3">
<small class="text-muted">Return-Path Domain</small>
<div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.return_path_org_domain}</code></div>
{/if}
</div>
</div>
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
<div class="mt-3">
{#each headerAnalysis.domain_alignment.issues as issue}
<div class="alert alert-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info' : 'warning'} py-2 small mb-2">
<i class="bi bi-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info-circle' : 'exclamation-triangle'} me-1"></i>
{issue}
<div class="row flex-grow-1">
<div class="col-md-3">
<small class="text-muted">Strict Alignment</small>
<div>
<span class="badge" class:bg-success={headerAnalysis.domain_alignment.aligned} class:bg-danger={!headerAnalysis.domain_alignment.aligned}>
<i class="bi {headerAnalysis.domain_alignment.aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'}</strong>
</span>
</div>
{/each}
<div class="small text-muted mt-1">Exact domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">Relaxed Alignment</small>
<div>
<span class="badge" class:bg-success={headerAnalysis.domain_alignment.relaxed_aligned} class:bg-danger={!headerAnalysis.domain_alignment.relaxed_aligned}>
<i class="bi {headerAnalysis.domain_alignment.relaxed_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{headerAnalysis.domain_alignment.relaxed_aligned ? 'Pass' : 'Fail'}</strong>
</span>
</div>
<div class="small text-muted mt-1">Organizational domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">From Domain</small>
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
{/if}
</div>
<div class="col-md-3">
<small class="text-muted">Return-Path Domain</small>
<div><code>{headerAnalysis.domain_alignment.return_path_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.return_path_org_domain}</code></div>
{/if}
</div>
</div>
{/if}
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
<div class="mt-3">
{#each headerAnalysis.domain_alignment.issues as issue}
<div class="alert alert-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info' : 'warning'} py-2 small mb-2">
<i class="bi bi-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info-circle' : 'exclamation-triangle'} me-1"></i>
{issue}
</div>
{/each}
</div>
{/if}
<!-- Alignment Information based on DMARC policy -->
{#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="alert mt-2 mb-0 small py-2 {dmarcRecord.spf_alignment === 'strict' ? 'alert-warning' : 'alert-info'}">
{#if dmarcRecord.spf_alignment === 'strict'}
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Strict SPF alignment required</strong> — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment.
{:else}
<i class="bi bi-info-circle me-1"></i>
<strong>Relaxed SPF alignment allowed</strong> — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass.
{/if}
<!-- Alignment Information based on DMARC policy -->
{#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="alert mt-2 mb-0 small py-2 {dmarcRecord.spf_alignment === 'strict' ? 'alert-warning' : 'alert-info'}">
{#if dmarcRecord.spf_alignment === 'strict'}
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Strict SPF alignment required</strong> — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment.
{:else}
<i class="bi bi-info-circle me-1"></i>
<strong>Relaxed SPF alignment allowed</strong> — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), SPF alignment can pass.
{/if}
</div>
{/if}
</div>
{#each headerAnalysis.domain_alignment.dkim_domains as dkim_domain}
{@const dkim_aligned = dkim_domain.domain === headerAnalysis.domain_alignment.from_domain}
{@const dkim_relaxed_aligned = dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain}
<div class="list-group-item d-flex ps-0">
<div class="d-flex align-items-center justify-content-center" style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 1.5rem; font-weight: bold; min-width: 3rem;">
DKIM
</div>
<div class="flex-fill">
<div class="row flex-grow-1">
<div class="col-md-3">
<small class="text-muted">Strict Alignment</small>
<div>
<span class="badge" class:bg-success={dkim_aligned} class:bg-danger={!dkim_aligned}>
<i class="bi {dkim_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{dkim_aligned ? 'Pass' : 'Fail'}</strong>
</span>
</div>
<div class="small text-muted mt-1">Exact domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">Relaxed Alignment</small>
<div>
<span class="badge" class:bg-success={dkim_relaxed_aligned} class:bg-danger={!dkim_relaxed_aligned}>
<i class="bi {dkim_relaxed_aligned ? 'bi-check-circle-fill' : 'bi-x-circle-fill'} me-1"></i>
<strong>{dkim_relaxed_aligned ? 'Pass' : 'Fail'}</strong>
</span>
</div>
<div class="small text-muted mt-1">Organizational domain match</div>
</div>
<div class="col-md-3">
<small class="text-muted">From Domain</small>
<div><code>{headerAnalysis.domain_alignment.from_domain || '-'}</code></div>
{#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain}
<div class="small text-muted mt-1">Org: <code>{headerAnalysis.domain_alignment.from_org_domain}</code></div>
{/if}
</div>
<div class="col-md-3">
<small class="text-muted">Signature Domain</small>
<div><code>{dkim_domain.domain || '-'}</code></div>
{#if dkim_domain.domain !== dkim_domain.org_domain}
<div class="small text-muted mt-1">Org: <code>{dkim_domain.org_domain}</code></div>
{/if}
</div>
</div>
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0}
<div class="mt-3">
{#each headerAnalysis.domain_alignment.issues as issue}
<div class="alert alert-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info' : 'warning'} py-2 small mb-2">
<i class="bi bi-{headerAnalysis.domain_alignment.relaxed_aligned ? 'info-circle' : 'exclamation-triangle'} me-1"></i>
{issue}
</div>
{/each}
</div>
{/if}
<!-- Alignment Information based on DMARC policy -->
{#if dmarcRecord && dkim_domain.domain !== headerAnalysis.domain_alignment.from_domain}
{#if dkim_domain.org_domain === headerAnalysis.domain_alignment.from_org_domain}
<div class="alert mt-2 mb-0 small py-2 {dmarcRecord.dkim_alignment === 'strict' ? 'alert-warning' : 'alert-info'}">
{#if dmarcRecord.dkim_alignment === 'strict'}
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Strict DKIM alignment required</strong> — Your DMARC policy requires exact domain match. The DKIM signature domain must exactly match the From domain for DKIM to pass DMARC alignment.
{:else}
<i class="bi bi-info-circle me-1"></i>
<strong>Relaxed DKIM alignment allowed</strong> — Your DMARC policy allows organizational domain matching. As long as both domains share the same organizational domain (e.g., mail.example.com and example.com), DKIM alignment can pass.
{/if}
</div>
{/if}
{/if}
</div>
</div>
{/if}
{/each}
</div>
</div>
{/if}