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" /> + +