diff --git a/api/schemas.yaml b/api/schemas.yaml index 53aa297..55246d7 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -537,6 +537,9 @@ components: x_aligned_from: $ref: '#/components/schemas/AuthResult' 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: type: object @@ -606,6 +609,29 @@ components: description: Additional details about the IP reverse lookup 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: type: object required: @@ -796,6 +822,13 @@ components: type: string description: A or AAAA records resolved from the PTR hostnames (forward confirmation) 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: type: array items: diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index bd8880d..bb34583 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -140,6 +140,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results.XAlignedFrom = a.parseXAlignedFromResult(part) } } + + // Parse x-ptr + if strings.HasPrefix(part, "x-ptr=") { + if results.XPtr == nil { + results.XPtr = a.parseXPtrResult(part) + } + } } } diff --git a/pkg/analyzer/authentication_x_ptr.go b/pkg/analyzer/authentication_x_ptr.go new file mode 100644 index 0000000..93ecd03 --- /dev/null +++ b/pkg/analyzer/authentication_x_ptr.go @@ -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 . +// +// 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 ( + "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 +} diff --git a/pkg/analyzer/authentication_x_ptr_test.go b/pkg/analyzer/authentication_x_ptr_test.go new file mode 100644 index 0000000..7015951 --- /dev/null +++ b/pkg/analyzer/authentication_x_ptr_test.go @@ -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 . +// +// 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 ( + "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) + } + }) + } +} diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 6bc7c39..c4d215c 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -88,6 +88,16 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head if len(forwardRecords) > 0 { 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 + } + } } } diff --git a/pkg/analyzer/dns_fcr.go b/pkg/analyzer/dns_fcr.go index 07e5ab9..2652b4c 100644 --- a/pkg/analyzer/dns_fcr.go +++ b/pkg/analyzer/dns_fcr.go @@ -23,6 +23,7 @@ package analyzer import ( "context" + "strings" "git.happydns.org/happyDeliver/internal/model" ) @@ -62,6 +63,21 @@ func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) { 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 func (d *DNSAnalyzer) calculatePTRScore(results *model.DNSResults, senderIP string) (score int) { if results.PtrRecords != nil && len(*results.PtrRecords) > 0 { @@ -73,6 +89,11 @@ func (d *DNSAnalyzer) calculatePTRScore(results *model.DNSResults, senderIP stri 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) // This means the PTR hostname resolves back to IPs that include the original sender IP if results.PtrForwardRecords != nil && len(*results.PtrForwardRecords) > 0 && senderIP != "" { diff --git a/pkg/analyzer/dns_fcr_test.go b/pkg/analyzer/dns_fcr_test.go new file mode 100644 index 0000000..2b9429b --- /dev/null +++ b/pkg/analyzer/dns_fcr_test.go @@ -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 . +// +// 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 ( + "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) + } + }) + } +} diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 46a4d2d..4f5ff6d 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -170,6 +170,54 @@ {/if} + + {#if authentication.x_ptr} +
+
+ +
+ HELO / PTR + + + {authentication.x_ptr.result} + + {#if authentication.x_ptr.helo} +
+ Announced HELO: + {authentication.x_ptr.helo} +
+ {/if} + {#if authentication.x_ptr.ptr} +
+ Reverse DNS (PTR): + {authentication.x_ptr.ptr} +
+ {/if} + {#if authentication.x_ptr.details} +
{authentication.x_ptr.details}
+ {/if} +
+
+
+ {/if} +
diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index 6dabe0b..e1d31cb 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -6,6 +6,7 @@ import DkimRecordsDisplay from "./DkimRecordsDisplay.svelte"; import DmarcRecordDisplay from "./DmarcRecordDisplay.svelte"; import GradeDisplay from "./GradeDisplay.svelte"; + import HeloPtrMatchDisplay from "./HeloPtrMatchDisplay.svelte"; import MxRecordsDisplay from "./MxRecordsDisplay.svelte"; import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte"; import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte"; @@ -92,6 +93,13 @@ {senderIp} /> + + +
diff --git a/web/src/lib/components/HeloPtrMatchDisplay.svelte b/web/src/lib/components/HeloPtrMatchDisplay.svelte new file mode 100644 index 0000000..1d8cee7 --- /dev/null +++ b/web/src/lib/components/HeloPtrMatchDisplay.svelte @@ -0,0 +1,87 @@ + + +{#if heloHostname} +
+
+
+ + HELO / PTR Consistency +
+ HELO +
+
+

+ 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. +

+
+ Announced HELO: {heloHostname} +
+ {#if ptrRecords && ptrRecords.length > 0} +
+ PTR Hostname(s): + {#each ptrRecords as ptr} +
+ {#if normalize(heloHostname) === normalize(ptr)} + Match + {:else} + Different + {/if} + {ptr} +
+ {/each} +
+ {/if} +
+ {#if !isMatch} +
+
+
+ + Warning: The announced HELO hostname + {heloHostname} + {#if ptrRecords && ptrRecords.length > 0} + does not match the sender's PTR record{ptrRecords.length > 1 ? "s" : ""} + ({#each ptrRecords as ptr, i}{ptr}{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. +
+
+
+ {/if} +
+{/if}