diff --git a/CHANGELOG.md b/CHANGELOG.md index 5783668d1d..8deeaef144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Add SMTP templates for common email providers, such as PostShiba, Amazon SES, Mailgun, SendGrid, Postmark, and Brevo, which fill in the host, port, and encryption +- Save more than one named SMTP server on the transactional email and email notification pages, and switch between them +- Give each SMTP server and Resend its own sender name and address, because each provider sends from its own verified domain + +### Changed + +- Select the email provider from one list on the transactional email and email notification pages, which then show only the settings of that provider + +### Removed + +- Remove the "Copy from instance settings" button from the email notification page. Select "Use system-wide settings" to send team email with the instance email settings + ## [v4.6.0] - 2026-11-02 ### Breaking Changes diff --git a/app/Http/Controllers/Api/InstanceEmailSettingsController.php b/app/Http/Controllers/Api/InstanceEmailSettingsController.php index ad84b6629d..ddd9754188 100644 --- a/app/Http/Controllers/Api/InstanceEmailSettingsController.php +++ b/app/Http/Controllers/Api/InstanceEmailSettingsController.php @@ -16,6 +16,7 @@ class InstanceEmailSettingsController extends Controller 'smtp_enabled', 'smtp_from_address', 'smtp_from_name', 'smtp_host', 'smtp_port', 'smtp_encryption', 'smtp_username', 'smtp_password', 'smtp_timeout', 'smtp_ehlo_domain', 'resend_enabled', 'resend_api_key', + 'resend_from_address', 'resend_from_name', ]; #[OA\Get( @@ -67,6 +68,8 @@ class InstanceEmailSettingsController extends Controller 'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname], 'resend_enabled' => 'sometimes|boolean', 'resend_api_key' => 'sometimes|nullable|string|max:255', + 'resend_from_address' => 'sometimes|nullable|email', + 'resend_from_name' => 'sometimes|nullable|string|max:255', ]); if ($validator->fails()) { diff --git a/app/Http/Controllers/Api/NotificationsController.php b/app/Http/Controllers/Api/NotificationsController.php index fe38dbd1d5..19efbb9251 100644 --- a/app/Http/Controllers/Api/NotificationsController.php +++ b/app/Http/Controllers/Api/NotificationsController.php @@ -42,6 +42,8 @@ class NotificationsController extends Controller 'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname], 'resend_enabled' => 'sometimes|boolean', 'resend_api_key' => 'sometimes|nullable|string|max:255', + 'resend_from_address' => 'sometimes|nullable|email', + 'resend_from_name' => 'sometimes|nullable|string|max:255', 'use_instance_email_settings' => 'sometimes|boolean', 'deployment_success_email_notifications' => 'sometimes|boolean', 'deployment_failure_email_notifications' => 'sometimes|boolean', diff --git a/app/Livewire/Concerns/ManagesSmtpServers.php b/app/Livewire/Concerns/ManagesSmtpServers.php new file mode 100644 index 0000000000..bd886a78b7 --- /dev/null +++ b/app/Livewire/Concerns/ManagesSmtpServers.php @@ -0,0 +1,189 @@ + + */ + public function smtpServerOptions(): array + { + $options = [['value' => 'active', 'label' => $this->settings->smtpServerName()]]; + foreach ($this->settings->savedSmtpServers() as $server) { + $options[] = ['value' => $server['id'], 'label' => $server['name']]; + } + + return $options; + } + + /** + * Start a new server, with the fields of the selected template or empty fields. The server in use stays saved until the new one is saved. + */ + public function addSmtpServer(): void + { + $this->authorize('update', $this->settings); + $this->validate([ + 'newSmtpServerName' => 'required|string|max:255', + 'newSmtpServerTemplate' => ['nullable', Rule::in(array_keys(SmtpPresets::PRESETS))], + ], [ + 'newSmtpServerName.required' => 'Server name is required.', + ]); + $preset = SmtpPresets::PRESETS[$this->newSmtpServerTemplate] ?? null; + + $this->isNewSmtpServer = true; + $this->smtpServerName = $this->newSmtpServerName; + $this->newSmtpServerName = null; + $this->newSmtpServerTemplate = null; + $this->smtpHost = $preset['host'] ?? null; + $this->smtpPort = isset($preset['port']) ? (string) $preset['port'] : null; + $this->smtpEncryption = $preset['encryption'] ?? 'starttls'; + $this->smtpUsername = $preset['username'] ?? null; + $this->smtpPassword = null; + $this->smtpTimeout = null; + $this->smtpEhloDomain = null; + } + + public function cancelNewSmtpServer(): void + { + $this->isNewSmtpServer = false; + $this->resetErrorBag(); + $this->loadEmailSettings(); + } + + public function switchSmtpServer(string $id) + { + try { + $this->authorize('update', $this->settings); + if ($id === 'active') { + return null; + } + if (! $this->settings->switchToSmtpServer($id)) { + throw new Exception('This SMTP server does not exist anymore. Reload the page.'); + } + + $this->saveEmailSettings(); + $this->isNewSmtpServer = false; + $this->resetErrorBag(); + $this->loadEmailSettings(); + $this->dispatch('success', 'Switched to SMTP server '.$this->settings->smtpServerName().'.'); + } catch (\Throwable $e) { + $this->selectedSmtpServer = 'active'; + + return handleError($e, $this); + } + + return null; + } + + /** + * Rename the server in use. Only the name is saved; other unsaved changes on the page stay unsaved. + */ + public function renameSmtpServer() + { + try { + $this->authorize('update', $this->settings); + $this->validate(['renamedSmtpServerName' => 'required|string|max:255'], [ + 'renamedSmtpServerName.required' => 'Server name is required.', + ]); + + $this->smtpServerName = $this->settings->smtp_server_name = $this->renamedSmtpServerName; + $this->renamedSmtpServerName = null; + $this->saveEmailSettings(); + $this->dispatch('success', 'SMTP server renamed.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + + return null; + } + + /** + * Delete the server in use and use the next saved server. + */ + public function deleteSmtpServer() + { + try { + $this->authorize('update', $this->settings); + if ($this->isNewSmtpServer) { + $this->cancelNewSmtpServer(); + + return null; + } + + $name = $this->settings->smtpServerName(); + if (! $this->settings->deleteActiveSmtpServer()) { + throw new Exception('This is the only SMTP server. To stop using SMTP, select another provider.'); + } + + $this->saveEmailSettings(); + $this->resetErrorBag(); + $this->loadEmailSettings(); + $this->dispatch('success', 'SMTP server '.$name.' deleted. Now using '.$this->settings->smtpServerName().'.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + + return null; + } + + /** + * Copy the SMTP fields, including the sender, to the settings model. A new server first keeps the + * server in use in the saved list, so set no smtp_* column before this call. + */ + protected function fillSmtpServerSettings(): void + { + if ($this->isNewSmtpServer) { + $this->settings->keepActiveSmtpServer(); + $this->isNewSmtpServer = false; + } + + $this->settings->smtp_server_name = $this->smtpServerName; + $this->settings->smtp_from_address = $this->smtpFromAddress; + $this->settings->smtp_from_name = $this->smtpFromName; + $this->settings->smtp_host = $this->smtpHost; + $this->settings->smtp_port = $this->smtpPort; + $this->settings->smtp_encryption = $this->smtpEncryption; + $this->settings->smtp_username = $this->smtpUsername; + $this->settings->smtp_password = $this->smtpPassword; + $this->settings->smtp_timeout = $this->smtpTimeout; + $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; + } +} diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 0913e93507..1a15db1631 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -2,20 +2,25 @@ namespace App\Livewire\Notifications; +use App\Livewire\Concerns\ManagesSmtpServers; use App\Livewire\Notifications\Concerns\TogglesNotificationEvents; use App\Models\EmailNotificationSettings; use App\Models\Team; use App\Notifications\Test; use App\Rules\ValidHostname; +use App\Support\SmtpPresets; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; +use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class Email extends Component { - use AuthorizesRequests, TogglesNotificationEvents; + use AuthorizesRequests, ManagesSmtpServers, TogglesNotificationEvents; protected $listeners = ['refresh' => '$refresh']; @@ -67,6 +72,15 @@ class Email extends Component #[Validate(['nullable', 'string'])] public ?string $resendApiKey = null; + #[Validate(['nullable', 'email'])] + public ?string $resendFromAddress = null; + + #[Validate(['nullable', 'string'])] + public ?string $resendFromName = null; + + #[Validate(['required', 'in:none,smtp,resend'])] + public string $emailProvider = 'none'; + #[Validate(['boolean'])] public bool $useInstanceEmailSettings = false; @@ -141,19 +155,13 @@ class Email extends Component $this->validate(); $this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]); $this->settings->smtp_enabled = $this->smtpEnabled; - $this->settings->smtp_from_address = $this->smtpFromAddress; - $this->settings->smtp_from_name = $this->smtpFromName; $this->settings->smtp_recipients = $this->smtpRecipients; - $this->settings->smtp_host = $this->smtpHost; - $this->settings->smtp_port = $this->smtpPort; - $this->settings->smtp_encryption = $this->smtpEncryption; - $this->settings->smtp_username = $this->smtpUsername; - $this->settings->smtp_password = $this->smtpPassword; - $this->settings->smtp_timeout = $this->smtpTimeout; - $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; + $this->fillSmtpServerSettings(); $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; + $this->settings->resend_from_address = $this->resendFromAddress; + $this->settings->resend_from_name = $this->resendFromName; $this->settings->use_instance_email_settings = $this->useInstanceEmailSettings; @@ -173,9 +181,7 @@ class Email extends Component $this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications; $this->settings->server_patch_email_notifications = $this->serverPatchEmailNotifications; $this->settings->traefik_outdated_email_notifications = $this->traefikOutdatedEmailNotifications; - $changedFields = array_keys($this->settings->getDirty()); - $this->settings->save(); - $this->auditNotificationSettings($changedFields); + $this->saveEmailSettings(); } else { $this->smtpEnabled = $this->settings->smtp_enabled; @@ -193,11 +199,17 @@ class Email extends Component $this->smtpEhloDomain = $this->settings->smtp_ehlo_domain; $this->resendEnabled = $this->settings->resend_enabled; + $this->resendFromAddress = $this->settings->resend_from_address; + $this->resendFromName = $this->settings->resend_from_name; $this->resendApiKey = auth()->user()->can('update', $this->settings) ? $this->settings->resend_api_key : null; + $this->smtpServerName = $this->settings->smtp_server_name; + $this->selectedSmtpServer = 'active'; + $this->useInstanceEmailSettings = $this->settings->use_instance_email_settings; + $this->emailProvider = $this->activeEmailProvider(); $this->deploymentSuccessEmailNotifications = $this->settings->deployment_success_email_notifications; $this->deploymentFailureEmailNotifications = $this->settings->deployment_failure_email_notifications; @@ -222,12 +234,54 @@ class Email extends Component { try { $this->resetErrorBag(); + if (! $this->useInstanceEmailSettings) { + $this->validateEnabledEmailProvider(); + } $this->saveModel(); } catch (\Throwable $e) { return handleError($e, $this); } } + /** + * Turn on the provider selected in the provider list and save. When a required field of the + * provider is empty, nothing is saved and the page only shows the fields to fill in. + */ + public function selectEmailProvider() + { + $this->smtpEnabled = $this->emailProvider === 'smtp'; + $this->resendEnabled = $this->emailProvider === 'resend'; + + try { + $this->validateEnabledEmailProvider(); + } catch (ValidationException) { + // Not complete yet: show the fields of the provider and the "Not active yet" notice, without an error. + $this->resetErrorBag(); + + return null; + } + + return $this->submit(); + } + + /** + * The provider that sends the team's email now. It differs from $emailProvider until the selected provider is saved. + */ + #[Computed] + public function activeEmailProvider(): string + { + return enabled_email_provider($this->settings) ?? 'none'; + } + + private function validateEnabledEmailProvider(): void + { + match (true) { + $this->smtpEnabled => $this->validateSmtpSettings(), + $this->resendEnabled => $this->validateResendSettings(), + default => null, + }; + } + public function saveModel() { $this->authorize('update', $this->settings); @@ -288,7 +342,6 @@ class Email extends Component } else { $this->validateSmtpSettings(); $this->smtpEnabled = true; - $this->resendEnabled = false; $this->submitSmtp(); } } catch (\Throwable $e) { @@ -311,7 +364,6 @@ class Email extends Component } else { $this->validateResendSettings(); $this->resendEnabled = true; - $this->smtpEnabled = false; $this->submitResend(); } } catch (\Throwable $e) { @@ -332,23 +384,13 @@ class Email extends Component $this->validateSmtpSettings(); if ($this->smtpEnabled) { - $this->settings->resend_enabled = $this->resendEnabled = false; + $this->disableEmailProvidersExcept('smtp'); } $this->settings->smtp_enabled = $this->smtpEnabled; - $this->settings->smtp_from_address = $this->smtpFromAddress; - $this->settings->smtp_from_name = $this->smtpFromName; - $this->settings->smtp_host = $this->smtpHost; - $this->settings->smtp_port = $this->smtpPort; - $this->settings->smtp_encryption = $this->smtpEncryption; - $this->settings->smtp_username = $this->smtpUsername; - $this->settings->smtp_password = $this->smtpPassword; - $this->settings->smtp_timeout = $this->smtpTimeout; - $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; + $this->fillSmtpServerSettings(); - $changedFields = array_keys($this->settings->getDirty()); - $this->settings->save(); - $this->auditNotificationSettings($changedFields); + $this->saveEmailSettings(); $this->dispatch('success', 'SMTP settings updated.'); } catch (\Throwable $e) { $this->smtpEnabled = false; @@ -365,34 +407,51 @@ class Email extends Component $this->resetErrorBag(); $this->validateResendSettings(); if ($this->resendEnabled) { - $this->settings->smtp_enabled = $this->smtpEnabled = false; + $this->disableEmailProvidersExcept('resend'); } $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; - $this->settings->smtp_from_address = $this->smtpFromAddress; - $this->settings->smtp_from_name = $this->smtpFromName; + $this->settings->resend_from_address = $this->resendFromAddress; + $this->settings->resend_from_name = $this->resendFromName; - $changedFields = array_keys($this->settings->getDirty()); - $this->settings->save(); - $this->auditNotificationSettings($changedFields); + $this->saveEmailSettings(); $this->dispatch('success', 'Resend settings updated.'); } catch (\Throwable $e) { return handleError($e, $this); } } + /** + * Turn off every email provider except the given one, so only one provider sends email. + */ + private function disableEmailProvidersExcept(string $provider): void + { + if ($provider !== 'smtp') { + $this->settings->smtp_enabled = $this->smtpEnabled = false; + } + if ($provider !== 'resend') { + $this->settings->resend_enabled = $this->resendEnabled = false; + } + } + + protected function loadEmailSettings(): void + { + $this->syncData(); + } + private function validateSmtpSettings(): void { $this->validate([ 'smtpEnabled' => 'boolean', + 'smtpServerName' => 'nullable|string|max:255', 'smtpFromAddress' => 'required|email', 'smtpFromName' => 'required|string', 'smtpHost' => 'required|string', 'smtpPort' => 'required|numeric', 'smtpEncryption' => 'required|string|in:starttls,tls,none', - 'smtpUsername' => 'nullable|string', - 'smtpPassword' => 'nullable|string', + 'smtpUsername' => [Rule::requiredIf(SmtpPresets::requiresLogin($this->smtpHost)), 'nullable', 'string'], + 'smtpPassword' => [Rule::requiredIf(SmtpPresets::requiresLogin($this->smtpHost)), 'nullable', 'required_with:smtpUsername', 'string'], 'smtpTimeout' => 'nullable|numeric', 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], ], [ @@ -402,6 +461,9 @@ class Email extends Component 'smtpHost.required' => 'SMTP Host is required.', 'smtpPort.required' => 'SMTP Port is required.', 'smtpPort.numeric' => 'SMTP Port must be a number.', + 'smtpUsername.required' => 'This provider needs a username.', + 'smtpPassword.required' => 'This provider needs a password.', + 'smtpPassword.required_with' => 'Enter the password for this username.', 'smtpEncryption.required' => 'Encryption type is required.', ]); } @@ -411,13 +473,13 @@ class Email extends Component $this->validate([ 'resendEnabled' => 'boolean', 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', + 'resendFromAddress' => 'required|email', + 'resendFromName' => 'required|string', ], [ 'resendApiKey.required' => 'Resend API Key is required.', - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', + 'resendFromAddress.required' => 'From Address is required.', + 'resendFromAddress.email' => 'Please enter a valid email address.', + 'resendFromName.required' => 'From Name is required.', ]); } @@ -450,44 +512,20 @@ class Email extends Component } } - public function copyFromInstanceSettings() - { - $this->authorize('update', $this->settings); - $settings = instanceSettings(); - $this->authorize('view', $settings); - $this->smtpFromAddress = $settings->smtp_from_address; - $this->smtpFromName = $settings->smtp_from_name; - - if ($settings->smtp_enabled) { - $this->smtpEnabled = true; - $this->resendEnabled = false; - } - - $this->smtpRecipients = $settings->smtp_recipients; - $this->smtpHost = $settings->smtp_host; - $this->smtpPort = $settings->smtp_port; - $this->smtpEncryption = $settings->smtp_encryption; - $this->smtpUsername = $settings->smtp_username; - $this->smtpPassword = $settings->smtp_password; - $this->smtpTimeout = $settings->smtp_timeout; - $this->smtpEhloDomain = $settings->smtp_ehlo_domain; - - if ($settings->resend_enabled) { - $this->resendEnabled = true; - $this->smtpEnabled = false; - } - $this->resendApiKey = $settings->resend_api_key; - $this->saveModel(); - - } - public function render() { return view('livewire.notifications.email'); } - private function auditNotificationSettings(array $changedFields): void + /** + * Save the settings, record the changed field names (never secret values), and show the saved provider. + */ + protected function saveEmailSettings(): void { + $changedFields = array_keys($this->settings->getDirty()); + $this->settings->save(); + $this->emailProvider = $this->activeEmailProvider(); + if ($changedFields !== []) { auditLog('ui.notifications.email.updated', [ 'team_id' => $this->team->id, diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 825d6a27c5..526b819144 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -116,13 +116,10 @@ class Index extends Component } // For self-hosted, check if email is enabled - if (! isCloud()) { - $settings = instanceSettings(); - if (! $settings->smtp_enabled && ! $settings->resend_enabled) { - $this->dispatch('error', 'Email functionality is not configured. Please contact your administrator.'); + if (! isCloud() && ! is_transactional_emails_enabled()) { + $this->dispatch('error', 'Email functionality is not configured. Please contact your administrator.'); - return; - } + return; } $this->validate([ diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index ba0be82420..9749ac1b07 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -2,19 +2,24 @@ namespace App\Livewire; +use App\Livewire\Concerns\ManagesSmtpServers; use App\Models\InstanceSettings; use App\Models\Team; use App\Notifications\TransactionalEmails\Test; use App\Rules\ValidHostname; +use App\Support\SmtpPresets; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; +use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class SettingsEmail extends Component { - use AuthorizesRequests; + use AuthorizesRequests, ManagesSmtpServers; public InstanceSettings $settings; @@ -60,6 +65,15 @@ class SettingsEmail extends Component #[Validate(['nullable', 'string'])] public ?string $resendApiKey = null; + #[Validate(['nullable', 'email'])] + public ?string $resendFromAddress = null; + + #[Validate(['nullable', 'string'])] + public ?string $resendFromName = null; + + #[Validate(['required', 'in:none,smtp,resend'])] + public string $emailProvider = 'none'; + #[Validate(['nullable', 'email'])] public ?string $testEmailAddress = null; @@ -80,19 +94,13 @@ class SettingsEmail extends Component $this->validate(); $this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]); $this->settings->smtp_enabled = $this->smtpEnabled; - $this->settings->smtp_host = $this->smtpHost; - $this->settings->smtp_port = $this->smtpPort; - $this->settings->smtp_encryption = $this->smtpEncryption; - $this->settings->smtp_username = $this->smtpUsername; - $this->settings->smtp_password = $this->smtpPassword; - $this->settings->smtp_timeout = $this->smtpTimeout; - $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; - $this->settings->smtp_from_address = $this->smtpFromAddress; - $this->settings->smtp_from_name = $this->smtpFromName; + $this->fillSmtpServerSettings(); $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; - $this->saveAndAudit(); + $this->settings->resend_from_address = $this->resendFromAddress; + $this->settings->resend_from_name = $this->resendFromName; + $this->saveEmailSettings(); } else { $this->smtpEnabled = $this->settings->smtp_enabled; $this->smtpHost = $this->settings->smtp_host; @@ -106,15 +114,31 @@ class SettingsEmail extends Component $this->smtpFromName = $this->settings->smtp_from_name; $this->resendEnabled = $this->settings->resend_enabled; + $this->resendFromAddress = $this->settings->resend_from_address; + $this->resendFromName = $this->settings->resend_from_name; $this->resendApiKey = $this->settings->resend_api_key; + + $this->smtpServerName = $this->settings->smtp_server_name; + $this->selectedSmtpServer = 'active'; + $this->emailProvider = $this->activeEmailProvider(); } } + /** + * The provider that sends email now. It differs from $emailProvider until the selected provider is saved. + */ + #[Computed] + public function activeEmailProvider(): string + { + return enabled_email_provider($this->settings) ?? 'none'; + } + public function submit() { try { $this->authorize('update', $this->settings); $this->resetErrorBag(); + $this->validateEnabledEmailProvider(); $this->syncData(true); $this->dispatch('success', 'Transactional email settings updated.'); } catch (\Throwable $e) { @@ -122,42 +146,34 @@ class SettingsEmail extends Component } } - public function instantSave(string $type) + /** + * Turn on the provider selected in the provider list and save. When a required field of the + * provider is empty, nothing is saved and the page only shows the fields to fill in. + */ + public function selectEmailProvider() { + $this->smtpEnabled = $this->emailProvider === 'smtp'; + $this->resendEnabled = $this->emailProvider === 'resend'; + try { - $this->authorize('update', $this->settings); - $currentSmtpEnabled = $this->settings->smtp_enabled; - $currentResendEnabled = $this->settings->resend_enabled; + $this->validateEnabledEmailProvider(); + } catch (ValidationException) { + // Not complete yet: show the fields of the provider and the "Not active yet" notice, without an error. $this->resetErrorBag(); - if ($type === 'SMTP') { - $this->submitSmtp(); - $this->resendEnabled = $this->settings->resend_enabled = false; - } elseif ($type === 'Resend') { - $this->submitResend(); - $this->smtpEnabled = $this->settings->smtp_enabled = false; - } - $this->saveAndAudit(); - - } catch (\Throwable $e) { - if ($type === 'SMTP') { - $this->smtpEnabled = $currentSmtpEnabled; - } elseif ($type === 'Resend') { - $this->resendEnabled = $currentResendEnabled; - } - - return handleError($e, $this); + return null; } + + return $this->submit(); } - public function instantSaveSmtp(): void + private function validateEnabledEmailProvider(): void { - $this->instantSave('SMTP'); - } - - public function instantSaveResend(): void - { - $this->instantSave('Resend'); + match (true) { + $this->smtpEnabled => $this->validateSmtpSettings(), + $this->resendEnabled => $this->validateResendSettings(), + default => null, + }; } public function toggleSmtp() @@ -173,7 +189,6 @@ class SettingsEmail extends Component } else { $this->validateSmtpSettings(); $this->smtpEnabled = true; - $this->resendEnabled = false; $this->submitSmtp(); } } catch (\Throwable $e) { @@ -196,7 +211,6 @@ class SettingsEmail extends Component } else { $this->validateResendSettings(); $this->resendEnabled = true; - $this->smtpEnabled = false; $this->submitResend(); } } catch (\Throwable $e) { @@ -213,21 +227,13 @@ class SettingsEmail extends Component $this->validateSmtpSettings(); if ($this->smtpEnabled) { - $this->settings->resend_enabled = $this->resendEnabled = false; + $this->disableEmailProvidersExcept('smtp'); } $this->settings->smtp_enabled = $this->smtpEnabled; - $this->settings->smtp_host = $this->smtpHost; - $this->settings->smtp_port = $this->smtpPort; - $this->settings->smtp_encryption = $this->smtpEncryption; - $this->settings->smtp_username = $this->smtpUsername; - $this->settings->smtp_password = $this->smtpPassword; - $this->settings->smtp_timeout = $this->smtpTimeout; - $this->settings->smtp_ehlo_domain = $this->smtpEhloDomain; - $this->settings->smtp_from_address = $this->smtpFromAddress; - $this->settings->smtp_from_name = $this->smtpFromName; + $this->fillSmtpServerSettings(); - $this->saveAndAudit(); + $this->saveEmailSettings(); $this->dispatch('success', 'SMTP settings updated.'); } catch (\Throwable $e) { @@ -244,15 +250,15 @@ class SettingsEmail extends Component $this->validateResendSettings(); if ($this->resendEnabled) { - $this->settings->smtp_enabled = $this->smtpEnabled = false; + $this->disableEmailProvidersExcept('resend'); } $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; - $this->settings->smtp_from_address = $this->smtpFromAddress; - $this->settings->smtp_from_name = $this->smtpFromName; + $this->settings->resend_from_address = $this->resendFromAddress; + $this->settings->resend_from_name = $this->resendFromName; - $this->saveAndAudit(); + $this->saveEmailSettings(); $this->dispatch('success', 'Resend settings updated.'); } catch (\Throwable $e) { @@ -262,13 +268,27 @@ class SettingsEmail extends Component } } + /** + * Turn off every email provider except the given one, so only one provider sends email. + */ + private function disableEmailProvidersExcept(string $provider): void + { + if ($provider !== 'smtp') { + $this->settings->smtp_enabled = $this->smtpEnabled = false; + } + if ($provider !== 'resend') { + $this->settings->resend_enabled = $this->resendEnabled = false; + } + } + /** * Save the instance email settings and record the changed field names (never their values). */ - private function saveAndAudit(): void + protected function saveEmailSettings(): void { $changedFields = auditChangedFields($this->settings); $this->settings->save(); + $this->emailProvider = $this->activeEmailProvider(); if ($changedFields === []) { return; @@ -282,17 +302,23 @@ class SettingsEmail extends Component ]); } + protected function loadEmailSettings(): void + { + $this->syncData(); + } + private function validateSmtpSettings(): void { $this->validate([ 'smtpEnabled' => 'boolean', + 'smtpServerName' => 'nullable|string|max:255', 'smtpFromAddress' => 'required|email', 'smtpFromName' => 'required|string', 'smtpHost' => 'required|string', 'smtpPort' => 'required|numeric', 'smtpEncryption' => 'required|string|in:starttls,tls,none', - 'smtpUsername' => 'nullable|string', - 'smtpPassword' => 'nullable|string', + 'smtpUsername' => [Rule::requiredIf(SmtpPresets::requiresLogin($this->smtpHost)), 'nullable', 'string'], + 'smtpPassword' => [Rule::requiredIf(SmtpPresets::requiresLogin($this->smtpHost)), 'nullable', 'required_with:smtpUsername', 'string'], 'smtpTimeout' => 'nullable|numeric', 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], ], [ @@ -302,6 +328,9 @@ class SettingsEmail extends Component 'smtpHost.required' => 'SMTP Host is required.', 'smtpPort.required' => 'SMTP Port is required.', 'smtpPort.numeric' => 'SMTP Port must be a number.', + 'smtpUsername.required' => 'This provider needs a username.', + 'smtpPassword.required' => 'This provider needs a password.', + 'smtpPassword.required_with' => 'Enter the password for this username.', 'smtpEncryption.required' => 'Encryption type is required.', ]); } @@ -311,13 +340,13 @@ class SettingsEmail extends Component $this->validate([ 'resendEnabled' => 'boolean', 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', + 'resendFromAddress' => 'required|email', + 'resendFromName' => 'required|string', ], [ 'resendApiKey.required' => 'Resend API Key is required.', - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', + 'resendFromAddress.required' => 'From Address is required.', + 'resendFromAddress.email' => 'Please enter a valid email address.', + 'resendFromName.required' => 'From Name is required.', ]); } @@ -327,19 +356,22 @@ 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->saveAndAudit(); + // Send with the sender on the page, so that a changed sender can be tested before Save. + if ($this->settings->resend_enabled) { + $this->validateResendSettings(); + $this->settings->resend_from_address = $this->resendFromAddress; + $this->settings->resend_from_name = $this->resendFromName; + } else { + $this->validateSmtpSettings(); + $this->settings->smtp_from_address = $this->smtpFromAddress; + $this->settings->smtp_from_name = $this->smtpFromName; + } + $this->saveEmailSettings(); $executed = RateLimiter::attempt( 'test-email:'.$this->team->id, diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 98e530ce84..def98785d1 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -2,12 +2,13 @@ namespace App\Models; +use App\Traits\HasSmtpServers; use App\Traits\TracksDeliveryFailures; use Illuminate\Database\Eloquent\Model; class EmailNotificationSettings extends Model { - use TracksDeliveryFailures; + use HasSmtpServers, TracksDeliveryFailures; public $timestamps = false; @@ -28,6 +29,8 @@ class EmailNotificationSettings extends Model 'resend_enabled', 'resend_api_key', + 'resend_from_address', + 'resend_from_name', 'use_instance_email_settings', @@ -57,6 +60,8 @@ class EmailNotificationSettings extends Model 'smtp_username', 'smtp_password', 'resend_api_key', + 'resend_from_address', + 'resend_from_name', ]; protected $casts = [ @@ -72,6 +77,8 @@ class EmailNotificationSettings extends Model 'resend_enabled' => 'boolean', 'resend_api_key' => 'encrypted', + 'resend_from_address' => 'encrypted', + 'resend_from_name' => 'encrypted', 'use_instance_email_settings' => 'boolean', @@ -96,7 +103,7 @@ class EmailNotificationSettings extends Model public function isEnabled() { - return $this->smtp_enabled || $this->resend_enabled || $this->use_instance_email_settings; + return filled(enabled_email_provider($this)) || $this->use_instance_email_settings; } public function notificationChannelName(): string diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index c8ad482d05..b13d2f1a07 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\HasSmtpServers; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Once; @@ -9,6 +10,8 @@ use Spatie\Url\Url; class InstanceSettings extends Model { + use HasSmtpServers; + protected $attributes = [ 'is_dashboard_force_https_enabled' => true, ]; @@ -38,6 +41,8 @@ class InstanceSettings extends Model 'smtp_ehlo_domain', 'resend_enabled', 'resend_api_key', + 'resend_from_address', + 'resend_from_name', 'is_dns_validation_enabled', 'custom_dns_servers', 'domain_connect_private_key', @@ -75,6 +80,8 @@ class InstanceSettings extends Model 'smtp_username', 'smtp_password', 'resend_api_key', + 'resend_from_address', + 'resend_from_name', 'domain_connect_private_key', 'sentinel_token', ]; @@ -92,6 +99,8 @@ class InstanceSettings extends Model 'resend_enabled' => 'boolean', 'resend_api_key' => 'encrypted', + 'resend_from_address' => 'encrypted', + 'resend_from_name' => 'encrypted', 'domain_connect_private_key' => 'encrypted', 'allowed_ip_ranges' => 'array', diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php index f4e8b294a4..4911559f08 100644 --- a/app/Notifications/Channels/TransactionalEmailChannel.php +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -13,7 +13,7 @@ class TransactionalEmailChannel public function send(User $notifiable, Notification $notification): void { $settings = instanceSettings(); - if (! data_get($settings, 'smtp_enabled') && ! data_get($settings, 'resend_enabled')) { + if (blank(enabled_email_provider($settings))) { return; } diff --git a/app/Support/SmtpPresets.php b/app/Support/SmtpPresets.php new file mode 100644 index 0000000000..edffb3b8b6 --- /dev/null +++ b/app/Support/SmtpPresets.php @@ -0,0 +1,51 @@ + + */ + public const PRESETS = [ + 'postshiba' => ['label' => 'PostShiba', 'host' => 'smtp.postshiba.com', 'port' => 587, 'encryption' => 'starttls'], + 'amazon-ses' => ['label' => 'Amazon SES (us-east-1)', 'host' => 'email-smtp.us-east-1.amazonaws.com', 'port' => 587, 'encryption' => 'starttls'], + 'brevo' => ['label' => 'Brevo', 'host' => 'smtp-relay.brevo.com', 'port' => 587, 'encryption' => 'starttls'], + 'gmail' => ['label' => 'Gmail', 'host' => 'smtp.gmail.com', 'port' => 587, 'encryption' => 'starttls'], + 'mailgun' => ['label' => 'Mailgun', 'host' => 'smtp.mailgun.org', 'port' => 587, 'encryption' => 'starttls'], + 'mailjet' => ['label' => 'Mailjet', 'host' => 'in-v3.mailjet.com', 'port' => 587, 'encryption' => 'starttls'], + 'microsoft-365' => ['label' => 'Microsoft 365', 'host' => 'smtp.office365.com', 'port' => 587, 'encryption' => 'starttls'], + 'postmark' => ['label' => 'Postmark', 'host' => 'smtp.postmarkapp.com', 'port' => 587, 'encryption' => 'starttls'], + 'proton-mail' => ['label' => 'Proton Mail', 'host' => 'smtp.protonmail.ch', 'port' => 587, 'encryption' => 'starttls'], + 'resend' => ['label' => 'Resend', 'host' => 'smtp.resend.com', 'port' => 465, 'encryption' => 'tls', 'username' => 'resend'], + 'sendgrid' => ['label' => 'SendGrid', 'host' => 'smtp.sendgrid.net', 'port' => 587, 'encryption' => 'starttls', 'username' => 'apikey'], + 'zoho' => ['label' => 'Zoho Mail', 'host' => 'smtp.zoho.com', 'port' => 465, 'encryption' => 'tls'], + ]; + + /** + * Whether the host belongs to a provider of the presets. All of them need a username and password. + */ + public static function requiresLogin(?string $host): bool + { + $host = strtolower(trim((string) $host)); + + return $host !== '' && collect(self::PRESETS)->contains(fn (array $preset) => $preset['host'] === $host); + } + + /** + * Options for a listbox. + * + * @return list + */ + public static function options(): array + { + return collect(self::PRESETS) + ->map(fn (array $preset, string $key) => ['value' => $key, 'label' => $preset['label']]) + ->values() + ->all(); + } +} diff --git a/app/Traits/HasSmtpServers.php b/app/Traits/HasSmtpServers.php new file mode 100644 index 0000000000..a4ceeac732 --- /dev/null +++ b/app/Traits/HasSmtpServers.php @@ -0,0 +1,117 @@ +> $smtp_servers + */ +trait HasSmtpServers +{ + public const DEFAULT_SMTP_SERVER_NAME = 'Default'; + + /** + * The columns that belong to one SMTP server, including its sender, because each server + * usually sends from its own verified domain. + */ + public const SMTP_SERVER_COLUMNS = [ + 'smtp_from_address', + 'smtp_from_name', + 'smtp_host', + 'smtp_port', + 'smtp_encryption', + 'smtp_username', + 'smtp_password', + 'smtp_timeout', + 'smtp_ehlo_domain', + ]; + + public function initializeHasSmtpServers(): void + { + $this->mergeCasts(['smtp_servers' => 'encrypted:array']); + $this->makeHidden('smtp_servers'); + } + + public function smtpServerName(): string + { + return filled($this->smtp_server_name) ? $this->smtp_server_name : self::DEFAULT_SMTP_SERVER_NAME; + } + + /** + * @return list> + */ + public function savedSmtpServers(): array + { + return array_values($this->smtp_servers ?? []); + } + + /** + * Keep the server in use in the saved list, so that new values can replace it. + * A server without a host has nothing worth keeping. + */ + public function keepActiveSmtpServer(): void + { + if (blank($this->smtp_host)) { + return; + } + + $server = ['id' => (string) Str::uuid(), 'name' => $this->smtpServerName()]; + foreach (self::SMTP_SERVER_COLUMNS as $column) { + $server[$column] = $this->{$column}; + } + + $this->smtp_servers = [...$this->savedSmtpServers(), $server]; + } + + /** + * Use the saved server with the given ID, and keep the server in use in the saved list. + */ + public function switchToSmtpServer(string $id): bool + { + $target = collect($this->savedSmtpServers())->firstWhere('id', $id); + if ($target === null) { + return false; + } + + $this->smtp_servers = collect($this->savedSmtpServers())->reject(fn (array $server) => $server['id'] === $id)->values()->all(); + $this->keepActiveSmtpServer(); + $this->useSmtpServer($target); + + return true; + } + + /** + * Delete the server in use and use the first saved server instead. + */ + public function deleteActiveSmtpServer(): bool + { + $servers = $this->savedSmtpServers(); + if ($servers === []) { + return false; + } + + $this->smtp_servers = array_slice($servers, 1); + $this->useSmtpServer($servers[0]); + + return true; + } + + /** + * @param array $server + */ + private function useSmtpServer(array $server): void + { + $this->smtp_server_name = $server['name'] ?? null; + foreach (self::SMTP_SERVER_COLUMNS as $column) { + $this->{$column} = $server[$column] ?? null; + } + } +} diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php index 0c85d5c3e7..0dac8e0797 100644 --- a/app/View/Components/Forms/Input.php +++ b/app/View/Components/Forms/Input.php @@ -37,6 +37,8 @@ class Input extends Component public bool $autoDisable = true, public bool $loading = false, public string $loadingText = 'Loading...', + // Alpine expression that makes the field required while it is true, for rules that depend on other unsaved fields. + public ?string $requiredWhen = null, ) { // Handle authorization-based disabling if ($this->canGate && $this->canResource && $this->autoDisable) { diff --git a/bootstrap/helpers/notifications.php b/bootstrap/helpers/notifications.php index e76487383d..49db54b094 100644 --- a/bootstrap/helpers/notifications.php +++ b/bootstrap/helpers/notifications.php @@ -12,7 +12,35 @@ function is_transactional_emails_enabled(): bool { $settings = instanceSettings(); - return $settings->smtp_enabled || $settings->resend_enabled; + return filled(enabled_email_provider($settings)); +} + +/** + * Labels of the email providers, in the order that the provider selectors show them. + * + * @return array<'none'|'smtp'|'resend', string> + */ +function email_provider_labels(): array +{ + return [ + 'none' => 'None', + 'smtp' => 'SMTP', + 'resend' => 'Resend', + ]; +} + +/** + * Get the email provider that is turned on. Only one provider can be on at a time. + * + * @return 'resend'|'smtp'|null + */ +function enabled_email_provider(?object $settings): ?string +{ + return match (true) { + (bool) data_get($settings, 'resend_enabled') => 'resend', + (bool) data_get($settings, 'smtp_enabled') => 'smtp', + default => null, + }; } /** @@ -20,13 +48,17 @@ function is_transactional_emails_enabled(): bool */ function mail_from_identity(object $settings): array { - if (blank($settings->smtp_from_address ?? null)) { + // Each provider has its own sender, because each one sends from its own verified domain. + $prefix = data_get($settings, 'resend_enabled') ? 'resend' : 'smtp'; + $address = data_get($settings, "{$prefix}_from_address"); + + if (blank($address)) { throw new InvalidArgumentException('Transactional email sender address is not configured.'); } - $address = (string) $settings->smtp_from_address; + $address = (string) $address; - $name = trim((string) ($settings->smtp_from_name ?? '')); + $name = trim((string) data_get($settings, "{$prefix}_from_name", '')); return [ 'address' => $address, @@ -116,20 +148,13 @@ function set_transanctional_email_settings($settings = null) if (! $settings) { $settings = instanceSettings(); } - if (! data_get($settings, 'smtp_enabled') && ! data_get($settings, 'resend_enabled')) { + $provider = enabled_email_provider($settings); + if (blank($provider)) { return null; } $configRepository = app('App\Services\ConfigurationRepository'::class); $configRepository->updateMailConfig($settings); - if (data_get($settings, 'resend_enabled')) { - return 'resend'; - } - - if (data_get($settings, 'smtp_enabled')) { - return 'smtp'; - } - - return null; + return $provider; } diff --git a/database/migrations/2026_11_03_225251_add_named_smtp_servers_to_email_settings.php b/database/migrations/2026_11_03_225251_add_named_smtp_servers_to_email_settings.php new file mode 100644 index 0000000000..46277253ba --- /dev/null +++ b/database/migrations/2026_11_03_225251_add_named_smtp_servers_to_email_settings.php @@ -0,0 +1,38 @@ +string('smtp_server_name')->nullable(); + $table->text('smtp_servers')->nullable(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach (['instance_settings', 'email_notification_settings'] as $tableName) { + Schema::table($tableName, function (Blueprint $table) { + $table->dropColumn(['smtp_server_name', 'smtp_servers']); + }); + } + } +}; diff --git a/database/migrations/2026_11_04_103949_add_resend_sender_to_email_settings.php b/database/migrations/2026_11_04_103949_add_resend_sender_to_email_settings.php new file mode 100644 index 0000000000..e759db732e --- /dev/null +++ b/database/migrations/2026_11_04_103949_add_resend_sender_to_email_settings.php @@ -0,0 +1,44 @@ +text('resend_from_address')->nullable(); + $table->text('resend_from_name')->nullable(); + }); + + DB::table($tableName)->update([ + 'resend_from_address' => DB::raw('smtp_from_address'), + 'resend_from_name' => DB::raw('smtp_from_name'), + ]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach (['instance_settings', 'email_notification_settings'] as $tableName) { + Schema::table($tableName, function (Blueprint $table) { + $table->dropColumn(['resend_from_address', 'resend_from_name']); + }); + } + } +}; diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index 96df72ff69..cd822e0d23 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -30,6 +30,8 @@