From a65b8084eeb27ce5bcb7694532a3c40579956be2 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 14:02:06 +0900 Subject: [PATCH] dns: add ReturnOK check for sender domain reachability Verify that the From and Return-Path domains can actually receive replies and bounces, mirroring Fastmail's authentication_milter ReturnOK handler. Each domain is checked for MX records, falling back to A/AAAA (implicit MX) and then to the organizational domain, yielding a pass/warn/fail status. Adds return_ok to DNSResults, a 10-point DNS sub-score penalty per domain that is wholly unreachable, and a new "Return Address Reachability" card. --- api/schemas.yaml | 37 ++++ pkg/analyzer/dns.go | 20 +++ pkg/analyzer/dns_returnok.go | 113 ++++++++++++ pkg/analyzer/dns_returnok_test.go | 170 ++++++++++++++++++ web/src/lib/components/DnsRecordsCard.svelte | 7 +- web/src/lib/components/ReturnOkDisplay.svelte | 106 +++++++++++ 6 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 pkg/analyzer/dns_returnok.go create mode 100644 pkg/analyzer/dns_returnok_test.go create mode 100644 web/src/lib/components/ReturnOkDisplay.svelte diff --git a/api/schemas.yaml b/api/schemas.yaml index 55246d7..662cf4c 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -829,12 +829,49 @@ components: helo_ptr_match: type: boolean description: Whether the announced HELO hostname matches one of the sender's PTR records (case-insensitive) + return_ok: + $ref: '#/components/schemas/ReturnOK' errors: type: array items: type: string description: DNS lookup errors + ReturnOK: + type: object + description: Whether the sender domains can receive replies and bounces (MX, with A/AAAA fallback) + properties: + from: + $ref: '#/components/schemas/ReturnOKDomain' + return_path: + $ref: '#/components/schemas/ReturnOKDomain' + + ReturnOKDomain: + type: object + required: + - domain + - status + properties: + domain: + type: string + description: Domain that was evaluated + example: "example.com" + status: + type: string + enum: [pass, warn, fail] + x-go-type: string + description: pass = MX present, warn = only A/AAAA records (implicit MX), fail = no records + has_mx: + type: boolean + description: Whether the domain has at least one MX record + has_address: + type: boolean + description: Whether the domain has an A or AAAA record (implicit MX fallback) + org_domain: + type: string + description: Organizational domain used as fallback when the domain itself had no records + example: "example.com" + MXRecord: type: object required: diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index c4d215c..9927d1b 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -110,6 +110,15 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head results.RpMxRecords = d.checkMXRecords(*results.RpDomain) } + // Verify the sender domains can actually receive replies/bounces (MX, with + // A/AAAA fallback), mirroring the ReturnOK milter check. + results.ReturnOk = &model.ReturnOK{ + From: d.checkReturnOKDomain(fromDomain, orgDomainOrEmpty(headersResults.DomainAlignment.FromOrgDomain)), + } + if results.RpDomain != nil && *results.RpDomain != "" { + results.ReturnOk.ReturnPath = d.checkReturnOKDomain(*results.RpDomain, orgDomainOrEmpty(headersResults.DomainAlignment.ReturnPathOrgDomain)) + } + // 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) @@ -148,6 +157,11 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults { // Check SPF records results.SpfRecords = d.checkSPFRecords(domain) + // Verify the domain can receive replies/bounces (MX, with A/AAAA fallback) + results.ReturnOk = &model.ReturnOK{ + From: d.checkReturnOKDomain(domain, ""), + } + // Check DMARC record results.DmarcRecord = d.checkDMARCRecord(domain) @@ -179,6 +193,9 @@ func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int, // DMARC Record: 40 points score += 40 * d.calculateDMARCScore(results) / 100 + // Penalty when a sender domain cannot receive replies/bounces at all + score += calculateReturnOKPenalty(results) + // BIMI Record: only bonus if results.BimiRecord != nil && results.BimiRecord.Valid { if score >= 100 { @@ -224,6 +241,9 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP stri // DMARC Record: 20 points score += 20 * d.calculateDMARCScore(results) / 100 + // Penalty when a sender domain cannot receive replies/bounces at all + score += calculateReturnOKPenalty(results) + // BIMI Record // BIMI is optional but indicates advanced email branding if results.BimiRecord != nil && results.BimiRecord.Valid { diff --git a/pkg/analyzer/dns_returnok.go b/pkg/analyzer/dns_returnok.go new file mode 100644 index 0000000..29e12b3 --- /dev/null +++ b/pkg/analyzer/dns_returnok.go @@ -0,0 +1,113 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" +) + +// ReturnOKDomain.Status values, matching the schema enum. Kept as a plain string +// in the generated model (x-go-type) to avoid colliding with other "pass"/"fail" +// enums in the global enum namespace. +const ( + returnOKStatusPass = "pass" + returnOKStatusWarn = "warn" + returnOKStatusFail = "fail" +) + +// domainCanReceive reports whether a domain can accept mail, looking up records +// in the same order as Fastmail's ReturnOK milter: MX first, then A/AAAA. +func (d *DNSAnalyzer) domainCanReceive(domain string) (hasMX, hasAddress bool) { + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + if mxRecords, err := d.resolver.LookupMX(ctx, domain); err == nil && len(mxRecords) > 0 { + return true, false + } + + if addrs, err := d.resolver.LookupHost(ctx, domain); err == nil && len(addrs) > 0 { + return false, true + } + + return false, false +} + +// checkReturnOKDomain verifies that a domain can receive replies/bounces. +// It checks the domain itself, then falls back to its organizational domain +// (when different) the same way the ReturnOK milter retries the org domain. +func (d *DNSAnalyzer) checkReturnOKDomain(domain, orgDomain string) *model.ReturnOKDomain { + if domain == "" { + return nil + } + + result := &model.ReturnOKDomain{Domain: domain} + + hasMX, hasAddress := d.domainCanReceive(domain) + + // Fall back to the organizational domain when the domain itself has nothing. + if !hasMX && !hasAddress && orgDomain != "" && orgDomain != domain { + if orgMX, orgAddr := d.domainCanReceive(orgDomain); orgMX || orgAddr { + hasMX, hasAddress = orgMX, orgAddr + result.OrgDomain = utils.PtrTo(orgDomain) + } + } + + result.HasMx = utils.PtrTo(hasMX) + result.HasAddress = utils.PtrTo(hasAddress) + + switch { + case hasMX: + result.Status = returnOKStatusPass + case hasAddress: + result.Status = returnOKStatusWarn + default: + result.Status = returnOKStatusFail + } + + return result +} + +// calculateReturnOKPenalty returns a non-positive value: each sender domain that +// can receive neither replies nor bounces (status=fail) costs points, since +// those messages would be silently lost. +func calculateReturnOKPenalty(results *model.DNSResults) (penalty int) { + if results.ReturnOk == nil { + return 0 + } + for _, dom := range []*model.ReturnOKDomain{results.ReturnOk.From, results.ReturnOk.ReturnPath} { + if dom != nil && dom.Status == returnOKStatusFail { + penalty -= 10 + } + } + return +} + +// orgDomainOrEmpty dereferences an optional organizational domain pointer. +func orgDomainOrEmpty(orgDomain *string) string { + if orgDomain == nil { + return "" + } + return *orgDomain +} diff --git a/pkg/analyzer/dns_returnok_test.go b/pkg/analyzer/dns_returnok_test.go new file mode 100644 index 0000000..55aaa5c --- /dev/null +++ b/pkg/analyzer/dns_returnok_test.go @@ -0,0 +1,170 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "context" + "net" + "testing" + "time" + + "git.happydns.org/happyDeliver/internal/model" +) + +// returnOKMockResolver lets tests control MX and host (A/AAAA) lookups per domain. +type returnOKMockResolver struct { + mx map[string][]*net.MX + hosts map[string][]string +} + +func (m *returnOKMockResolver) LookupMX(_ context.Context, name string) ([]*net.MX, error) { + if recs, ok := m.mx[name]; ok { + return recs, nil + } + return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} +} + +func (m *returnOKMockResolver) LookupHost(_ context.Context, host string) ([]string, error) { + if recs, ok := m.hosts[host]; ok { + return recs, nil + } + return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true} +} + +func (m *returnOKMockResolver) LookupTXT(_ context.Context, _ string) ([]string, error) { + return nil, nil +} +func (m *returnOKMockResolver) LookupAddr(_ context.Context, _ string) ([]string, error) { + return nil, nil +} + +func TestCheckReturnOKDomain(t *testing.T) { + mx := []*net.MX{{Host: "mail.example.com.", Pref: 10}} + + tests := []struct { + name string + domain string + orgDomain string + resolver *returnOKMockResolver + wantStatus string + wantHasMX bool + wantHasAddr bool + wantOrgDomain string // "" means OrgDomain should be nil + }{ + { + name: "domain with MX passes", + domain: "example.com", + resolver: &returnOKMockResolver{mx: map[string][]*net.MX{"example.com": mx}}, + wantStatus: returnOKStatusPass, + wantHasMX: true, + wantHasAddr: false, + }, + { + name: "no MX but A/AAAA warns", + domain: "example.com", + resolver: &returnOKMockResolver{hosts: map[string][]string{"example.com": {"192.0.2.1"}}}, + wantStatus: returnOKStatusWarn, + wantHasMX: false, + wantHasAddr: true, + }, + { + name: "fallback to org domain MX", + domain: "sub.example.com", + orgDomain: "example.com", + resolver: &returnOKMockResolver{mx: map[string][]*net.MX{"example.com": mx}}, + wantStatus: returnOKStatusPass, + wantHasMX: true, + wantHasAddr: false, + wantOrgDomain: "example.com", + }, + { + name: "nothing anywhere fails", + domain: "example.com", + orgDomain: "example.com", + resolver: &returnOKMockResolver{}, + wantStatus: returnOKStatusFail, + wantHasMX: false, + wantHasAddr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := NewDNSAnalyzerWithResolver(5*time.Second, tt.resolver) + got := d.checkReturnOKDomain(tt.domain, tt.orgDomain) + if got == nil { + t.Fatalf("checkReturnOKDomain returned nil") + } + if got.Status != tt.wantStatus { + t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus) + } + if got.HasMx == nil || *got.HasMx != tt.wantHasMX { + t.Errorf("HasMx = %v, want %v", got.HasMx, tt.wantHasMX) + } + if got.HasAddress == nil || *got.HasAddress != tt.wantHasAddr { + t.Errorf("HasAddress = %v, want %v", got.HasAddress, tt.wantHasAddr) + } + if tt.wantOrgDomain == "" { + if got.OrgDomain != nil { + t.Errorf("OrgDomain = %v, want nil", *got.OrgDomain) + } + } else { + if got.OrgDomain == nil || *got.OrgDomain != tt.wantOrgDomain { + t.Errorf("OrgDomain = %v, want %q", got.OrgDomain, tt.wantOrgDomain) + } + } + }) + } +} + +func TestCheckReturnOKDomainEmpty(t *testing.T) { + d := NewDNSAnalyzerWithResolver(5*time.Second, &returnOKMockResolver{}) + if got := d.checkReturnOKDomain("", ""); got != nil { + t.Errorf("checkReturnOKDomain(\"\") = %v, want nil", got) + } +} + +func TestCalculateReturnOKPenalty(t *testing.T) { + fail := &model.ReturnOKDomain{Domain: "a.example", Status: returnOKStatusFail} + pass := &model.ReturnOKDomain{Domain: "b.example", Status: returnOKStatusPass} + warn := &model.ReturnOKDomain{Domain: "c.example", Status: returnOKStatusWarn} + + tests := []struct { + name string + results *model.DNSResults + want int + }{ + {"nil return_ok", &model.DNSResults{}, 0}, + {"both pass", &model.DNSResults{ReturnOk: &model.ReturnOK{From: pass, ReturnPath: pass}}, 0}, + {"warn is not penalised", &model.DNSResults{ReturnOk: &model.ReturnOK{From: warn}}, 0}, + {"one fail", &model.DNSResults{ReturnOk: &model.ReturnOK{From: fail, ReturnPath: pass}}, -10}, + {"both fail", &model.DNSResults{ReturnOk: &model.ReturnOK{From: fail, ReturnPath: fail}}, -20}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := calculateReturnOKPenalty(tt.results); got != tt.want { + t.Errorf("calculateReturnOKPenalty() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index e1d31cb..eedd0db 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -10,6 +10,7 @@ import MxRecordsDisplay from "./MxRecordsDisplay.svelte"; import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte"; import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte"; + import ReturnOkDisplay from "./ReturnOkDisplay.svelte"; import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte"; interface Props { @@ -100,6 +101,9 @@ heloPtrMatch={dnsResults.helo_ptr_match} /> + + +
@@ -150,8 +154,7 @@ {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} - Differs from Return-Path - domain + Differs from Return-Path domain {/if} diff --git a/web/src/lib/components/ReturnOkDisplay.svelte b/web/src/lib/components/ReturnOkDisplay.svelte new file mode 100644 index 0000000..11d4c00 --- /dev/null +++ b/web/src/lib/components/ReturnOkDisplay.svelte @@ -0,0 +1,106 @@ + + +{#if rows.length > 0} +
+
+
+ + Return Address Reachability +
+ RETURN-OK +
+
+

+ Replies (to the From address) and bounces (to the Return-Path) can only be delivered + if the sender's domains accept mail. A domain should publish MX records; an A/AAAA + record works as an implicit fallback but is not recommended. A domain with neither + is unreachable and silently drops replies and bounces. +

+
+
+ {#each rows as { label, entry } (label)} +
+
+ {label} domain: + {entry.domain} + + {badgeLabel(entry.status)} + + {#if entry.org_domain} + + via organizational domain {entry.org_domain} + + {/if} +
+
+ {/each} +
+ {#if hasFail || hasWarn} +
+
+ {#if hasFail} +
+ + Error: At least one sender domain has no MX and no A/AAAA record. + Replies or bounce messages to that domain will be lost. Publish an MX record pointing + to a mail server that accepts mail. +
+ {:else if hasWarn} +
+ + Warning: A sender domain has no MX record and relies on its A/AAAA + record (implicit MX). Mail is still deliverable, but publishing an explicit MX + record is recommended. +
+ {/if} +
+
+ {/if} +
+{/if}