mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
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:
co-authored by
Claude Opus 5.5
parent
bf4b6ad921
commit
f7867923b7
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user