diff --git a/app/views/email_smtp_settings/index.html.erb b/app/views/email_smtp_settings/index.html.erb index 821d8335..e2cfa82e 100644 --- a/app/views/email_smtp_settings/index.html.erb +++ b/app/views/email_smtp_settings/index.html.erb @@ -3,7 +3,15 @@

Email SMTP

<% value = @encrypted_config.value || {} %> + <% external_smtp = ExternalConfig.smtp_configured? %> + <% if external_smtp %> +
+ SMTP is configured via environment variables. These settings are read-only. +
+ <% value = value.merge(ExternalConfig.smtp_settings.transform_keys { |k| { address: 'host', user_name: 'username' }.fetch(k, k.to_s) }.stringify_keys) %> + <% end %> <%= form_for @encrypted_config, url: settings_email_index_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %> +
class="space-y-4"> <%= f.fields_for :value do |ff| %>
@@ -56,6 +64,7 @@
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
+
<% end %>
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