Compare commits

...

4 commits

Author SHA1 Message Date
f14209d4fa blacklist: add domain reputation check via checker-blacklist
Some checks reported errors
continuous-integration/drone/push Build was killed
Integrates the checker-blacklist module behind a new POST /blacklist/domain
endpoint that aggregates reputation/blocklist sources for a given domain,
plus a SvelteKit UI under /blacklist/domain mirroring the existing IP
blacklist flow. Per-source credentials (VirusTotal, Safe Browsing) are
exposed as CLI flags; free sources run unconditionally.

Closes: #96
2026-06-06 16:44:27 +09:00
d53c1b1e00 tls: surface transport TLS status in email path and authentication
All checks were successful
continuous-integration/drone/push Build is passing
Parse TLS details (version, cipher, bits, cert verification) from the
Postfix Received header parenthetical and expose them per hop, rendered
as a per-hop badge in the Email Path card.

Add an x-tls Authentication-Results result: parse it when present, and
otherwise synthesize it from the inbound hop's TLS info. A negative
result (unencrypted inbound connection) applies a -10 authentication
score penalty and is shown in the Authentication card. Enable the TLS
handler in authentication_milter.

Closes: #40
2026-06-06 16:44:27 +09:00
8e7e56851b postfix: add tlsmgr service to enable STARTTLS
Without tlsmgr, smtpd has no PRNG/entropy source and disables TLS,
rejecting STARTTLS with "454 4.7.0 TLS not available due to local problem".
2026-06-06 16:44:27 +09:00
a65b8084ee dns: add ReturnOK check for sender domain reachability
Verify that the From and Return-Path domains can actually receive replies
and bounces, mirroring Fastmail's authentication_milter ReturnOK handler.
Each domain is checked for MX records, falling back to A/AAAA (implicit MX)
and then to the organizational domain, yielding a pass/warn/fail status.
Adds return_ok to DNSResults, a 10-point DNS sub-score penalty per domain
that is wholly unreachable, and a new "Return Address Reachability" card.
2026-06-06 16:44:24 +09:00
29 changed files with 1702 additions and 23 deletions

View file

@ -363,6 +363,12 @@ components:
$ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest' $ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest'
BlacklistCheckResponse: BlacklistCheckResponse:
$ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse' $ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse'
DomainBlacklistResult:
$ref: './schemas.yaml#/components/schemas/DomainBlacklistResult'
DomainBlacklistSourceResult:
$ref: './schemas.yaml#/components/schemas/DomainBlacklistSourceResult'
DomainBlacklistEvidence:
$ref: './schemas.yaml#/components/schemas/DomainBlacklistEvidence'
TestSummary: TestSummary:
$ref: './schemas.yaml#/components/schemas/TestSummary' $ref: './schemas.yaml#/components/schemas/TestSummary'
TestListResponse: TestListResponse:

View file

@ -434,6 +434,29 @@ 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
@ -540,6 +563,11 @@ components:
x_ptr: x_ptr:
$ref: '#/components/schemas/XPtrResult' $ref: '#/components/schemas/XPtrResult'
description: X-Ptr result (HELO hostname vs reverse DNS consistency check) 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
@ -829,12 +857,49 @@ components:
helo_ptr_match: helo_ptr_match:
type: boolean type: boolean
description: Whether the announced HELO hostname matches one of the sender's PTR records (case-insensitive) description: Whether the announced HELO hostname matches one of the sender's PTR records (case-insensitive)
return_ok:
$ref: '#/components/schemas/ReturnOK'
errors: errors:
type: array type: array
items: items:
type: string type: string
description: DNS lookup errors description: DNS lookup errors
ReturnOK:
type: object
description: Whether the sender domains can receive replies and bounces (MX, with A/AAAA fallback)
properties:
from:
$ref: '#/components/schemas/ReturnOKDomain'
return_path:
$ref: '#/components/schemas/ReturnOKDomain'
ReturnOKDomain:
type: object
required:
- domain
- status
properties:
domain:
type: string
description: Domain that was evaluated
example: "example.com"
status:
type: string
enum: [pass, warn, fail]
x-go-type: string
description: pass = MX present, warn = only A/AAAA records (implicit MX), fail = no records
has_mx:
type: boolean
description: Whether the domain has at least one MX record
has_address:
type: boolean
description: Whether the domain has an A or AAAA record (implicit MX fallback)
org_domain:
type: string
description: Organizational domain used as fallback when the domain itself had no records
example: "example.com"
MXRecord: MXRecord:
type: object type: object
required: required:
@ -1152,6 +1217,9 @@ components:
example: "A" example: "A"
dns_results: dns_results:
$ref: '#/components/schemas/DNSResults' $ref: '#/components/schemas/DNSResults'
blacklist:
$ref: '#/components/schemas/DomainBlacklistResult'
description: Domain reputation/blacklist aggregation (omitted when the check could not be run)
BlacklistCheckRequest: BlacklistCheckRequest:
type: object type: object
@ -1203,6 +1271,103 @@ components:
$ref: '#/components/schemas/BlacklistCheck' $ref: '#/components/schemas/BlacklistCheck'
description: List of DNS whitelist check results (informational only) description: List of DNS whitelist check results (informational only)
DomainBlacklistResult:
type: object
required:
- registered_domain
- collected_at
- results
properties:
registered_domain:
type: string
description: eTLD+1 of the input domain
example: "example.com"
collected_at:
type: string
format: date-time
description: When the aggregation finished
score:
type: integer
minimum: 0
maximum: 100
description: Reputation score (0-100, higher is better). Omitted when the verdict is inconclusive (no usable source).
example: 100
grade:
type: string
enum: [A+, A, B, C, D, E, F]
description: Letter grade derived from the score. Omitted when the verdict is inconclusive.
example: "A+"
results:
type: array
items:
$ref: '#/components/schemas/DomainBlacklistSourceResult'
description: One entry per registered source (disabled sources included with enabled=false)
DomainBlacklistSourceResult:
type: object
required:
- source_id
- source_name
- enabled
- listed
properties:
source_id:
type: string
example: "quad9"
source_name:
type: string
example: "Quad9"
subject:
type: string
description: Per-zone identifier (DNSBL zones only)
enabled:
type: boolean
description: False when the source is disabled or missing credentials
listed:
type: boolean
description: Verdict from the source's Evaluate (false when disabled or errored)
blocked_query:
type: boolean
description: Resolver returned a block response (not a real listing)
severity:
type: string
description: Severity attached to the verdict (crit, warn, info, ok, or empty)
reasons:
type: array
items: { type: string }
evidence:
type: array
items:
$ref: '#/components/schemas/DomainBlacklistEvidence'
lookup_url:
type: string
removal_url:
type: string
reference:
type: string
error:
type: string
details:
type: object
additionalProperties: true
description: Source-specific structured data (free-form)
DomainBlacklistEvidence:
type: object
required:
- label
- value
properties:
label:
type: string
value:
type: string
status:
type: string
extra:
type: object
additionalProperties: { type: string }
TestSummary: TestSummary:
type: object type: object
required: required:

View file

@ -52,6 +52,8 @@
"PTR" : {}, "PTR" : {},
"TLS" : {},
"SenderID" : { "SenderID" : {
"hide_none" : 1 "hide_none" : 1
}, },

View file

@ -36,5 +36,8 @@ 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

View file

@ -3,6 +3,9 @@
# 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

2
go.mod
View file

@ -3,6 +3,8 @@ module git.happydns.org/happyDeliver
go 1.25.0 go 1.25.0
require ( require (
git.happydns.org/checker-blacklist v0.4.0
git.happydns.org/checker-sdk-go v1.9.0
github.com/JGLTechnologies/gin-rate-limit v1.5.8 github.com/JGLTechnologies/gin-rate-limit v1.5.8
github.com/emersion/go-smtp v0.24.0 github.com/emersion/go-smtp v0.24.0
github.com/getkin/kin-openapi v0.138.0 github.com/getkin/kin-openapi v0.138.0

4
go.sum
View file

@ -1,3 +1,7 @@
git.happydns.org/checker-blacklist v0.4.0 h1:mTOWz2tcMXGU2WFVM9VLxnSJ7mFXL2Lhq5NBq+lUU7g=
git.happydns.org/checker-blacklist v0.4.0/go.mod h1:huOwQWfAA+Wo+WbUbtyRIS/Y0eA+C3YrguGuJL+3qEE=
git.happydns.org/checker-sdk-go v1.9.0 h1:orBRymir+p6PMHVa4focryPKhTVWT7JAv6u9Ido5KF0=
git.happydns.org/checker-sdk-go v1.9.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0= github.com/JGLTechnologies/gin-rate-limit v1.5.8 h1:KiaHIEbpYxHpDvjhpjIif8fnVmjdw/afCMdGoN1AsB0=
github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g= github.com/JGLTechnologies/gin-rate-limit v1.5.8/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=

View file

@ -22,6 +22,7 @@
package api package api
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"time" "time"
@ -30,8 +31,10 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
openapi_types "github.com/oapi-codegen/runtime/types" openapi_types "github.com/oapi-codegen/runtime/types"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/reputation"
"git.happydns.org/happyDeliver/internal/storage" "git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/internal/utils" "git.happydns.org/happyDeliver/internal/utils"
"git.happydns.org/happyDeliver/internal/version" "git.happydns.org/happyDeliver/internal/version"
@ -50,15 +53,17 @@ type APIHandler struct {
storage storage.Storage storage storage.Storage
config *config.Config config *config.Config
analyzer EmailAnalyzer analyzer EmailAnalyzer
blacklistProvider sdk.ObservationProvider
startTime time.Time startTime time.Time
} }
// NewAPIHandler creates a new API handler // NewAPIHandler creates a new API handler
func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer) *APIHandler { func NewAPIHandler(store storage.Storage, cfg *config.Config, analyzer EmailAnalyzer, blacklistProvider sdk.ObservationProvider) *APIHandler {
return &APIHandler{ return &APIHandler{
storage: store, storage: store,
config: cfg, config: cfg,
analyzer: analyzer, analyzer: analyzer,
blacklistProvider: blacklistProvider,
startTime: time.Now(), startTime: time.Now(),
} }
} }
@ -339,6 +344,7 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
Score: score, Score: score,
Grade: responseGrade, Grade: responseGrade,
DnsResults: *dnsResults, DnsResults: *dnsResults,
Blacklist: h.runDomainBlacklist(c.Request.Context(), request.Domain),
} }
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
@ -383,6 +389,32 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
// runDomainBlacklist runs the checker-blacklist aggregation against a domain.
// It returns nil (and logs nothing fatal) when the check cannot be run, so the
// surrounding domain analysis still succeeds.
func (h *APIHandler) runDomainBlacklist(ctx context.Context, domain string) *model.DomainBlacklistResult {
opts := h.config.Analysis.Blacklist.AsCheckerOptions()
// "domain_name" is the option key the checker-blacklist provider reads
// (see checker/collect.go in the checker-blacklist module).
opts["domain_name"] = domain
// Cap the aggregation: sources run concurrently, each with its own
// timeouts; this is the host-side ceiling.
timeout := h.config.Analysis.HTTPTimeout
if timeout <= 0 {
timeout = 30 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
raw, err := h.blacklistProvider.Collect(ctx, opts)
if err != nil {
return nil
}
return reputation.FromObservation(raw)
}
// ListTests returns a paginated list of test summaries // ListTests returns a paginated list of test summaries
// (GET /tests) // (GET /tests)
func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) {

View file

@ -30,6 +30,7 @@ import (
ratelimit "github.com/JGLTechnologies/gin-rate-limit" ratelimit "github.com/JGLTechnologies/gin-rate-limit"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
blacklist "git.happydns.org/checker-blacklist/checker"
"git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/lmtp" "git.happydns.org/happyDeliver/internal/lmtp"
@ -70,7 +71,7 @@ func RunServer(cfg *config.Config) error {
analyzerAdapter := analyzer.NewAPIAdapter(cfg) analyzerAdapter := analyzer.NewAPIAdapter(cfg)
// Create API handler // Create API handler
handler := api.NewAPIHandler(store, cfg, analyzerAdapter) handler := api.NewAPIHandler(store, cfg, analyzerAdapter, blacklist.Provider())
// Set up Gin router // Set up Gin router
if os.Getenv("GIN_MODE") == "" { if os.Getenv("GIN_MODE") == "" {

View file

@ -0,0 +1,40 @@
// 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 config
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// AsCheckerOptions returns the map that checker-blacklist sources read
// via stringOpt(). Empty values are omitted so sources that require a
// credential stay disabled rather than failing with an empty key.
func (b BlacklistConfig) AsCheckerOptions() sdk.CheckerOptions {
opts := sdk.CheckerOptions{}
if b.VirusTotalAPIKey != "" {
opts["virustotal_api_key"] = b.VirusTotalAPIKey
}
if b.SafeBrowsingAPIKey != "" {
opts["safebrowsing_api_key"] = b.SafeBrowsingAPIKey
}
return opts
}

View file

@ -40,6 +40,8 @@ func declareFlags(o *Config) {
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)") flag.BoolVar(&o.Analysis.CheckAllIPs, "check-all-ips", o.Analysis.CheckAllIPs, "Check all IPs found in email headers against RBLs (not just the first one)")
flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)") flag.StringVar(&o.Analysis.RspamdAPIURL, "rspamd-api-url", o.Analysis.RspamdAPIURL, "rspamd API URL for symbol descriptions (default: use embedded list)")
flag.StringVar(&o.Analysis.Blacklist.VirusTotalAPIKey, "blacklist-virustotal-api-key", o.Analysis.Blacklist.VirusTotalAPIKey, "VirusTotal v3 API key for the domain blacklist checker")
flag.StringVar(&o.Analysis.Blacklist.SafeBrowsingAPIKey, "blacklist-safebrowsing-api-key", o.Analysis.Blacklist.SafeBrowsingAPIKey, "Google Safe Browsing API key for the domain blacklist checker")
flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever") flag.DurationVar(&o.ReportRetention, "report-retention", o.ReportRetention, "How long to keep reports (e.g., 720h, 30d). 0 = keep forever")
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)") flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey") flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")

View file

@ -75,6 +75,18 @@ type AnalysisConfig struct {
DNSWLs []string DNSWLs []string
CheckAllIPs bool // Check all IPs found in headers, not just the first one CheckAllIPs bool // Check all IPs found in headers, not just the first one
RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list) RspamdAPIURL string // rspamd API URL for fetching symbol descriptions (empty = use embedded list)
Blacklist BlacklistConfig
}
// BlacklistConfig holds per-source credentials/options for the
// domain-oriented checker-blacklist provider. Keys must match the
// option IDs declared by each source in the checker-blacklist module
// (see checker/virustotal.go, checker/safebrowsing.go, …). Free sources
// (Quad9, OISD, URLhaus, OpenPhish, Disconnect, Botvrij, …) need no
// configuration.
type BlacklistConfig struct {
VirusTotalAPIKey string
SafeBrowsingAPIKey string
} }
// DefaultConfig returns a configuration with sensible defaults // DefaultConfig returns a configuration with sensible defaults

View file

@ -147,6 +147,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
results.XPtr = a.parseXPtrResult(part) results.XPtr = a.parseXPtrResult(part)
} }
} }
// Parse x-tls
if strings.HasPrefix(part, "x-tls=") {
if results.XTls == nil {
results.XTls = a.parseXTLSResult(part)
}
}
} }
} }
@ -183,6 +190,9 @@ 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

View file

@ -0,0 +1,154 @@
// 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, ", ")
}

View file

@ -0,0 +1,165 @@
// 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")
}
}

View file

@ -110,6 +110,15 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head
results.RpMxRecords = d.checkMXRecords(*results.RpDomain) results.RpMxRecords = d.checkMXRecords(*results.RpDomain)
} }
// Verify the sender domains can actually receive replies/bounces (MX, with
// A/AAAA fallback), mirroring the ReturnOK milter check.
results.ReturnOk = &model.ReturnOK{
From: d.checkReturnOKDomain(fromDomain, orgDomainOrEmpty(headersResults.DomainAlignment.FromOrgDomain)),
}
if results.RpDomain != nil && *results.RpDomain != "" {
results.ReturnOk.ReturnPath = d.checkReturnOKDomain(*results.RpDomain, orgDomainOrEmpty(headersResults.DomainAlignment.ReturnPathOrgDomain))
}
// Check SPF records (for Return-Path domain - this is the envelope sender) // Check SPF records (for Return-Path domain - this is the envelope sender)
// SPF validates the MAIL FROM command, which corresponds to Return-Path // SPF validates the MAIL FROM command, which corresponds to Return-Path
results.SpfRecords = d.checkSPFRecords(spfDomain) results.SpfRecords = d.checkSPFRecords(spfDomain)
@ -148,6 +157,11 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults {
// Check SPF records // Check SPF records
results.SpfRecords = d.checkSPFRecords(domain) results.SpfRecords = d.checkSPFRecords(domain)
// Verify the domain can receive replies/bounces (MX, with A/AAAA fallback)
results.ReturnOk = &model.ReturnOK{
From: d.checkReturnOKDomain(domain, ""),
}
// Check DMARC record // Check DMARC record
results.DmarcRecord = d.checkDMARCRecord(domain) results.DmarcRecord = d.checkDMARCRecord(domain)
@ -179,6 +193,9 @@ func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int,
// DMARC Record: 40 points // DMARC Record: 40 points
score += 40 * d.calculateDMARCScore(results) / 100 score += 40 * d.calculateDMARCScore(results) / 100
// Penalty when a sender domain cannot receive replies/bounces at all
score += calculateReturnOKPenalty(results)
// BIMI Record: only bonus // BIMI Record: only bonus
if results.BimiRecord != nil && results.BimiRecord.Valid { if results.BimiRecord != nil && results.BimiRecord.Valid {
if score >= 100 { if score >= 100 {
@ -224,6 +241,9 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP stri
// DMARC Record: 20 points // DMARC Record: 20 points
score += 20 * d.calculateDMARCScore(results) / 100 score += 20 * d.calculateDMARCScore(results) / 100
// Penalty when a sender domain cannot receive replies/bounces at all
score += calculateReturnOKPenalty(results)
// BIMI Record // BIMI Record
// BIMI is optional but indicates advanced email branding // BIMI is optional but indicates advanced email branding
if results.BimiRecord != nil && results.BimiRecord.Valid { if results.BimiRecord != nil && results.BimiRecord.Valid {

View file

@ -0,0 +1,113 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"context"
"git.happydns.org/happyDeliver/internal/model"
"git.happydns.org/happyDeliver/internal/utils"
)
// ReturnOKDomain.Status values, matching the schema enum. Kept as a plain string
// in the generated model (x-go-type) to avoid colliding with other "pass"/"fail"
// enums in the global enum namespace.
const (
returnOKStatusPass = "pass"
returnOKStatusWarn = "warn"
returnOKStatusFail = "fail"
)
// domainCanReceive reports whether a domain can accept mail, looking up records
// in the same order as Fastmail's ReturnOK milter: MX first, then A/AAAA.
func (d *DNSAnalyzer) domainCanReceive(domain string) (hasMX, hasAddress bool) {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
if mxRecords, err := d.resolver.LookupMX(ctx, domain); err == nil && len(mxRecords) > 0 {
return true, false
}
if addrs, err := d.resolver.LookupHost(ctx, domain); err == nil && len(addrs) > 0 {
return false, true
}
return false, false
}
// checkReturnOKDomain verifies that a domain can receive replies/bounces.
// It checks the domain itself, then falls back to its organizational domain
// (when different) the same way the ReturnOK milter retries the org domain.
func (d *DNSAnalyzer) checkReturnOKDomain(domain, orgDomain string) *model.ReturnOKDomain {
if domain == "" {
return nil
}
result := &model.ReturnOKDomain{Domain: domain}
hasMX, hasAddress := d.domainCanReceive(domain)
// Fall back to the organizational domain when the domain itself has nothing.
if !hasMX && !hasAddress && orgDomain != "" && orgDomain != domain {
if orgMX, orgAddr := d.domainCanReceive(orgDomain); orgMX || orgAddr {
hasMX, hasAddress = orgMX, orgAddr
result.OrgDomain = utils.PtrTo(orgDomain)
}
}
result.HasMx = utils.PtrTo(hasMX)
result.HasAddress = utils.PtrTo(hasAddress)
switch {
case hasMX:
result.Status = returnOKStatusPass
case hasAddress:
result.Status = returnOKStatusWarn
default:
result.Status = returnOKStatusFail
}
return result
}
// calculateReturnOKPenalty returns a non-positive value: each sender domain that
// can receive neither replies nor bounces (status=fail) costs points, since
// those messages would be silently lost.
func calculateReturnOKPenalty(results *model.DNSResults) (penalty int) {
if results.ReturnOk == nil {
return 0
}
for _, dom := range []*model.ReturnOKDomain{results.ReturnOk.From, results.ReturnOk.ReturnPath} {
if dom != nil && dom.Status == returnOKStatusFail {
penalty -= 10
}
}
return
}
// orgDomainOrEmpty dereferences an optional organizational domain pointer.
func orgDomainOrEmpty(orgDomain *string) string {
if orgDomain == nil {
return ""
}
return *orgDomain
}

View file

@ -0,0 +1,170 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"context"
"net"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/model"
)
// returnOKMockResolver lets tests control MX and host (A/AAAA) lookups per domain.
type returnOKMockResolver struct {
mx map[string][]*net.MX
hosts map[string][]string
}
func (m *returnOKMockResolver) LookupMX(_ context.Context, name string) ([]*net.MX, error) {
if recs, ok := m.mx[name]; ok {
return recs, nil
}
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
}
func (m *returnOKMockResolver) LookupHost(_ context.Context, host string) ([]string, error) {
if recs, ok := m.hosts[host]; ok {
return recs, nil
}
return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}
}
func (m *returnOKMockResolver) LookupTXT(_ context.Context, _ string) ([]string, error) {
return nil, nil
}
func (m *returnOKMockResolver) LookupAddr(_ context.Context, _ string) ([]string, error) {
return nil, nil
}
func TestCheckReturnOKDomain(t *testing.T) {
mx := []*net.MX{{Host: "mail.example.com.", Pref: 10}}
tests := []struct {
name string
domain string
orgDomain string
resolver *returnOKMockResolver
wantStatus string
wantHasMX bool
wantHasAddr bool
wantOrgDomain string // "" means OrgDomain should be nil
}{
{
name: "domain with MX passes",
domain: "example.com",
resolver: &returnOKMockResolver{mx: map[string][]*net.MX{"example.com": mx}},
wantStatus: returnOKStatusPass,
wantHasMX: true,
wantHasAddr: false,
},
{
name: "no MX but A/AAAA warns",
domain: "example.com",
resolver: &returnOKMockResolver{hosts: map[string][]string{"example.com": {"192.0.2.1"}}},
wantStatus: returnOKStatusWarn,
wantHasMX: false,
wantHasAddr: true,
},
{
name: "fallback to org domain MX",
domain: "sub.example.com",
orgDomain: "example.com",
resolver: &returnOKMockResolver{mx: map[string][]*net.MX{"example.com": mx}},
wantStatus: returnOKStatusPass,
wantHasMX: true,
wantHasAddr: false,
wantOrgDomain: "example.com",
},
{
name: "nothing anywhere fails",
domain: "example.com",
orgDomain: "example.com",
resolver: &returnOKMockResolver{},
wantStatus: returnOKStatusFail,
wantHasMX: false,
wantHasAddr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := NewDNSAnalyzerWithResolver(5*time.Second, tt.resolver)
got := d.checkReturnOKDomain(tt.domain, tt.orgDomain)
if got == nil {
t.Fatalf("checkReturnOKDomain returned nil")
}
if got.Status != tt.wantStatus {
t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus)
}
if got.HasMx == nil || *got.HasMx != tt.wantHasMX {
t.Errorf("HasMx = %v, want %v", got.HasMx, tt.wantHasMX)
}
if got.HasAddress == nil || *got.HasAddress != tt.wantHasAddr {
t.Errorf("HasAddress = %v, want %v", got.HasAddress, tt.wantHasAddr)
}
if tt.wantOrgDomain == "" {
if got.OrgDomain != nil {
t.Errorf("OrgDomain = %v, want nil", *got.OrgDomain)
}
} else {
if got.OrgDomain == nil || *got.OrgDomain != tt.wantOrgDomain {
t.Errorf("OrgDomain = %v, want %q", got.OrgDomain, tt.wantOrgDomain)
}
}
})
}
}
func TestCheckReturnOKDomainEmpty(t *testing.T) {
d := NewDNSAnalyzerWithResolver(5*time.Second, &returnOKMockResolver{})
if got := d.checkReturnOKDomain("", ""); got != nil {
t.Errorf("checkReturnOKDomain(\"\") = %v, want nil", got)
}
}
func TestCalculateReturnOKPenalty(t *testing.T) {
fail := &model.ReturnOKDomain{Domain: "a.example", Status: returnOKStatusFail}
pass := &model.ReturnOKDomain{Domain: "b.example", Status: returnOKStatusPass}
warn := &model.ReturnOKDomain{Domain: "c.example", Status: returnOKStatusWarn}
tests := []struct {
name string
results *model.DNSResults
want int
}{
{"nil return_ok", &model.DNSResults{}, 0},
{"both pass", &model.DNSResults{ReturnOk: &model.ReturnOK{From: pass, ReturnPath: pass}}, 0},
{"warn is not penalised", &model.DNSResults{ReturnOk: &model.ReturnOK{From: warn}}, 0},
{"one fail", &model.DNSResults{ReturnOk: &model.ReturnOK{From: fail, ReturnPath: pass}}, -10},
{"both fail", &model.DNSResults{ReturnOk: &model.ReturnOK{From: fail, ReturnPath: fail}}, -20},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := calculateReturnOKPenalty(tt.results); got != tt.want {
t.Errorf("calculateReturnOKPenalty() = %d, want %d", got, tt.want)
}
})
}
}

View file

@ -26,6 +26,7 @@ import (
"net" "net"
"net/mail" "net/mail"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
@ -693,5 +694,50 @@ 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
}

View file

@ -677,6 +677,77 @@ 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()
@ -908,6 +979,10 @@ 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>"

View file

@ -85,6 +85,10 @@ 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)

View file

@ -218,6 +218,40 @@
</div> </div>
{/if} {/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">

View file

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

View file

@ -0,0 +1,260 @@
<script lang="ts">
import type { DomainBlacklistSourceResult } from "$lib/api/types.gen";
import { theme } from "$lib/stores/theme";
interface Props {
results: DomainBlacklistSourceResult[];
}
let { results }: Props = $props();
// Paid sources that show a "configure API key" hint when disabled.
const paidSourceIds = new Set(["virustotal", "safebrowsing"]);
type Bucket = "listed" | "errored" | "clean" | "disabled";
function classify(r: DomainBlacklistSourceResult): Bucket {
if (!r.enabled) return "disabled";
if (r.error) return "errored";
if (r.listed) return "listed";
return "clean";
}
function severityRank(sev: string | undefined): number {
switch (sev) {
case "crit":
return 0;
case "warn":
return 1;
case "info":
return 2;
default:
return 3;
}
}
function bucketRank(b: Bucket): number {
switch (b) {
case "listed":
return 0;
case "errored":
return 1;
case "clean":
return 2;
case "disabled":
return 3;
}
}
let sorted = $derived(
[...results].sort((a, b) => {
const ba = classify(a);
const bb = classify(b);
if (ba !== bb) return bucketRank(ba) - bucketRank(bb);
if (ba === "listed") {
const r = severityRank(a.severity) - severityRank(b.severity);
if (r !== 0) return r;
}
return a.source_name.localeCompare(b.source_name);
}),
);
function statusLabel(r: DomainBlacklistSourceResult): string {
if (!r.enabled) return "Disabled";
if (r.error) return "Error";
if (r.listed) {
if (r.severity && r.severity !== "ok") {
return `Listed (${r.severity})`;
}
return "Listed";
}
return "Clean";
}
function statusBadgeClass(r: DomainBlacklistSourceResult): string {
if (!r.enabled) return "bg-secondary";
if (r.error) return "bg-dark";
if (r.listed) {
switch (r.severity) {
case "crit":
return "bg-danger";
case "warn":
return "bg-warning text-dark";
case "info":
return "bg-info text-dark";
default:
return "bg-danger";
}
}
return "bg-success";
}
let openRows = $state(new Set<string>());
function rowKey(r: DomainBlacklistSourceResult): string {
return `${r.source_id}::${r.subject ?? ""}`;
}
function toggle(key: string) {
const next = new Set(openRows);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
openRows = next;
}
function hasDetails(r: DomainBlacklistSourceResult): boolean {
return (r.reasons?.length ?? 0) > 1 || (r.evidence?.length ?? 0) > 0;
}
function firstReason(r: DomainBlacklistSourceResult): string {
if (r.error) return r.error;
if (r.reasons && r.reasons.length > 0) return r.reasons[0];
if (!r.enabled && paidSourceIds.has(r.source_id)) {
return "API key not configured by the operator";
}
if (!r.enabled) return "Source disabled";
return "—";
}
</script>
<div class="card shadow-sm mt-4" id="domain-blacklist-details">
<div class="card-header" class:bg-white={$theme === "light"} class:bg-dark={$theme !== "light"}>
<h4 class="mb-0">
<i class="bi bi-shield-shaded me-2"></i>
Source Verdicts
</h4>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-striped table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col" class="text-nowrap">Status</th>
<th scope="col">Source</th>
<th scope="col">Detail</th>
<th scope="col" class="text-end text-nowrap">Links</th>
</tr>
</thead>
<tbody>
{#each sorted as r (rowKey(r))}
{@const key = rowKey(r)}
{@const open = openRows.has(key)}
{@const expandable = hasDetails(r)}
<tr class:text-muted={!r.enabled}>
<td class="text-nowrap">
<span class="badge {statusBadgeClass(r)}">{statusLabel(r)}</span>
</td>
<td>
<div class="fw-semibold">{r.source_name}</div>
<small class="text-muted">
<code>{r.source_id}</code>
{#if r.subject}
· <code>{r.subject}</code>
{/if}
</small>
</td>
<td>
<span class="detail-text">{firstReason(r)}</span>
{#if expandable}
<button
type="button"
class="btn btn-link btn-sm p-0 ms-1 align-baseline"
onclick={() => toggle(key)}
aria-expanded={open}
>
{open ? "Hide details" : "Show details"}
</button>
{/if}
</td>
<td class="text-end text-nowrap">
{#if r.lookup_url}
<a
href={r.lookup_url}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-outline-secondary"
title="Open lookup page"
aria-label="Open lookup page"
>
<i class="bi bi-box-arrow-up-right"></i>
</a>
{/if}
</td>
</tr>
{#if expandable && open}
<tr class="detail-row">
<td></td>
<td colspan="3">
{#if r.reasons && r.reasons.length > 0}
<ul class="small mb-2">
{#each r.reasons as reason}
<li>{reason}</li>
{/each}
</ul>
{/if}
{#if r.evidence && r.evidence.length > 0}
<table
class="table table-sm table-bordered mb-0 evidence-table"
>
<thead>
<tr>
<th scope="col">Label</th>
<th scope="col">Value</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{#each r.evidence as ev}
<tr>
<td class="text-nowrap">{ev.label}</td>
<td>
<code class="small">{ev.value}</code>
</td>
<td class="text-nowrap">
{#if ev.status}
<span
class="badge bg-light text-dark"
>{ev.status}</span
>
{:else}
<span class="text-muted"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{#if r.reference}
<p class="small text-muted mt-2 mb-0">
Reference: {r.reference}
</p>
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
</div>
</div>
<style>
.detail-text {
display: inline-block;
max-width: 100%;
overflow-wrap: anywhere;
}
.detail-row td {
background-color: rgba(0, 0, 0, 0.025);
}
.evidence-table code {
word-break: break-all;
}
</style>

View file

@ -7,6 +7,21 @@
} }
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}
@ -60,6 +75,63 @@
{/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>

View file

@ -0,0 +1,106 @@
<script lang="ts">
import type { SchemasReturnOk, SchemasReturnOkDomain } from "$lib/api/types.gen";
interface Props {
returnOk?: SchemasReturnOk;
}
let { returnOk }: Props = $props();
type Row = { label: string; entry: SchemasReturnOkDomain };
const rows = $derived<Row[]>(
[
returnOk?.from ? { label: "From", entry: returnOk.from } : undefined,
returnOk?.return_path
? { label: "Return-Path", entry: returnOk.return_path }
: undefined,
].filter((r): r is Row => r !== undefined),
);
const hasFail = $derived(rows.some((r) => r.entry.status === "fail"));
const hasWarn = $derived(rows.some((r) => r.entry.status === "warn"));
const allPass = $derived(rows.length > 0 && rows.every((r) => r.entry.status === "pass"));
// Header icon reflects the worst status across the checked domains.
const headerOk = $derived(allPass);
function badgeClass(status: string): string {
if (status === "pass") return "bg-success";
if (status === "warn") return "bg-warning text-dark";
return "bg-danger";
}
function badgeLabel(status: string): string {
if (status === "pass") return "MX";
if (status === "warn") return "A/AAAA only";
return "Unreachable";
}
</script>
{#if rows.length > 0}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="text-muted mb-0">
<i
class="bi"
class:bi-check-circle-fill={headerOk}
class:text-success={headerOk}
class:bi-exclamation-triangle-fill={!headerOk && !hasFail}
class:text-warning={!headerOk && !hasFail}
class:bi-x-circle-fill={hasFail}
class:text-danger={hasFail}
></i>
Return Address Reachability
</h5>
<span class="badge bg-secondary">RETURN-OK</span>
</div>
<div class="card-body">
<p class="card-text small text-muted mb-0">
Replies (to the From address) and bounces (to the Return-Path) can only be delivered
if the sender's domains accept mail. A domain should publish MX records; an A/AAAA
record works as an implicit fallback but is not recommended. A domain with neither
is unreachable and silently drops replies and bounces.
</p>
</div>
<div class="list-group list-group-flush">
{#each rows as { label, entry } (label)}
<div class="list-group-item">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="text-muted" style="min-width: 6.5rem">{label} domain:</span>
<code>{entry.domain}</code>
<span class="badge {badgeClass(entry.status)}">
{badgeLabel(entry.status)}
</span>
{#if entry.org_domain}
<small class="text-muted">
via organizational domain <code>{entry.org_domain}</code>
</small>
{/if}
</div>
</div>
{/each}
</div>
{#if hasFail || hasWarn}
<div class="list-group list-group-flush">
<div class="list-group-item">
{#if hasFail}
<div class="alert alert-danger mb-0">
<i class="bi bi-x-circle me-1"></i>
<strong>Error:</strong> At least one sender domain has no MX and no A/AAAA record.
Replies or bounce messages to that domain will be lost. Publish an MX record pointing
to a mail server that accepts mail.
</div>
{:else if hasWarn}
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Warning:</strong> A sender domain has no MX record and relies on its A/AAAA
record (implicit MX). Mail is still deliverable, but publishing an explicit MX
record is recommended.
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}

View file

@ -6,6 +6,7 @@ export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte";
export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte"; export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte";
export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte"; export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.svelte";
export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte"; export { default as DnsRecordsCard } from "./DnsRecordsCard.svelte";
export { default as DomainBlacklistCard } from "./DomainBlacklistCard.svelte";
export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte"; export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte";
export { default as EmailPathCard } from "./EmailPathCard.svelte"; export { default as EmailPathCard } from "./EmailPathCard.svelte";
export { default as ErrorDisplay } from "./ErrorDisplay.svelte"; export { default as ErrorDisplay } from "./ErrorDisplay.svelte";

View file

@ -161,6 +161,10 @@
<i class="bi bi-envelope-plus me-1"></i> <i class="bi bi-envelope-plus me-1"></i>
Send Test Email Send Test Email
</a> </a>
<a href="/domain" class="btn btn-sm btn-outline-primary ms-1">
<i class="bi bi-shield-shaded me-1"></i>
Check a Domain
</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -4,7 +4,7 @@
import { testDomain } from "$lib/api"; import { testDomain } from "$lib/api";
import type { DomainTestResponse } from "$lib/api/types.gen"; import type { DomainTestResponse } from "$lib/api/types.gen";
import { DnsRecordsCard, GradeDisplay, TinySurvey } from "$lib/components"; import { DnsRecordsCard, DomainBlacklistCard, GradeDisplay, TinySurvey } from "$lib/components";
import { theme } from "$lib/stores/theme"; import { theme } from "$lib/stores/theme";
let domain = $derived(page.params.domain); let domain = $derived(page.params.domain);
@ -12,6 +12,44 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let result = $state<DomainTestResponse | null>(null); let result = $state<DomainTestResponse | null>(null);
let blacklist = $derived(result?.blacklist ?? null);
let blacklistSummary = $derived.by(() => {
if (!blacklist) return null;
const enabled = blacklist.results.filter((r) => r.enabled);
const disabled = blacklist.results.length - enabled.length;
const errored = enabled.filter((r) => r.error).length;
const listed = enabled.filter((r) => r.listed);
const critical = listed.filter((r) => r.severity === "crit").length;
return {
total: blacklist.results.length,
enabled: enabled.length,
disabled,
errored,
listed: listed.length,
critical,
};
});
type Verdict = "danger" | "warn" | "inconclusive" | "ok";
let blacklistVerdict = $derived.by<Verdict | null>(() => {
const s = blacklistSummary;
if (!s) return null;
if (s.critical > 0) return "danger";
if (s.listed > 0) return "warn";
if (s.enabled > 0 && s.errored === s.enabled) return "inconclusive";
return "ok";
});
function formatCollectedAt(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
async function analyzeDomain() { async function analyzeDomain() {
loading = true; loading = true;
error = null; error = null;
@ -74,7 +112,9 @@
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>
<h3 class="h5">Analyzing {domain}...</h3> <h3 class="h5">Analyzing {domain}...</h3>
<p class="text-muted mb-0">Checking DNS records and configuration</p> <p class="text-muted mb-0">
Checking DNS records, configuration and domain reputation
</p>
</div> </div>
</div> </div>
{:else if error} {:else if error}
@ -116,15 +156,32 @@
<p class="text-muted mb-0">Domain Configuration Score</p> <p class="text-muted mb-0">Domain Configuration Score</p>
{/if} {/if}
</div> </div>
<div class="offset-md-3 col-md-3 text-center"> <div class="col-md-6">
<div
class="d-flex justify-content-md-end justify-content-center gap-3"
>
<div <div
class="p-2 rounded text-center summary-card" class="p-2 rounded text-center summary-card"
class:bg-light={$theme === "light"} class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"} class:bg-secondary={$theme !== "light"}
> >
<GradeDisplay score={result.score} grade={result.grade} /> <GradeDisplay
score={result.score}
grade={result.grade}
/>
<small class="text-muted d-block">DNS</small> <small class="text-muted d-block">DNS</small>
</div> </div>
{#if blacklist}
<div
class="p-2 rounded text-center summary-card"
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<GradeDisplay grade={blacklist.grade ?? "?"} />
<small class="text-muted d-block">Reputation</small>
</div>
{/if}
</div>
</div> </div>
</div> </div>
<div class="d-flex justify-content-end me-lg-5 mt-3"> <div class="d-flex justify-content-end me-lg-5 mt-3">
@ -144,6 +201,119 @@
domainOnly={true} domainOnly={true}
/> />
<!-- Domain Reputation / Blacklist -->
{#if blacklist && blacklistSummary}
<div class="card shadow-sm mt-4">
<div class="card-body p-4">
<div class="row align-items-center">
<div class="col-md-7 text-center text-md-start mb-3 mb-md-0">
<h3 class="h5 mb-2">
<i class="bi bi-shield-shaded me-2"></i>
Domain Reputation
</h3>
{#if blacklist.registered_domain && blacklist.registered_domain !== result.domain}
<p class="text-muted small mb-2">
Registered domain:
<code>{blacklist.registered_domain}</code>
</p>
{/if}
{#if blacklistVerdict === "danger"}
<div class="alert alert-danger mb-0 d-inline-block">
<i class="bi bi-exclamation-octagon me-2"></i>
<strong
>Listed on {blacklistSummary.critical} high-severity
source{blacklistSummary.critical > 1
? "s"
: ""}</strong
>
<p class="mb-0 mt-1 small">
This domain is reported by sources flagged
<em>critical</em>. Take action to delist.
</p>
</div>
{:else if blacklistVerdict === "warn"}
<div class="alert alert-warning mb-0 d-inline-block">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong
>Listed on {blacklistSummary.listed} source{blacklistSummary.listed >
1
? "s"
: ""}</strong
>
<p class="mb-0 mt-1 small">
Listed without critical severity — review the
source verdicts below.
</p>
</div>
{:else if blacklistVerdict === "inconclusive"}
<div class="alert alert-warning mb-0 d-inline-block">
<i class="bi bi-question-octagon me-2"></i>
<strong>Inconclusive</strong>
<p class="mb-0 mt-1 small">
All enabled sources returned errors. Try again
later.
</p>
</div>
{:else}
<div class="alert alert-success mb-0 d-inline-block">
<i class="bi bi-check-circle me-2"></i>
<strong>No source reports this domain</strong>
<p class="mb-0 mt-1 small">
Clean across all {blacklistSummary.enabled} enabled
source{blacklistSummary.enabled > 1 ? "s" : ""}.
</p>
</div>
{/if}
</div>
<div class="col-md-5 text-center">
<div
class="p-3 rounded summary-card"
class:bg-light={$theme === "light"}
class:bg-secondary={$theme !== "light"}
>
<div class="d-flex justify-content-around mb-2">
<div>
<div class="h4 mb-0">
{blacklistSummary.enabled}
</div>
<small class="text-muted">Enabled</small>
</div>
<div>
<div class="h4 mb-0 text-danger">
{blacklistSummary.listed}
</div>
<small class="text-muted">Listed</small>
</div>
<div>
<div class="h4 mb-0 text-secondary">
{blacklistSummary.disabled}
</div>
<small class="text-muted">Disabled</small>
</div>
</div>
{#if blacklistSummary.errored > 0}
<small class="text-muted d-block">
{blacklistSummary.errored} source{blacklistSummary.errored >
1
? "s"
: ""} errored
</small>
{/if}
<small class="text-muted d-block">
Collected {formatCollectedAt(
blacklist.collected_at,
)}
</small>
</div>
</div>
</div>
</div>
</div>
<DomainBlacklistCard results={blacklist.results} />
{/if}
<!-- Next Steps --> <!-- Next Steps -->
<div class="card shadow-sm border-primary mt-4"> <div class="card shadow-sm border-primary mt-4">
<div class="card-body"> <div class="card-body">
@ -152,9 +322,9 @@
Want Complete Email Analysis? Want Complete Email Analysis?
</h3> </h3>
<p class="mb-3"> <p class="mb-3">
This domain-only test checks DNS configuration. For comprehensive This domain test checks DNS configuration and domain reputation. For
deliverability testing including DKIM verification, content comprehensive deliverability testing including DKIM verification,
analysis, spam scoring, and blacklist checks: content analysis, spam scoring, and sending-IP blacklist checks:
</p> </p>
<a href="/" class="btn btn-primary"> <a href="/" class="btn btn-primary">
<i class="bi bi-envelope-plus me-2"></i> <i class="bi bi-envelope-plus me-2"></i>