mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 08:42:38 -04:00
Team notifications sent through the instance email settings, the S3 connection error alert, and the channel disabled alert now use the optional notification from address and name. Account mails keep the default sender. Remove the unused emails command and its views.
211 lines
9.8 KiB
PHP
211 lines
9.8 KiB
PHP
<?php
|
|
|
|
namespace App\Notifications\Channels;
|
|
|
|
use App\Exceptions\NonReportableException;
|
|
use App\Models\EmailNotificationSettings;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\Team;
|
|
use App\Services\Security\SensitiveDataRedactor;
|
|
use App\Support\SmtpTransportFactory;
|
|
use Exception;
|
|
use Illuminate\Notifications\Notification;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Resend;
|
|
use Resend\Exceptions\ErrorException;
|
|
use Resend\Exceptions\TransporterException;
|
|
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
|
use Symfony\Component\Mailer\Mailer;
|
|
use Symfony\Component\Mime\Email;
|
|
|
|
class EmailChannel
|
|
{
|
|
public function __construct() {}
|
|
|
|
public function send(SendsEmail $notifiable, Notification $notification): void
|
|
{
|
|
$settings = null;
|
|
// Test notifications always show their error and never count as delivery failures.
|
|
$isTestNotification = data_get($notification, 'isTestNotification', false);
|
|
|
|
try {
|
|
// Get team and validate membership before proceeding
|
|
$team = data_get($notifiable, 'id');
|
|
$members = Team::find($team)->members;
|
|
|
|
$useInstanceEmailSettings = $notifiable->emailNotificationSettings->use_instance_email_settings;
|
|
$isTransactionalEmail = data_get($notification, 'isTransactionalEmail', false);
|
|
$customEmails = data_get($notification, 'emails', null);
|
|
|
|
if ($useInstanceEmailSettings || $isTransactionalEmail) {
|
|
$settings = instanceSettings();
|
|
} else {
|
|
$settings = $notifiable->emailNotificationSettings;
|
|
}
|
|
|
|
// Only the instance settings have a notification sender. A test email can ask for it explicitly.
|
|
$asNotification = $settings instanceof InstanceSettings
|
|
&& (data_get($notification, 'usesNotificationSender') ?? ! $isTransactionalEmail);
|
|
|
|
$isResendEnabled = $settings->resend_enabled;
|
|
$isSmtpEnabled = $settings->smtp_enabled;
|
|
|
|
if ($customEmails) {
|
|
$recipients = [$customEmails];
|
|
} else {
|
|
$recipients = $notifiable->getRecipients();
|
|
}
|
|
|
|
// Validate team membership for all recipients
|
|
if (count($recipients) === 0) {
|
|
throw new Exception('No email recipients found');
|
|
}
|
|
|
|
// Skip team membership validation for test notifications
|
|
if (! $isTestNotification) {
|
|
foreach ($recipients as $recipient) {
|
|
// Check if the recipient is part of the team
|
|
if (! $members->contains('email', $recipient)) {
|
|
$emailSettings = $notifiable->emailNotificationSettings;
|
|
data_set($emailSettings, 'smtp_password', '********');
|
|
data_set($emailSettings, 'resend_api_key', '********');
|
|
send_internal_notification(sprintf(
|
|
"Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s",
|
|
$recipient,
|
|
$team,
|
|
get_class($notification),
|
|
get_class($notifiable),
|
|
json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
|
|
));
|
|
throw new Exception('Recipient is not part of the team');
|
|
}
|
|
}
|
|
}
|
|
|
|
$mailMessage = $notification->toMail($notifiable);
|
|
|
|
if ($isResendEnabled) {
|
|
$resend = Resend::client($settings->resend_api_key);
|
|
$response = $resend->emails->send([
|
|
'from' => mail_from_formatted($settings, $asNotification),
|
|
'to' => $recipients,
|
|
'subject' => $mailMessage->subject,
|
|
'html' => (string) $mailMessage->render(),
|
|
]);
|
|
|
|
// The Resend SDK only throws for error names it knows; any other API error is returned as a response.
|
|
if (blank($response->getAttribute('id'))) {
|
|
$error = $response->toArray();
|
|
$error['message'] = $error['message'] ?? 'Resend did not accept the email.';
|
|
|
|
throw new ErrorException($error);
|
|
}
|
|
} elseif ($isSmtpEnabled) {
|
|
$transport = SmtpTransportFactory::fromSettings(
|
|
$settings,
|
|
config('mail.mailers.smtp.local_domain')
|
|
);
|
|
$mailer = new Mailer($transport);
|
|
|
|
$email = mail_from_email(new Email, $settings, $asNotification)
|
|
->to(...$recipients)
|
|
->subject($mailMessage->subject)
|
|
->html((string) $mailMessage->render());
|
|
|
|
$mailer->send($email);
|
|
}
|
|
|
|
if (! $isTestNotification && $settings instanceof EmailNotificationSettings) {
|
|
$settings->recordDeliverySuccess();
|
|
}
|
|
} catch (ErrorException $e) {
|
|
['status' => $statusCode, 'name' => $errorName] = self::resendErrorDetails($e);
|
|
|
|
// Map HTTP status codes and Resend error names to user-friendly messages.
|
|
// An invalid key comes back as 401 "validation_error"; an unverified domain as 403 "validation_error".
|
|
$userMessage = match (true) {
|
|
$errorName === 'restricted_api_key', $statusCode === 401 && $errorName === null => 'Your Resend API key has restricted permissions. Please use an API key with Full Access permissions.',
|
|
$statusCode === 401, $statusCode === 403 && $errorName !== 'validation_error' => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.',
|
|
in_array($statusCode, [400, 422], true), $errorName === 'validation_error' => 'Email validation failed: '.$e->getErrorMessage(),
|
|
$statusCode === 429 && in_array($errorName, [null, 'rate_limit_exceeded'], true) => 'Resend rate limit exceeded. Please try again in a few minutes.',
|
|
default => 'Failed to send email via Resend: '.$e->getErrorMessage(),
|
|
};
|
|
|
|
if (! $isTestNotification && $settings instanceof EmailNotificationSettings) {
|
|
// The team's own settings: an invalid key or domain counts as a delivery failure, and
|
|
// other client errors are only logged. Neither is retried, because a retry fails again.
|
|
if (in_array($statusCode, [401, 403], true)) {
|
|
$settings->recordDeliveryFailure($userMessage);
|
|
|
|
return;
|
|
}
|
|
|
|
if (in_array($statusCode, [400, 422], true)) {
|
|
Log::warning('Resend rejected the email', ['team_id' => $settings->team_id, 'reason' => $userMessage]);
|
|
|
|
return;
|
|
}
|
|
|
|
throw new Exception($userMessage, $e->getCode(), $e);
|
|
}
|
|
|
|
// Log detailed error for admin debugging (redact sensitive data)
|
|
$emailSettings = $notifiable->emailNotificationSettings ?? instanceSettings();
|
|
data_set($emailSettings, 'smtp_password', '********');
|
|
data_set($emailSettings, 'resend_api_key', '********');
|
|
|
|
send_internal_notification(sprintf(
|
|
"Resend Error\nStatus Code: %s\nMessage: %s\nNotification: %s\nEmail Settings:\n%s",
|
|
$statusCode,
|
|
remove_iip($e->getErrorMessage()),
|
|
get_class($notification),
|
|
json_encode(app(SensitiveDataRedactor::class)->redactValue($emailSettings), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
|
|
));
|
|
|
|
// Don't report expected errors (invalid keys, validation) to error tracking
|
|
if (in_array($statusCode, [403, 401, 400, 422], true)) {
|
|
throw NonReportableException::fromException(new Exception($userMessage, $e->getCode(), $e));
|
|
}
|
|
|
|
throw new Exception($userMessage, $e->getCode(), $e);
|
|
} catch (TransportExceptionInterface $e) {
|
|
// SMTP 530/535: the SMTP server does not accept the username or password.
|
|
if (! $isTestNotification && $settings instanceof EmailNotificationSettings && in_array($e->getCode(), [530, 535], true)) {
|
|
$settings->recordDeliveryFailure('SMTP authentication failed. Please verify the SMTP username and password.');
|
|
|
|
return;
|
|
}
|
|
|
|
throw $e;
|
|
} catch (TransporterException $e) {
|
|
send_internal_notification('Resend Transport Error: '.remove_iip($e->getMessage()));
|
|
throw new Exception('Unable to connect to Resend API. Please check your internet connection and try again.');
|
|
} catch (\Throwable $e) {
|
|
// Check if this is a Resend domain verification error on cloud instances
|
|
if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) {
|
|
// Throw as NonReportableException so it won't go to error tracking
|
|
throw NonReportableException::fromException($e);
|
|
}
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the status code and error name from a Resend error.
|
|
*
|
|
* getErrorCode() and getErrorType() fail when a body has neither "code"/"statusCode"
|
|
* nor "type"/"name" (for example the legacy nested format), so read the raw contents.
|
|
*
|
|
* @return array{status: int, name: ?string}
|
|
*/
|
|
private static function resendErrorDetails(ErrorException $e): array
|
|
{
|
|
$contents = (fn (): array => $this->contents)->call($e);
|
|
|
|
return [
|
|
'status' => (int) ($contents['code'] ?? $contents['statusCode'] ?? 0),
|
|
'name' => isset($contents['name']) ? (string) $contents['name'] : null,
|
|
];
|
|
}
|
|
}
|