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