From 4133ec99ae839cbc920c90de9ad4c893c2816bc3 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:29:41 +0100 Subject: [PATCH] feat: extract session alert email into Mails listener Moves session alert email side effect out of the account controller into a dedicated `Mails` listener that reacts to a new `SessionCreated` bus event. The event is now always dispatched on session creation; the listener owns all conditional logic (first session, sessionAlerts flag, email-link sessions, user email presence). Co-Authored-By: Claude Sonnet 4.6 --- app/controllers/api/account.php | 193 ++++----------------- app/listeners.php | 2 + src/Appwrite/Bus/Events/SessionCreated.php | 23 +++ src/Appwrite/Bus/Listeners/Mails.php | 182 +++++++++++++++++++ 4 files changed, 237 insertions(+), 163 deletions(-) create mode 100644 src/Appwrite/Bus/Events/SessionCreated.php create mode 100644 src/Appwrite/Bus/Listeners/Mails.php diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 67588ffd5d..38cf5499e1 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -10,6 +10,7 @@ use Appwrite\Auth\Validator\PasswordHistory; use Appwrite\Auth\Validator\PersonalData; use Appwrite\Auth\Validator\Phone; use Appwrite\Detector\Detector; +use Appwrite\Bus\Events\SessionCreated; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Mail; @@ -60,6 +61,7 @@ use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; use Utopia\Emails\Email; use Utopia\Emails\Validator\Email as EmailValidator; +use Utopia\Bus\Bus; use Utopia\Http\Http; use Utopia\Locale\Locale; use Utopia\Storage\Validator\FileName; @@ -75,139 +77,8 @@ use Utopia\Validator\WhiteList; $oauthDefaultSuccess = '/console/auth/oauth2/success'; $oauthDefaultFailure = '/console/auth/oauth2/failure'; -function sendSessionAlert(Locale $locale, Document $user, Document $project, array $platform, Document $session, Mail $queueForMails) -{ - $subject = $locale->getText("emails.sessionAlert.subject"); - $preview = $locale->getText("emails.sessionAlert.preview"); - $customTemplate = $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->default] ?? []; - $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); - $validator = new FileName(); - if (!$validator->isValid($smtpBaseTemplate)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid template path'); - } - - $bodyTemplate = __DIR__ . '/../../config/locale/templates/' . $smtpBaseTemplate . '.tpl'; - - $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-session-alert.tpl'); - $message - ->setParam('{{hello}}', $locale->getText("emails.sessionAlert.hello")) - ->setParam('{{body}}', $locale->getText("emails.sessionAlert.body")) - ->setParam('{{listDevice}}', $locale->getText("emails.sessionAlert.listDevice")) - ->setParam('{{listIpAddress}}', $locale->getText("emails.sessionAlert.listIpAddress")) - ->setParam('{{listCountry}}', $locale->getText("emails.sessionAlert.listCountry")) - ->setParam('{{footer}}', $locale->getText("emails.sessionAlert.footer")) - ->setParam('{{thanks}}', $locale->getText("emails.sessionAlert.thanks")) - ->setParam('{{signature}}', $locale->getText("emails.sessionAlert.signature")); - - $body = $message->render(); - - $smtp = $project->getAttribute('smtp', []); - $smtpEnabled = $smtp['enabled'] ?? false; - - $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); - $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; - - if ($smtpEnabled) { - if (!empty($smtp['senderEmail'])) { - $senderEmail = $smtp['senderEmail']; - } - if (!empty($smtp['senderName'])) { - $senderName = $smtp['senderName']; - } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; - } - - $queueForMails - ->setSmtpHost($smtp['host'] ?? '') - ->setSmtpPort($smtp['port'] ?? '') - ->setSmtpUsername($smtp['username'] ?? '') - ->setSmtpPassword($smtp['password'] ?? '') - ->setSmtpSecure($smtp['secure'] ?? ''); - - if (!empty($customTemplate)) { - if (!empty($customTemplate['senderEmail'])) { - $senderEmail = $customTemplate['senderEmail']; - } - if (!empty($customTemplate['senderName'])) { - $senderName = $customTemplate['senderName']; - } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; - } - - $body = $customTemplate['message'] ?? ''; - $subject = $customTemplate['subject'] ?? $subject; - } - - $queueForMails - ->setSmtpReplyTo($replyTo) - ->setSmtpSenderEmail($senderEmail) - ->setSmtpSenderName($senderName); - } - - // session alerts should always have a client name! - $clientName = $session->getAttribute('clientName'); - if (empty($clientName)) { - // fallback to the user agent and then unknown! - $userAgent = $session->getAttribute('userAgent'); - $clientName = !empty($userAgent) ? $userAgent : 'UNKNOWN'; - - $session->setAttribute('clientName', $clientName); - } - - $projectName = $project->getAttribute('name'); - if ($project->getId() === 'console') { - $projectName = $platform['platformName']; - } - - $emailVariables = [ - 'direction' => $locale->getText('settings.direction'), - 'date' => (new \DateTime())->format('F j'), - 'year' => (new \DateTime())->format('YYYY'), - 'time' => (new \DateTime())->format('H:i:s'), - 'user' => $user->getAttribute('name'), - 'project' => $projectName, - 'device' => $session->getAttribute('clientName'), - 'ipAddress' => $session->getAttribute('ip'), - 'country' => $locale->getText('countries.' . $session->getAttribute('countryCode'), $locale->getText('locale.country.unknown')), - ]; - - if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) { - $emailVariables = array_merge($emailVariables, [ - 'accentColor' => $platform['accentColor'], - 'logoUrl' => $platform['logoUrl'], - 'twitter' => $platform['twitterUrl'], - 'discord' => $platform['discordUrl'], - 'github' => $platform['githubUrl'], - 'terms' => $platform['termsUrl'], - 'privacy' => $platform['privacyUrl'], - 'platform' => $platform['platformName'], - ]); - } - - $email = $user->getAttribute('email'); - - $queueForMails - ->setSubject($subject) - ->setPreview($preview) - ->setBody($body) - ->setBodyTemplate($bodyTemplate) - ->appendVariables($emailVariables) - ->setRecipient($email); - - // since this is console project, set email sender name! - if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) { - $queueForMails->setSenderName($platform['emailSenderName']); - } - - $queueForMails->trigger(); -} - - -$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) { +$createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Bus $bus, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) { // Attempt to decode secret as a JWT (used by OAuth2 token flow to carry provider info) $oauthProvider = null; @@ -318,23 +189,17 @@ $createSession = function (string $userId, string $secret, Request $request, Res throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB'); } - $isAllowedTokenType = match ($verifiedToken->getAttribute('type')) { - TOKEN_TYPE_MAGIC_URL, - TOKEN_TYPE_EMAIL => false, - default => true - }; - - $hasUserEmail = $user->getAttribute('email', false) !== false; - - $isSessionAlertsEnabled = $project->getAttribute('auths', [])['sessionAlerts'] ?? false; - - $isNotFirstSession = $dbForProject->count('sessions', [ + $isFirstSession = $dbForProject->count('sessions', [ Query::equal('userId', [$user->getId()]), - ]) !== 1; + ]) === 1; - if ($isAllowedTokenType && $hasUserEmail && $isSessionAlertsEnabled && $isNotFirstSession) { - sendSessionAlert($locale, $user, $project, $platform, $session, $queueForMails); - } + $bus->dispatch(new SessionCreated( + user: $user->getArrayCopy(), + project: $project->getArrayCopy(), + session: $session->getArrayCopy(), + locale: $locale->default, + isFirstSession: $isFirstSession, + )); $queueForEvents ->setParam('userId', $user->getId()) @@ -1034,7 +899,7 @@ Http::post('/v1/account/sessions/email') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') - ->inject('queueForMails') + ->inject('bus') ->inject('hooks') ->inject('store') ->inject('proofForPassword') @@ -1042,7 +907,7 @@ Http::post('/v1/account/sessions/email') ->inject('domainVerification') ->inject('cookieDomain') ->inject('authorization') - ->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) { + ->action(function (string $email, string $password, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Bus $bus, Hooks $hooks, Store $store, ProofsPassword $proofForPassword, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, Authorization $authorization) { $email = \strtolower($email); $protocol = $request->getProtocol(); @@ -1141,15 +1006,17 @@ Http::post('/v1/account/sessions/email') ->setParam('sessionId', $session->getId()) ; - if ($project->getAttribute('auths', [])['sessionAlerts'] ?? false) { - if ( - $dbForProject->count('sessions', [ - Query::equal('userId', [$user->getId()]), - ]) !== 1 - ) { - sendSessionAlert($locale, $user, $project, $platform, $session, $queueForMails); - } - } + $isFirstSession = $dbForProject->count('sessions', [ + Query::equal('userId', [$user->getId()]), + ]) === 1; + + $bus->dispatch(new SessionCreated( + user: $user->getArrayCopy(), + project: $project->getArrayCopy(), + session: $session->getArrayCopy(), + locale: $locale->default, + isFirstSession: $isFirstSession, + )); $response->dynamic($session, Response::MODEL_SESSION); }); @@ -1343,7 +1210,7 @@ Http::post('/v1/account/sessions/token') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') - ->inject('queueForMails') + ->inject('bus') ->inject('store') ->inject('proofForToken') ->inject('proofForCode') @@ -2897,16 +2764,16 @@ Http::put('/v1/account/sessions/magic-url') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') - ->inject('queueForMails') + ->inject('bus') ->inject('store') ->inject('proofForCode') ->inject('domainVerification') ->inject('cookieDomain') ->inject('authorization') - ->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForCode, $domainVerification, $cookieDomain, $authorization) use ($createSession) { + ->action(function ($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $bus, $store, $proofForCode, $domainVerification, $cookieDomain, $authorization) use ($createSession) { $proofForToken = new ProofsToken(TOKEN_LENGTH_MAGIC_URL); $proofForToken->setHash(new Sha()); - $createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $queueForMails, $store, $proofForToken, $proofForCode, $domainVerification, $cookieDomain, $authorization); + $createSession($userId, $secret, $request, $response, $user, $dbForProject, $project, $platform, $locale, $geodb, $queueForEvents, $bus, $store, $proofForToken, $proofForCode, $domainVerification, $cookieDomain, $authorization); }); Http::put('/v1/account/sessions/phone') @@ -2948,7 +2815,7 @@ Http::put('/v1/account/sessions/phone') ->inject('locale') ->inject('geodb') ->inject('queueForEvents') - ->inject('queueForMails') + ->inject('bus') ->inject('store') ->inject('proofForToken') ->inject('proofForCode') diff --git a/app/listeners.php b/app/listeners.php index 714c255974..240225dc45 100644 --- a/app/listeners.php +++ b/app/listeners.php @@ -1,9 +1,11 @@ $user + * @param array $project + * @param array $platform + * @param array $session + */ + public function __construct( + public readonly array $user, + public readonly array $project, + public readonly array $session, + public readonly string $locale, + public readonly bool $isFirstSession, + ) { + } +} diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php new file mode 100644 index 0000000000..90805a7514 --- /dev/null +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -0,0 +1,182 @@ +desc('Sends session alert emails') + ->inject('publisher') + ->inject('locale') + ->inject('platform') + ->callback($this->handle(...)); + } + + public function handle(SessionCreated $event, Publisher $publisher, Locale $locale, array $platform): void + { + $provider = $event->session['provider'] ?? ''; + $factors = $event->session['factors'] ?? []; + $isEmailLinkSession = in_array($provider, [SESSION_PROVIDER_MAGIC_URL, SESSION_PROVIDER_TOKEN]) + && in_array(Type::EMAIL, $factors); + + $hasUserEmail = !empty($event->user['email']); + $isSessionAlertsEnabled = $event->project['auths']['sessionAlerts'] ?? false; + + if ($isEmailLinkSession || !$hasUserEmail || !$isSessionAlertsEnabled || $event->isFirstSession) { + return; + } + + $locale->setDefault($event->locale); + + $user = new Document($event->user); + $project = new Document($event->project); + $session = new Document($event->session); + + $subject = $locale->getText("emails.sessionAlert.subject"); + $preview = $locale->getText("emails.sessionAlert.preview"); + $customTemplate = $project->getAttribute('templates', [])['email.sessionAlert-' . $event->locale] ?? []; + $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); + + $validator = new FileName(); + if (!$validator->isValid($smtpBaseTemplate)) { + throw new \Exception('Invalid template path'); + } + + $bodyTemplate = __DIR__ . '/../../../../app/config/locale/templates/' . $smtpBaseTemplate . '.tpl'; + + $message = Template::fromFile(__DIR__ . '/../../../../app/config/locale/templates/email-session-alert.tpl'); + $message + ->setParam('{{hello}}', $locale->getText("emails.sessionAlert.hello")) + ->setParam('{{body}}', $locale->getText("emails.sessionAlert.body")) + ->setParam('{{listDevice}}', $locale->getText("emails.sessionAlert.listDevice")) + ->setParam('{{listIpAddress}}', $locale->getText("emails.sessionAlert.listIpAddress")) + ->setParam('{{listCountry}}', $locale->getText("emails.sessionAlert.listCountry")) + ->setParam('{{footer}}', $locale->getText("emails.sessionAlert.footer")) + ->setParam('{{thanks}}', $locale->getText("emails.sessionAlert.thanks")) + ->setParam('{{signature}}', $locale->getText("emails.sessionAlert.signature")); + + $body = $message->render(); + + $smtp = $project->getAttribute('smtp', []); + $smtpEnabled = $smtp['enabled'] ?? false; + + $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); + $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); + $replyTo = ""; + + $queueForMails = new Mail($publisher); + + if ($smtpEnabled) { + if (!empty($smtp['senderEmail'])) { + $senderEmail = $smtp['senderEmail']; + } + if (!empty($smtp['senderName'])) { + $senderName = $smtp['senderName']; + } + if (!empty($smtp['replyTo'])) { + $replyTo = $smtp['replyTo']; + } + + $queueForMails + ->setSmtpHost($smtp['host'] ?? '') + ->setSmtpPort($smtp['port'] ?? '') + ->setSmtpUsername($smtp['username'] ?? '') + ->setSmtpPassword($smtp['password'] ?? '') + ->setSmtpSecure($smtp['secure'] ?? ''); + + if (!empty($customTemplate)) { + if (!empty($customTemplate['senderEmail'])) { + $senderEmail = $customTemplate['senderEmail']; + } + if (!empty($customTemplate['senderName'])) { + $senderName = $customTemplate['senderName']; + } + if (!empty($customTemplate['replyTo'])) { + $replyTo = $customTemplate['replyTo']; + } + + $body = $customTemplate['message'] ?? ''; + $subject = $customTemplate['subject'] ?? $subject; + } + + $queueForMails + ->setSmtpReplyTo($replyTo) + ->setSmtpSenderEmail($senderEmail) + ->setSmtpSenderName($senderName); + } + + $clientName = $session->getAttribute('clientName'); + if (empty($clientName)) { + $userAgent = $session->getAttribute('userAgent'); + $clientName = !empty($userAgent) ? $userAgent : 'UNKNOWN'; + $session->setAttribute('clientName', $clientName); + } + + $projectName = $project->getAttribute('name'); + if ($project->getId() === 'console') { + $projectName = $platform['platformName']; + } + + $emailVariables = [ + 'direction' => $locale->getText('settings.direction'), + 'date' => (new \DateTime())->format('F j'), + 'year' => (new \DateTime())->format('YYYY'), + 'time' => (new \DateTime())->format('H:i:s'), + 'user' => $user->getAttribute('name'), + 'project' => $projectName, + 'device' => $session->getAttribute('clientName'), + 'ipAddress' => $session->getAttribute('ip'), + 'country' => $locale->getText('countries.' . $session->getAttribute('countryCode'), $locale->getText('locale.country.unknown')), + ]; + + if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) { + $emailVariables = array_merge($emailVariables, [ + 'accentColor' => $platform['accentColor'], + 'logoUrl' => $platform['logoUrl'], + 'twitter' => $platform['twitterUrl'], + 'discord' => $platform['discordUrl'], + 'github' => $platform['githubUrl'], + 'terms' => $platform['termsUrl'], + 'privacy' => $platform['privacyUrl'], + 'platform' => $platform['platformName'], + ]); + } + + $queueForMails + ->setSubject($subject) + ->setPreview($preview) + ->setBody($body) + ->setBodyTemplate($bodyTemplate) + ->appendVariables($emailVariables) + ->setRecipient($user->getAttribute('email')); + + if ($smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE) { + $queueForMails->setSenderName($platform['emailSenderName']); + } + + $queueForMails->trigger(); + } +}