From a5b4e1d32e2f6fc5830771294d7c92b8ad3a00d5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:19:50 +0100 Subject: [PATCH] feat(email): add a separate notification sender to the instance email settings Team notifications sent through the instance email settings, the S3 connection error alert, and the channel disabled alert now use the optional notification from address and name. Account mails keep the default sender. Remove the unused emails command and its views. --- app/Console/Commands/Emails.php | 277 ------------------ .../Api/InstanceEmailSettingsController.php | 5 +- app/Livewire/SettingsEmail.php | 22 +- app/Models/InstanceSettings.php | 6 + app/Models/S3Storage.php | 2 +- app/Notifications/Channels/EmailChannel.php | 9 +- .../TransactionalEmails/Test.php | 7 +- app/Traits/TracksDeliveryFailures.php | 2 +- bootstrap/helpers/notifications.php | 49 ++-- ...tion_sender_to_instance_settings_table.php | 23 ++ .../emails/before-trial-conversion.blade.php | 7 - resources/views/emails/updates.blade.php | 3 - .../settings-email-send-test.blade.php | 1 + .../views/livewire/settings-email.blade.php | 8 +- 14 files changed, 108 insertions(+), 313 deletions(-) delete mode 100644 app/Console/Commands/Emails.php create mode 100644 database/migrations/2026_11_03_194650_add_notification_sender_to_instance_settings_table.php delete mode 100644 resources/views/emails/before-trial-conversion.blade.php delete mode 100644 resources/views/emails/updates.blade.php diff --git a/app/Console/Commands/Emails.php b/app/Console/Commands/Emails.php deleted file mode 100644 index 9dc0bc0cec..0000000000 --- a/app/Console/Commands/Emails.php +++ /dev/null @@ -1,277 +0,0 @@ - 'Send Update Email to all users', - 'emails-test' => 'Test', - 'database-backup-statuses-daily' => 'Database - Backup Statuses (Daily)', - 'application-deployment-success-daily' => 'Application - Deployment Success (Daily)', - 'application-deployment-success' => 'Application - Deployment Success', - 'application-deployment-failed' => 'Application - Deployment Failed', - 'application-status-changed' => 'Application - Status Changed', - 'backup-success' => 'Database - Backup Success', - 'backup-failed' => 'Database - Backup Failed', - // 'invitation-link' => 'Invitation Link', - 'realusers-before-trial' => 'REAL - Registered Users Before Trial without Subscription', - 'realusers-server-lost-connection' => 'REAL - Server Lost Connection', - ], - ); - $emailsGathered = ['realusers-before-trial', 'realusers-server-lost-connection']; - if (isDev()) { - $this->email = 'test@example.com'; - } else { - if (! in_array($type, $emailsGathered)) { - $this->email = text('Email Address to send to:'); - } - } - set_transanctional_email_settings(); - - $this->mail = new MailMessage; - $this->mail->subject('Test Email'); - switch ($type) { - case 'updates': - $teams = Team::all(); - if (! $teams || $teams->isEmpty()) { - echo 'No teams found.'.PHP_EOL; - - return; - } - $emails = []; - foreach ($teams as $team) { - foreach ($team->members as $member) { - if ($member->email && $member->marketing_emails) { - $emails[] = $member->email; - } - } - } - $emails = array_unique($emails); - $this->info('Sending to '.count($emails).' emails.'); - foreach ($emails as $email) { - $this->info($email); - } - $confirmed = confirm('Are you sure?'); - if ($confirmed) { - foreach ($emails as $email) { - $this->mail = new MailMessage; - $this->mail->subject('One-click Services, Docker Compose support'); - $unsubscribeUrl = route('unsubscribe.marketing.emails', [ - 'token' => encrypt($email), - ]); - $this->mail->view('emails.updates', ['unsubscribeUrl' => $unsubscribeUrl]); - $this->sendEmail($email); - } - } - break; - case 'emails-test': - $this->mail = (new Test)->toMail(); - $this->sendEmail(); - break; - case 'application-deployment-success-daily': - $applications = Application::all(); - foreach ($applications as $application) { - if (! $application->hasDeploymentsInLastDays()) { - continue; - } - $this->mail = (new DeploymentSuccess($application, 'test'))->toMail(); - $this->sendEmail(); - } - break; - case 'application-deployment-success': - $application = Application::all()->first(); - $this->mail = (new DeploymentSuccess($application, 'test'))->toMail(); - $this->sendEmail(); - break; - case 'application-deployment-failed': - $application = Application::all()->first(); - $preview = ApplicationPreview::all()->first(); - if (! $preview) { - $preview = ApplicationPreview::create([ - 'application_id' => $application->id, - 'pull_request_id' => 1, - 'pull_request_html_url' => 'http://example.com', - 'fqdn' => $application->fqdn, - ]); - } - $this->mail = (new DeploymentFailed($application, 'test'))->toMail(); - $this->sendEmail(); - $this->mail = (new DeploymentFailed($application, 'test', $preview))->toMail(); - $this->sendEmail(); - break; - case 'application-status-changed': - $application = Application::all()->first(); - $this->mail = (new StatusChanged($application))->toMail(); - $this->sendEmail(); - break; - case 'backup-failed': - $backup = ScheduledDatabaseBackup::all()->first(); - $db = StandalonePostgresql::all()->first(); - if (! $backup) { - $backup = ScheduledDatabaseBackup::create([ - 'enabled' => true, - 'frequency' => 'daily', - 'save_s3' => false, - 'database_id' => $db->id, - 'database_type' => $db->getMorphClass(), - 'team_id' => 0, - ]); - } - $output = 'Because of an error, the backup of the database '.$db->name.' failed.'; - $this->mail = (new BackupFailed($backup, $db, $output, $backup->database_name ?? 'unknown'))->toMail(); - $this->sendEmail(); - break; - case 'backup-success': - $backup = ScheduledDatabaseBackup::all()->first(); - $db = StandalonePostgresql::all()->first(); - if (! $backup) { - $backup = ScheduledDatabaseBackup::create([ - 'enabled' => true, - 'frequency' => 'daily', - 'save_s3' => false, - 'database_id' => $db->id, - 'database_type' => $db->getMorphClass(), - 'team_id' => 0, - ]); - } - // $this->mail = (new BackupSuccess($backup->frequency, $db->name))->toMail(); - $this->sendEmail(); - break; - // case 'invitation-link': - // $user = User::all()->first(); - // $invitation = TeamInvitation::whereEmail($user->email)->first(); - // if (!$invitation) { - // $invitation = TeamInvitation::create([ - // 'uuid' => Str::uuid(), - // 'email' => $user->email, - // 'team_id' => 1, - // 'link' => 'http://example.com', - // ]); - // } - // $this->mail = (new InvitationLink($user))->toMail(); - // $this->sendEmail(); - // break; - case 'realusers-before-trial': - $this->mail = new MailMessage; - $this->mail->view('emails.before-trial-conversion'); - $this->mail->subject('Trial period has been added for all subscription plans.'); - $teams = Team::doesntHave('subscription')->where('id', '!=', 0)->get(); - if (! $teams || $teams->isEmpty()) { - echo 'No teams found.'.PHP_EOL; - - return; - } - $emails = []; - foreach ($teams as $team) { - foreach ($team->members as $member) { - if ($member->email) { - $emails[] = $member->email; - } - } - } - $emails = array_unique($emails); - $this->info('Sending to '.count($emails).' emails.'); - foreach ($emails as $email) { - $this->info($email); - } - $confirmed = confirm('Are you sure?'); - if ($confirmed) { - foreach ($emails as $email) { - $this->sendEmail($email); - } - } - break; - case 'realusers-server-lost-connection': - $serverId = text('Server Id'); - $server = Server::find($serverId); - if (! $server) { - throw new Exception('Server not found'); - } - $admins = []; - $members = $server->team->members; - foreach ($members as $member) { - if ($member->isAdmin()) { - $admins[] = $member->email; - } - } - $this->info('Sending to '.count($admins).' admins.'); - foreach ($admins as $admin) { - $this->info($admin); - } - $this->mail = new MailMessage; - $this->mail->view('emails.server-lost-connection', [ - 'name' => $server->name, - ]); - $this->mail->subject('Action required: Server '.$server->name.' lost connection.'); - foreach ($admins as $email) { - $this->sendEmail($email); - } - break; - } - } - - private function sendEmail(?string $email = null) - { - if ($email) { - $this->email = $email; - } - Mail::send( - [], - [], - fn (Message $message) => $message - ->to($this->email) - ->subject($this->mail->subject) - ->html((string) $this->mail->render()) - ); - $this->info("Email sent to $this->email successfully. 📧"); - } -} diff --git a/app/Http/Controllers/Api/InstanceEmailSettingsController.php b/app/Http/Controllers/Api/InstanceEmailSettingsController.php index ad84b6629d..7dd1ad53c9 100644 --- a/app/Http/Controllers/Api/InstanceEmailSettingsController.php +++ b/app/Http/Controllers/Api/InstanceEmailSettingsController.php @@ -13,7 +13,8 @@ use OpenApi\Attributes as OA; class InstanceEmailSettingsController extends Controller { private const FIELDS = [ - 'smtp_enabled', 'smtp_from_address', 'smtp_from_name', 'smtp_host', + 'smtp_enabled', 'smtp_from_address', 'smtp_from_name', + 'notification_from_address', 'notification_from_name', 'smtp_host', 'smtp_port', 'smtp_encryption', 'smtp_username', 'smtp_password', 'smtp_timeout', 'smtp_ehlo_domain', 'resend_enabled', 'resend_api_key', ]; @@ -58,6 +59,8 @@ class InstanceEmailSettingsController extends Controller 'smtp_enabled' => 'sometimes|boolean', 'smtp_from_address' => 'sometimes|nullable|email', 'smtp_from_name' => 'sometimes|nullable|string|max:255', + 'notification_from_address' => 'sometimes|nullable|email', + 'notification_from_name' => 'sometimes|nullable|string|max:255', 'smtp_host' => 'sometimes|nullable|string|max:255', 'smtp_port' => 'sometimes|nullable|integer|min:1|max:65535', 'smtp_encryption' => 'sometimes|nullable|string|in:starttls,tls,none', diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index ba0be82420..c25dfe2440 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -30,6 +30,12 @@ class SettingsEmail extends Component #[Validate(['nullable', 'string'])] public ?string $smtpFromName = null; + #[Validate(['nullable', 'email'])] + public ?string $notificationFromAddress = null; + + #[Validate(['nullable', 'string', 'max:255'])] + public ?string $notificationFromName = null; + #[Validate(['nullable', 'string'])] public ?string $smtpRecipients = null; @@ -63,6 +69,8 @@ class SettingsEmail extends Component #[Validate(['nullable', 'email'])] public ?string $testEmailAddress = null; + public bool $testUsesNotificationSender = false; + public function mount() { if (isInstanceAdmin() === false) { @@ -89,6 +97,8 @@ class SettingsEmail extends Component $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; $this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_name = $this->smtpFromName; + $this->settings->notification_from_address = $this->notificationFromAddress; + $this->settings->notification_from_name = $this->notificationFromName; $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; @@ -104,6 +114,8 @@ class SettingsEmail extends Component $this->smtpEhloDomain = $this->settings->smtp_ehlo_domain; $this->smtpFromAddress = $this->settings->smtp_from_address; $this->smtpFromName = $this->settings->smtp_from_name; + $this->notificationFromAddress = $this->settings->notification_from_address; + $this->notificationFromName = $this->settings->notification_from_name; $this->resendEnabled = $this->settings->resend_enabled; $this->resendApiKey = $this->settings->resend_api_key; @@ -329,7 +341,10 @@ class SettingsEmail extends Component 'testEmailAddress' => 'required|email', 'smtpFromAddress' => 'required|email', 'smtpFromName' => 'required|string', + 'notificationFromAddress' => $this->testUsesNotificationSender ? 'required|email' : 'nullable|email', + 'notificationFromName' => 'nullable|string|max:255', ], [ + 'notificationFromAddress.required' => 'Notification from address is required to test the notification sender.', 'testEmailAddress.required' => 'Test email address is required.', 'testEmailAddress.email' => 'Please enter a valid email address.', 'smtpFromAddress.required' => 'From Address is required.', @@ -339,13 +354,18 @@ class SettingsEmail extends Component $this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_name = $this->smtpFromName; + $this->settings->notification_from_address = $this->notificationFromAddress; + $this->settings->notification_from_name = $this->notificationFromName; $this->saveAndAudit(); $executed = RateLimiter::attempt( 'test-email:'.$this->team->id, $perMinute = 0, function () { - $this->team?->notifyNow(new Test($this->testEmailAddress)); + $this->team?->notifyNow(new Test( + $this->testEmailAddress, + usesNotificationSender: $this->testUsesNotificationSender, + )); $this->dispatch('success', 'Test Email sent.'); }, $decaySeconds = 10, diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index c8ad482d05..bce3938bd4 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -28,6 +28,8 @@ class InstanceSettings extends Model 'smtp_enabled', 'smtp_from_address', 'smtp_from_name', + 'notification_from_address', + 'notification_from_name', 'smtp_recipients', 'smtp_host', 'smtp_port', @@ -70,6 +72,8 @@ class InstanceSettings extends Model protected $hidden = [ 'smtp_from_address', 'smtp_from_name', + 'notification_from_address', + 'notification_from_name', 'smtp_recipients', 'smtp_host', 'smtp_username', @@ -83,6 +87,8 @@ class InstanceSettings extends Model 'smtp_enabled' => 'boolean', 'smtp_from_address' => 'encrypted', 'smtp_from_name' => 'encrypted', + 'notification_from_address' => 'encrypted', + 'notification_from_name' => 'encrypted', 'smtp_recipients' => 'encrypted', 'smtp_host' => 'encrypted', 'smtp_port' => 'integer', diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index ac0c9a9628..d063aa13b7 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -268,7 +268,7 @@ class S3Storage extends BaseModel // Get admins directly from the pivot relationship for this specific team $users = $team->members()->wherePivotIn('role', ['admin', 'owner'])->get(['users.id', 'users.email']); foreach ($users as $user) { - send_user_an_email($mail, $user->email); + send_user_an_email($mail, $user->email, asNotification: true); } $this->unusable_email_sent = true; } catch (\Throwable $emailException) { diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index b4c70f2f79..ffe89f7c4c 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -4,6 +4,7 @@ namespace App\Notifications\Channels; use App\Exceptions\NonReportableException; use App\Models\EmailNotificationSettings; +use App\Models\InstanceSettings; use App\Models\Team; use App\Services\Security\SensitiveDataRedactor; use App\Support\SmtpTransportFactory; @@ -42,6 +43,10 @@ class EmailChannel $settings = $notifiable->emailNotificationSettings; } + // Only the instance settings have a notification sender. A test email can ask for it explicitly. + $asNotification = $settings instanceof InstanceSettings + && (data_get($notification, 'usesNotificationSender') ?? ! $isTransactionalEmail); + $isResendEnabled = $settings->resend_enabled; $isSmtpEnabled = $settings->smtp_enabled; @@ -82,7 +87,7 @@ class EmailChannel if ($isResendEnabled) { $resend = Resend::client($settings->resend_api_key); $response = $resend->emails->send([ - 'from' => mail_from_formatted($settings), + 'from' => mail_from_formatted($settings, $asNotification), 'to' => $recipients, 'subject' => $mailMessage->subject, 'html' => (string) $mailMessage->render(), @@ -102,7 +107,7 @@ class EmailChannel ); $mailer = new Mailer($transport); - $email = mail_from_email(new Email, $settings) + $email = mail_from_email(new Email, $settings, $asNotification) ->to(...$recipients) ->subject($mailMessage->subject) ->html((string) $mailMessage->render()); diff --git a/app/Notifications/TransactionalEmails/Test.php b/app/Notifications/TransactionalEmails/Test.php index 2f7d70bbf0..169e13be76 100644 --- a/app/Notifications/TransactionalEmails/Test.php +++ b/app/Notifications/TransactionalEmails/Test.php @@ -10,8 +10,11 @@ class Test extends CustomEmailNotification { public bool $isTestNotification = true; - public function __construct(public string $emails, public bool $isTransactionalEmail = true) - { + public function __construct( + public string $emails, + public bool $isTransactionalEmail = true, + public ?bool $usesNotificationSender = null, + ) { $this->onQueue('high'); } diff --git a/app/Traits/TracksDeliveryFailures.php b/app/Traits/TracksDeliveryFailures.php index 8ab56705d4..aa9076f697 100644 --- a/app/Traits/TracksDeliveryFailures.php +++ b/app/Traits/TracksDeliveryFailures.php @@ -132,7 +132,7 @@ trait TracksDeliveryFailures $admins = $this->team->members()->wherePivotIn('role', ['admin', 'owner'])->get(['users.id', 'users.email']); foreach ($admins as $admin) { - send_user_an_email($mail, $admin->email); + send_user_an_email($mail, $admin->email, asNotification: true); } } catch (Throwable $e) { Log::warning('Failed to send notification channel disabled email: '.$e->getMessage()); diff --git a/bootstrap/helpers/notifications.php b/bootstrap/helpers/notifications.php index e76487383d..c6578b3f15 100644 --- a/bootstrap/helpers/notifications.php +++ b/bootstrap/helpers/notifications.php @@ -16,17 +16,28 @@ function is_transactional_emails_enabled(): bool } /** + * The sender of an email. With $asNotification, the optional notification sender of the + * instance settings replaces the default sender, so alerts do not share its reputation. + * * @return array{address: string, name: string} */ -function mail_from_identity(object $settings): array +function mail_from_identity(object $settings, bool $asNotification = false): array { - if (blank($settings->smtp_from_address ?? null)) { - throw new InvalidArgumentException('Transactional email sender address is not configured.'); + if ($asNotification && filled($settings->notification_from_address ?? null)) { + $address = (string) $settings->notification_from_address; + $name = trim((string) ($settings->notification_from_name ?? '')); + } else { + if (blank($settings->smtp_from_address ?? null)) { + throw new InvalidArgumentException('Transactional email sender address is not configured.'); + } + + $address = (string) $settings->smtp_from_address; + $name = ''; } - $address = (string) $settings->smtp_from_address; - - $name = trim((string) ($settings->smtp_from_name ?? '')); + if ($name === '') { + $name = trim((string) ($settings->smtp_from_name ?? '')); + } return [ 'address' => $address, @@ -34,29 +45,29 @@ function mail_from_identity(object $settings): array ]; } -function mail_from_address(object $settings): Address +function mail_from_address(object $settings, bool $asNotification = false): Address { - $identity = mail_from_identity($settings); + $identity = mail_from_identity($settings, $asNotification); return new Address($identity['address'], $identity['name']); } -function mail_from_formatted(object $settings): string +function mail_from_formatted(object $settings, bool $asNotification = false): string { - return mail_from_address($settings)->toString(); + return mail_from_address($settings, $asNotification)->toString(); } -function mail_from_email(Email $email, object $settings): Email +function mail_from_email(Email $email, object $settings, bool $asNotification = false): Email { - $email->from(mail_from_address($settings)); + $email->from(mail_from_address($settings, $asNotification)); prevent_mail_from_header_folding($email, $settings); return $email; } -function mail_from_message(Message $message, object $settings): Message +function mail_from_message(Message $message, object $settings, bool $asNotification = false): Message { - $identity = mail_from_identity($settings); + $identity = mail_from_identity($settings, $asNotification); $message->from($identity['address'], $identity['name']); prevent_mail_from_header_folding($message->getSymfonyMessage(), $settings); @@ -81,7 +92,11 @@ function send_internal_notification(string $message): void } } -function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null): void +/** + * Send an email through the instance email settings. Use $asNotification for alerts, so they + * come from the notification sender when one is set. + */ +function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null, bool $asNotification = false): void { $settings = instanceSettings(); $type = set_transanctional_email_settings($settings); @@ -92,7 +107,7 @@ function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null Mail::send( [], [], - fn (Message $message) => mail_from_message($message, $settings) + fn (Message $message) => mail_from_message($message, $settings, $asNotification) ->to($email) ->replyTo($email) ->cc($cc) @@ -103,7 +118,7 @@ function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null Mail::send( [], [], - fn (Message $message) => mail_from_message($message, $settings) + fn (Message $message) => mail_from_message($message, $settings, $asNotification) ->to($email) ->subject($mail->subject) ->html((string) $mail->render()) diff --git a/database/migrations/2026_11_03_194650_add_notification_sender_to_instance_settings_table.php b/database/migrations/2026_11_03_194650_add_notification_sender_to_instance_settings_table.php new file mode 100644 index 0000000000..e43e3377d3 --- /dev/null +++ b/database/migrations/2026_11_03_194650_add_notification_sender_to_instance_settings_table.php @@ -0,0 +1,23 @@ +text('notification_from_address')->nullable(); + $table->text('notification_from_name')->nullable(); + }); + } + + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn(['notification_from_address', 'notification_from_name']); + }); + } +}; diff --git a/resources/views/emails/before-trial-conversion.blade.php b/resources/views/emails/before-trial-conversion.blade.php deleted file mode 100644 index 09b9a54beb..0000000000 --- a/resources/views/emails/before-trial-conversion.blade.php +++ /dev/null @@ -1,7 +0,0 @@ - -We would like to inform you that a {{ config('constants.limits.trial_period') }} days of trial has been added to all subscription plans. - -You can try out Coolify, without payment information for free. If you like it, you can upgrade to a paid plan at any time. - -[Click here](https://app.coolify.io/subscription/new) to start your trial. - diff --git a/resources/views/emails/updates.blade.php b/resources/views/emails/updates.blade.php deleted file mode 100644 index c215727a64..0000000000 --- a/resources/views/emails/updates.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - -If you do not like to receive these emails, you can unsubscribe [here]({{ $unsubscribeUrl }}). - diff --git a/resources/views/livewire/partials/settings-email-send-test.blade.php b/resources/views/livewire/partials/settings-email-send-test.blade.php index a4757f8067..69d170e958 100644 --- a/resources/views/livewire/partials/settings-email-send-test.blade.php +++ b/resources/views/livewire/partials/settings-email-send-test.blade.php @@ -13,6 +13,7 @@
+
diff --git a/resources/views/livewire/settings-email.blade.php b/resources/views/livewire/settings-email.blade.php index dcec396ae7..22c0c9cad0 100644 --- a/resources/views/livewire/settings-email.blade.php +++ b/resources/views/livewire/settings-email.blade.php @@ -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. --}} + targets="smtpFromName,smtpFromAddress,notificationFromName,notificationFromAddress,smtpHost,smtpPort,smtpEncryption,smtpUsername,smtpPassword,smtpTimeout,smtpEhloDomain,resendApiKey" /> @@ -20,6 +20,12 @@ label="From name" /> + +