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.
This commit is contained in:
nemunaire 2026-06-06 14:02:06 +09:00
commit a65b8084ee
6 changed files with 451 additions and 2 deletions

View file

@ -829,12 +829,49 @@ components:
helo_ptr_match: helo_ptr_match:
type: boolean type: boolean
description: Whether the announced HELO hostname matches one of the sender's PTR records (case-insensitive) description: Whether the announced HELO hostname matches one of the sender's PTR records (case-insensitive)
return_ok:
$ref: '#/components/schemas/ReturnOK'
errors: errors:
type: array type: array
items: items:
type: string type: string
description: DNS lookup errors 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: MXRecord:
type: object type: object
required: required:

View file

@ -110,6 +110,15 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head
results.RpMxRecords = d.checkMXRecords(*results.RpDomain) 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) // Check SPF records (for Return-Path domain - this is the envelope sender)
// SPF validates the MAIL FROM command, which corresponds to Return-Path // SPF validates the MAIL FROM command, which corresponds to Return-Path
results.SpfRecords = d.checkSPFRecords(spfDomain) results.SpfRecords = d.checkSPFRecords(spfDomain)
@ -148,6 +157,11 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults {
// Check SPF records // Check SPF records
results.SpfRecords = d.checkSPFRecords(domain) 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 // Check DMARC record
results.DmarcRecord = d.checkDMARCRecord(domain) results.DmarcRecord = d.checkDMARCRecord(domain)
@ -179,6 +193,9 @@ func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int,
// DMARC Record: 40 points // DMARC Record: 40 points
score += 40 * d.calculateDMARCScore(results) / 100 score += 40 * d.calculateDMARCScore(results) / 100
// Penalty when a sender domain cannot receive replies/bounces at all
score += calculateReturnOKPenalty(results)
// BIMI Record: only bonus // BIMI Record: only bonus
if results.BimiRecord != nil && results.BimiRecord.Valid { if results.BimiRecord != nil && results.BimiRecord.Valid {
if score >= 100 { if score >= 100 {
@ -224,6 +241,9 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP stri
// DMARC Record: 20 points // DMARC Record: 20 points
score += 20 * d.calculateDMARCScore(results) / 100 score += 20 * d.calculateDMARCScore(results) / 100
// Penalty when a sender domain cannot receive replies/bounces at all
score += calculateReturnOKPenalty(results)
// BIMI Record // BIMI Record
// BIMI is optional but indicates advanced email branding // BIMI is optional but indicates advanced email branding
if results.BimiRecord != nil && results.BimiRecord.Valid { if results.BimiRecord != nil && results.BimiRecord.Valid {

View file

@ -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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
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
}

View file

@ -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 <contact@happydomain.org>.
//
// 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 <https://www.gnu.org/licenses/>.
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)
}
})
}
}

View file

@ -10,6 +10,7 @@
import MxRecordsDisplay from "./MxRecordsDisplay.svelte"; import MxRecordsDisplay from "./MxRecordsDisplay.svelte";
import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte"; import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte";
import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte"; import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte";
import ReturnOkDisplay from "./ReturnOkDisplay.svelte";
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte"; import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
interface Props { interface Props {
@ -100,6 +101,9 @@
heloPtrMatch={dnsResults.helo_ptr_match} heloPtrMatch={dnsResults.helo_ptr_match}
/> />
<!-- Return Address Reachability (ReturnOK) -->
<ReturnOkDisplay returnOk={dnsResults.return_ok} />
<hr class="my-4" /> <hr class="my-4" />
<!-- Return-Path Domain Section --> <!-- Return-Path Domain Section -->
@ -150,8 +154,7 @@
</h4> </h4>
{#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain}
<span class="badge bg-danger ms-2"> <span class="badge bg-danger ms-2">
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path <i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path domain
domain
</span> </span>
{/if} {/if}
</div> </div>

View file

@ -0,0 +1,106 @@
<script lang="ts">
import type { SchemasReturnOk, SchemasReturnOkDomain } from "$lib/api/types.gen";
interface Props {
returnOk?: SchemasReturnOk;
}
let { returnOk }: Props = $props();
type Row = { label: string; entry: SchemasReturnOkDomain };
const rows = $derived<Row[]>(
[
returnOk?.from ? { label: "From", entry: returnOk.from } : undefined,
returnOk?.return_path
? { label: "Return-Path", entry: returnOk.return_path }
: undefined,
].filter((r): r is Row => r !== undefined),
);
const hasFail = $derived(rows.some((r) => r.entry.status === "fail"));
const hasWarn = $derived(rows.some((r) => r.entry.status === "warn"));
const allPass = $derived(rows.length > 0 && rows.every((r) => r.entry.status === "pass"));
// Header icon reflects the worst status across the checked domains.
const headerOk = $derived(allPass);
function badgeClass(status: string): string {
if (status === "pass") return "bg-success";
if (status === "warn") return "bg-warning text-dark";
return "bg-danger";
}
function badgeLabel(status: string): string {
if (status === "pass") return "MX";
if (status === "warn") return "A/AAAA only";
return "Unreachable";
}
</script>
{#if rows.length > 0}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="text-muted mb-0">
<i
class="bi"
class:bi-check-circle-fill={headerOk}
class:text-success={headerOk}
class:bi-exclamation-triangle-fill={!headerOk && !hasFail}
class:text-warning={!headerOk && !hasFail}
class:bi-x-circle-fill={hasFail}
class:text-danger={hasFail}
></i>
Return Address Reachability
</h5>
<span class="badge bg-secondary">RETURN-OK</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-0">
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.
</p>
</div>
<div class="list-group list-group-flush">
{#each rows as { label, entry } (label)}
<div class="list-group-item">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="text-muted" style="min-width: 6.5rem">{label} domain:</span>
<code>{entry.domain}</code>
<span class="badge {badgeClass(entry.status)}">
{badgeLabel(entry.status)}
</span>
{#if entry.org_domain}
<small class="text-muted">
via organizational domain <code>{entry.org_domain}</code>
</small>
{/if}
</div>
</div>
{/each}
</div>
{#if hasFail || hasWarn}
<div class="list-group list-group-flush">
<div class="list-group-item">
{#if hasFail}
<div class="alert alert-danger mb-0">
<i class="bi bi-x-circle me-1"></i>
<strong>Error:</strong> 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.
</div>
{:else if hasWarn}
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Warning:</strong> 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.
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}