dns: add HELO/PTR consistency check

Compare the HELO/EHLO hostname announced by the sending server (first
Received hop) against the sender IP's PTR records, surfacing the same
signal as x-ptr/policy.ptr in Authentication-Results. Adds helo_hostname
and helo_ptr_match to DNSResults, applies a 15-point PTR sub-score
penalty on mismatch, and displays the result in a new HELO/PTR
Consistency card.
This commit is contained in:
nemunaire 2026-06-06 13:27:35 +09:00
commit e168446b44
10 changed files with 460 additions and 0 deletions

View file

@ -537,6 +537,9 @@ components:
x_aligned_from: x_aligned_from:
$ref: '#/components/schemas/AuthResult' $ref: '#/components/schemas/AuthResult'
description: X-Aligned-From authentication result (checks address alignment) description: X-Aligned-From authentication result (checks address alignment)
x_ptr:
$ref: '#/components/schemas/XPtrResult'
description: X-Ptr result (HELO hostname vs reverse DNS consistency check)
AuthResult: AuthResult:
type: object type: object
@ -606,6 +609,29 @@ components:
description: Additional details about the IP reverse lookup description: Additional details about the IP reverse lookup
example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)" example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)"
XPtrResult:
type: object
required:
- result
properties:
result:
type: string
enum: [pass, fail, none, temperror, permerror]
description: HELO/PTR consistency check result
example: "fail"
helo:
type: string
description: HELO/EHLO hostname announced by the sending server (smtp.helo)
example: "relay.example.org"
ptr:
type: string
description: Reverse DNS (PTR) hostname of the sender IP (policy.ptr)
example: "mail.example.com"
details:
type: string
description: Additional details about the x-ptr check
example: "smtp.helo=relay.example.org policy.ptr=mail.example.com"
SpamAssassinResult: SpamAssassinResult:
type: object type: object
required: required:
@ -796,6 +822,13 @@ components:
type: string type: string
description: A or AAAA records resolved from the PTR hostnames (forward confirmation) description: A or AAAA records resolved from the PTR hostnames (forward confirmation)
example: ["192.0.2.1", "2001:db8::1"] example: ["192.0.2.1", "2001:db8::1"]
helo_hostname:
type: string
description: HELO/EHLO hostname announced by the sending server (from the first Received hop)
example: "mail.example.com"
helo_ptr_match:
type: boolean
description: Whether the announced HELO hostname matches one of the sender's PTR records (case-insensitive)
errors: errors:
type: array type: array
items: items:

View file

@ -140,6 +140,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
results.XAlignedFrom = a.parseXAlignedFromResult(part) results.XAlignedFrom = a.parseXAlignedFromResult(part)
} }
} }
// Parse x-ptr
if strings.HasPrefix(part, "x-ptr=") {
if results.XPtr == nil {
results.XPtr = a.parseXPtrResult(part)
}
}
} }
} }

View file

@ -0,0 +1,61 @@
// 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 (
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// parseXPtrResult parses the x-ptr result from Authentication-Results.
// Example: x-ptr=fail smtp.helo=relay.example.org policy.ptr=mail.example.com
func (a *AuthenticationAnalyzer) parseXPtrResult(part string) *model.XPtrResult {
result := &model.XPtrResult{}
// Extract result (pass, fail, none, temperror, permerror)
re := regexp.MustCompile(`x-ptr=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = model.XPtrResultResult(resultStr)
}
// Extract announced HELO hostname (smtp.helo)
heloRe := regexp.MustCompile(`smtp\.helo=([^\s;()]+)`)
if matches := heloRe.FindStringSubmatch(part); len(matches) > 1 {
helo := matches[1]
result.Helo = &helo
}
// Extract reverse DNS hostname (policy.ptr)
ptrRe := regexp.MustCompile(`policy\.ptr=([^\s;()]+)`)
if matches := ptrRe.FindStringSubmatch(part); len(matches) > 1 {
ptr := matches[1]
result.Ptr = &ptr
}
result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-ptr="))
return result
}

View file

@ -0,0 +1,81 @@
// 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 (
"testing"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
func TestParseXPtrResult(t *testing.T) {
a := NewAuthenticationAnalyzer("receiver.com")
tests := []struct {
name string
part string
expectedResult model.XPtrResultResult
expectedHelo *string
expectedPtr *string
}{
{
name: "x-ptr fail with helo and ptr",
part: "x-ptr=fail smtp.helo=relay.example.org policy.ptr=mail.example.com",
expectedResult: model.XPtrResultResultFail,
expectedHelo: utils.PtrTo("relay.example.org"),
expectedPtr: utils.PtrTo("mail.example.com"),
},
{
name: "x-ptr pass",
part: "x-ptr=pass smtp.helo=mail.example.com policy.ptr=mail.example.com",
expectedResult: model.XPtrResultResultPass,
expectedHelo: utils.PtrTo("mail.example.com"),
expectedPtr: utils.PtrTo("mail.example.com"),
},
{
name: "x-ptr none without ptr",
part: "x-ptr=none smtp.helo=relay.example.org",
expectedResult: model.XPtrResultResultNone,
expectedHelo: utils.PtrTo("relay.example.org"),
expectedPtr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := a.parseXPtrResult(tt.part)
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Result != tt.expectedResult {
t.Errorf("Result = %q, want %q", result.Result, tt.expectedResult)
}
if !equalStrPtr(result.Helo, tt.expectedHelo) {
t.Errorf("Helo = %v, want %v", result.Helo, tt.expectedHelo)
}
if !equalStrPtr(result.Ptr, tt.expectedPtr) {
t.Errorf("Ptr = %v, want %v", result.Ptr, tt.expectedPtr)
}
})
}
}

View file

@ -88,6 +88,16 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head
if len(forwardRecords) > 0 { if len(forwardRecords) > 0 {
results.PtrForwardRecords = &forwardRecords results.PtrForwardRecords = &forwardRecords
} }
// Record the announced HELO name and whether it matches the PTR record
if firstHop.From != nil && *firstHop.From != "" {
helo := *firstHop.From
results.HeloHostname = &helo
if len(ptrRecords) > 0 {
match := checkHeloPtrMatch(helo, ptrRecords)
results.HeloPtrMatch = &match
}
}
} }
} }

View file

@ -23,6 +23,7 @@ package analyzer
import ( import (
"context" "context"
"strings"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/model"
) )
@ -62,6 +63,21 @@ func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) {
return ptrNames, forwardIPs return ptrNames, forwardIPs
} }
// checkHeloPtrMatch reports whether the announced HELO hostname matches one of
// the sender's PTR records (case-insensitive, trailing dot ignored).
func checkHeloPtrMatch(helo string, ptrRecords []string) bool {
helo = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(helo)), ".")
if helo == "" {
return false
}
for _, ptr := range ptrRecords {
if strings.TrimSuffix(strings.ToLower(ptr), ".") == helo {
return true
}
}
return false
}
// Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability // Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability
func (d *DNSAnalyzer) calculatePTRScore(results *model.DNSResults, senderIP string) (score int) { func (d *DNSAnalyzer) calculatePTRScore(results *model.DNSResults, senderIP string) (score int) {
if results.PtrRecords != nil && len(*results.PtrRecords) > 0 { if results.PtrRecords != nil && len(*results.PtrRecords) > 0 {
@ -73,6 +89,11 @@ func (d *DNSAnalyzer) calculatePTRScore(results *model.DNSResults, senderIP stri
score -= 15 score -= 15
} }
// Penalty when the announced HELO name doesn't match the PTR hostname
if results.HeloPtrMatch != nil && !*results.HeloPtrMatch {
score -= 15
}
// Additional 50 points for forward-confirmed reverse DNS (FCrDNS) // Additional 50 points for forward-confirmed reverse DNS (FCrDNS)
// This means the PTR hostname resolves back to IPs that include the original sender IP // This means the PTR hostname resolves back to IPs that include the original sender IP
if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" { if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" {

View file

@ -0,0 +1,104 @@
// 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 (
"testing"
"git.happydns.org/happyDeliver/internal/model"
)
func TestCheckHeloPtrMatch(t *testing.T) {
tests := []struct {
name string
helo string
ptrRecords []string
want bool
}{
{"exact match", "mail.example.com", []string{"mail.example.com"}, true},
{"case insensitive", "Mail.Example.COM", []string{"mail.example.com"}, true},
{"trailing dot ignored", "mail.example.com.", []string{"mail.example.com"}, true},
{"mismatch", "relay.example.org", []string{"mail.example.com"}, false},
{"match among several", "smtp.example.com", []string{"mail.example.com", "smtp.example.com"}, true},
{"empty helo", "", []string{"mail.example.com"}, false},
{"no ptr records", "mail.example.com", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := checkHeloPtrMatch(tt.helo, tt.ptrRecords); got != tt.want {
t.Errorf("checkHeloPtrMatch(%q, %v) = %v, want %v", tt.helo, tt.ptrRecords, got, tt.want)
}
})
}
}
func TestCalculatePTRScoreHeloMismatch(t *testing.T) {
d := NewDNSAnalyzer(0)
senderIP := "80.67.179.207"
ptr := []string{"mail.example.com"}
forward := []string{senderIP}
matchTrue := true
matchFalse := false
tests := []struct {
name string
results *model.DNSResults
want int
}{
{
name: "helo matches ptr - no penalty (PTR+FCrDNS)",
results: &model.DNSResults{
PtrRecords: &ptr,
PtrForwardRecords: &forward,
HeloPtrMatch: &matchTrue,
},
want: 100,
},
{
name: "helo mismatch - 15 point penalty",
results: &model.DNSResults{
PtrRecords: &ptr,
PtrForwardRecords: &forward,
HeloPtrMatch: &matchFalse,
},
want: 85,
},
{
name: "no helo info - no penalty",
results: &model.DNSResults{
PtrRecords: &ptr,
PtrForwardRecords: &forward,
},
want: 100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := d.calculatePTRScore(tt.results, senderIP); got != tt.want {
t.Errorf("calculatePTRScore() = %d, want %d", got, tt.want)
}
})
}
}

View file

@ -170,6 +170,54 @@
</div> </div>
{/if} {/if}
<!-- X-Ptr (HELO / reverse DNS consistency) -->
{#if authentication.x_ptr}
<div class="list-group-item" id="authentication-x-ptr">
<div class="d-flex align-items-start">
<i
class="bi {getAuthResultIcon(
authentication.x_ptr.result,
true,
)} {getAuthResultClass(authentication.x_ptr.result, true)} me-2 fs-5"
></i>
<div>
<strong>HELO / PTR</strong>
<i
class="bi bi-info-circle text-muted ms-1"
title="Checks that the HELO/EHLO hostname announced by the sending server matches the sender IP's reverse DNS (PTR) record."
></i>
<span
class="text-uppercase ms-2 {getAuthResultClass(
authentication.x_ptr.result,
true,
)}"
>
{authentication.x_ptr.result}
</span>
{#if authentication.x_ptr.helo}
<div class="small">
<strong>Announced HELO:</strong>
<span class="text-muted">{authentication.x_ptr.helo}</span>
</div>
{/if}
{#if authentication.x_ptr.ptr}
<div class="small">
<strong>Reverse DNS (PTR):</strong>
<span class="text-muted">{authentication.x_ptr.ptr}</span>
</div>
{/if}
{#if authentication.x_ptr.details}
<pre
class="p-2 mb-0 {$theme === 'light'
? 'bg-light'
: 'bg-secondary'} text-muted small"
style="white-space: pre-wrap">{authentication.x_ptr.details}</pre>
{/if}
</div>
</div>
</div>
{/if}
<!-- SPF (Required) --> <!-- SPF (Required) -->
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex align-items-start" id="authentication-spf"> <div class="d-flex align-items-start" id="authentication-spf">

View file

@ -6,6 +6,7 @@
import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte"; import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte";
import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte"; import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte";
import GradeDisplay from "./GradeDisplay.svelte"; import GradeDisplay from "./GradeDisplay.svelte";
import HeloPtrMatchDisplay from "./HeloPtrMatchDisplay.svelte";
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";
@ -92,6 +93,13 @@
{senderIp} {senderIp}
/> />
<!-- HELO / PTR Consistency -->
<HeloPtrMatchDisplay
heloHostname={dnsResults.helo_hostname ?? receivedChain?.[0]?.from}
ptrRecords={dnsResults.ptr_records}
heloPtrMatch={dnsResults.helo_ptr_match}
/>
<hr class="my-4" /> <hr class="my-4" />
<!-- Return-Path Domain Section --> <!-- Return-Path Domain Section -->

View file

@ -0,0 +1,87 @@
<script lang="ts">
interface Props {
heloHostname?: string;
ptrRecords?: string[];
heloPtrMatch?: boolean;
}
let { heloHostname, ptrRecords, heloPtrMatch }: Props = $props();
const normalize = (host: string) => host.replace(/\.$/, "").trim().toLowerCase();
// Local comparison, identical to the per-record badge logic below, so the
// summary alert can never contradict the individual "Match" badges.
const localMatch = $derived(
!!heloHostname &&
!!ptrRecords &&
ptrRecords.some((ptr) => normalize(heloHostname) === normalize(ptr)),
);
// Prefer the backend verdict when it is present; otherwise fall back to the
// local comparison (e.g. for results produced before helo_ptr_match existed).
const isMatch = $derived(heloPtrMatch ?? localMatch);
</script>
{#if heloHostname}
<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={isMatch}
class:text-success={isMatch}
class:bi-x-circle-fill={!isMatch}
class:text-danger={!isMatch}
></i>
HELO / PTR Consistency
</h5>
<span class="badge bg-secondary">HELO</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-0">
The HELO/EHLO hostname is the name the sending server announces when it connects.
Many mail servers check that this name matches the sender IP's reverse DNS (PTR)
record. A mismatch is a common spam signal and can hurt deliverability.
</p>
<div class="mt-2">
<strong>Announced HELO:</strong> <code>{heloHostname}</code>
</div>
{#if ptrRecords && ptrRecords.length > 0}
<div class="mt-1">
<strong>PTR Hostname(s):</strong>
{#each ptrRecords as ptr}
<div class="d-flex gap-2 align-items-center mt-1">
{#if normalize(heloHostname) === normalize(ptr)}
<span class="badge bg-success">Match</span>
{:else}
<span class="badge bg-secondary">Different</span>
{/if}
<code>{ptr}</code>
</div>
{/each}
</div>
{/if}
</div>
{#if !isMatch}
<div class="list-group list-group-flush">
<div class="list-group-item">
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Warning:</strong> The announced HELO hostname
<code>{heloHostname}</code>
{#if ptrRecords && ptrRecords.length > 0}
does not match the sender's PTR record{ptrRecords.length > 1 ? "s" : ""}
({#each ptrRecords as ptr, i}<code>{ptr}</code>{i <
ptrRecords.length - 1
? ", "
: ""}{/each}).
{:else}
could not be matched against a PTR record.
{/if}
Configuring the HELO name to match reverse DNS improves deliverability.
</div>
</div>
</div>
{/if}
</div>
{/if}