From 29f2fcd56f25035b1badbf5fde631a02bdd9e25c Mon Sep 17 00:00:00 2001 From: Andreas de Pretis Date: Wed, 15 Apr 2026 21:36:37 +0200 Subject: [PATCH 1/2] Add support for authentication with SMTP relays --- app/lib/smtp_client/endpoint.rb | 7 +++++- app/lib/smtp_client/server.rb | 6 ++++- app/senders/smtp_sender.rb | 6 ++++- doc/config/environment-variables.md | 2 +- doc/config/yaml.yml | 2 +- lib/postal/config_schema.rb | 7 ++++-- spec/lib/postal/legacy_config_source_spec.rb | 22 ++++++++++++++++++ spec/lib/smtp_client/endpoint_spec.rb | 24 +++++++++++++++++++- spec/lib/smtp_client/server_spec.rb | 15 +++++++++++- spec/senders/smtp_sender_spec.rb | 9 ++++++++ 10 files changed, 91 insertions(+), 9 deletions(-) diff --git a/app/lib/smtp_client/endpoint.rb b/app/lib/smtp_client/endpoint.rb index 5449e695a..963f35aaf 100644 --- a/app/lib/smtp_client/endpoint.rb +++ b/app/lib/smtp_client/endpoint.rb @@ -82,7 +82,12 @@ def start_smtp_session(source_ip_address: nil, allow_ssl: true) @smtp_client.disable_tls end - @smtp_client.start(@source_ip_address ? @source_ip_address.hostname : self.class.default_helo_hostname) + helo_hostname = @source_ip_address ? @source_ip_address.hostname : self.class.default_helo_hostname + if @server.username.present? + @smtp_client.start(helo_hostname, @server.username, @server.password, :login) + else + @smtp_client.start(helo_hostname) + end @smtp_client end diff --git a/app/lib/smtp_client/server.rb b/app/lib/smtp_client/server.rb index 8630fffd4..5f2693d6d 100644 --- a/app/lib/smtp_client/server.rb +++ b/app/lib/smtp_client/server.rb @@ -5,12 +5,16 @@ class Server attr_reader :hostname attr_reader :port + attr_reader :username + attr_reader :password attr_accessor :ssl_mode - def initialize(hostname, port: 25, ssl_mode: SSLModes::AUTO) + def initialize(hostname, port: 25, ssl_mode: SSLModes::AUTO, username: nil, password: nil) @hostname = hostname @port = port @ssl_mode = ssl_mode + @username = username + @password = password end # Return all IP addresses for this server by resolving its hostname. diff --git a/app/senders/smtp_sender.rb b/app/senders/smtp_sender.rb index 1f3c863fd..22bb84d2a 100644 --- a/app/senders/smtp_sender.rb +++ b/app/senders/smtp_sender.rb @@ -247,7 +247,11 @@ def smtp_relays relays = relays.filter_map do |relay| next unless relay.host.present? - SMTPClient::Server.new(relay.host, port: relay.port, ssl_mode: relay.ssl_mode) + SMTPClient::Server.new(relay.host, + port: relay.port, + ssl_mode: relay.ssl_mode, + username: relay.username, + password: relay.password) end @smtp_relays = relays.empty? ? nil : relays diff --git a/doc/config/environment-variables.md b/doc/config/environment-variables.md index 940424e04..ada4366ed 100644 --- a/doc/config/environment-variables.md +++ b/doc/config/environment-variables.md @@ -16,7 +16,7 @@ This document contains all the environment variables which are available for thi | `POSTAL_USE_LOCAL_NS_FOR_DOMAIN_VERIFICATION` | Boolean | Domain verification and checking usually checks with a domain's nameserver. Enable this to check with the server's local nameservers. | false | | `POSTAL_USE_RESENT_SENDER_HEADER` | Boolean | Append a Resend-Sender header to all outgoing e-mails | true | | `POSTAL_SIGNING_KEY_PATH` | String | Path to the private key used for signing | $config-file-root/signing.key | -| `POSTAL_SMTP_RELAYS` | Array of strings | An array of SMTP relays in the format of smtp://host:port | [] | +| `POSTAL_SMTP_RELAYS` | Array of strings | An array of SMTP relays in the format of smtp://host:port or smtp://username:password@host:port | [] | | `POSTAL_TRUSTED_PROXIES` | Array of strings | An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses) | [] | | `POSTAL_QUEUED_MESSAGE_LOCK_STALE_DAYS` | Integer | The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried. | 1 | | `POSTAL_BATCH_QUEUED_MESSAGES` | Boolean | When enabled queued messages will be de-queued in batches based on their destination | true | diff --git a/doc/config/yaml.yml b/doc/config/yaml.yml index f3a735a9f..7de96cd4c 100644 --- a/doc/config/yaml.yml +++ b/doc/config/yaml.yml @@ -25,7 +25,7 @@ postal: use_resent_sender_header: true # Path to the private key used for signing signing_key_path: $config-file-root/signing.key - # An array of SMTP relays in the format of smtp://host:port + # An array of SMTP relays in the format of smtp://host:port or smtp://username:password@host:port smtp_relays: [] # An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses) trusted_proxies: [] diff --git a/lib/postal/config_schema.rb b/lib/postal/config_schema.rb index e3c6415d5..ae9bd425a 100644 --- a/lib/postal/config_schema.rb +++ b/lib/postal/config_schema.rb @@ -74,15 +74,18 @@ module Postal string :smtp_relays do array - description "An array of SMTP relays in the format of smtp://host:port" + description "An array of SMTP relays in the format of smtp://host:port or smtp://username:password@host:port" transform do |value| uri = URI.parse(value) query = uri.query ? CGI.parse(uri.query) : {} - { + relay = { host: uri.host, port: uri.port || 25, ssl_mode: query["ssl_mode"]&.first || "Auto" } + relay[:username] = CGI.unescape(uri.user) if uri.user + relay[:password] = CGI.unescape(uri.password) if uri.password + relay end end diff --git a/spec/lib/postal/legacy_config_source_spec.rb b/spec/lib/postal/legacy_config_source_spec.rb index e9d9f6784..4406dc259 100644 --- a/spec/lib/postal/legacy_config_source_spec.rb +++ b/spec/lib/postal/legacy_config_source_spec.rb @@ -76,6 +76,28 @@ module Postal { "host" => "2.2.2.2", "port" => 2525, "ssl_mode" => "None" }, ] end + + it "parses relay URLs with credentials" do + source = described_class.new( + SOURCE_CONFIG.merge("smtp_relays" => ["smtp://relay-user:relay-pass@relay.example.com:587?ssl_mode=TLS"]) + ) + config = Konfig::Config.build(ConfigSchema, sources: [source]) + + expect(config.postal.smtp_relays).to eq [ + { "host" => "relay.example.com", "port" => 587, "ssl_mode" => "TLS", "username" => "relay-user", "password" => "relay-pass" }, + ] + end + + it "decodes percent-encoded relay credentials" do + source = described_class.new( + SOURCE_CONFIG.merge("smtp_relays" => ["smtp://relay%40user:pa%24%24%3Aword@relay.example.com:587?ssl_mode=TLS"]) + ) + config = Konfig::Config.build(ConfigSchema, sources: [source]) + + expect(config.postal.smtp_relays).to eq [ + { "host" => "relay.example.com", "port" => 587, "ssl_mode" => "TLS", "username" => "relay@user", "password" => "pa$$:word" }, + ] + end end describe "the 'web_server' group" do diff --git a/spec/lib/smtp_client/endpoint_spec.rb b/spec/lib/smtp_client/endpoint_spec.rb index 5af326735..c038e4388 100644 --- a/spec/lib/smtp_client/endpoint_spec.rb +++ b/spec/lib/smtp_client/endpoint_spec.rb @@ -6,7 +6,9 @@ module SMTPClient RSpec.describe Endpoint do let(:ssl_mode) { SSLModes::AUTO } - let(:server) { Server.new("mx1.example.com", port: 25, ssl_mode: ssl_mode) } + let(:username) { nil } + let(:password) { nil } + let(:server) { Server.new("mx1.example.com", port: 25, ssl_mode: ssl_mode, username: username, password: password) } let(:ip) { "1.2.3.4" } before do @@ -88,6 +90,16 @@ module SMTPClient expect(endpoint.smtp_client).to have_received(:start).with(Postal::Config.postal.smtp_hostname) end + context "when relay credentials are provided" do + let(:username) { "relay-user" } + let(:password) { "relay-pass" } + + it "starts the SMTP client with login authentication" do + endpoint.start_smtp_session + expect(endpoint.smtp_client).to have_received(:start).with(Postal::Config.postal.smtp_hostname, "relay-user", "relay-pass", :login) + end + end + context "when the SSL mode is Auto" do it "enables STARTTLS auto " do client = endpoint.start_smtp_session @@ -155,6 +167,16 @@ module SMTPClient endpoint.start_smtp_session(source_ip_address: ip_address) expect(endpoint.smtp_client).to have_received(:start).with(ip_address.hostname) end + + context "when relay credentials are provided" do + let(:username) { "relay-user" } + let(:password) { "relay-pass" } + + it "starts the SMTP client with the source hostname and login authentication" do + endpoint.start_smtp_session(source_ip_address: ip_address) + expect(endpoint.smtp_client).to have_received(:start).with(ip_address.hostname, "relay-user", "relay-pass", :login) + end + end end end diff --git a/spec/lib/smtp_client/server_spec.rb b/spec/lib/smtp_client/server_spec.rb index 0f3ac1ba3..5880c981e 100644 --- a/spec/lib/smtp_client/server_spec.rb +++ b/spec/lib/smtp_client/server_spec.rb @@ -8,8 +8,21 @@ module SMTPClient let(:hostname) { "example.com" } let(:port) { 25 } let(:ssl_mode) { SSLModes::AUTO } + let(:username) { nil } + let(:password) { nil } - subject(:server) { described_class.new(hostname, port: port, ssl_mode: ssl_mode) } + subject(:server) { described_class.new(hostname, port: port, ssl_mode: ssl_mode, username: username, password: password) } + + describe "attributes" do + context "when credentials are provided" do + let(:username) { "relay-user" } + let(:password) { "relay-pass" } + + it "stores the relay credentials" do + expect(server).to have_attributes(username: "relay-user", password: "relay-pass") + end + end + end describe "#endpoints" do context "when there are A and AAAA records" do diff --git a/spec/senders/smtp_sender_spec.rb b/spec/senders/smtp_sender_spec.rb index 6008a2655..4ff08daab 100644 --- a/spec/senders/smtp_sender_spec.rb +++ b/spec/senders/smtp_sender_spec.rb @@ -560,5 +560,14 @@ have_attributes(hostname: "test2.example.com", port: 2525, ssl_mode: "TLS"), ] end + + it "returns relays with credentials when configured" do + allow(Postal::Config.postal).to receive(:smtp_relays).and_return([ + Hashie::Mash.new(host: "relay.example.com", port: 587, ssl_mode: "TLS", username: "relay-user", password: "relay-pass"), + ]) + expect(described_class.smtp_relays).to match [ + have_attributes(hostname: "relay.example.com", port: 587, ssl_mode: "TLS", username: "relay-user", password: "relay-pass"), + ] + end end end From 389c861b4dab728cf811c0df92c189db8fb91a06 Mon Sep 17 00:00:00 2001 From: Andreas de Pretis Date: Wed, 15 Apr 2026 21:54:35 +0200 Subject: [PATCH 2/2] Fix tests for SMTP relay authentication --- spec/lib/postal/config_schema_spec.rb | 68 ++++++++++++++++++++ spec/lib/postal/legacy_config_source_spec.rb | 22 ------- 2 files changed, 68 insertions(+), 22 deletions(-) create mode 100644 spec/lib/postal/config_schema_spec.rb diff --git a/spec/lib/postal/config_schema_spec.rb b/spec/lib/postal/config_schema_spec.rb new file mode 100644 index 000000000..666d10bc0 --- /dev/null +++ b/spec/lib/postal/config_schema_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" +require "konfig/error" +require "konfig/sources/abstract" + +module Postal + RSpec.describe ConfigSchema do + let(:schema) { described_class } + + class TestConfigSource < Konfig::Sources::Abstract + def initialize(config) + super() + @config = config + end + + def get(path, attribute: nil) + value = path.reduce(@config) do |memo, key| + next nil unless memo.is_a?(Hash) + + memo[key] || memo[key.to_s] || memo[key.to_sym] + end + raise Konfig::ValueNotPresentError if value.nil? + + value + end + end + + def build_config(smtp_relays) + Konfig::Config.build( + schema, + sources: [ + TestConfigSource.new( + "postal" => { + "smtp_relays" => smtp_relays + } + ) + ] + ) + end + + describe "postal.smtp_relays" do + it "parses relay URLs without credentials" do + config = build_config(["smtp://relay.example.com:587?ssl_mode=TLS"]) + + expect(config.postal.smtp_relays).to eq [ + { "host" => "relay.example.com", "port" => 587, "ssl_mode" => "TLS" }, + ] + end + + it "parses relay URLs with credentials" do + config = build_config(["smtp://relay-user:relay-pass@relay.example.com:587?ssl_mode=TLS"]) + + expect(config.postal.smtp_relays).to eq [ + { "host" => "relay.example.com", "port" => 587, "ssl_mode" => "TLS", "username" => "relay-user", "password" => "relay-pass" }, + ] + end + + it "decodes percent-encoded relay credentials" do + config = build_config(["smtp://relay%40user:pa%24%24%3Aword@relay.example.com:587?ssl_mode=TLS"]) + + expect(config.postal.smtp_relays).to eq [ + { "host" => "relay.example.com", "port" => 587, "ssl_mode" => "TLS", "username" => "relay@user", "password" => "pa$$:word" }, + ] + end + end + end +end diff --git a/spec/lib/postal/legacy_config_source_spec.rb b/spec/lib/postal/legacy_config_source_spec.rb index 4406dc259..e9d9f6784 100644 --- a/spec/lib/postal/legacy_config_source_spec.rb +++ b/spec/lib/postal/legacy_config_source_spec.rb @@ -76,28 +76,6 @@ module Postal { "host" => "2.2.2.2", "port" => 2525, "ssl_mode" => "None" }, ] end - - it "parses relay URLs with credentials" do - source = described_class.new( - SOURCE_CONFIG.merge("smtp_relays" => ["smtp://relay-user:relay-pass@relay.example.com:587?ssl_mode=TLS"]) - ) - config = Konfig::Config.build(ConfigSchema, sources: [source]) - - expect(config.postal.smtp_relays).to eq [ - { "host" => "relay.example.com", "port" => 587, "ssl_mode" => "TLS", "username" => "relay-user", "password" => "relay-pass" }, - ] - end - - it "decodes percent-encoded relay credentials" do - source = described_class.new( - SOURCE_CONFIG.merge("smtp_relays" => ["smtp://relay%40user:pa%24%24%3Aword@relay.example.com:587?ssl_mode=TLS"]) - ) - config = Konfig::Config.build(ConfigSchema, sources: [source]) - - expect(config.postal.smtp_relays).to eq [ - { "host" => "relay.example.com", "port" => 587, "ssl_mode" => "TLS", "username" => "relay@user", "password" => "pa$$:word" }, - ] - end end describe "the 'web_server' group" do