diff --git a/api/openapi.yaml b/api/openapi.yaml index 2dbf304..1250268 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -363,6 +363,12 @@ components: $ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest' 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: $ref: './schemas.yaml#/components/schemas/TestSummary' TestListResponse: diff --git a/api/schemas.yaml b/api/schemas.yaml index 55246d7..1e33dac 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -434,6 +434,29 @@ components: type: string description: Reverse DNS (PTR record) for the IP address 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: type: object @@ -540,6 +563,11 @@ components: 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: type: object @@ -829,12 +857,49 @@ components: 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: type: array items: type: string 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: type: object required: @@ -1152,6 +1217,9 @@ components: example: "A" dns_results: $ref: '#/components/schemas/DNSResults' + blacklist: + $ref: '#/components/schemas/DomainBlacklistResult' + description: Domain reputation/blacklist aggregation (omitted when the check could not be run) BlacklistCheckRequest: type: object @@ -1203,6 +1271,103 @@ components: $ref: '#/components/schemas/BlacklistCheck' 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: type: object required: diff --git a/docker/authentication_milter/authentication_milter.json b/docker/authentication_milter/authentication_milter.json index 5db3bbc..cd3bd03 100644 --- a/docker/authentication_milter/authentication_milter.json +++ b/docker/authentication_milter/authentication_milter.json @@ -52,6 +52,8 @@ "PTR" : {}, + "TLS" : {}, + "SenderID" : { "hide_none" : 1 }, diff --git a/docker/postfix/main.cf b/docker/postfix/main.cf index 764b62b..9f09396 100644 --- a/docker/postfix/main.cf +++ b/docker/postfix/main.cf @@ -36,5 +36,8 @@ smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination +# TLS - record the negotiated cipher/protocol in the Received: header +smtpd_tls_received_header = yes + # Logging debug_peer_level = 2 diff --git a/docker/postfix/master.cf b/docker/postfix/master.cf index 9c2ac57..822d56e 100644 --- a/docker/postfix/master.cf +++ b/docker/postfix/master.cf @@ -3,6 +3,9 @@ # SMTP service smtp inet n - n - - smtpd +# TLS session cache and PRNG manager (required for STARTTLS) +tlsmgr unix - - n 1000? 1 tlsmgr + # Pickup service pickup unix n - n 60 1 pickup diff --git a/go.mod b/go.mod index c638f4a..e1a37fe 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module git.happydns.org/happyDeliver go 1.25.0 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/emersion/go-smtp v0.24.0 github.com/getkin/kin-openapi v0.138.0 diff --git a/go.sum b/go.sum index f467434..1896d42 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:t9eLOUxikPI0TzKy0VYRbZJr7hBP2Qg9E3JigoxF70g= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= diff --git a/internal/api/handlers.go b/internal/api/handlers.go index de2d5df..4a499dc 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -22,6 +22,7 @@ package api import ( + "context" "fmt" "net/http" "time" @@ -30,8 +31,10 @@ import ( "github.com/google/uuid" 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/model" + "git.happydns.org/happyDeliver/internal/reputation" "git.happydns.org/happyDeliver/internal/storage" "git.happydns.org/happyDeliver/internal/utils" "git.happydns.org/happyDeliver/internal/version" @@ -47,19 +50,21 @@ type EmailAnalyzer interface { // APIHandler implements the ServerInterface for handling API requests type APIHandler struct { - storage storage.Storage - config *config.Config - analyzer EmailAnalyzer - startTime time.Time + storage storage.Storage + config *config.Config + analyzer EmailAnalyzer + blacklistProvider sdk.ObservationProvider + startTime time.Time } // 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{ - storage: store, - config: cfg, - analyzer: analyzer, - startTime: time.Now(), + storage: store, + config: cfg, + analyzer: analyzer, + blacklistProvider: blacklistProvider, + startTime: time.Now(), } } @@ -339,6 +344,7 @@ func (h *APIHandler) TestDomain(c *gin.Context) { Score: score, Grade: responseGrade, DnsResults: *dnsResults, + Blacklist: h.runDomainBlacklist(c.Request.Context(), request.Domain), } c.JSON(http.StatusOK, response) @@ -383,6 +389,32 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) { 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 // (GET /tests) func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { diff --git a/internal/app/server.go b/internal/app/server.go index 7149f45..efcf8df 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -30,6 +30,7 @@ import ( ratelimit "github.com/JGLTechnologies/gin-rate-limit" "github.com/gin-gonic/gin" + blacklist "git.happydns.org/checker-blacklist/checker" "git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/lmtp" @@ -70,7 +71,7 @@ func RunServer(cfg *config.Config) error { analyzerAdapter := analyzer.NewAPIAdapter(cfg) // Create API handler - handler := api.NewAPIHandler(store, cfg, analyzerAdapter) + handler := api.NewAPIHandler(store, cfg, analyzerAdapter, blacklist.Provider()) // Set up Gin router if os.Getenv("GIN_MODE") == "" { diff --git a/internal/config/blacklist.go b/internal/config/blacklist.go new file mode 100644 index 0000000..8185431 --- /dev/null +++ b/internal/config/blacklist.go @@ -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 . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package 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 +} diff --git a/internal/config/cli.go b/internal/config/cli.go index fcc914f..9779c94 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -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.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.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.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") diff --git a/internal/config/config.go b/internal/config/config.go index b264994..6cf8110 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -75,6 +75,18 @@ type AnalysisConfig struct { DNSWLs []string 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) + 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 diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index bb34583..666f9ee 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -147,6 +147,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, 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) 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 if score > 100 { score = 100 diff --git a/pkg/analyzer/authentication_x_tls.go b/pkg/analyzer/authentication_x_tls.go new file mode 100644 index 0000000..440f806 --- /dev/null +++ b/pkg/analyzer/authentication_x_tls.go @@ -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 . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "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, ", ") +} diff --git a/pkg/analyzer/authentication_x_tls_test.go b/pkg/analyzer/authentication_x_tls_test.go new file mode 100644 index 0000000..52a655c --- /dev/null +++ b/pkg/analyzer/authentication_x_tls_test.go @@ -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 . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "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") + } +} diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index c4d215c..9927d1b 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -110,6 +110,15 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head 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) // SPF validates the MAIL FROM command, which corresponds to Return-Path results.SpfRecords = d.checkSPFRecords(spfDomain) @@ -148,6 +157,11 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults { // Check SPF records 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 results.DmarcRecord = d.checkDMARCRecord(domain) @@ -179,6 +193,9 @@ func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int, // DMARC Record: 40 points score += 40 * d.calculateDMARCScore(results) / 100 + // Penalty when a sender domain cannot receive replies/bounces at all + score += calculateReturnOKPenalty(results) + // BIMI Record: only bonus if results.BimiRecord != nil && results.BimiRecord.Valid { if score >= 100 { @@ -224,6 +241,9 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP stri // DMARC Record: 20 points score += 20 * d.calculateDMARCScore(results) / 100 + // Penalty when a sender domain cannot receive replies/bounces at all + score += calculateReturnOKPenalty(results) + // BIMI Record // BIMI is optional but indicates advanced email branding if results.BimiRecord != nil && results.BimiRecord.Valid { diff --git a/pkg/analyzer/dns_returnok.go b/pkg/analyzer/dns_returnok.go new file mode 100644 index 0000000..29e12b3 --- /dev/null +++ b/pkg/analyzer/dns_returnok.go @@ -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 . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "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 +} diff --git a/pkg/analyzer/dns_returnok_test.go b/pkg/analyzer/dns_returnok_test.go new file mode 100644 index 0000000..55aaa5c --- /dev/null +++ b/pkg/analyzer/dns_returnok_test.go @@ -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 . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "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) + } + }) + } +} diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 6d7b547..448de57 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -26,6 +26,7 @@ import ( "net" "net/mail" "regexp" + "strconv" "strings" "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 } + +// 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 +} diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index d7469d7..7b453fa 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -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) { analyzer := NewHeaderAnalyzer() @@ -908,6 +979,10 @@ func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { + return &b +} + func ptrToStr(p *string) string { if p == nil { return "" diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 26cd59d..e20e571 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -85,6 +85,10 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { // Run all analyzers results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) 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.RBL = r.rblChecker.CheckEmail(email) results.DNSWL = r.dnswlChecker.CheckEmail(email) diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 4f5ff6d..749263d 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -218,6 +218,40 @@ {/if} + + {#if authentication.x_tls} +
+
+ +
+ Transport TLS + + + {authentication.x_tls.result} + + {#if authentication.x_tls.details} +
+ {authentication.x_tls.details} +
+ {/if} +
+
+
+ {/if} +
diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index e1d31cb..eedd0db 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -10,6 +10,7 @@ import MxRecordsDisplay from "./MxRecordsDisplay.svelte"; import PtrForwardRecordsDisplay from "./PtrForwardRecordsDisplay.svelte"; import PtrRecordsDisplay from "./PtrRecordsDisplay.svelte"; + import ReturnOkDisplay from "./ReturnOkDisplay.svelte"; import SpfRecordsDisplay from "./SpfRecordsDisplay.svelte"; interface Props { @@ -100,6 +101,9 @@ heloPtrMatch={dnsResults.helo_ptr_match} /> + + +
@@ -150,8 +154,7 @@ {#if dnsResults.rp_domain && dnsResults.rp_domain !== dnsResults.from_domain} - Differs from Return-Path - domain + Differs from Return-Path domain {/if}
diff --git a/web/src/lib/components/DomainBlacklistCard.svelte b/web/src/lib/components/DomainBlacklistCard.svelte new file mode 100644 index 0000000..2559446 --- /dev/null +++ b/web/src/lib/components/DomainBlacklistCard.svelte @@ -0,0 +1,260 @@ + + +
+
+

+ + Source Verdicts +

+
+
+
+ + + + + + + + + + + {#each sorted as r (rowKey(r))} + {@const key = rowKey(r)} + {@const open = openRows.has(key)} + {@const expandable = hasDetails(r)} + + + + + + + {#if expandable && open} + + + + + {/if} + {/each} + +
StatusSourceDetailLinks
+ {statusLabel(r)} + +
{r.source_name}
+ + {r.source_id} + {#if r.subject} + · {r.subject} + {/if} + +
+ {firstReason(r)} + {#if expandable} + + {/if} + + {#if r.lookup_url} + + + + {/if} +
+ {#if r.reasons && r.reasons.length > 0} +
    + {#each r.reasons as reason} +
  • {reason}
  • + {/each} +
+ {/if} + {#if r.evidence && r.evidence.length > 0} + + + + + + + + + + {#each r.evidence as ev} + + + + + + {/each} + +
LabelValueStatus
{ev.label} + {ev.value} + + {#if ev.status} + {ev.status} + {:else} + + {/if} +
+ {/if} + {#if r.reference} +

+ Reference: {r.reference} +

+ {/if} +
+
+
+
+ + diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte index a4fda45..72cfd94 100644 --- a/web/src/lib/components/EmailPathCard.svelte +++ b/web/src/lib/components/EmailPathCard.svelte @@ -7,6 +7,21 @@ } 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"); + } {#if receivedChain && receivedChain.length > 0} @@ -60,6 +75,63 @@ {/if}

{/if} +

+ {#if hop.tls} + + TLS + + {#if hop.tls.version} + + Version: + {hop.tls.version} + + {/if} + {#if hop.tls.cipher} + + Cipher: + {hop.tls.cipher} + + {/if} + {#if hop.tls.bits} + + Strength: + {hop.tls.bits} bits + + {/if} + {#if hop.tls.verified !== undefined} + + + {hop.tls.verified + ? "Certificate trusted" + : "Certificate not trusted"} + + {/if} + {:else if protocolIndicatesTLS(hop.with)} + + TLS + + {:else if hop.with} + + No TLS + + {:else} + + TLS unknown + + {/if} + {#if protocolIndicatesAuth(hop.with)} + + Authenticated + + {/if} +

{/each} diff --git a/web/src/lib/components/ReturnOkDisplay.svelte b/web/src/lib/components/ReturnOkDisplay.svelte new file mode 100644 index 0000000..11d4c00 --- /dev/null +++ b/web/src/lib/components/ReturnOkDisplay.svelte @@ -0,0 +1,106 @@ + + +{#if rows.length > 0} +
+
+
+ + Return Address Reachability +
+ RETURN-OK +
+
+

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

+
+
+ {#each rows as { label, entry } (label)} +
+
+ {label} domain: + {entry.domain} + + {badgeLabel(entry.status)} + + {#if entry.org_domain} + + via organizational domain {entry.org_domain} + + {/if} +
+
+ {/each} +
+ {#if hasFail || hasWarn} +
+
+ {#if hasFail} +
+ + Error: 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. +
+ {:else if hasWarn} +
+ + Warning: 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. +
+ {/if} +
+
+ {/if} +
+{/if} diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index a593801..9d73db7 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -6,6 +6,7 @@ export { default as ContentAnalysisCard } from "./ContentAnalysisCard.svelte"; export { default as DkimRecordsDisplay } from "./DkimRecordsDisplay.svelte"; export { default as DmarcRecordDisplay } from "./DmarcRecordDisplay.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 EmailPathCard } from "./EmailPathCard.svelte"; export { default as ErrorDisplay } from "./ErrorDisplay.svelte"; diff --git a/web/src/routes/blacklist/+page.svelte b/web/src/routes/blacklist/+page.svelte index d2946b8..4d2e8e4 100644 --- a/web/src/routes/blacklist/+page.svelte +++ b/web/src/routes/blacklist/+page.svelte @@ -161,6 +161,10 @@ Send Test Email + + + Check a Domain + diff --git a/web/src/routes/domain/[domain]/+page.svelte b/web/src/routes/domain/[domain]/+page.svelte index d866e21..8c61cb2 100644 --- a/web/src/routes/domain/[domain]/+page.svelte +++ b/web/src/routes/domain/[domain]/+page.svelte @@ -4,7 +4,7 @@ import { testDomain } from "$lib/api"; 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"; let domain = $derived(page.params.domain); @@ -12,6 +12,44 @@ let error = $state(null); let result = $state(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(() => { + 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() { loading = true; error = null; @@ -74,7 +112,9 @@ Loading...

Analyzing {domain}...

-

Checking DNS records and configuration

+

+ Checking DNS records, configuration and domain reputation +

{:else if error} @@ -116,14 +156,31 @@

Domain Configuration Score

{/if} -
+
- - DNS +
+ + DNS +
+ {#if blacklist} +
+ + Reputation +
+ {/if}
@@ -144,6 +201,119 @@ domainOnly={true} /> + + {#if blacklist && blacklistSummary} +
+
+
+
+

+ + Domain Reputation +

+ {#if blacklist.registered_domain && blacklist.registered_domain !== result.domain} +

+ Registered domain: + {blacklist.registered_domain} +

+ {/if} + + {#if blacklistVerdict === "danger"} +
+ + Listed on {blacklistSummary.critical} high-severity + source{blacklistSummary.critical > 1 + ? "s" + : ""} +

+ This domain is reported by sources flagged + critical. Take action to delist. +

+
+ {:else if blacklistVerdict === "warn"} +
+ + Listed on {blacklistSummary.listed} source{blacklistSummary.listed > + 1 + ? "s" + : ""} +

+ Listed without critical severity — review the + source verdicts below. +

+
+ {:else if blacklistVerdict === "inconclusive"} +
+ + Inconclusive +

+ All enabled sources returned errors. Try again + later. +

+
+ {:else} +
+ + No source reports this domain +

+ Clean across all {blacklistSummary.enabled} enabled + source{blacklistSummary.enabled > 1 ? "s" : ""}. +

+
+ {/if} +
+
+
+
+
+
+ {blacklistSummary.enabled} +
+ Enabled +
+
+
+ {blacklistSummary.listed} +
+ Listed +
+
+
+ {blacklistSummary.disabled} +
+ Disabled +
+
+ {#if blacklistSummary.errored > 0} + + {blacklistSummary.errored} source{blacklistSummary.errored > + 1 + ? "s" + : ""} errored + + {/if} + + Collected {formatCollectedAt( + blacklist.collected_at, + )} + +
+
+
+
+
+ + + {/if} +
@@ -152,9 +322,9 @@ Want Complete Email Analysis?

- This domain-only test checks DNS configuration. For comprehensive - deliverability testing including DKIM verification, content - analysis, spam scoring, and blacklist checks: + This domain test checks DNS configuration and domain reputation. For + comprehensive deliverability testing including DKIM verification, + content analysis, spam scoring, and sending-IP blacklist checks: