fix(email): support custom SMTP EHLO domains (#11398)

This commit is contained in:
Andras Bacsai
2026-08-19 11:25:46 +02:00
committed by GitHub
parent 9e6afc0408
commit 849ba8f34c
13 changed files with 145 additions and 4 deletions
+10
View File
@@ -6,6 +6,7 @@ use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\EmailNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
use App\Rules\ValidHostname;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Locked;
@@ -57,6 +58,9 @@ class Email extends Component
#[Validate(['nullable', 'numeric'])]
public ?string $smtpTimeout = null;
#[Validate(['nullable', 'string'])]
public ?string $smtpEhloDomain = null;
#[Validate(['boolean'])]
public bool $resendEnabled = false;
@@ -129,6 +133,7 @@ class Email extends Component
{
if ($toModel) {
$this->validate();
$this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]);
$this->authorize('update', $this->settings);
$this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_from_address = $this->smtpFromAddress;
@@ -140,6 +145,7 @@ class Email extends Component
$this->settings->smtp_username = $this->smtpUsername;
$this->settings->smtp_password = $this->smtpPassword;
$this->settings->smtp_timeout = $this->smtpTimeout;
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
$this->settings->resend_enabled = $this->resendEnabled;
$this->settings->resend_api_key = $this->resendApiKey;
@@ -175,6 +181,7 @@ class Email extends Component
? $this->settings->smtp_password
: null;
$this->smtpTimeout = $this->settings->smtp_timeout;
$this->smtpEhloDomain = $this->settings->smtp_ehlo_domain;
$this->resendEnabled = $this->settings->resend_enabled;
$this->resendApiKey = auth()->user()->can('update', $this->settings)
@@ -261,6 +268,7 @@ class Email extends Component
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
@@ -284,6 +292,7 @@ class Email extends Component
$this->settings->smtp_username = $this->smtpUsername;
$this->settings->smtp_password = $this->smtpPassword;
$this->settings->smtp_timeout = $this->smtpTimeout;
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
$this->settings->save();
$this->dispatch('success', 'SMTP settings updated.');
@@ -375,6 +384,7 @@ class Email extends Component
$this->smtpUsername = $settings->smtp_username;
$this->smtpPassword = $settings->smtp_password;
$this->smtpTimeout = $settings->smtp_timeout;
$this->smtpEhloDomain = $settings->smtp_ehlo_domain;
if ($settings->resend_enabled) {
$this->resendEnabled = true;
+9
View File
@@ -5,6 +5,7 @@ namespace App\Livewire;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Notifications\TransactionalEmails\Test;
use App\Rules\ValidHostname;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Locked;
@@ -50,6 +51,9 @@ class SettingsEmail extends Component
#[Validate(['nullable', 'numeric'])]
public ?string $smtpTimeout = null;
#[Validate(['nullable', 'string'])]
public ?string $smtpEhloDomain = null;
#[Validate(['boolean'])]
public bool $resendEnabled = false;
@@ -74,6 +78,7 @@ class SettingsEmail extends Component
{
if ($toModel) {
$this->validate();
$this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]);
$this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_host = $this->smtpHost;
$this->settings->smtp_port = $this->smtpPort;
@@ -81,6 +86,7 @@ class SettingsEmail extends Component
$this->settings->smtp_username = $this->smtpUsername;
$this->settings->smtp_password = $this->smtpPassword;
$this->settings->smtp_timeout = $this->smtpTimeout;
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
$this->settings->smtp_from_address = $this->smtpFromAddress;
$this->settings->smtp_from_name = $this->smtpFromName;
@@ -95,6 +101,7 @@ class SettingsEmail extends Component
$this->smtpUsername = $this->settings->smtp_username;
$this->smtpPassword = $this->settings->smtp_password;
$this->smtpTimeout = $this->settings->smtp_timeout;
$this->smtpEhloDomain = $this->settings->smtp_ehlo_domain;
$this->smtpFromAddress = $this->settings->smtp_from_address;
$this->smtpFromName = $this->settings->smtp_from_name;
@@ -167,6 +174,7 @@ class SettingsEmail extends Component
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
@@ -184,6 +192,7 @@ class SettingsEmail extends Component
$this->settings->smtp_username = $this->smtpUsername;
$this->settings->smtp_password = $this->smtpPassword;
$this->settings->smtp_timeout = $this->smtpTimeout;
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
$this->settings->smtp_from_address = $this->smtpFromAddress;
$this->settings->smtp_from_name = $this->smtpFromName;
+1
View File
@@ -21,6 +21,7 @@ class EmailNotificationSettings extends Model
'smtp_username',
'smtp_password',
'smtp_timeout',
'smtp_ehlo_domain',
'resend_enabled',
'resend_api_key',
+1
View File
@@ -33,6 +33,7 @@ class InstanceSettings extends Model
'smtp_username',
'smtp_password',
'smtp_timeout',
'smtp_ehlo_domain',
'resend_enabled',
'resend_api_key',
'is_dns_validation_enabled',
+4 -1
View File
@@ -82,7 +82,10 @@ class EmailChannel
'html' => (string) $mailMessage->render(),
]);
} elseif ($isSmtpEnabled) {
$transport = SmtpTransportFactory::fromSettings($settings);
$transport = SmtpTransportFactory::fromSettings(
$settings,
config('mail.mailers.smtp.local_domain')
);
$mailer = new Mailer($transport);
$email = (new Email)
+3 -1
View File
@@ -29,6 +29,8 @@ class ConfigurationRepository
if ($settings->smtp_enabled) {
$mailerOptions = SmtpTransportFactory::mailerOptions($settings);
$localDomain = $settings->smtp_ehlo_domain
?? $this->config->get('mail.mailers.smtp.local_domain');
$this->config->set('mail.default', 'smtp');
$this->applyMailFrom($from);
@@ -41,7 +43,7 @@ class ConfigurationRepository
'username' => $settings->smtp_username,
'password' => $settings->smtp_password,
'timeout' => $settings->smtp_timeout,
'local_domain' => null,
'local_domain' => $localDomain,
'auto_tls' => $mailerOptions['auto_tls'],
]);
}
+6 -1
View File
@@ -7,7 +7,7 @@ use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream;
class SmtpTransportFactory
{
public static function fromSettings(object $settings): EsmtpTransport
public static function fromSettings(object $settings, ?string $localDomain = null): EsmtpTransport
{
$mode = self::encryptionMode($settings);
@@ -28,6 +28,11 @@ class SmtpTransportFactory
$transport->setUsername($settings->smtp_username ?? '');
$transport->setPassword($settings->smtp_password ?? '');
$localDomain = $settings->smtp_ehlo_domain ?? $localDomain;
if ($localDomain !== null && $localDomain !== '') {
$transport->setLocalDomain($localDomain);
}
$stream = $transport->getStream();
if (isset($settings->smtp_timeout) && $stream instanceof SocketStream) {
$stream->setTimeout((float) $settings->smtp_timeout);
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('smtp_ehlo_domain')->nullable();
});
Schema::table('email_notification_settings', function (Blueprint $table) {
$table->string('smtp_ehlo_domain')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('smtp_ehlo_domain');
});
Schema::table('email_notification_settings', function (Blueprint $table) {
$table->dropColumn('smtp_ehlo_domain');
});
}
};
@@ -113,6 +113,10 @@
@endcan
<x-forms.input canGate="update" :canResource="$settings" id="smtpTimeout" type="number"
helper="Timeout value for sending emails." label="Timeout" />
<x-forms.input canGate="update" :canResource="$settings" id="smtpEhloDomain"
placeholder="coolify.example.com"
helper="Fully qualified domain sent in the SMTP EHLO command. Uses the system default when empty."
label="EHLO domain" />
</div>
</x-application.settings-section>
</div>
@@ -8,7 +8,7 @@
{{-- One bar for the whole page. Three stacked bars made Save run
submitResend(), which required an API key even when Resend was off. --}}
<x-unsaved-bar action="submit"
targets="smtpFromName,smtpFromAddress,smtpHost,smtpPort,smtpEncryption,smtpUsername,smtpPassword,smtpTimeout,resendApiKey" />
targets="smtpFromName,smtpFromAddress,smtpHost,smtpPort,smtpEncryption,smtpUsername,smtpPassword,smtpTimeout,smtpEhloDomain,resendApiKey" />
<form wire:submit="submit">
<x-application.settings-section title="Sender">
@@ -48,6 +48,9 @@
autocomplete="new-password" />
<x-forms.input id="smtpTimeout" type="number"
helper="Maximum delivery time in seconds." label="Timeout" />
<x-forms.input id="smtpEhloDomain" placeholder="coolify.example.com"
helper="Fully qualified domain sent in the SMTP EHLO command. Uses the system default when empty."
label="EHLO domain" />
</div>
</x-application.settings-section>
</form>
@@ -1,5 +1,6 @@
<?php
use App\Livewire\Notifications\Email as NotificationEmail;
use App\Livewire\SettingsEmail;
use App\Models\InstanceSettings;
use App\Models\Team;
@@ -34,6 +35,7 @@ function smtpSetupPayload(): array
'smtpHost' => 'smtp.example.com',
'smtpPort' => '587',
'smtpEncryption' => 'starttls',
'smtpEhloDomain' => 'coolify.example.com',
'resendEnabled' => false,
'resendApiKey' => null,
];
@@ -56,9 +58,30 @@ test('saving smtp settings does not require a resend api key when resend is disa
expect($settings->smtp_enabled)->toBeTrue()
->and($settings->smtp_host)->toBe('smtp.example.com')
->and($settings->smtp_ehlo_domain)->toBe('coolify.example.com')
->and($settings->resend_enabled)->toBeFalse();
});
test('team smtp settings save their own ehlo domain', function () {
setupInstanceAdminForEmailSettings();
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => $team]);
Livewire::test(NotificationEmail::class)
->fill(smtpSetupPayload())
->set('smtpEnabled', true)
->call('submitSmtp')
->assertHasNoErrors()
->assertNotDispatched('error');
expect($team->emailNotificationSettings->fresh()->smtp_ehlo_domain)
->toBe('coolify.example.com');
});
test('saving transactional email settings does not require a resend api key when resend is disabled', function () {
$user = setupInstanceAdminForEmailSettings();
@@ -27,3 +27,29 @@ it('configures the laravel smtp scheme from encryption', function (string $mode,
'none on port 465' => ['none', 465, 'smtp', null, '0'],
'tls on a non-465 port' => ['tls', 587, 'smtps', 'tls', ''],
]);
it('preserves the configured smtp ehlo domain', function () {
$config = new Repository([
'mail' => [
'mailers' => [
'smtp' => ['local_domain' => 'coolify.example.com'],
],
],
]);
$repository = new ConfigurationRepository($config);
$repository->updateMailConfig((object) [
'resend_enabled' => false,
'smtp_enabled' => true,
'smtp_encryption' => 'starttls',
'smtp_host' => 'smtp-relay.gmail.com',
'smtp_port' => 587,
'smtp_username' => null,
'smtp_password' => null,
'smtp_timeout' => null,
'smtp_from_address' => 'from@example.com',
'smtp_from_name' => 'Coolify',
]);
expect($config->get('mail.mailers.smtp.local_domain'))->toBe('coolify.example.com');
});
+18
View File
@@ -86,6 +86,24 @@ it('applies a configured SMTP timeout to the transport stream', function () {
expect($transport->getStream()->getTimeout())->toBe(15.0);
});
it('applies the configured ehlo domain to the transport', function () {
$transport = SmtpTransportFactory::fromSettings(
smtpSettings(['smtp_ehlo_domain' => 'team.example.com']),
'instance.example.com'
);
expect($transport->getLocalDomain())->toBe('team.example.com');
});
it('falls back to the instance ehlo domain for legacy smtp settings', function () {
$transport = SmtpTransportFactory::fromSettings(
smtpSettings(),
'instance.example.com'
);
expect($transport->getLocalDomain())->toBe('instance.example.com');
});
it('maps none encryption to laravel mailer options that disable auto tls', function () {
expect(SmtpTransportFactory::mailerOptions(smtpSettings([
'smtp_encryption' => 'none',