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 @@
-