From d53c1b1e005f4aa6c617044d771dcfbf8c10b8e8 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 15:15:32 +0900 Subject: [PATCH] tls: surface transport TLS status in email path and authentication 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: https://git.nemunai.re/happyDomain/happyDeliver/issues/40 --- api/schemas.yaml | 28 +++ .../authentication_milter.json | 2 + docker/postfix/main.cf | 3 + pkg/analyzer/authentication.go | 10 ++ pkg/analyzer/authentication_x_tls.go | 154 ++++++++++++++++ pkg/analyzer/authentication_x_tls_test.go | 165 ++++++++++++++++++ pkg/analyzer/headers.go | 46 +++++ pkg/analyzer/headers_test.go | 75 ++++++++ pkg/analyzer/report.go | 4 + .../lib/components/AuthenticationCard.svelte | 34 ++++ web/src/lib/components/EmailPathCard.svelte | 72 ++++++++ 11 files changed, 593 insertions(+) create mode 100644 pkg/analyzer/authentication_x_tls.go create mode 100644 pkg/analyzer/authentication_x_tls_test.go diff --git a/api/schemas.yaml b/api/schemas.yaml index 662cf4c..042a3b3 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 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/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/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/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}