Compare commits
1 commit
master
...
renovate/m
| Author | SHA1 | Date | |
|---|---|---|---|
| 419b5af148 |
34 changed files with 535 additions and 3455 deletions
|
|
@ -434,29 +434,6 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: Reverse DNS (PTR record) for the IP address
|
description: Reverse DNS (PTR record) for the IP address
|
||||||
example: "mail.example.com"
|
example: "mail.example.com"
|
||||||
tls:
|
|
||||||
$ref: '#/components/schemas/TLSInfo'
|
|
||||||
description: TLS details of the connection for this hop, if encrypted
|
|
||||||
|
|
||||||
TLSInfo:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
version:
|
|
||||||
type: string
|
|
||||||
description: TLS protocol version
|
|
||||||
example: "TLSv1.3"
|
|
||||||
cipher:
|
|
||||||
type: string
|
|
||||||
description: Cipher suite used
|
|
||||||
example: "TLS_AES_256_GCM_SHA384"
|
|
||||||
bits:
|
|
||||||
type: integer
|
|
||||||
description: Cipher strength in bits
|
|
||||||
example: 256
|
|
||||||
verified:
|
|
||||||
type: boolean
|
|
||||||
description: Whether the peer certificate was verified/trusted
|
|
||||||
example: true
|
|
||||||
|
|
||||||
DKIMDomainInfo:
|
DKIMDomainInfo:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -560,14 +537,6 @@ 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)
|
|
||||||
x_tls:
|
|
||||||
$ref: '#/components/schemas/AuthResult'
|
|
||||||
description: >-
|
|
||||||
Transport TLS encryption of the inbound connection (x-tls).
|
|
||||||
Synthesized from the inbound Received hop when no x-tls header is present.
|
|
||||||
|
|
||||||
AuthResult:
|
AuthResult:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -637,29 +606,6 @@ 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:
|
||||||
|
|
@ -850,56 +796,12 @@ 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)
|
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,6 @@
|
||||||
|
|
||||||
"PTR" : {},
|
"PTR" : {},
|
||||||
|
|
||||||
"TLS" : {},
|
|
||||||
|
|
||||||
"SenderID" : {
|
"SenderID" : {
|
||||||
"hide_none" : 1
|
"hide_none" : 1
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ myhostname = __HOSTNAME__
|
||||||
mydomain = __DOMAIN__
|
mydomain = __DOMAIN__
|
||||||
myorigin = $mydomain
|
myorigin = $mydomain
|
||||||
inet_interfaces = all
|
inet_interfaces = all
|
||||||
inet_protocols = all
|
inet_protocols = ipv4
|
||||||
|
|
||||||
# Recipient settings
|
# Recipient settings
|
||||||
mydestination = localhost.$mydomain, localhost
|
mydestination = localhost.$mydomain, localhost
|
||||||
|
|
@ -36,8 +36,5 @@ smtpd_recipient_restrictions =
|
||||||
permit_mynetworks,
|
permit_mynetworks,
|
||||||
reject_unauth_destination
|
reject_unauth_destination
|
||||||
|
|
||||||
# TLS - record the negotiated cipher/protocol in the Received: header
|
|
||||||
smtpd_tls_received_header = yes
|
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
debug_peer_level = 2
|
debug_peer_level = 2
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,6 @@
|
||||||
# SMTP service
|
# SMTP service
|
||||||
smtp inet n - n - - smtpd
|
smtp inet n - n - - smtpd
|
||||||
|
|
||||||
# TLS session cache and PRNG manager (required for STARTTLS)
|
|
||||||
tlsmgr unix - - n 1000? 1 tlsmgr
|
|
||||||
|
|
||||||
# Pickup service
|
# Pickup service
|
||||||
pickup unix n - n 60 1 pickup
|
pickup unix n - n 60 1 pickup
|
||||||
|
|
||||||
|
|
|
||||||
8
go.mod
8
go.mod
|
|
@ -9,7 +9,7 @@ require (
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/oapi-codegen/runtime v1.4.0
|
github.com/oapi-codegen/runtime v1.4.0
|
||||||
golang.org/x/net v0.55.0
|
golang.org/x/net v0.54.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
|
|
@ -36,7 +36,7 @@ require (
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
|
@ -69,10 +69,10 @@ require (
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.23.0 // indirect
|
golang.org/x/arch v0.23.0 // indirect
|
||||||
golang.org/x/crypto v0.52.0 // indirect
|
golang.org/x/crypto v0.51.0 // indirect
|
||||||
golang.org/x/mod v0.35.0 // indirect
|
golang.org/x/mod v0.35.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
golang.org/x/tools v0.44.0 // indirect
|
golang.org/x/tools v0.44.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
|
|
||||||
16
go.sum
16
go.sum
|
|
@ -93,8 +93,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
|
@ -215,8 +215,8 @@ golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
|
|
@ -227,8 +227,8 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
|
@ -249,8 +249,8 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
|
|
||||||
|
|
@ -140,20 +140,6 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse x-tls
|
|
||||||
if strings.HasPrefix(part, "x-tls=") {
|
|
||||||
if results.XTls == nil {
|
|
||||||
results.XTls = a.parseXTLSResult(part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,9 +176,6 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.Aut
|
||||||
// Penalty-only: X-Aligned-From (up to -5 points on failure)
|
// Penalty-only: X-Aligned-From (up to -5 points on failure)
|
||||||
score += 5 * a.calculateXAlignedFromScore(results) / 100
|
score += 5 * a.calculateXAlignedFromScore(results) / 100
|
||||||
|
|
||||||
// Penalty-only: X-TLS / transport encryption (-10 points when not encrypted)
|
|
||||||
score += 10 * a.calculateXTLSScore(results) / 100
|
|
||||||
|
|
||||||
// Ensure score doesn't exceed 100
|
// Ensure score doesn't exceed 100
|
||||||
if score > 100 {
|
if score > 100 {
|
||||||
score = 100
|
score = 100
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2025 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 (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
// parseXTLSResult parses the x-tls result from Authentication-Results.
|
|
||||||
// Example: x-tls=pass smtp.version=TLSv1.3 smtp.cipher=TLS_AES_256_GCM_SHA384 smtp.bits=256
|
|
||||||
func (a *AuthenticationAnalyzer) parseXTLSResult(part string) *model.AuthResult {
|
|
||||||
result := &model.AuthResult{}
|
|
||||||
|
|
||||||
// Extract result (pass, fail, none, ...)
|
|
||||||
re := regexp.MustCompile(`x-tls=(\w+)`)
|
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
|
||||||
result.Result = model.AuthResultResult(strings.ToLower(matches[1]))
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Details = utils.PtrTo(formatTLSDetails(
|
|
||||||
submatch(part, `smtp\.version=([^\s;()]+)`),
|
|
||||||
submatch(part, `smtp\.cipher=([^\s;()]+)`),
|
|
||||||
submatch(part, `smtp\.bits=(\d+)`),
|
|
||||||
))
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateXTLSScore returns a penalty for a negative transport-TLS result.
|
|
||||||
// pass (or absent) does not alter the score; any other result is penalized.
|
|
||||||
func (a *AuthenticationAnalyzer) calculateXTLSScore(results *model.AuthenticationResults) (score int) {
|
|
||||||
if results.XTls != nil {
|
|
||||||
switch results.XTls.Result {
|
|
||||||
case model.AuthResultResultPass:
|
|
||||||
// pass: don't alter the score
|
|
||||||
default:
|
|
||||||
return -100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReconcileXTLS fills in the x-tls result from the inbound connection's parsed TLS
|
|
||||||
// information when no x-tls Authentication-Results header was present. The inbound
|
|
||||||
// connection is the most recent hop (index 0) of the received chain.
|
|
||||||
func (a *AuthenticationAnalyzer) ReconcileXTLS(results *model.AuthenticationResults, chain *[]model.ReceivedHop) {
|
|
||||||
if results == nil || results.XTls != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if chain == nil || len(*chain) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
inbound := (*chain)[0]
|
|
||||||
switch {
|
|
||||||
case inbound.Tls != nil:
|
|
||||||
// Full TLS parenthetical present (smtpd_tls_received_header = yes).
|
|
||||||
var version, cipher, bits string
|
|
||||||
if inbound.Tls.Version != nil {
|
|
||||||
version = *inbound.Tls.Version
|
|
||||||
}
|
|
||||||
if inbound.Tls.Cipher != nil {
|
|
||||||
cipher = *inbound.Tls.Cipher
|
|
||||||
}
|
|
||||||
if inbound.Tls.Bits != nil {
|
|
||||||
bits = fmt.Sprintf("%d", *inbound.Tls.Bits)
|
|
||||||
}
|
|
||||||
results.XTls = &model.AuthResult{
|
|
||||||
Result: model.AuthResultResultPass,
|
|
||||||
Details: utils.PtrTo(formatTLSDetails(version, cipher, bits)),
|
|
||||||
}
|
|
||||||
|
|
||||||
case protocolIndicatesTLS(inbound.With):
|
|
||||||
// No TLS parenthetical (smtpd_tls_received_header may be disabled), but the
|
|
||||||
// transport keyword (ESMTPS, ESMTPSA, ...) tells us the session was encrypted.
|
|
||||||
// We just don't have the cipher details.
|
|
||||||
results.XTls = &model.AuthResult{
|
|
||||||
Result: model.AuthResultResultPass,
|
|
||||||
Details: utils.PtrTo(fmt.Sprintf("Encrypted connection (%s); cipher details unavailable", *inbound.With)),
|
|
||||||
}
|
|
||||||
|
|
||||||
case inbound.With != nil:
|
|
||||||
// A plaintext transport keyword (SMTP, ESMTP, ESMTPA, ...) is positive
|
|
||||||
// evidence the inbound connection was not encrypted.
|
|
||||||
results.XTls = &model.AuthResult{
|
|
||||||
Result: model.AuthResultResultNone,
|
|
||||||
Details: utils.PtrTo(fmt.Sprintf("Inbound connection was not encrypted (%s)", *inbound.With)),
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Neither TLS details nor a transport keyword: we cannot tell whether the
|
|
||||||
// connection was encrypted. Leave x-tls unset rather than wrongly penalize.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// protocolIndicatesTLS reports whether an SMTP "with" transport keyword denotes a
|
|
||||||
// TLS-encrypted session. Per RFC 3848 the keyword gains a trailing "S" when STARTTLS
|
|
||||||
// (or implicit TLS) was negotiated: ESMTPS, ESMTPSA, SMTPS, LMTPS, LMTPSA, UTF8SMTPS...
|
|
||||||
// The plaintext variants end in "P" (SMTP, ESMTP, LMTP) or "A" (ESMTPA, LMTPA).
|
|
||||||
func protocolIndicatesTLS(with *string) bool {
|
|
||||||
if with == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
p := strings.ToUpper(strings.TrimSpace(*with))
|
|
||||||
return strings.HasSuffix(p, "S") || strings.HasSuffix(p, "SA")
|
|
||||||
}
|
|
||||||
|
|
||||||
// submatch returns the first capture group of pattern in s, or "".
|
|
||||||
func submatch(s, pattern string) string {
|
|
||||||
if matches := regexp.MustCompile(pattern).FindStringSubmatch(s); len(matches) > 1 {
|
|
||||||
return matches[1]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatTLSDetails builds a human-readable summary of the TLS parameters.
|
|
||||||
func formatTLSDetails(version, cipher, bits string) string {
|
|
||||||
var parts []string
|
|
||||||
if version != "" {
|
|
||||||
parts = append(parts, version)
|
|
||||||
}
|
|
||||||
if cipher != "" {
|
|
||||||
parts = append(parts, "cipher "+cipher)
|
|
||||||
}
|
|
||||||
if bits != "" {
|
|
||||||
parts = append(parts, bits+" bits")
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ", ")
|
|
||||||
}
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
|
||||||
// Copyright (c) 2025 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 (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseXTLSResult(t *testing.T) {
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
|
||||||
|
|
||||||
result := analyzer.parseXTLSResult("x-tls=pass smtp.version=TLSv1.3 smtp.cipher=TLS_AES_256_GCM_SHA384 smtp.bits=256")
|
|
||||||
|
|
||||||
if result.Result != model.AuthResultResultPass {
|
|
||||||
t.Errorf("Result = %v, want pass", result.Result)
|
|
||||||
}
|
|
||||||
if result.Details == nil {
|
|
||||||
t.Fatal("Details should not be nil")
|
|
||||||
}
|
|
||||||
for _, want := range []string{"TLSv1.3", "TLS_AES_256_GCM_SHA384", "256 bits"} {
|
|
||||||
if !strings.Contains(*result.Details, want) {
|
|
||||||
t.Errorf("Details %q should contain %q", *result.Details, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculateXTLSScore(t *testing.T) {
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
xtls *model.AuthResult
|
|
||||||
score int
|
|
||||||
}{
|
|
||||||
{"nil", nil, 0},
|
|
||||||
{"pass", &model.AuthResult{Result: model.AuthResultResultPass}, 0},
|
|
||||||
{"none", &model.AuthResult{Result: model.AuthResultResultNone}, -100},
|
|
||||||
{"fail", &model.AuthResult{Result: model.AuthResultResultFail}, -100},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
results := &model.AuthenticationResults{XTls: tt.xtls}
|
|
||||||
if got := analyzer.calculateXTLSScore(results); got != tt.score {
|
|
||||||
t.Errorf("calculateXTLSScore = %d, want %d", got, tt.score)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReconcileXTLS(t *testing.T) {
|
|
||||||
analyzer := NewAuthenticationAnalyzer("")
|
|
||||||
|
|
||||||
t.Run("keeps existing x-tls header result", func(t *testing.T) {
|
|
||||||
existing := &model.AuthResult{Result: model.AuthResultResultFail}
|
|
||||||
results := &model.AuthenticationResults{XTls: existing}
|
|
||||||
chain := &[]model.ReceivedHop{{Tls: &model.TLSInfo{Version: utils.PtrTo("TLSv1.3")}}}
|
|
||||||
analyzer.ReconcileXTLS(results, chain)
|
|
||||||
if results.XTls != existing {
|
|
||||||
t.Error("existing XTls should be preserved")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("synthesizes pass from encrypted inbound hop", func(t *testing.T) {
|
|
||||||
results := &model.AuthenticationResults{}
|
|
||||||
chain := &[]model.ReceivedHop{{Tls: &model.TLSInfo{
|
|
||||||
Version: utils.PtrTo("TLSv1.3"),
|
|
||||||
Cipher: utils.PtrTo("TLS_AES_256_GCM_SHA384"),
|
|
||||||
Bits: utils.PtrTo(256),
|
|
||||||
}}}
|
|
||||||
analyzer.ReconcileXTLS(results, chain)
|
|
||||||
if results.XTls == nil || results.XTls.Result != model.AuthResultResultPass {
|
|
||||||
t.Fatalf("expected synthesized pass, got %+v", results.XTls)
|
|
||||||
}
|
|
||||||
if results.XTls.Details == nil || !strings.Contains(*results.XTls.Details, "TLSv1.3") {
|
|
||||||
t.Errorf("details should mention TLS version, got %v", results.XTls.Details)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("synthesizes pass from ESMTPS protocol without TLS parenthetical", func(t *testing.T) {
|
|
||||||
// smtpd_tls_received_header disabled: no TLS details, but ESMTPS proves encryption.
|
|
||||||
results := &model.AuthenticationResults{}
|
|
||||||
chain := &[]model.ReceivedHop{{With: utils.PtrTo("ESMTPS")}}
|
|
||||||
analyzer.ReconcileXTLS(results, chain)
|
|
||||||
if results.XTls == nil || results.XTls.Result != model.AuthResultResultPass {
|
|
||||||
t.Fatalf("expected synthesized pass, got %+v", results.XTls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("synthesizes none from plaintext ESMTP protocol", func(t *testing.T) {
|
|
||||||
results := &model.AuthenticationResults{}
|
|
||||||
chain := &[]model.ReceivedHop{{With: utils.PtrTo("ESMTP")}}
|
|
||||||
analyzer.ReconcileXTLS(results, chain)
|
|
||||||
if results.XTls == nil || results.XTls.Result != model.AuthResultResultNone {
|
|
||||||
t.Fatalf("expected synthesized none, got %+v", results.XTls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("leaves nil when neither TLS info nor protocol is known", func(t *testing.T) {
|
|
||||||
results := &model.AuthenticationResults{}
|
|
||||||
chain := &[]model.ReceivedHop{{}}
|
|
||||||
analyzer.ReconcileXTLS(results, chain)
|
|
||||||
if results.XTls != nil {
|
|
||||||
t.Errorf("expected nil XTls when undetermined, got %+v", results.XTls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("leaves nil with empty chain", func(t *testing.T) {
|
|
||||||
results := &model.AuthenticationResults{}
|
|
||||||
analyzer.ReconcileXTLS(results, &[]model.ReceivedHop{})
|
|
||||||
if results.XTls != nil {
|
|
||||||
t.Errorf("expected nil XTls, got %+v", results.XTls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProtocolIndicatesTLS(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
with string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"ESMTPS", true},
|
|
||||||
{"ESMTPSA", true},
|
|
||||||
{"SMTPS", true},
|
|
||||||
{"LMTPS", true},
|
|
||||||
{"LMTPSA", true},
|
|
||||||
{"SMTP", false},
|
|
||||||
{"ESMTP", false},
|
|
||||||
{"ESMTPA", false},
|
|
||||||
{"LMTP", false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.with, func(t *testing.T) {
|
|
||||||
if got := protocolIndicatesTLS(utils.PtrTo(tt.with)); got != tt.want {
|
|
||||||
t.Errorf("protocolIndicatesTLS(%q) = %v, want %v", tt.with, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if protocolIndicatesTLS(nil) {
|
|
||||||
t.Error("protocolIndicatesTLS(nil) should be false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -501,11 +501,6 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace email addresses with just their domain part to avoid false positives
|
|
||||||
// e.g. "john.doe@example.com" → "example.com" so local-part dots don't look like domains
|
|
||||||
emailAddrRegex := regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@([a-z0-9.\-]+\.[a-z]{2,})`)
|
|
||||||
linkText = emailAddrRegex.ReplaceAllString(linkText, "$1")
|
|
||||||
|
|
||||||
// Common generic link texts that shouldn't trigger warnings
|
// Common generic link texts that shouldn't trigger warnings
|
||||||
genericTexts := []string{
|
genericTexts := []string{
|
||||||
"click here", "read more", "learn more", "download", "subscribe",
|
"click here", "read more", "learn more", "download", "subscribe",
|
||||||
|
|
|
||||||
|
|
@ -88,16 +88,6 @@ 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,15 +100,6 @@ 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)
|
||||||
|
|
@ -157,11 +138,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -193,9 +169,6 @@ 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 {
|
||||||
|
|
@ -241,9 +214,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.BIMIRecord
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %s", formatDNSError(err))),
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ func (d *DNSAnalyzer) checkDKIMRecord(h DKIMHeader) *model.DKIMRecord {
|
||||||
Domain: h.Domain,
|
Domain: h.Domain,
|
||||||
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
|
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %s", formatDNSError(err))),
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &model.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %s", formatDNSError(err))),
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if foundDomain == "" {
|
if foundDomain == "" {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/model"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
@ -63,21 +62,6 @@ 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 {
|
||||||
|
|
@ -89,11 +73,6 @@ 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 != "" {
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -39,7 +39,7 @@ func (d *DNSAnalyzer) checkMXRecords(domain string) *[]model.MXRecord {
|
||||||
return &[]model.MXRecord{
|
return &[]model.MXRecord{
|
||||||
{
|
{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup MX records: %s", formatDNSError(err))),
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,22 +23,9 @@ package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"net"
|
"net"
|
||||||
)
|
)
|
||||||
|
|
||||||
// formatDNSError renders a resolution error without exposing the upstream
|
|
||||||
// resolver address that net.DNSError.Error() normally appends as " on <addr>".
|
|
||||||
func formatDNSError(err error) string {
|
|
||||||
var dnsErr *net.DNSError
|
|
||||||
if errors.As(err, &dnsErr) {
|
|
||||||
sanitized := *dnsErr
|
|
||||||
sanitized.Server = ""
|
|
||||||
return sanitized.Error()
|
|
||||||
}
|
|
||||||
return err.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DNSResolver defines the interface for DNS resolution operations.
|
// DNSResolver defines the interface for DNS resolution operations.
|
||||||
// This interface abstracts DNS lookups to allow for custom implementations,
|
// This interface abstracts DNS lookups to allow for custom implementations,
|
||||||
// such as mock resolvers for testing or caching resolvers for performance.
|
// such as mock resolvers for testing or caching resolvers for performance.
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -67,7 +67,7 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
|
||||||
{
|
{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %s", formatDNSError(err))),
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -589,53 +588,9 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIss
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for fake reply/forward: Subject has Re:/Fwd: prefix but no thread headers
|
|
||||||
subject := email.GetHeaderValue("Subject")
|
|
||||||
if h.hasReplyPrefix(subject) && !email.HasHeader("References") && !email.HasHeader("In-Reply-To") {
|
|
||||||
issues = append(issues, model.HeaderIssue{
|
|
||||||
Header: "Subject",
|
|
||||||
Severity: model.HeaderIssueSeverityHigh,
|
|
||||||
Message: "Subject indicates a reply or forward but no References or In-Reply-To header is present",
|
|
||||||
Advice: utils.PtrTo("Remove the Re:/Fwd: prefix from the subject, or add References/In-Reply-To headers if this is a genuine reply"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return issues
|
return issues
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasReplyPrefix reports whether a subject line starts with a reply or forward prefix.
|
|
||||||
func (h *HeaderAnalyzer) hasReplyPrefix(subject string) bool {
|
|
||||||
// Normalize: collapse leading whitespace and make comparison case-insensitive
|
|
||||||
s := strings.ToLower(strings.TrimSpace(subject))
|
|
||||||
|
|
||||||
prefixes := []string{
|
|
||||||
"re:", // English / universal
|
|
||||||
"fwd:", // English forward
|
|
||||||
"fw:", // English forward (short)
|
|
||||||
"aw:", // German Antwort
|
|
||||||
"wg:", // German Weitergeleitet
|
|
||||||
"sv:", // Scandinavian Svar
|
|
||||||
"vs:", // Finnish Vastaus / Norwegian
|
|
||||||
"ref:", // Some clients
|
|
||||||
"rép:", // French Réponse
|
|
||||||
"tr:", // French Transfert
|
|
||||||
"odp:", // Polish Odpowiedź
|
|
||||||
"ynt:", // Turkish Yanıt
|
|
||||||
"res:", // Portuguese/Spanish Resposta/Respuesta
|
|
||||||
"enc:", // Spanish Enviado/Reenviado
|
|
||||||
"vl:", // Dutch Verwijzing
|
|
||||||
"antw:", // Dutch Antwoord
|
|
||||||
"rv:", // Norwegian/Swedish
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range prefixes {
|
|
||||||
if strings.HasPrefix(s, p) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseReceivedChain extracts the chain of Received headers from an email
|
// parseReceivedChain extracts the chain of Received headers from an email
|
||||||
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop {
|
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop {
|
||||||
if email == nil || email.Header == nil {
|
if email == nil || email.Header == nil {
|
||||||
|
|
@ -738,50 +693,5 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *model.Receiv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract TLS details from the Received header parentheticals
|
|
||||||
// (e.g. "(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) ...)")
|
|
||||||
hop.Tls = parseReceivedTLS(normalized)
|
|
||||||
|
|
||||||
return hop
|
return hop
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseReceivedTLS extracts TLS connection details from a normalized Received header value.
|
|
||||||
// Returns nil when the hop was not encrypted (no TLS version/cipher found).
|
|
||||||
func parseReceivedTLS(normalized string) *model.TLSInfo {
|
|
||||||
tls := &model.TLSInfo{}
|
|
||||||
found := false
|
|
||||||
|
|
||||||
// TLS protocol version, e.g. "using TLSv1.3"
|
|
||||||
if matches := regexp.MustCompile(`(?i)using\s+(TLSv[0-9.]+|SSLv[0-9.]+)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
|
||||||
tls.Version = &matches[1]
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cipher suite, e.g. "with cipher TLS_AES_256_GCM_SHA384"
|
|
||||||
if matches := regexp.MustCompile(`(?i)with cipher\s+([A-Za-z0-9_-]+)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
|
||||||
tls.Cipher = &matches[1]
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cipher strength, e.g. "(256/256 bits)"
|
|
||||||
if matches := regexp.MustCompile(`\((\d+)/\d+ bits\)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
|
||||||
if bits, err := strconv.Atoi(matches[1]); err == nil {
|
|
||||||
tls.Bits = &bits
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Certificate verification status. Postfix emits "(verified OK)" when the peer
|
|
||||||
// certificate was trusted, "(not verified)" otherwise. "No client certificate
|
|
||||||
// requested" leaves the field unset (trust is simply not applicable).
|
|
||||||
if regexp.MustCompile(`(?i)verified OK`).MatchString(normalized) {
|
|
||||||
tls.Verified = utils.PtrTo(true)
|
|
||||||
} else if regexp.MustCompile(`(?i)not verified`).MatchString(normalized) {
|
|
||||||
tls.Verified = utils.PtrTo(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tls
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -677,77 +677,6 @@ func TestParseReceivedHeader(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseReceivedTLS(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
receivedValue string
|
|
||||||
expectNil bool
|
|
||||||
expectVersion *string
|
|
||||||
expectCipher *string
|
|
||||||
expectBits *int
|
|
||||||
expectVerified *bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "TLS 1.3 no client certificate",
|
|
||||||
receivedValue: "from mail.example.com (unknown [IPv6:2001:db8::1]) " +
|
|
||||||
"(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) " +
|
|
||||||
"key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256) " +
|
|
||||||
"(No client certificate requested) " +
|
|
||||||
"by mx.example.org (Postfix) with ESMTPSA id 1EFD11611EA; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
|
|
||||||
expectVersion: strPtr("TLSv1.3"),
|
|
||||||
expectCipher: strPtr("TLS_AES_256_GCM_SHA384"),
|
|
||||||
expectBits: intPtr(256),
|
|
||||||
expectVerified: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "TLS with verified client certificate",
|
|
||||||
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) " +
|
|
||||||
"(using TLSv1.2 with cipher ECDHE-RSA-AES128-GCM-SHA256 (128/128 bits)) " +
|
|
||||||
"(Client CN \"example\", Issuer \"CA\" (verified OK)) " +
|
|
||||||
"by mx.receiver.com (Postfix) with ESMTPS id ABC; Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
expectVersion: strPtr("TLSv1.2"),
|
|
||||||
expectCipher: strPtr("ECDHE-RSA-AES128-GCM-SHA256"),
|
|
||||||
expectBits: intPtr(128),
|
|
||||||
expectVerified: boolPtr(true),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Plaintext (no TLS)",
|
|
||||||
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTP id ABC; Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
expectNil: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
normalized := strings.Join(strings.Fields(tt.receivedValue), " ")
|
|
||||||
tls := parseReceivedTLS(normalized)
|
|
||||||
|
|
||||||
if tt.expectNil {
|
|
||||||
if tls != nil {
|
|
||||||
t.Fatalf("expected nil TLS info, got %+v", tls)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if tls == nil {
|
|
||||||
t.Fatal("parseReceivedTLS returned nil")
|
|
||||||
}
|
|
||||||
if !equalStrPtr(tls.Version, tt.expectVersion) {
|
|
||||||
t.Errorf("Version = %v, want %v", ptrToStr(tls.Version), ptrToStr(tt.expectVersion))
|
|
||||||
}
|
|
||||||
if !equalStrPtr(tls.Cipher, tt.expectCipher) {
|
|
||||||
t.Errorf("Cipher = %v, want %v", ptrToStr(tls.Cipher), ptrToStr(tt.expectCipher))
|
|
||||||
}
|
|
||||||
if (tls.Bits == nil) != (tt.expectBits == nil) || (tls.Bits != nil && *tls.Bits != *tt.expectBits) {
|
|
||||||
t.Errorf("Bits = %v, want %v", tls.Bits, tt.expectBits)
|
|
||||||
}
|
|
||||||
if (tls.Verified == nil) != (tt.expectVerified == nil) || (tls.Verified != nil && *tls.Verified != *tt.expectVerified) {
|
|
||||||
t.Errorf("Verified = %v, want %v", tls.Verified, tt.expectVerified)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
|
func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
|
||||||
analyzer := NewHeaderAnalyzer()
|
analyzer := NewHeaderAnalyzer()
|
||||||
|
|
||||||
|
|
@ -974,155 +903,11 @@ func TestCheckHeader_DateValidation(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasReplyPrefix(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
subject string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
// Positive cases
|
|
||||||
{"Re: Hello", true},
|
|
||||||
{"RE: Hello", true},
|
|
||||||
{"re: Hello", true},
|
|
||||||
{"Fwd: Hello", true},
|
|
||||||
{"FWD: Hello", true},
|
|
||||||
{"fw: Hello", true},
|
|
||||||
{"FW: Hello", true},
|
|
||||||
{"Aw: Hallo", true},
|
|
||||||
{"WG: Weitergeleitet", true},
|
|
||||||
{"Sv: Hej", true},
|
|
||||||
{"Vs: Vastaus", true},
|
|
||||||
{"Ref: something", true},
|
|
||||||
{"Rép: Bonjour", true},
|
|
||||||
{"TR: Transféré", true},
|
|
||||||
{"Odp: Odpowiedź", true},
|
|
||||||
{"Ynt: Yanıt", true},
|
|
||||||
{"Res: Resposta", true},
|
|
||||||
{"Enc: Reenviado", true},
|
|
||||||
{"Vl: Verwijzing", true},
|
|
||||||
{"Antw: Antwoord", true},
|
|
||||||
{"Rv: Svar", true},
|
|
||||||
// Negative cases
|
|
||||||
{"Hello", false},
|
|
||||||
{"", false},
|
|
||||||
{"react: something", false},
|
|
||||||
{"reference: check this", false},
|
|
||||||
{"Resources available", false},
|
|
||||||
{"Friendly reminder", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewHeaderAnalyzer()
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.subject, func(t *testing.T) {
|
|
||||||
result := analyzer.hasReplyPrefix(tt.subject)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("hasReplyPrefix(%q) = %v, want %v", tt.subject, result, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindHeaderIssues_FakeReply(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
headers map[string]string
|
|
||||||
expectIssueType string // non-empty means we expect an issue containing this substring
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Re: subject without thread headers",
|
|
||||||
headers: map[string]string{
|
|
||||||
"From": "sender@example.com",
|
|
||||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
"Message-ID": "<abc@example.com>",
|
|
||||||
"Subject": "Re: Your invoice",
|
|
||||||
},
|
|
||||||
expectIssueType: "References or In-Reply-To",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Fwd: subject without thread headers",
|
|
||||||
headers: map[string]string{
|
|
||||||
"From": "sender@example.com",
|
|
||||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
"Message-ID": "<abc@example.com>",
|
|
||||||
"Subject": "Fwd: Important update",
|
|
||||||
},
|
|
||||||
expectIssueType: "References or In-Reply-To",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Re: subject with References header - no issue",
|
|
||||||
headers: map[string]string{
|
|
||||||
"From": "sender@example.com",
|
|
||||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
"Message-ID": "<abc@example.com>",
|
|
||||||
"Subject": "Re: Your invoice",
|
|
||||||
"References": "<original@example.com>",
|
|
||||||
},
|
|
||||||
expectIssueType: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Re: subject with In-Reply-To only - no issue",
|
|
||||||
headers: map[string]string{
|
|
||||||
"From": "sender@example.com",
|
|
||||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
"Message-ID": "<abc@example.com>",
|
|
||||||
"Subject": "Re: Your invoice",
|
|
||||||
"In-Reply-To": "<original@example.com>",
|
|
||||||
},
|
|
||||||
expectIssueType: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Normal subject without thread headers - no issue",
|
|
||||||
headers: map[string]string{
|
|
||||||
"From": "sender@example.com",
|
|
||||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
|
||||||
"Message-ID": "<abc@example.com>",
|
|
||||||
"Subject": "Your invoice",
|
|
||||||
},
|
|
||||||
expectIssueType: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewHeaderAnalyzer()
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
email := &EmailMessage{
|
|
||||||
Header: createHeaderWithFields(tt.headers),
|
|
||||||
}
|
|
||||||
|
|
||||||
issues := analyzer.findHeaderIssues(email)
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, issue := range issues {
|
|
||||||
if strings.Contains(issue.Message, tt.expectIssueType) {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.expectIssueType != "" && !found {
|
|
||||||
t.Errorf("expected issue containing %q, but none found (issues: %v)", tt.expectIssueType, issues)
|
|
||||||
}
|
|
||||||
if tt.expectIssueType == "" {
|
|
||||||
for _, issue := range issues {
|
|
||||||
if strings.Contains(issue.Message, "References or In-Reply-To") {
|
|
||||||
t.Errorf("unexpected fake-reply issue found: %s", issue.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for testing
|
// Helper functions for testing
|
||||||
func strPtr(s string) *string {
|
func strPtr(s string) *string {
|
||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolPtr(b bool) *bool {
|
|
||||||
return &b
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptrToStr(p *string) string {
|
func ptrToStr(p *string) string {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return "<nil>"
|
return "<nil>"
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,6 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
||||||
// Run all analyzers
|
// Run all analyzers
|
||||||
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
||||||
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
||||||
// Fall back to the received chain's inbound TLS when no x-tls header was present.
|
|
||||||
if results.Authentication != nil && results.Headers != nil {
|
|
||||||
r.authAnalyzer.ReconcileXTLS(results.Authentication, results.Headers.ReceivedChain)
|
|
||||||
}
|
|
||||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
|
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
|
||||||
results.RBL = r.rblChecker.CheckEmail(email)
|
results.RBL = r.rblChecker.CheckEmail(email)
|
||||||
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
||||||
|
|
|
||||||
1447
web/package-lock.json
generated
1447
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -34,7 +34,7 @@
|
||||||
"typescript": "^6.0.0",
|
"typescript": "^6.0.0",
|
||||||
"typescript-eslint": "^8.44.1",
|
"typescript-eslint": "^8.44.1",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
|
|
||||||
|
|
@ -170,88 +170,6 @@
|
||||||
</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}
|
|
||||||
|
|
||||||
<!-- X-TLS (Transport encryption) -->
|
|
||||||
{#if authentication.x_tls}
|
|
||||||
<div class="list-group-item" id="authentication-x-tls">
|
|
||||||
<div class="d-flex align-items-start">
|
|
||||||
<i
|
|
||||||
class="bi {getAuthResultIcon(
|
|
||||||
authentication.x_tls.result,
|
|
||||||
true,
|
|
||||||
)} {getAuthResultClass(authentication.x_tls.result, true)} me-2 fs-5"
|
|
||||||
></i>
|
|
||||||
<div>
|
|
||||||
<strong>Transport TLS</strong>
|
|
||||||
<i
|
|
||||||
class="bi bi-info-circle text-muted ms-1"
|
|
||||||
title="Whether the inbound connection that delivered this message used TLS encryption (x-tls). Falls back to the inbound Received hop when no x-tls header is present."
|
|
||||||
></i>
|
|
||||||
<span
|
|
||||||
class="text-uppercase ms-2 {getAuthResultClass(
|
|
||||||
authentication.x_tls.result,
|
|
||||||
true,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{authentication.x_tls.result}
|
|
||||||
</span>
|
|
||||||
{#if authentication.x_tls.details}
|
|
||||||
<div class="small text-muted mt-1">
|
|
||||||
{authentication.x_tls.details}
|
|
||||||
</div>
|
|
||||||
{/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">
|
||||||
|
|
|
||||||
|
|
@ -72,26 +72,6 @@
|
||||||
{bimiRecord.error}
|
{bimiRecord.error}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !bimiRecord.valid}
|
|
||||||
<div class="alert alert-info mt-3 mb-0">
|
|
||||||
<h6 class="alert-heading">
|
|
||||||
<i class="bi bi-lightbulb me-1"></i>
|
|
||||||
Explicitly decline BIMI participation
|
|
||||||
</h6>
|
|
||||||
<p class="mb-2 small">
|
|
||||||
If you do not intend to publish a brand logo, you can add a declination
|
|
||||||
record to signal that this domain deliberately opts out of BIMI. This
|
|
||||||
prevents mail clients from falling back to a parent-domain record:
|
|
||||||
</p>
|
|
||||||
<code class="d-block bg-white rounded p-2 text-break border"
|
|
||||||
>{bimiRecord.selector}._bimi.{bimiRecord.domain}. IN TXT "v=BIMI1; l=; a="</code
|
|
||||||
>
|
|
||||||
<p class="mt-1 mb-0 small text-muted">
|
|
||||||
Declination record format as defined in § 4.3.1 of
|
|
||||||
<em>draft-brand-indicators-for-message-identification</em>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,9 @@
|
||||||
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";
|
||||||
import ReturnOkDisplay from "./ReturnOkDisplay.svelte";
|
|
||||||
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
|
import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -94,16 +92,6 @@
|
||||||
{senderIp}
|
{senderIp}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- HELO / PTR Consistency -->
|
|
||||||
<HeloPtrMatchDisplay
|
|
||||||
heloHostname={dnsResults.helo_hostname ?? receivedChain?.[0]?.from}
|
|
||||||
ptrRecords={dnsResults.ptr_records}
|
|
||||||
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 -->
|
||||||
|
|
@ -154,7 +142,8 @@
|
||||||
</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 domain
|
<i class="bi bi-exclamation-triangle-fill"></i> Differs from Return-Path
|
||||||
|
domain
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let { receivedChain }: Props = $props();
|
let { receivedChain }: Props = $props();
|
||||||
|
|
||||||
// Mirror of the backend protocolIndicatesTLS (RFC 3848): the transport keyword
|
|
||||||
// gains a trailing "S" when TLS was used (ESMTPS, ESMTPSA, SMTPS, LMTPS, LMTPSA...).
|
|
||||||
function protocolIndicatesTLS(withProto: string | undefined | null): boolean {
|
|
||||||
if (!withProto) return false;
|
|
||||||
const p = withProto.trim().toUpperCase();
|
|
||||||
return p.endsWith("S") || p.endsWith("SA");
|
|
||||||
}
|
|
||||||
|
|
||||||
// RFC 3848: a trailing "A" means the sender authenticated (SMTP AUTH):
|
|
||||||
// ESMTPA, ESMTPSA, LMTPA, LMTPSA...
|
|
||||||
function protocolIndicatesAuth(withProto: string | undefined | null): boolean {
|
|
||||||
if (!withProto) return false;
|
|
||||||
return withProto.trim().toUpperCase().endsWith("A");
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if receivedChain && receivedChain.length > 0}
|
{#if receivedChain && receivedChain.length > 0}
|
||||||
|
|
@ -75,63 +60,6 @@
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="mb-0 small d-flex flex-wrap align-items-center gap-3">
|
|
||||||
{#if hop.tls}
|
|
||||||
<span class="badge bg-success">
|
|
||||||
<i class="bi bi-lock-fill me-1"></i>TLS
|
|
||||||
</span>
|
|
||||||
{#if hop.tls.version}
|
|
||||||
<span>
|
|
||||||
<span class="text-muted">Version:</span>
|
|
||||||
<code>{hop.tls.version}</code>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if hop.tls.cipher}
|
|
||||||
<span>
|
|
||||||
<span class="text-muted">Cipher:</span>
|
|
||||||
<code>{hop.tls.cipher}</code>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if hop.tls.bits}
|
|
||||||
<span>
|
|
||||||
<span class="text-muted">Strength:</span>
|
|
||||||
<code>{hop.tls.bits} bits</code>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if hop.tls.verified !== undefined}
|
|
||||||
<span
|
|
||||||
class:text-success={hop.tls.verified}
|
|
||||||
class:text-warning={!hop.tls.verified}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="bi {hop.tls.verified
|
|
||||||
? 'bi-patch-check-fill'
|
|
||||||
: 'bi-patch-exclamation-fill'} me-1"
|
|
||||||
></i>
|
|
||||||
{hop.tls.verified
|
|
||||||
? "Certificate trusted"
|
|
||||||
: "Certificate not trusted"}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{:else if protocolIndicatesTLS(hop.with)}
|
|
||||||
<span class="badge bg-success">
|
|
||||||
<i class="bi bi-lock-fill me-1"></i>TLS
|
|
||||||
</span>
|
|
||||||
{:else if hop.with}
|
|
||||||
<span class="badge bg-secondary">
|
|
||||||
<i class="bi bi-unlock me-1"></i>No TLS
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="badge bg-light text-muted border">
|
|
||||||
<i class="bi bi-question-circle me-1"></i>TLS unknown
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if protocolIndicatesAuth(hop.with)}
|
|
||||||
<span class="badge bg-info">
|
|
||||||
<i class="bi bi-person-check-fill me-1"></i>Authenticated
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
<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}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
<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}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue