diff --git a/api/openapi.yaml b/api/openapi.yaml index ce39bdd..88532b3 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -605,10 +605,18 @@ components: type: string description: Domain from From header example: "example.com" + from_org_domain: + type: string + description: Organizational domain extracted from From header (using Public Suffix List) + example: "example.com" return_path_domain: type: string description: Domain from Return-Path header example: "example.com" + return_path_org_domain: + type: string + description: Organizational domain extracted from Return-Path header (using Public Suffix List) + example: "example.com" dkim_domains: type: array items: @@ -617,7 +625,11 @@ components: example: ["example.com"] aligned: type: boolean - description: Whether all domains align + description: Whether all domains align (strict alignment - exact match) + example: true + relaxed_aligned: + type: boolean + description: Whether domains satisfy relaxed alignment (organizational domain match) example: true issues: type: array diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 57973b1..954f229 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -28,6 +28,8 @@ import ( "strings" "time" + "golang.org/x/net/publicsuffix" + "git.happydns.org/happyDeliver/internal/api" ) @@ -52,6 +54,8 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int // 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 { maxGrade -= 2 } @@ -280,7 +284,8 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp // analyzeDomainAlignment checks domain alignment between headers func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment { alignment := &api.DomainAlignment{ - Aligned: api.PtrTo(true), + Aligned: api.PtrTo(true), + RelaxedAligned: api.PtrTo(true), } // Extract From domain @@ -289,6 +294,9 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain domain := h.extractDomain(fromAddr) if domain != "" { alignment.FromDomain = &domain + // Extract organizational domain + orgDomain := h.getOrganizationalDomain(domain) + alignment.FromOrgDomain = &orgDomain } } @@ -298,15 +306,40 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.Domain domain := h.extractDomain(returnPath) if domain != "" { alignment.ReturnPathDomain = &domain + // Extract organizational domain + orgDomain := h.getOrganizationalDomain(domain) + alignment.ReturnPathOrgDomain = &orgDomain } } - // Check alignment + // Check alignment (strict and relaxed) issues := []string{} if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil { - if *alignment.FromDomain != *alignment.ReturnPathDomain { - *alignment.Aligned = false - issues = append(issues, "Return-Path domain does not match From domain") + fromDomain := *alignment.FromDomain + rpDomain := *alignment.ReturnPathDomain + + // Strict alignment: exact match (case-insensitive) + strictAligned := strings.EqualFold(fromDomain, rpDomain) + + // Relaxed alignment: organizational domain match + var fromOrgDomain, rpOrgDomain string + if alignment.FromOrgDomain != nil { + fromOrgDomain = *alignment.FromOrgDomain + } + if alignment.ReturnPathOrgDomain != nil { + rpOrgDomain = *alignment.ReturnPathOrgDomain + } + relaxedAligned := strings.EqualFold(fromOrgDomain, rpOrgDomain) + + *alignment.Aligned = strictAligned + *alignment.RelaxedAligned = relaxedAligned + + if !strictAligned { + if relaxedAligned { + 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)) + } } } @@ -335,6 +368,27 @@ func (h *HeaderAnalyzer) extractDomain(emailAddr string) string { return domain } +// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name +// using the Public Suffix List (PSL) to correctly handle multi-level TLDs. +// For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk +func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string { + domain = strings.ToLower(strings.TrimSpace(domain)) + + // Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain) + // This correctly handles cases like .co.uk, .com.au, etc. + etldPlusOne, err := publicsuffix.EffectiveTLDPlusOne(domain) + if err != nil { + // Fallback to simple two-label extraction if PSL lookup fails + labels := strings.Split(domain, ".") + if len(labels) <= 2 { + return domain + } + return strings.Join(labels[len(labels)-2:], ".") + } + + return etldPlusOne +} + // findHeaderIssues identifies issues with headers func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue { var issues []api.HeaderIssue @@ -458,8 +512,8 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.Received // Try parsing with common email date formats formats := []string{ - time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700" - time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST" + time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700" + time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST" "Mon, 2 Jan 2006 15:04:05 -0700", "Mon, 2 Jan 2006 15:04:05 MST", "2 Jan 2006 15:04:05 -0700", diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 647a1d2..03b992b 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -1,5 +1,5 @@
@@ -59,7 +60,7 @@
- + Domain Alignment
@@ -68,34 +69,64 @@ 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.

-
- Aligned +
+ Strict Alignment
- {headerAnalysis.domain_alignment.aligned ? 'Yes' : 'No'} + {headerAnalysis.domain_alignment.aligned ? 'Pass' : 'Fail'}
+
Exact domain match
-
+
+ Relaxed Alignment +
+ + + {headerAnalysis.domain_alignment.relaxed_aligned ? 'Pass' : 'Fail'} + +
+
Organizational domain match
+
+
From Domain
{headerAnalysis.domain_alignment.from_domain || '-'}
+ {#if headerAnalysis.domain_alignment.from_org_domain && headerAnalysis.domain_alignment.from_org_domain !== headerAnalysis.domain_alignment.from_domain} +
Org: {headerAnalysis.domain_alignment.from_org_domain}
+ {/if}
-
+
Return-Path Domain
{headerAnalysis.domain_alignment.return_path_domain || '-'}
+ {#if headerAnalysis.domain_alignment.return_path_org_domain && headerAnalysis.domain_alignment.return_path_org_domain !== headerAnalysis.domain_alignment.return_path_domain} +
Org: {headerAnalysis.domain_alignment.return_path_org_domain}
+ {/if}
{#if headerAnalysis.domain_alignment.issues && headerAnalysis.domain_alignment.issues.length > 0} -
+
{#each headerAnalysis.domain_alignment.issues as issue} -
- +
+ {issue}
{/each}
{/if} + + + {#if dmarcRecord && headerAnalysis.domain_alignment.return_path_domain && headerAnalysis.domain_alignment.return_path_domain !== headerAnalysis.domain_alignment.from_domain} +
+ {#if dmarcRecord.spf_alignment === 'strict'} + + Strict SPF alignment required — Your DMARC policy requires exact domain match. The Return-Path domain must exactly match the From domain for SPF to pass DMARC alignment. + {:else} + + Relaxed SPF alignment allowed — 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} +
+ {/if}
{/if} diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 5731485..28a140a 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -143,6 +143,7 @@