diff --git a/lib/action_mailer_configs_interceptor.rb b/lib/action_mailer_configs_interceptor.rb
index 5b2ad19b..0a99f2e5 100644
--- a/lib/action_mailer_configs_interceptor.rb
+++ b/lib/action_mailer_configs_interceptor.rb
@@ -15,6 +15,17 @@ module ActionMailerConfigsInterceptor
return message
end
+ # External SMTP config via env vars takes precedence over EncryptedConfig.
+ if ExternalConfig.smtp_configured?
+ settings = ExternalConfig.smtp_settings
+ from = settings.delete(:from)
+
+ message.delivery_method(:smtp, settings)
+ message.from = from if from.present? && message[:from].blank?
+
+ return message
+ end
+
if Rails.env.production? && Rails.application.config.action_mailer.delivery_method
from = ENV.fetch('SMTP_FROM').to_s.split(',').sample
diff --git a/lib/external_config.rb b/lib/external_config.rb
new file mode 100644
index 00000000..abc5258a
--- /dev/null
+++ b/lib/external_config.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+# External configuration loaded from environment variables.
+# Currently exposes SMTP settings; additional external config concerns can be
+# added here as needed.
+module ExternalConfig
+ SMTP_ENV_KEYS = {
+ address: 'DOCUSEAL_CONFIG_SMTP_ADDRESS',
+ port: 'DOCUSEAL_CONFIG_SMTP_PORT',
+ user_name: 'DOCUSEAL_CONFIG_SMTP_USERNAME',
+ password: 'DOCUSEAL_CONFIG_SMTP_PASSWORD',
+ domain: 'DOCUSEAL_CONFIG_SMTP_DOMAIN',
+ from: 'DOCUSEAL_CONFIG_SMTP_FROM'
+ }.freeze
+
+ module_function
+
+ # SMTP is considered configured as soon as an address is provided via ENV.
+ def smtp_configured?
+ ENV.fetch(SMTP_ENV_KEYS[:address], nil).present?
+ end
+
+ # Returns an ActionMailer-compatible SMTP settings hash built from ENV vars.
+ # The :from key is returned alongside but is intended for message[:from]
+ # rewriting, not for Net::SMTP.
+ def smtp_settings
+ return {} unless smtp_configured?
+
+ {
+ address: ENV.fetch(SMTP_ENV_KEYS[:address], nil),
+ port: ENV.fetch(SMTP_ENV_KEYS[:port], '587').to_i,
+ user_name: ENV.fetch(SMTP_ENV_KEYS[:user_name], nil),
+ password: ENV.fetch(SMTP_ENV_KEYS[:password], nil),
+ domain: ENV.fetch(SMTP_ENV_KEYS[:domain], nil),
+ from: ENV.fetch(SMTP_ENV_KEYS[:from], nil),
+ authentication: ENV.fetch(SMTP_ENV_KEYS[:password], nil).present? ? :plain : nil,
+ enable_starttls_auto: true,
+ open_timeout: ENV.fetch('SMTP_OPEN_TIMEOUT', '15').to_i,
+ read_timeout: ENV.fetch('SMTP_READ_TIMEOUT', '25').to_i
+ }.compact_blank
+ end
+end
diff --git a/playwright/tests/v0.2.0-external-smtp.spec.ts b/playwright/tests/v0.2.0-external-smtp.spec.ts
new file mode 100644
index 00000000..005c990a
--- /dev/null
+++ b/playwright/tests/v0.2.0-external-smtp.spec.ts
@@ -0,0 +1,16 @@
+import { test, expect } from '@playwright/test';
+import { loginAsAdmin } from './helpers/auth';
+
+// Phase 0.2 — External SMTP via env vars.
+// Default state (no env override): form fields are editable.
+
+test.describe('External SMTP', () => {
+ test('SMTP settings form is editable when no env override is set', async ({ page }) => {
+ await loginAsAdmin(page);
+ await page.goto('/settings/email');
+
+ const host = page.locator('input[name="encrypted_config[value][host]"]');
+ await expect(host).toBeVisible();
+ await expect(host).toBeEditable();
+ });
+});
diff --git a/spec/lib/action_mailer_configs_interceptor_spec.rb b/spec/lib/action_mailer_configs_interceptor_spec.rb
new file mode 100644
index 00000000..d1bef3da
--- /dev/null
+++ b/spec/lib/action_mailer_configs_interceptor_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require 'action_mailer_configs_interceptor'
+
+RSpec.describe ActionMailerConfigsInterceptor do
+ let(:message) do
+ Mail.new do
+ to 'user@example.com'
+ from 'sender@example.com'
+ subject 'Hi'
+ body 'Hello'
+ end
+ end
+
+ describe '.delivering_email' do
+ before { allow(Rails.env).to receive(:production?).and_return(true) }
+
+ it 'applies env SMTP settings when ExternalConfig.smtp_configured?' do
+ envs = {
+ 'DOCUSEAL_CONFIG_SMTP_ADDRESS' => 'smtp.example.com',
+ 'DOCUSEAL_CONFIG_SMTP_PORT' => '2525',
+ 'DOCUSEAL_CONFIG_SMTP_USERNAME' => 'user',
+ 'DOCUSEAL_CONFIG_SMTP_PASSWORD' => 'secret'
+ }
+ with_env(envs) do
+ described_class.delivering_email(message)
+ method = message.delivery_method
+ expect(method.settings[:address]).to eq('smtp.example.com')
+ expect(method.settings[:port]).to eq(2525)
+ expect(method.settings[:user_name]).to eq('user')
+ end
+ end
+
+ it 'does not use env SMTP settings when env is absent' do
+ with_env('DOCUSEAL_CONFIG_SMTP_ADDRESS' => nil) do
+ # Falls through to the legacy branches; we only assert it did not pick env settings.
+ described_class.delivering_email(message)
+ method = message.delivery_method
+ expect(method.settings[:address]).not_to eq('smtp.example.com')
+ end
+ end
+ end
+end
diff --git a/spec/lib/external_config_spec.rb b/spec/lib/external_config_spec.rb
new file mode 100644
index 00000000..d20ee38d
--- /dev/null
+++ b/spec/lib/external_config_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require 'external_config'
+
+RSpec.describe ExternalConfig do
+ describe '.smtp_configured?' do
+ it 'returns true when DOCUSEAL_CONFIG_SMTP_ADDRESS is set' do
+ with_env('DOCUSEAL_CONFIG_SMTP_ADDRESS' => 'smtp.example.com') do
+ expect(described_class.smtp_configured?).to be(true)
+ end
+ end
+
+ it 'returns false when the env var is absent' do
+ with_env('DOCUSEAL_CONFIG_SMTP_ADDRESS' => nil) do
+ expect(described_class.smtp_configured?).to be(false)
+ end
+ end
+ end
+
+ describe '.smtp_settings' do
+ it 'returns an empty hash when not configured' do
+ with_env('DOCUSEAL_CONFIG_SMTP_ADDRESS' => nil) do
+ expect(described_class.smtp_settings).to eq({})
+ end
+ end
+
+ it 'returns a hash built from env vars' do
+ envs = {
+ 'DOCUSEAL_CONFIG_SMTP_ADDRESS' => 'smtp.example.com',
+ 'DOCUSEAL_CONFIG_SMTP_PORT' => '2525',
+ 'DOCUSEAL_CONFIG_SMTP_USERNAME' => 'user',
+ 'DOCUSEAL_CONFIG_SMTP_PASSWORD' => 'secret',
+ 'DOCUSEAL_CONFIG_SMTP_DOMAIN' => 'example.com',
+ 'DOCUSEAL_CONFIG_SMTP_FROM' => 'noreply@example.com'
+ }
+ with_env(envs) do
+ settings = described_class.smtp_settings
+ expect(settings[:address]).to eq('smtp.example.com')
+ expect(settings[:port]).to eq(2525)
+ expect(settings[:user_name]).to eq('user')
+ expect(settings[:password]).to eq('secret')
+ expect(settings[:domain]).to eq('example.com')
+ expect(settings[:from]).to eq('noreply@example.com')
+ expect(settings[:authentication]).to eq(:plain)
+ end
+ end
+ end
+end