feat(email): add named SMTP servers, provider templates, Resend sender (#12471)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-05 11:38:17 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bf4b6ad921
commit f7867923b7
22 changed files with 1009 additions and 389 deletions
+14
View File
@@ -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
@@ -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()) {
@@ -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',
@@ -0,0 +1,189 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\EmailNotificationSettings;
use App\Models\InstanceSettings;
use App\Support\SmtpPresets;
use Exception;
use Illuminate\Validation\Rule;
/**
* Named SMTP servers and SMTP templates on an email settings page.
*
* The component keeps the SMTP fields of the server in use (smtpHost, smtpPort, ...).
* Switching and deleting save at once. A new server only empties the fields, and a
* template fills them in the browser (smtp-server-picker partial); the normal Save stores them.
*
* @property InstanceSettings|EmailNotificationSettings $settings
*/
trait ManagesSmtpServers
{
public ?string $smtpServerName = null;
public string $selectedSmtpServer = 'active';
public bool $isNewSmtpServer = false;
public ?string $newSmtpServerName = null;
public ?string $renamedSmtpServerName = null;
public ?string $newSmtpServerTemplate = null;
/**
* Save the settings model.
*/
abstract protected function saveEmailSettings(): void;
/**
* Load the component fields from the settings model.
*/
abstract protected function loadEmailSettings(): void;
/**
* @return list<array{value: string, label: string}>
*/
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;
}
}
+111 -73
View File
@@ -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,
+3 -6
View File
@@ -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([
+104 -72
View File
@@ -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,
+9 -2
View File
@@ -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
+9
View File
@@ -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',
@@ -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;
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Support;
/**
* SMTP settings of common email providers. A preset only fills the connection fields;
* the user still enters the username and password from the provider.
*/
class SmtpPresets
{
/**
* @var array<string, array{label: string, host: string, port: int, encryption: 'starttls'|'tls', username?: string}>
*/
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<array{value: string, label: string}>
*/
public static function options(): array
{
return collect(self::PRESETS)
->map(fn (array $preset, string $key) => ['value' => $key, 'label' => $preset['label']])
->values()
->all();
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace App\Traits;
use Illuminate\Support\Str;
/**
* Named SMTP servers for email settings.
*
* The smtp_* columns always hold the server in use, so sending and the API read them as before.
* smtp_server_name is the name of that server. smtp_servers holds the other saved servers;
* switching swaps the server in use with a saved one. The methods change the model but do not save it.
*
* @property ?string $smtp_server_name
* @property ?array<int, array<string, mixed>> $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<array<string, mixed>>
*/
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<string, mixed> $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;
}
}
}
+2
View File
@@ -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) {
+39 -14
View File
@@ -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;
}
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* The smtp_* columns stay the SMTP server in use. smtp_server_name is its name, and
* smtp_servers holds the other saved servers (encrypted, because they contain passwords).
*/
public function up(): void
{
foreach (['instance_settings', 'email_notification_settings'] as $tableName) {
if (Schema::hasColumn($tableName, 'smtp_servers')) {
continue;
}
Schema::table($tableName, function (Blueprint $table) {
$table->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']);
});
}
}
};
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Resend gets its own sender. It starts as a copy of the current sender, so that nothing changes
* for existing installs. Both columns use the same encrypted cast, so the stored values copy as they are.
*/
public function up(): void
{
foreach (['instance_settings', 'email_notification_settings'] as $tableName) {
if (Schema::hasColumn($tableName, 'resend_from_address')) {
continue;
}
Schema::table($tableName, function (Blueprint $table) {
$table->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']);
});
}
}
};
@@ -30,6 +30,8 @@
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4">{{ $label }}
@if ($required)
<x-highlighted text="*" />
@elseif ($requiredWhen)
<span x-cloak x-show="{{ $requiredWhen }}"><x-highlighted text="*" /></span>
@endif
@isset($labelSuffix)
{{ $labelSuffix }}
@@ -49,7 +51,7 @@
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
x-bind:type="type"
x-bind:class="{ 'truncate': type === 'text' && ! $el.disabled }"
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @if ($requiredWhen) x-bind:required="{{ $requiredWhen }}" @endif
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
wire:loading.attr="disabled"
@readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
@@ -79,7 +81,7 @@
<div class="relative">
@endif
<input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly)
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @if ($requiredWhen) x-bind:required="{{ $requiredWhen }}" @endif @readonly($readonly)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
wire:loading.attr="disabled"
type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}"
@@ -9,31 +9,38 @@
<x-unsaved-bar action="submit" />
<x-application.settings-section title="Email delivery">
<x-slot:actions>
@if (auth()->user()->isAdminFromSession())
@can('sendTest', $settings)
@if ($team->isNotificationEnabled('email'))
<x-modal-input title="Send Test Email">
<x-slot:content>
<button type="button" class="button">
<x-reicon name="notifications" class="size-3.5" />
Send test
</button>
</x-slot:content>
<form wire:submit.prevent="sendTestEmail" class="flex w-full flex-col gap-4">
<x-forms.input wire:model="testEmailAddress" placeholder="test@example.com"
id="testEmailAddress" label="Recipient" required />
<div class="flex justify-end border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<button type="submit" @click="modalOpen=false"
class="button button-highlighted">
Send email
</button>
</div>
</form>
</x-modal-input>
@else
<button type="button" class="button" disabled>Send test</button>
@endif
@endcan
@php
$canSendTest = auth()->user()->isAdminFromSession() && auth()->user()->can('sendTest', $settings);
$sendTestReason = match (true) {
! $canSendTest => 'Only admins can send a test email.',
! $team->isNotificationEnabled('email') => 'Save an email provider first.',
default => null,
};
@endphp
@if ($sendTestReason === null)
<x-modal-input title="Send Test Email">
<x-slot:content>
<button type="button" class="button">
<x-reicon name="notifications" class="size-3.5" />
Send test
</button>
</x-slot:content>
<form wire:submit.prevent="sendTestEmail" class="flex w-full flex-col gap-4">
<x-forms.input wire:model="testEmailAddress" placeholder="test@example.com"
id="testEmailAddress" label="Recipient" required />
<div class="flex justify-end border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<button type="submit" @click="modalOpen=false"
class="button button-highlighted">
Send email
</button>
</div>
</form>
</x-modal-input>
@else
<button type="button" class="button" disabled title="{{ $sendTestReason }}">
<x-reicon name="notifications" class="size-3.5" />
Send test
</button>
@endif
</x-slot:actions>
@@ -59,84 +66,64 @@
</div>
@endif
</div>
@if (!$useInstanceEmailSettings)
<x-forms.input canGate="update" :canResource="$settings" required id="smtpFromName"
helper="Name used in emails." label="From name" />
<x-forms.input canGate="update" :canResource="$settings" required id="smtpFromAddress"
helper="Email address used in emails." label="From address" />
@if (isInstanceAdmin())
<div class="lg:col-span-2">
<x-forms.button type="button" wire:click="copyFromInstanceSettings">
Copy from instance settings
</x-forms.button>
</div>
@endif
@endif
</div>
</x-application.settings-section>
</form>
@if (!$useInstanceEmailSettings)
<div class="application-settings-form">
<x-application.settings-section title="SMTP server"
description="Deliver messages through your own SMTP server.">
<div class="grid gap-4 lg:grid-cols-3">
<div class="lg:col-span-3">
<div class="w-full sm:w-72">
<x-forms.listbox canGate="update" :canResource="$settings" id="smtpEnabled" label="SMTP delivery"
onChange="submitSmtp"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
</div>
<x-application.settings-section title="Email provider"
description="Only one provider sends email. Select a provider to see its settings.">
<x-slot:badges>
<div class="w-40">
<x-forms.listbox canGate="update" :canResource="$settings" id="emailProvider"
onChange="selectEmailProvider"
:disabled="!auth()->user()->can('update', $settings)"
:options="collect(email_provider_labels())
->map(fn ($label, $value) => ['value' => $value, 'label' => $label])
->values()
->all()" />
</div>
<x-forms.input canGate="update" :canResource="$settings" required id="smtpHost"
placeholder="smtp.mailgun.org" label="Host" />
<x-forms.input canGate="update" :canResource="$settings" required id="smtpPort"
type="number" placeholder="587" label="Port" />
<x-forms.listbox canGate="update" :canResource="$settings" id="smtpEncryption" label="Encryption" required
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => 'starttls', 'label' => 'StartTLS'],
['value' => 'tls', 'label' => 'TLS / SSL'],
['value' => 'none', 'label' => 'None'],
]" />
<x-forms.input canGate="update" :canResource="$settings" id="smtpUsername"
label="SMTP username" />
@can('update', $settings)
<x-forms.input canGate="update" :canResource="$settings" id="smtpPassword" type="password"
label="SMTP password" />
@else
<x-forms.input disabled label="SMTP password" value="Hidden (only admins can view)" />
@endcan
<x-forms.input canGate="update" :canResource="$settings" id="smtpTimeout" type="number"
helper="Timeout value for sending emails." label="Timeout" />
<x-forms.input canGate="update" :canResource="$settings" id="smtpEhloDomain"
placeholder="coolify.example.com"
helper="Fully qualified domain sent in the SMTP EHLO command. Uses the system default when empty."
label="EHLO domain" />
</div>
</x-application.settings-section>
</div>
</x-slot:badges>
<div class="grid gap-4 lg:grid-cols-3">
@if ($emailProvider === 'none')
<p class="text-sm text-neutral-500 lg:col-span-3 dark:text-neutral-400">
The team does not get email notifications. Select a provider next to the title to set it up.
</p>
@endif
<div class="application-settings-form">
<x-application.settings-section title="Resend">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox canGate="update" :canResource="$settings" id="resendEnabled" label="Resend delivery"
onChange="submitResend"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
@can('update', $settings)
<x-forms.input canGate="update" :canResource="$settings" :required="$resendEnabled"
type="password" id="resendApiKey" placeholder="API key" label="API key"
autocomplete="new-password" />
@else
<x-forms.input disabled label="API key" value="Hidden (only admins can view)" />
@endcan
@if ($emailProvider !== $this->activeEmailProvider)
<div class="lg:col-span-3">
<x-callout type="warning" title="Not active yet">
Enter the {{ email_provider_labels()[$emailProvider] }} settings and save.
@if ($this->activeEmailProvider === 'none')
Until then, the team does not get email notifications.
@else
Until then, Coolify sends email with
{{ email_provider_labels()[$this->activeEmailProvider] }}.
@endif
</x-callout>
</div>
@endif
@if ($emailProvider === 'smtp')
@include('livewire.partials.smtp-server-picker')
@elseif ($emailProvider === 'resend')
<div>
@can('update', $settings)
<x-forms.input canGate="update" :canResource="$settings" required type="password"
id="resendApiKey" placeholder="API key" label="Resend API key"
autocomplete="new-password" />
@else
<x-forms.input disabled label="Resend API key" value="Hidden (only admins can view)" />
@endcan
</div>
<x-forms.input canGate="update" :canResource="$settings" required id="resendFromName"
helper="Name shown in email from Resend." label="From name" />
<x-forms.input canGate="update" :canResource="$settings" required id="resendFromAddress"
helper="Address used in email from Resend. It must be on a domain verified in Resend."
label="From address" />
@endif
</div>
</x-application.settings-section>
</div>
@@ -18,4 +18,11 @@
</div>
</form>
</x-modal-input>
@else
{{-- Always show the button, so that admins know a test is possible once a provider is saved. --}}
<button type="button" class="button" disabled
title="{{ is_transactional_emails_enabled() ? 'Only admins can send a test email.' : 'Save an email provider first.' }}">
<x-reicon name="notifications" class="size-3.5" />
Send test
</button>
@endif
@@ -0,0 +1,132 @@
{{-- All SMTP settings: the named servers and the fields of the server in use, in groups.
The Add server modal also offers the SMTP templates. Used on the transactional email and email
notification pages (components with ManagesSmtpServers). Put it inside a grid with three columns. --}}
<div class="flex flex-col gap-3 lg:col-span-3">
@if ($isNewSmtpServer)
<x-callout type="info" title="New SMTP server: {{ $smtpServerName }}">
Enter the settings, then save. The server
{{ $settings->smtpServerName() }} stays in the server list.
<div class="mt-2">
<button type="button" class="button" wire:click="cancelNewSmtpServer">Cancel</button>
</div>
</x-callout>
@else
<div class="flex flex-wrap items-end gap-2">
<div class="w-full sm:w-72">
<x-forms.listbox canGate="update" :canResource="$settings" id="selectedSmtpServer" label="SMTP server"
onChange="switchSmtpServer" :onChangeArgs="[]"
:disabled="!auth()->user()->can('update', $settings)" :options="$this->smtpServerOptions()" />
</div>
@can('update', $settings)
<x-modal-input title="Rename SMTP server">
<x-slot:content>
{{-- Set the name in the browser: a request would redraw the input and take its focus. --}}
<button type="button" class="icon-button mb-0.5" title="Rename server"
aria-label="Rename SMTP server {{ $settings->smtpServerName() }}"
x-on:click="$wire.renamedSmtpServerName = @js($settings->smtpServerName())">
<x-reicon name="edit" class="size-3.5" />
</button>
</x-slot:content>
<form wire:submit.prevent="renameSmtpServer" class="application-settings-form flex flex-col gap-4">
<x-forms.input id="renamedSmtpServerName" label="Name" required
helper="Name to find this server in the SMTP server list." />
<div class="flex justify-end border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<button type="submit" class="button button-highlighted" @click="modalOpen=false">
Save
</button>
</div>
</form>
</x-modal-input>
@if (count($settings->savedSmtpServers()) > 0)
<button type="button" wire:click="deleteSmtpServer"
class="icon-button mb-0.5 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
title="Delete server" aria-label="Delete SMTP server {{ $settings->smtpServerName() }}"
wire:confirm="Delete the SMTP server {{ $settings->smtpServerName() }}? Coolify then uses the next server in the list.">
<x-reicon name="trash" class="size-3.5" />
</button>
@endif
<x-modal-input title="Add SMTP server">
<x-slot:content>
<button type="button" class="button ml-2">
<x-reicon name="plus" class="size-3.5" />
Add server
</button>
</x-slot:content>
<form wire:submit.prevent="addSmtpServer" class="application-settings-form flex flex-col gap-4">
<x-forms.input id="newSmtpServerName" label="Name" placeholder="PostShiba production"
helper="Name to find this server in the SMTP server list." required />
<x-forms.listbox id="newSmtpServerTemplate" label="Template" placeholder="None (enter the settings yourself)"
helper="Fills in the host, port, and encryption of the provider. You then enter the username and password from the provider."
:options="\App\Support\SmtpPresets::options()" />
<div class="flex justify-end border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<button type="submit" class="button button-highlighted" @click="modalOpen=false">
Add server
</button>
</div>
</form>
</x-modal-input>
@endcan
</div>
@endif
</div>
@php($smtpGroupClass = 'flex flex-col gap-3 border-t border-neutral-200 pt-4 lg:col-span-3 dark:border-white/[0.08]')
@php($smtpGroupTitleClass = 'text-sm font-semibold text-black dark:text-fg')
<div class="{{ $smtpGroupClass }}">
<h4 class="{{ $smtpGroupTitleClass }}">Sender</h4>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input canGate="update" :canResource="$settings" required id="smtpFromName"
helper="Name shown in email from this server." label="From name" />
<x-forms.input canGate="update" :canResource="$settings" required id="smtpFromAddress"
helper="Address used in email from this server. It must be on a domain that this server can send from."
label="From address" />
</div>
</div>
<div class="{{ $smtpGroupClass }}">
<h4 class="{{ $smtpGroupTitleClass }}">Connection</h4>
<div class="grid gap-4 lg:grid-cols-3">
<x-forms.input canGate="update" :canResource="$settings" required id="smtpHost"
placeholder="smtp.mailgun.org" label="Host" />
<x-forms.input canGate="update" :canResource="$settings" required id="smtpPort" type="number"
placeholder="587" label="Port" />
<x-forms.listbox canGate="update" :canResource="$settings" required id="smtpEncryption" label="Encryption"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => 'starttls', 'label' => 'StartTLS'],
['value' => 'tls', 'label' => 'TLS / SSL'],
['value' => 'none', 'label' => 'None'],
]" />
</div>
</div>
<div class="{{ $smtpGroupClass }}">
<h4 class="{{ $smtpGroupTitleClass }}">Authentication</h4>
{{-- Same rule as SmtpPresets::requiresLogin(), so the required marks follow the fields while you type. --}}
<div class="grid gap-4 lg:grid-cols-2"
x-data="{ providerHosts: @js(collect(\App\Support\SmtpPresets::PRESETS)->pluck('host')->all()),
isProviderHost(host) { return this.providerHosts.includes(String(host ?? '').trim().toLowerCase()) } }">
<x-forms.input canGate="update" :canResource="$settings" id="smtpUsername" label="Username"
:requiredWhen="'isProviderHost($wire.smtpHost)'"
helper="The SMTP username from your provider. Leave it empty only for a relay that does not need a login, for example a local mail server." />
@can('update', $settings)
<x-forms.input canGate="update" :canResource="$settings" id="smtpPassword" type="password"
label="Password" autocomplete="new-password"
:requiredWhen="'isProviderHost($wire.smtpHost) || !! $wire.smtpUsername'"
helper="The SMTP password from your provider. It is required when you enter a username." />
@else
<x-forms.input disabled label="Password" value="Hidden (only admins can view)" />
@endcan
</div>
</div>
<div class="{{ $smtpGroupClass }}">
<h4 class="{{ $smtpGroupTitleClass }}">Advanced</h4>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input canGate="update" :canResource="$settings" id="smtpTimeout" type="number"
helper="Maximum delivery time in seconds." label="Timeout" />
<x-forms.input canGate="update" :canResource="$settings" id="smtpEhloDomain" placeholder="coolify.example.com"
helper="Fully qualified domain sent in the SMTP EHLO command. Uses the system default when empty."
label="EHLO domain" />
</div>
</div>
@@ -5,66 +5,57 @@
<x-settings.layout>
<div class="application-settings-form mx-auto flex w-full max-w-none min-w-0 flex-col gap-6">
{{-- One bar for the whole page. Three stacked bars made Save run
submitResend(), which required an API key even when Resend was off. --}}
{{-- One bar for the whole page. Save validates only the provider that is selected. --}}
<x-unsaved-bar action="submit"
targets="smtpFromName,smtpFromAddress,smtpHost,smtpPort,smtpEncryption,smtpUsername,smtpPassword,smtpTimeout,smtpEhloDomain,resendApiKey" />
targets="smtpFromName,smtpFromAddress,smtpHost,smtpPort,smtpEncryption,smtpUsername,smtpPassword,smtpTimeout,smtpEhloDomain,resendApiKey,resendFromName,resendFromAddress" />
<form wire:submit="submit">
<x-application.settings-section title="Sender">
<x-application.settings-section title="Email delivery"
description="Only one provider sends email. Select a provider to see its settings.">
<x-slot:badges>
<div class="w-40">
<x-forms.listbox id="emailProvider" onChange="selectEmailProvider"
:options="collect(email_provider_labels())
->map(fn ($label, $value) => ['value' => $value, 'label' => $label])
->values()
->all()" />
</div>
</x-slot:badges>
<x-slot:actions>
@include('livewire.partials.settings-email-send-test')
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input required id="smtpFromName" helper="Name shown in outgoing email."
label="From name" />
<x-forms.input required id="smtpFromAddress" helper="Address used for outgoing email."
label="From address" />
</div>
</x-application.settings-section>
</form>
<form wire:submit.prevent="submitSmtp">
<x-application.settings-section title="SMTP server">
<div class="grid gap-4 lg:grid-cols-3">
<div class="lg:col-span-3">
<div class="w-full sm:w-72">
<x-forms.listbox id="smtpEnabled" label="SMTP delivery"
onChange="instantSaveSmtp" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
</div>
</div>
<x-forms.input required id="smtpHost" placeholder="smtp.mailgun.org" label="Host" />
<x-forms.input required id="smtpPort" type="number" placeholder="587" label="Port" />
<x-forms.listbox required id="smtpEncryption" label="Encryption" :options="[
['value' => 'starttls', 'label' => 'StartTLS'],
['value' => 'tls', 'label' => 'TLS / SSL'],
['value' => 'none', 'label' => 'None'],
]" />
<x-forms.input id="smtpUsername" label="Username" />
<x-forms.input id="smtpPassword" type="password" label="Password"
autocomplete="new-password" />
<x-forms.input id="smtpTimeout" type="number"
helper="Maximum delivery time in seconds." label="Timeout" />
<x-forms.input id="smtpEhloDomain" placeholder="coolify.example.com"
helper="Fully qualified domain sent in the SMTP EHLO command. Uses the system default when empty."
label="EHLO domain" />
</div>
</x-application.settings-section>
</form>
@if ($emailProvider === 'none')
<p class="text-sm text-neutral-500 lg:col-span-3 dark:text-neutral-400">
Coolify does not send email. Select a provider next to the title to set it up.
</p>
@endif
<form wire:submit.prevent="submitResend">
<x-application.settings-section title="Resend">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="resendEnabled" label="Resend delivery"
onChange="instantSaveResend" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
<x-forms.input type="password" id="resendApiKey" placeholder="API key"
:required="$resendEnabled" label="API key" autocomplete="new-password" />
@if ($emailProvider !== $this->activeEmailProvider)
<div class="lg:col-span-3">
<x-callout type="warning" title="Not active yet">
Enter the {{ email_provider_labels()[$emailProvider] }} settings and save.
@if ($this->activeEmailProvider === 'none')
Until then, Coolify does not send email.
@else
Until then, Coolify sends email with
{{ email_provider_labels()[$this->activeEmailProvider] }}.
@endif
</x-callout>
</div>
@endif
@if ($emailProvider === 'smtp')
@include('livewire.partials.smtp-server-picker')
@elseif ($emailProvider === 'resend')
<x-forms.input required type="password" id="resendApiKey" placeholder="API key"
label="Resend API key" autocomplete="new-password" />
<x-forms.input required id="resendFromName" helper="Name shown in email from Resend."
label="From name" />
<x-forms.input required id="resendFromAddress"
helper="Address used in email from Resend. It must be on a domain verified in Resend."
label="From address" />
@endif
</div>
</x-application.settings-section>
</form>
@@ -270,70 +270,6 @@ test('member cannot update resend email transport directly', function () {
->assertForbidden();
});
test('member cannot copy instance email settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->call('copyFromInstanceSettings')
->assertForbidden();
});
test('team admin cannot copy instance email credentials', function () {
InstanceSettings::query()->findOrFail(0)->update([
'smtp_password' => 'instance-smtp-secret',
'resend_api_key' => 'instance-resend-secret',
]);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->assertSet('smtpPassword', null)
->assertSet('resendApiKey', null)
->call('copyFromInstanceSettings')
->assertForbidden();
expect($this->team->emailNotificationSettings->fresh()->smtp_password)->toBeNull();
expect($this->team->emailNotificationSettings->fresh()->resend_api_key)->toBeNull();
});
test('instance admin can copy instance email credentials to an authorized team', function () {
$rootTeam = Team::factory()->create(['id' => 0]);
$this->admin->teams()->attach($rootTeam, ['role' => 'admin']);
InstanceSettings::query()->findOrFail(0)->update([
'smtp_from_address' => 'instance@example.com',
'smtp_from_name' => 'Instance',
'smtp_password' => 'instance-smtp-secret',
'resend_api_key' => 'instance-resend-secret',
]);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->call('copyFromInstanceSettings')
->assertHasNoErrors()
->assertSet('smtpPassword', 'instance-smtp-secret')
->assertSet('resendApiKey', 'instance-resend-secret');
expect($this->team->emailNotificationSettings->fresh()->smtp_password)->toBe('instance-smtp-secret');
expect($this->team->emailNotificationSettings->fresh()->resend_api_key)->toBe('instance-resend-secret');
});
test('instance admin cannot copy credentials to a team they cannot update', function () {
$rootTeam = Team::factory()->create(['id' => 0]);
$this->member->teams()->attach($rootTeam, ['role' => 'admin']);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->call('copyFromInstanceSettings')
->assertForbidden();
});
test('admin can update email notification settings', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
@@ -360,11 +296,8 @@ test('admin can save team smtp settings without a resend api key when resend is
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->set('smtpFromAddress', 'alerts@example.com')
->set('smtpFromName', 'Coolify')
->set('smtpHost', 'smtp.example.com')
->set('smtpPort', '587')
->set('smtpEncryption', 'starttls')
->set('resendFromAddress', 'alerts@example.com')
->set('resendFromName', 'Coolify')
->set('resendEnabled', false)
->set('resendApiKey', null)
->call('submitResend')
@@ -486,9 +419,9 @@ test('member cannot send test on any notification channel', function () {
expect($this->member->can('sendTest', $this->team->webhookNotificationSettings))->toBeFalse();
});
test('member cannot view notification secrets', function (string $component, string $settingsRelation, array $secrets) {
test('member cannot view notification secrets', function (string $component, string $settingsRelation, array $secrets, array $state = []) {
$settings = $this->team->{$settingsRelation};
$settings->update($secrets);
$settings->update([...$secrets, ...$state]);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
@@ -525,7 +458,7 @@ test('member cannot view notification secrets', function (string $component, str
'email credentials' => [EmailNotification::class, 'emailNotificationSettings', [
'smtp_password' => 'smtp-secret-password',
'resend_api_key' => 'resend-secret-api-key',
]],
], ['smtp_enabled' => true]],
]);
test('admin can view notification secrets', function (string $component, string $settingsRelation, array $secrets) {