fix(email): apply configured sender identity (#11393)

This commit is contained in:
Andras Bacsai
2026-08-19 10:43:25 +02:00
committed by GitHub
parent 292c3aa231
commit 41f7152441
8 changed files with 255 additions and 18 deletions
+9
View File
@@ -234,11 +234,20 @@ class SettingsEmail extends Component
$this->authorize('update', $this->settings);
$this->validate([
'testEmailAddress' => 'required|email',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'testEmailAddress.required' => 'Test email address is required.',
'testEmailAddress.email' => 'Please enter a valid email address.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
$this->settings->smtp_from_address = $this->smtpFromAddress;
$this->settings->smtp_from_name = $this->smtpFromName;
$this->settings->save();
$executed = RateLimiter::attempt(
'test-email:'.$this->team->id,
$perMinute = 0,
+15 -14
View File
@@ -7,6 +7,11 @@ use App\Models\Team;
use Exception;
use Illuminate\Notifications\Notification;
use Resend;
use Resend\Exceptions\ErrorException;
use Resend\Exceptions\TransporterException;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mime\Email;
class EmailChannel
{
@@ -70,9 +75,8 @@ class EmailChannel
if ($isResendEnabled) {
$resend = Resend::client($settings->resend_api_key);
$from = "{$settings->smtp_from_name} <{$settings->smtp_from_address}>";
$resend->emails->send([
'from' => $from,
'from' => mail_from_formatted($settings),
'to' => $recipients,
'subject' => $mailMessage->subject,
'html' => (string) $mailMessage->render(),
@@ -85,7 +89,7 @@ class EmailChannel
default => null,
};
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
$transport = new EsmtpTransport(
$settings->smtp_host,
$settings->smtp_port,
$encryption
@@ -93,20 +97,17 @@ class EmailChannel
$transport->setUsername($settings->smtp_username ?? '');
$transport->setPassword($settings->smtp_password ?? '');
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
$mailer = new Mailer($transport);
$fromEmail = $settings->smtp_from_address ?? 'noreply@localhost';
$fromName = $settings->smtp_from_name ?? 'System';
$from = new \Symfony\Component\Mime\Address($fromEmail, $fromName);
$email = (new \Symfony\Component\Mime\Email)
->from($from)
$email = (new Email)
->from(mail_from_address($settings))
->to(...$recipients)
->subject($mailMessage->subject)
->html((string) $mailMessage->render());
$mailer->send($email);
}
} catch (\Resend\Exceptions\ErrorException $e) {
} catch (ErrorException $e) {
// Map HTTP status codes to user-friendly messages
$userMessage = match ($e->getErrorCode()) {
403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.',
@@ -131,13 +132,13 @@ class EmailChannel
// Don't report expected errors (invalid keys, validation) to Sentry
if (in_array($e->getErrorCode(), [403, 401, 400])) {
throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e));
throw NonReportableException::fromException(new Exception($userMessage, $e->getCode(), $e));
}
throw new \Exception($userMessage, $e->getCode(), $e);
} catch (\Resend\Exceptions\TransporterException $e) {
throw new Exception($userMessage, $e->getCode(), $e);
} catch (TransporterException $e) {
send_internal_notification("Resend Transport Error: {$e->getMessage()}");
throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.');
throw new Exception('Unable to connect to Resend API. Please check your internet connection and try again.');
} catch (\Throwable $e) {
// Check if this is a Resend domain verification error on cloud instances
if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) {
@@ -27,10 +27,12 @@ class TransactionalEmailChannel
}
$this->bootConfigs();
$mailMessage = $notification->toMail($notifiable);
$from = mail_from_identity($settings);
Mail::send(
[],
[],
fn (Message $message) => $message
->from($from['address'], $from['name'])
->to($email)
->subject($mailMessage->subject)
->html((string) $mailMessage->render())
@@ -54,7 +54,9 @@ class ResetPassword extends Notification
protected function buildMailMessage($url)
{
$from = mail_from_identity($this->settings);
$mail = new MailMessage;
$mail->from($from['address'], $from['name']);
$mail->subject('Coolify: Reset Password');
$mail->view('emails.reset-password', ['url' => $url, 'count' => config('auth.passwords.'.config('auth.defaults.passwords').'.expire')]);
+18 -4
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use Illuminate\Config\Repository;
use Illuminate\Support\Facades\Mail;
class ConfigurationRepository
{
@@ -15,10 +16,11 @@ class ConfigurationRepository
public function updateMailConfig($settings): void
{
$from = mail_from_identity($settings);
if ($settings->resend_enabled) {
$this->config->set('mail.default', 'resend');
$this->config->set('mail.from.address', $settings->smtp_from_address ?? 'test@example.com');
$this->config->set('mail.from.name', $settings->smtp_from_name ?? 'Test');
$this->applyMailFrom($from);
$this->config->set('resend.api_key', $settings->resend_api_key);
return;
@@ -33,8 +35,7 @@ class ConfigurationRepository
};
$this->config->set('mail.default', 'smtp');
$this->config->set('mail.from.address', $settings->smtp_from_address ?? 'test@example.com');
$this->config->set('mail.from.name', $settings->smtp_from_name ?? 'Test');
$this->applyMailFrom($from);
$this->config->set('mail.mailers.smtp', [
'transport' => 'smtp',
'host' => $settings->smtp_host,
@@ -49,6 +50,19 @@ class ConfigurationRepository
}
}
/**
* @param array{address: string, name: string} $from
*/
private function applyMailFrom(array $from): void
{
$this->config->set('mail.from.address', $from['address']);
$this->config->set('mail.from.name', $from['name']);
if (app()->bound('mail.manager')) {
Mail::purge();
}
}
public function disableSshMux(): void
{
$this->config->set('constants.ssh.mux_enabled', false);
+36
View File
@@ -5,6 +5,7 @@ use App\Notifications\Internal\GeneralNotification;
use Illuminate\Mail\Message;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Mail;
use Symfony\Component\Mime\Address;
function is_transactional_emails_enabled(): bool
{
@@ -13,6 +14,37 @@ function is_transactional_emails_enabled(): bool
return $settings->smtp_enabled || $settings->resend_enabled;
}
/**
* @return array{address: string, name: string}
*/
function mail_from_identity(object $settings): array
{
if (blank($settings->smtp_from_address ?? null)) {
throw new InvalidArgumentException('Transactional email sender address is not configured.');
}
$address = (string) $settings->smtp_from_address;
$name = trim((string) ($settings->smtp_from_name ?? ''));
return [
'address' => $address,
'name' => $name !== '' ? $name : 'Coolify',
];
}
function mail_from_address(object $settings): Address
{
$identity = mail_from_identity($settings);
return new Address($identity['address'], $identity['name']);
}
function mail_from_formatted(object $settings): string
{
return mail_from_address($settings)->toString();
}
function send_internal_notification(string $message): void
{
try {
@@ -29,11 +61,14 @@ function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null
if (blank($type)) {
throw new Exception('No email settings found.');
}
$from = mail_from_identity($settings);
if ($cc) {
Mail::send(
[],
[],
fn (Message $message) => $message
->from($from['address'], $from['name'])
->to($email)
->replyTo($email)
->cc($cc)
@@ -45,6 +80,7 @@ function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null
[],
[],
fn (Message $message) => $message
->from($from['address'], $from['name'])
->to($email)
->subject($mail->subject)
->html((string) $mail->render())
@@ -0,0 +1,121 @@
<?php
use App\Livewire\SettingsEmail;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use App\Notifications\Channels\TransactionalEmailChannel;
use App\Notifications\TransactionalEmails\EmailChangeVerification;
use App\Services\ConfigurationRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function setupTransactionalEmailFromNameAdmin(): User
{
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
InstanceSettings::forceCreate([
'id' => 0,
'smtp_enabled' => true,
'smtp_from_address' => 'admin@example.com',
'smtp_from_name' => 'admin',
'smtp_host' => 'coolify-mail',
'smtp_port' => 1025,
]);
Once::flush();
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
return $user;
}
test('saving transactional sender persists the configured from name', function () {
$user = setupTransactionalEmailFromNameAdmin();
$this->actingAs($user);
session(['currentTeam' => ['id' => 0]]);
Livewire::test(SettingsEmail::class)
->set('smtpFromName', 'Coolify')
->set('smtpFromAddress', 'admin@example.com')
->call('submit')
->assertHasNoErrors();
Once::flush();
expect(instanceSettings()->smtp_from_name)->toBe('Coolify')
->and(instanceSettings()->smtp_from_address)->toBe('admin@example.com');
});
test('sending a test email persists the current from name before delivery', function () {
$user = setupTransactionalEmailFromNameAdmin();
$this->actingAs($user);
session(['currentTeam' => ['id' => 0]]);
Notification::fake();
Livewire::test(SettingsEmail::class)
->set('smtpFromName', 'Coolify')
->set('smtpFromAddress', 'admin@example.com')
->set('testEmailAddress', $user->email)
->call('sendTestEmail')
->assertHasNoErrors();
Once::flush();
expect(instanceSettings()->smtp_from_name)->toBe('Coolify');
});
test('transactional emails send with the configured from name instead of the address local part', function () {
setupTransactionalEmailFromNameAdmin();
InstanceSettings::findOrFail(0)->update([
'smtp_from_name' => 'Coolify',
'smtp_from_address' => 'admin@example.com',
]);
Once::flush();
config([
'mail.default' => 'array',
'mail.from.address' => 'hello@example.com',
'mail.from.name' => 'Example',
]);
Mail::purge();
Mail::mailer();
$this->mock(ConfigurationRepository::class, function ($mock) {
$mock->shouldReceive('updateMailConfig')->andReturnUsing(function ($settings) {
config([
'mail.from.address' => $settings->smtp_from_address,
'mail.from.name' => $settings->smtp_from_name,
]);
});
});
$user = User::factory()->create(['email' => 'recipient@example.com']);
$notification = new EmailChangeVerification(
$user,
'123456',
'new@example.com',
now()->addMinutes(10),
);
$channel = new TransactionalEmailChannel;
$channel->send($user, $notification);
$messages = app('mail.manager')->mailer('array')->getSymfonyTransport()->messages();
expect($messages)->not->toBeEmpty();
$from = $messages->first()->getOriginalMessage()->getFrom()[0];
expect($from->getAddress())->toBe('admin@example.com')
->and($from->getName())->toBe('Coolify')
->and($from->getName())->not->toBe('admin');
});
+52
View File
@@ -0,0 +1,52 @@
<?php
use Symfony\Component\Mime\Address;
it('uses the configured transactional from name and address', function () {
$identity = mail_from_identity((object) [
'smtp_from_address' => 'admin@example.com',
'smtp_from_name' => 'Coolify',
]);
expect($identity['address'])->toBe('admin@example.com')
->and($identity['name'])->toBe('Coolify');
});
it('does not fall back to the email local part when a from name is set', function () {
$address = mail_from_address((object) [
'smtp_from_address' => 'admin@example.com',
'smtp_from_name' => 'Coolify',
]);
expect($address)->toBeInstanceOf(Address::class)
->and($address->getAddress())->toBe('admin@example.com')
->and($address->getName())->toBe('Coolify')
->and($address->getName())->not->toBe('admin');
});
it('formats the transactional sender for resend', function () {
$formattedAddress = mail_from_formatted((object) [
'smtp_from_address' => 'admin@example.com',
'smtp_from_name' => 'Coolify',
]);
expect($formattedAddress)->toBe('"Coolify" <admin@example.com>');
});
it('treats a blank from name as missing instead of sending an unnamed address', function () {
$identity = mail_from_identity((object) [
'smtp_from_address' => 'admin@example.com',
'smtp_from_name' => ' ',
]);
expect($identity['name'])->toBe('Coolify')
->and($identity['name'])->not->toBe('admin');
});
it('rejects enabled email settings without a from address', function () {
mail_from_identity((object) [
'smtp_enabled' => true,
'smtp_from_address' => null,
'smtp_from_name' => 'Coolify',
]);
})->throws(InvalidArgumentException::class, 'Transactional email sender address is not configured.');