From 69735b383b6eed5c40b7284053a182415e974d93 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 17:09:58 +0530 Subject: [PATCH 01/10] feat: add email template reset and get-default endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /v1/project/templates/email/:templateId/default that returns Appwrite built-in defaults, ignoring any custom project overrides - Add reset param to PATCH /v1/project/templates/email — when reset=true, clears the custom template entry and returns defaults without requiring SMTP to be enabled --- .../Project/Templates/Email/GetDefault.php | 118 ++++++++++++++++++ .../Http/Project/Templates/Email/Update.php | 102 ++++++++++++--- .../Modules/Project/Services/Http.php | 2 + 3 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php new file mode 100644 index 0000000000..c89c39e0d6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php @@ -0,0 +1,118 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/console/templates/email/:templateId') + ->desc('Get default project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'console', + group: 'templates', + name: 'getEmailTemplate', + description: <<param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Email template type. Can be one of: ' . \implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) + ->inject('response') + ->callback($this->action(...)); + } + + public function action( + string $templateId, + string $locale, + Response $response, + ): void { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + $response->dynamic(new Document([ + 'templateId' => $templateId, + 'locale' => $locale, + 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), + 'message' => $this->getDefaultMessage($templateId, $localeObj), + 'senderName' => '', + 'senderEmail' => '', + 'replyToEmail' => '', + 'replyToName' => '', + ]), Response::MODEL_EMAIL_TEMPLATE); + } + + private function getDefaultMessage(string $templateId, Locale $localeObj): string + { + $templateConfigs = [ + 'magicSession' => [ + 'file' => 'email-magic-url.tpl', + 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] + ], + 'mfaChallenge' => [ + 'file' => 'email-mfa-challenge.tpl', + 'placeholders' => ['description', 'clientInfo'] + ], + 'otpSession' => [ + 'file' => 'email-otp.tpl', + 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] + ], + 'sessionAlert' => [ + 'file' => 'email-session-alert.tpl', + 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] + ], + ]; + + $config = $templateConfigs[$templateId] ?? [ + 'file' => 'email-inner-base.tpl', + 'placeholders' => ['buttonText', 'body', 'footer'] + ]; + + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); + $message = Template::fromString($templateString); + + foreach ($config['placeholders'] as $param) { + $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); + } + + $message + ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); + + return $message->render(useContent: true); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index ef93abf683..55824f2102 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -7,15 +7,18 @@ use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Template\Template; use Appwrite\Utopia\Response; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Emails\Validator\Email; +use Utopia\Locale\Locale; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -64,6 +67,7 @@ class Update extends Action ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', optional: true) ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', optional: true) ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', optional: true) + ->param('reset', false, new Boolean(), 'Reset template to Appwrite default, removing any custom override.', optional: true) ->inject('response') ->inject('queueForEvents') ->inject('dbForPlatform') @@ -81,6 +85,7 @@ class Update extends Action ?string $senderEmail, ?string $replyToEmail, ?string $replyToName, + bool $reset, Response $response, QueueEvent $queueForEvents, Database $dbForPlatform, @@ -89,14 +94,40 @@ class Update extends Action ) { $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + $templates = $project->getAttribute('templates', []); + + if ($reset) { + unset($templates['email.' . $templateId . '-' . $locale]); + $authorization->skip(fn () => $dbForPlatform->updateDocument( + 'projects', + $project->getId(), + new Document(['templates' => $templates]) + )); + + $queueForEvents->setParam('templateId', $templateId); + + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + $response->dynamic(new Document([ + 'templateId' => $templateId, + 'locale' => $locale, + 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), + 'message' => $this->getDefaultMessage($templateId, $localeObj), + 'senderName' => '', + 'senderEmail' => '', + 'replyToEmail' => '', + 'replyToName' => '', + ]), Response::MODEL_EMAIL_TEMPLATE); + return; + } + // Prevent template update if custom SMTP is not configured $smtp = $project->getAttribute('smtp', []); if (($smtp['enabled'] ?? false) !== true) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to configure custom email templates.'); } - // Fetch current configuration - $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $templateId . '-' . $locale] ?? []; // Apply changes @@ -120,25 +151,66 @@ class Update extends Action } } - // Save configuration $templates['email.' . $templateId . '-' . $locale] = $template; - $updates = new Document([ - 'templates' => $templates, - ]); - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $authorization->skip(fn () => $dbForPlatform->updateDocument( + 'projects', + $project->getId(), + new Document(['templates' => $templates]) + )); $queueForEvents->setParam('templateId', $templateId); $response->dynamic(new Document([ - 'templateId' => $templateId, - 'locale' => $locale, - 'subject' => $template['subject'], - 'message' => $template['message'], - 'senderName' => $template['senderName'] ?? '', - 'senderEmail' => $template['senderEmail'] ?? '', + 'templateId' => $templateId, + 'locale' => $locale, + 'subject' => $template['subject'], + 'message' => $template['message'], + 'senderName' => $template['senderName'] ?? '', + 'senderEmail' => $template['senderEmail'] ?? '', 'replyToEmail' => $template['replyToEmail'] ?? '', - 'replyToName' => $template['replyToName'] ?? '', + 'replyToName' => $template['replyToName'] ?? '', ]), Response::MODEL_EMAIL_TEMPLATE); } + + private function getDefaultMessage(string $templateId, Locale $localeObj): string + { + $templateConfigs = [ + 'magicSession' => [ + 'file' => 'email-magic-url.tpl', + 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] + ], + 'mfaChallenge' => [ + 'file' => 'email-mfa-challenge.tpl', + 'placeholders' => ['description', 'clientInfo'] + ], + 'otpSession' => [ + 'file' => 'email-otp.tpl', + 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] + ], + 'sessionAlert' => [ + 'file' => 'email-session-alert.tpl', + 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] + ], + ]; + + $config = $templateConfigs[$templateId] ?? [ + 'file' => 'email-inner-base.tpl', + 'placeholders' => ['buttonText', 'body', 'footer'] + ]; + + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); + $message = Template::fromString($templateString); + + foreach ($config['placeholders'] as $param) { + $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); + } + + $message + ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); + + return $message->render(useContent: true); + } } diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 3fe9f63d9e..17eba57735 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -90,6 +90,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdatePro use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\GetDefault as DefaultTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\XList as ListTemplates; @@ -124,6 +125,7 @@ class Http extends Service $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); + $this->addAction(DefaultTemplate::getName(), new DefaultTemplate()); // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); From 0e157efabe3aa7ee70ad1aa81f421c1bedb5c583 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 17:17:32 +0530 Subject: [PATCH 02/10] chore: fix import ordering in Http.php --- src/Appwrite/Platform/Modules/Project/Services/Http.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 17eba57735..4357f38f4b 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -90,8 +90,8 @@ use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdatePro use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; -use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\GetDefault as DefaultTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\GetDefault as DefaultTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\XList as ListTemplates; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; From a614afe5c7ff4536e7d5dbdb4e89803e2fad9118 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 18:03:12 +0530 Subject: [PATCH 03/10] refactor: move email template defaults to console module and add tests - Move GetDefault endpoint to Console module as Get.php (GET /v1/console/templates/email/:templateId, console.getEmailTemplate) - Remove from Project module - Add e2e tests for console.getEmailTemplate and reset=true --- .../Http/Templates/Email/Get.php} | 16 +-- .../Modules/Console/Services/Http.php | 2 + .../Modules/Project/Services/Http.php | 2 - tests/e2e/Services/Project/TemplatesBase.php | 136 ++++++++++++++++++ 4 files changed, 146 insertions(+), 10 deletions(-) rename src/Appwrite/Platform/Modules/{Project/Http/Project/Templates/Email/GetDefault.php => Console/Http/Templates/Email/Get.php} (91%) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php similarity index 91% rename from src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php rename to src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php index c89c39e0d6..97d48603df 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/GetDefault.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/console/templates/email/:templateId') - ->desc('Get default project email template') - ->groups(['api', 'project']) - ->label('scope', 'templates.read') + ->desc('Get email template') + ->groups(['api', 'projects']) + ->label('scope', 'projects.read') ->label('sdk', new Method( namespace: 'console', group: 'templates', name: 'getEmailTemplate', description: <<addAction(Web::getName(), new Web()); $this->addAction(GetVariables::getName(), new GetVariables()); + $this->addAction(GetEmailTemplate::getName(), new GetEmailTemplate()); $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers()); $this->addAction(ListKeyScopes::getName(), new ListKeyScopes()); $this->addAction(ListOrganizationScopes::getName(), new ListOrganizationScopes()); diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 4357f38f4b..3fe9f63d9e 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -91,7 +91,6 @@ use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProj use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; -use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\GetDefault as DefaultTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\XList as ListTemplates; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; @@ -125,7 +124,6 @@ class Http extends Service $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); - $this->addAction(DefaultTemplate::getName(), new DefaultTemplate()); // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index b240c945b3..a1e46debe7 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -1147,6 +1147,142 @@ trait TemplatesBase return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', $headers, $params); } + // Console email template (default) tests + + public function testGetConsoleEmailTemplate(): void + { + $response = $this->getConsoleEmailTemplate('verification', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + $this->assertSame('', $response['body']['senderName']); + $this->assertSame('', $response['body']['senderEmail']); + $this->assertSame('', $response['body']['replyToEmail']); + $this->assertSame('', $response['body']['replyToName']); + } + + public function testGetConsoleEmailTemplateIgnoresCustomOverride(): void + { + $this->ensureSMTPEnabled(); + + // Set a custom override on the project template. + $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'en', + subject: 'Custom subject', + message: 'Custom message', + senderName: 'Custom Sender', + senderEmail: 'custom@appwrite.io', + ); + + // Console endpoint must always return the built-in default, not the override. + $response = $this->getConsoleEmailTemplate('recovery', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('recovery', $response['body']['templateId']); + $this->assertNotSame('Custom subject', $response['body']['subject']); + $this->assertSame('', $response['body']['senderName']); + $this->assertSame('', $response['body']['senderEmail']); + } + + public function testGetConsoleEmailTemplateDefaultLocale(): void + { + $response = $this->getConsoleEmailTemplate('magicSession'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + } + + public function testGetConsoleEmailTemplateAllTypes(): void + { + $types = [ + 'verification', + 'magicSession', + 'recovery', + 'invitation', + 'mfaChallenge', + 'sessionAlert', + 'otpSession', + ]; + + foreach ($types as $type) { + $response = $this->getConsoleEmailTemplate($type, 'en'); + $this->assertSame(200, $response['headers']['status-code'], "type={$type}"); + $this->assertNotEmpty($response['body']['subject'], "type={$type} must have subject"); + $this->assertNotEmpty($response['body']['message'], "type={$type} must have message"); + } + } + + public function testResetEmailTemplate(): void + { + $this->ensureSMTPEnabled(); + + // Apply a custom override. + $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: 'Custom invite subject', + message: 'Custom invite message', + senderName: 'Bad Sender', + senderEmail: 'bad@example.com', + ); + + $custom = $this->getEmailTemplate('invitation', 'en'); + $this->assertSame('Custom invite subject', $custom['body']['subject']); + $this->assertSame('Bad Sender', $custom['body']['senderName']); + + // Reset to default. + $response = $this->resetEmailTemplate('invitation', 'en'); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['senderName']); + $this->assertSame('', $response['body']['senderEmail']); + $this->assertNotSame('Custom invite subject', $response['body']['subject']); + + // Confirm the project template now reflects the default. + $after = $this->getEmailTemplate('invitation', 'en'); + $this->assertSame('', $after['body']['senderName']); + $this->assertSame('', $after['body']['senderEmail']); + } + + public function testResetEmailTemplateWithoutSMTP(): void + { + // Reset must work even without SMTP enabled. + $response = $this->resetEmailTemplate('recovery', 'en'); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['senderName']); + $this->assertSame('', $response['body']['senderEmail']); + } + + protected function getConsoleEmailTemplate(string $templateId, ?string $locale = null): mixed + { + $params = []; + if ($locale !== null) { + $params['locale'] = $locale; + } + + return $this->client->call(Client::METHOD_GET, '/console/templates/email/' . $templateId, \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + } + + protected function resetEmailTemplate(string $templateId, ?string $locale = null): mixed + { + $params = ['templateId' => $templateId, 'reset' => true]; + if ($locale !== null) { + $params['locale'] = $locale; + } + + return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + } + protected function ensureSMTPEnabled(): void { $this->client->call( From 8781942633aa99f5e6cdfbb58f70d1925b5834c4 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 18:18:10 +0530 Subject: [PATCH 04/10] Fix console email template spec enum names --- src/Appwrite/SDK/Specification/Format.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index fc67dedb13..d236ee53ce 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -466,6 +466,14 @@ abstract class Format return 'ConsoleResourceValue'; } break; + case 'getEmailTemplate': + switch ($param) { + case 'templateId': + return 'ConsoleEmailTemplateId'; + case 'locale': + return 'ConsoleEmailTemplateLocale'; + } + break; } break; case 'account': From 0e7c50c181ef68b4b81a36a99e0c27c098f08e4e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 18:45:38 +0530 Subject: [PATCH 05/10] Align console email template endpoint metadata --- .../Platform/Modules/Console/Http/Templates/Email/Get.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php index 97d48603df..41b56f2aa6 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php @@ -29,16 +29,16 @@ class Get extends Action $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/console/templates/email/:templateId') ->desc('Get email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.read') + ->groups(['api']) + ->label('scope', 'public') ->label('sdk', new Method( namespace: 'console', - group: 'templates', + group: null, name: 'getEmailTemplate', description: << Date: Fri, 15 May 2026 18:51:22 +0530 Subject: [PATCH 06/10] Remove email template reset API changes --- .../Http/Project/Templates/Email/Update.php | 74 ------------------- src/Appwrite/SDK/Specification/Format.php | 4 +- tests/e2e/Services/Project/TemplatesBase.php | 60 +++------------ 3 files changed, 13 insertions(+), 125 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index 55824f2102..8fc2f6eec9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -7,18 +7,14 @@ use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; -use Appwrite\Template\Template; use Appwrite\Utopia\Response; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; use Utopia\Emails\Validator\Email; -use Utopia\Locale\Locale; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -use Utopia\System\System; -use Utopia\Validator\Boolean; use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -67,7 +63,6 @@ class Update extends Action ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', optional: true) ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', optional: true) ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', optional: true) - ->param('reset', false, new Boolean(), 'Reset template to Appwrite default, removing any custom override.', optional: true) ->inject('response') ->inject('queueForEvents') ->inject('dbForPlatform') @@ -85,7 +80,6 @@ class Update extends Action ?string $senderEmail, ?string $replyToEmail, ?string $replyToName, - bool $reset, Response $response, QueueEvent $queueForEvents, Database $dbForPlatform, @@ -96,32 +90,6 @@ class Update extends Action $templates = $project->getAttribute('templates', []); - if ($reset) { - unset($templates['email.' . $templateId . '-' . $locale]); - $authorization->skip(fn () => $dbForPlatform->updateDocument( - 'projects', - $project->getId(), - new Document(['templates' => $templates]) - )); - - $queueForEvents->setParam('templateId', $templateId); - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - $response->dynamic(new Document([ - 'templateId' => $templateId, - 'locale' => $locale, - 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), - 'message' => $this->getDefaultMessage($templateId, $localeObj), - 'senderName' => '', - 'senderEmail' => '', - 'replyToEmail' => '', - 'replyToName' => '', - ]), Response::MODEL_EMAIL_TEMPLATE); - return; - } - // Prevent template update if custom SMTP is not configured $smtp = $project->getAttribute('smtp', []); if (($smtp['enabled'] ?? false) !== true) { @@ -171,46 +139,4 @@ class Update extends Action 'replyToName' => $template['replyToName'] ?? '', ]), Response::MODEL_EMAIL_TEMPLATE); } - - private function getDefaultMessage(string $templateId, Locale $localeObj): string - { - $templateConfigs = [ - 'magicSession' => [ - 'file' => 'email-magic-url.tpl', - 'placeholders' => ['optionButton', 'buttonText', 'optionUrl', 'clientInfo', 'securityPhrase'] - ], - 'mfaChallenge' => [ - 'file' => 'email-mfa-challenge.tpl', - 'placeholders' => ['description', 'clientInfo'] - ], - 'otpSession' => [ - 'file' => 'email-otp.tpl', - 'placeholders' => ['description', 'clientInfo', 'securityPhrase'] - ], - 'sessionAlert' => [ - 'file' => 'email-session-alert.tpl', - 'placeholders' => ['body', 'listDevice', 'listIpAddress', 'listCountry', 'footer'] - ], - ]; - - $config = $templateConfigs[$templateId] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); - $message = Template::fromString($templateString); - - foreach ($config['placeholders'] as $param) { - $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); - - return $message->render(useContent: true); - } } diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index d236ee53ce..6c5d50e016 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -469,9 +469,9 @@ abstract class Format case 'getEmailTemplate': switch ($param) { case 'templateId': - return 'ConsoleEmailTemplateId'; + return 'ProjectEmailTemplateId'; case 'locale': - return 'ConsoleEmailTemplateLocale'; + return 'ProjectEmailTemplateLocale'; } break; } diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index a1e46debe7..15c24f74c9 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -1217,44 +1217,18 @@ trait TemplatesBase } } - public function testResetEmailTemplate(): void + public function testGetConsoleEmailTemplateInvalidTemplateId(): void { - $this->ensureSMTPEnabled(); + $response = $this->getConsoleEmailTemplate('invalidTemplate', 'en'); - // Apply a custom override. - $this->updateEmailTemplate( - templateId: 'invitation', - locale: 'en', - subject: 'Custom invite subject', - message: 'Custom invite message', - senderName: 'Bad Sender', - senderEmail: 'bad@example.com', - ); - - $custom = $this->getEmailTemplate('invitation', 'en'); - $this->assertSame('Custom invite subject', $custom['body']['subject']); - $this->assertSame('Bad Sender', $custom['body']['senderName']); - - // Reset to default. - $response = $this->resetEmailTemplate('invitation', 'en'); - $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame('', $response['body']['senderName']); - $this->assertSame('', $response['body']['senderEmail']); - $this->assertNotSame('Custom invite subject', $response['body']['subject']); - - // Confirm the project template now reflects the default. - $after = $this->getEmailTemplate('invitation', 'en'); - $this->assertSame('', $after['body']['senderName']); - $this->assertSame('', $after['body']['senderEmail']); + $this->assertSame(400, $response['headers']['status-code']); } - public function testResetEmailTemplateWithoutSMTP(): void + public function testGetConsoleEmailTemplateInvalidLocale(): void { - // Reset must work even without SMTP enabled. - $response = $this->resetEmailTemplate('recovery', 'en'); - $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame('', $response['body']['senderName']); - $this->assertSame('', $response['body']['senderEmail']); + $response = $this->getConsoleEmailTemplate('recovery', 'not-a-locale'); + + $this->assertSame(400, $response['headers']['status-code']); } protected function getConsoleEmailTemplate(string $templateId, ?string $locale = null): mixed @@ -1264,23 +1238,11 @@ trait TemplatesBase $params['locale'] = $locale; } - return $this->client->call(Client::METHOD_GET, '/console/templates/email/' . $templateId, \array_merge([ + return $this->client->call(Client::METHOD_GET, '/console/templates/email/' . $templateId, [ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), $params); - } - - protected function resetEmailTemplate(string $templateId, ?string $locale = null): mixed - { - $params = ['templateId' => $templateId, 'reset' => true]; - if ($locale !== null) { - $params['locale'] = $locale; - } - - return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), $params); + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ], $params); } protected function ensureSMTPEnabled(): void From 7b718e7dcc92a9a2ee0e58677bc0b9587338c86f Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 18:57:08 +0530 Subject: [PATCH 07/10] Add email template default integration test --- .../Console/Http/Templates/Email/Get.php | 5 + .../Project/EmailTemplatesIntegrationTest.php | 194 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php diff --git a/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php index 41b56f2aa6..6906c1fd79 100644 --- a/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Console/Http/Templates/Email/Get.php @@ -105,6 +105,11 @@ class Get extends Action foreach ($config['placeholders'] as $param) { $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + if ($templateId === 'magicSession' && $param === 'securityPhrase') { + $message->setParam('{{securityPhrase}}', ''); + continue; + } + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); } diff --git a/tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php b/tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php new file mode 100644 index 0000000000..7d8a724c38 --- /dev/null +++ b/tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php @@ -0,0 +1,194 @@ +clearMaildev(); + + $recipientEmail = 'magic-template-' . \uniqid() . '@appwrite.io'; + + $this->updateSMTP(['enabled' => false]); + + $firstEmail = $this->triggerMagicUrlAndGetEmail($recipientEmail); + $defaultSnapshot = $this->normalizeMagicUrlEmail($firstEmail); + + $this->updateSMTP([ + 'enabled' => true, + 'senderName' => 'Template Test Mailer', + 'senderEmail' => 'template-test@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ]); + + $customSubject = 'Custom magic login ' . \uniqid(); + $customMarker = 'CUSTOM_MAGIC_TEMPLATE_' . \uniqid(); + $this->updateEmailTemplate([ + 'templateId' => 'magicSession', + 'locale' => 'en', + 'subject' => $customSubject, + 'message' => '

' . $customMarker . '

{{redirect}}

', + ]); + + $customEmail = $this->triggerMagicUrlAndGetEmail($recipientEmail); + $this->assertSame($customSubject, $customEmail['subject']); + $this->assertStringContainsString($customMarker, $customEmail['text']); + $this->assertStringContainsString($customMarker, $customEmail['html']); + + $defaultTemplate = $this->getConsoleEmailTemplate('magicSession', 'en'); + $this->assertSame(200, $defaultTemplate['headers']['status-code']); + + $this->updateEmailTemplate([ + 'templateId' => 'magicSession', + 'locale' => 'en', + 'subject' => $defaultTemplate['body']['subject'], + 'message' => $defaultTemplate['body']['message'], + ]); + + $restoredEmail = $this->triggerMagicUrlAndGetEmail($recipientEmail); + $restoredSnapshot = $this->normalizeMagicUrlEmail($restoredEmail); + + $this->assertSame($defaultSnapshot, $restoredSnapshot); + } + + /** + * @param array $params + */ + private function updateSMTP(array $params): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/smtp', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $params); + + $this->assertSame(200, $response['headers']['status-code']); + } + + /** + * @param array $params + */ + private function updateEmailTemplate(array $params): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/templates/email', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $params); + + $this->assertSame(200, $response['headers']['status-code']); + } + + private function getConsoleEmailTemplate(string $templateId, string $locale): array + { + return $this->client->call(Client::METHOD_GET, '/console/templates/email/' . $templateId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ], [ + 'locale' => $locale, + ]); + } + + private function triggerMagicUrlAndGetEmail(string $recipientEmail): array + { + $previousCount = $this->countEmailsTo($recipientEmail); + + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => ID::unique(), + 'email' => $recipientEmail, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + + return $this->getNextEmailByAddress($recipientEmail, $previousCount); + } + + private function countEmailsTo(string $address): int + { + $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? []; + $count = 0; + + foreach ($emails as $email) { + foreach ($email['to'] ?? [] as $recipient) { + if (($recipient['address'] ?? '') === $address) { + $count++; + } + } + } + + return $count; + } + + private function clearMaildev(): void + { + $context = \stream_context_create([ + 'http' => [ + 'method' => 'DELETE', + ], + ]); + + \file_get_contents('http://maildev:1080/email/all', false, $context); + } + + private function getNextEmailByAddress(string $address, int $previousCount): array + { + $result = []; + + $this->assertEventually(function () use (&$result, $address, $previousCount) { + $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? []; + $matches = []; + + foreach ($emails as $email) { + foreach ($email['to'] ?? [] as $recipient) { + if (($recipient['address'] ?? '') === $address) { + $matches[] = $email; + break; + } + } + } + + $this->assertGreaterThan($previousCount, \count($matches), 'Expected a new email for ' . $address); + $result = $matches[\count($matches) - 1]; + }, 15_000, 500); + + return $result; + } + + /** + * @return array{subject: string, text: string, html: string} + */ + private function normalizeMagicUrlEmail(array $email): array + { + return [ + 'subject' => $this->normalizeMagicUrlContent($email['subject'] ?? ''), + 'text' => $this->normalizeMagicUrlContent($email['text'] ?? ''), + 'html' => $this->normalizeMagicUrlContent($email['html'] ?? ''), + ]; + } + + private function normalizeMagicUrlContent(string $content): string + { + $content = \html_entity_decode($content, ENT_QUOTES); + $content = \preg_replace('/([?&](?:secret|expire)=)[^&\s<"]+/', '$1{value}', $content) ?? $content; + + return \trim($content); + } +} From 6428b7396630d47f16086fe55e3d5c4a2362749a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 19:05:01 +0530 Subject: [PATCH 08/10] Fix email template update System import --- .../Modules/Project/Http/Project/Templates/Email/Update.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index 8fc2f6eec9..256ffcf7d4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -15,6 +15,7 @@ use Utopia\Database\Validator\Authorization; use Utopia\Emails\Validator\Email; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\System\System; use Utopia\Validator\Nullable; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; From 6a2ddb221cfd0b0a02f5d571cc7c7c66d21e964b Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 19:34:15 +0530 Subject: [PATCH 09/10] Clean up template update diff and add locale test --- .../Http/Project/Templates/Email/Update.php | 29 ++++++++++--------- tests/e2e/Services/Project/TemplatesBase.php | 11 +++++++ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index 256ffcf7d4..ef93abf683 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -89,14 +89,14 @@ class Update extends Action ) { $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); - $templates = $project->getAttribute('templates', []); - // Prevent template update if custom SMTP is not configured $smtp = $project->getAttribute('smtp', []); if (($smtp['enabled'] ?? false) !== true) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to configure custom email templates.'); } + // Fetch current configuration + $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $templateId . '-' . $locale] ?? []; // Apply changes @@ -120,24 +120,25 @@ class Update extends Action } } + // Save configuration $templates['email.' . $templateId . '-' . $locale] = $template; - $authorization->skip(fn () => $dbForPlatform->updateDocument( - 'projects', - $project->getId(), - new Document(['templates' => $templates]) - )); + $updates = new Document([ + 'templates' => $templates, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); $queueForEvents->setParam('templateId', $templateId); $response->dynamic(new Document([ - 'templateId' => $templateId, - 'locale' => $locale, - 'subject' => $template['subject'], - 'message' => $template['message'], - 'senderName' => $template['senderName'] ?? '', - 'senderEmail' => $template['senderEmail'] ?? '', + 'templateId' => $templateId, + 'locale' => $locale, + 'subject' => $template['subject'], + 'message' => $template['message'], + 'senderName' => $template['senderName'] ?? '', + 'senderEmail' => $template['senderEmail'] ?? '', 'replyToEmail' => $template['replyToEmail'] ?? '', - 'replyToName' => $template['replyToName'] ?? '', + 'replyToName' => $template['replyToName'] ?? '', ]), Response::MODEL_EMAIL_TEMPLATE); } } diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 15c24f74c9..11dc6dc80b 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -1197,6 +1197,17 @@ trait TemplatesBase $this->assertNotEmpty($response['body']['subject']); } + public function testGetConsoleEmailTemplateNonDefaultLocale(): void + { + $response = $this->getConsoleEmailTemplate('verification', 'fr'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('fr', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + public function testGetConsoleEmailTemplateAllTypes(): void { $types = [ From cb80f0e9c892e39ecf7c6bd24e97ba5cdab2205e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 15 May 2026 19:44:20 +0530 Subject: [PATCH 10/10] Remove email template integration test --- .../Project/EmailTemplatesIntegrationTest.php | 194 ------------------ 1 file changed, 194 deletions(-) delete mode 100644 tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php diff --git a/tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php b/tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php deleted file mode 100644 index 7d8a724c38..0000000000 --- a/tests/e2e/Services/Project/EmailTemplatesIntegrationTest.php +++ /dev/null @@ -1,194 +0,0 @@ -clearMaildev(); - - $recipientEmail = 'magic-template-' . \uniqid() . '@appwrite.io'; - - $this->updateSMTP(['enabled' => false]); - - $firstEmail = $this->triggerMagicUrlAndGetEmail($recipientEmail); - $defaultSnapshot = $this->normalizeMagicUrlEmail($firstEmail); - - $this->updateSMTP([ - 'enabled' => true, - 'senderName' => 'Template Test Mailer', - 'senderEmail' => 'template-test@appwrite.io', - 'host' => 'maildev', - 'port' => 1025, - 'username' => 'user', - 'password' => 'password', - ]); - - $customSubject = 'Custom magic login ' . \uniqid(); - $customMarker = 'CUSTOM_MAGIC_TEMPLATE_' . \uniqid(); - $this->updateEmailTemplate([ - 'templateId' => 'magicSession', - 'locale' => 'en', - 'subject' => $customSubject, - 'message' => '

' . $customMarker . '

{{redirect}}

', - ]); - - $customEmail = $this->triggerMagicUrlAndGetEmail($recipientEmail); - $this->assertSame($customSubject, $customEmail['subject']); - $this->assertStringContainsString($customMarker, $customEmail['text']); - $this->assertStringContainsString($customMarker, $customEmail['html']); - - $defaultTemplate = $this->getConsoleEmailTemplate('magicSession', 'en'); - $this->assertSame(200, $defaultTemplate['headers']['status-code']); - - $this->updateEmailTemplate([ - 'templateId' => 'magicSession', - 'locale' => 'en', - 'subject' => $defaultTemplate['body']['subject'], - 'message' => $defaultTemplate['body']['message'], - ]); - - $restoredEmail = $this->triggerMagicUrlAndGetEmail($recipientEmail); - $restoredSnapshot = $this->normalizeMagicUrlEmail($restoredEmail); - - $this->assertSame($defaultSnapshot, $restoredSnapshot); - } - - /** - * @param array $params - */ - private function updateSMTP(array $params): void - { - $response = $this->client->call(Client::METHOD_PATCH, '/project/smtp', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ], $params); - - $this->assertSame(200, $response['headers']['status-code']); - } - - /** - * @param array $params - */ - private function updateEmailTemplate(array $params): void - { - $response = $this->client->call(Client::METHOD_PATCH, '/project/templates/email', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ], $params); - - $this->assertSame(200, $response['headers']['status-code']); - } - - private function getConsoleEmailTemplate(string $templateId, string $locale): array - { - return $this->client->call(Client::METHOD_GET, '/console/templates/email/' . $templateId, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => 'console', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ], [ - 'locale' => $locale, - ]); - } - - private function triggerMagicUrlAndGetEmail(string $recipientEmail): array - { - $previousCount = $this->countEmailsTo($recipientEmail); - - $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], [ - 'userId' => ID::unique(), - 'email' => $recipientEmail, - ]); - - $this->assertSame(201, $response['headers']['status-code']); - - return $this->getNextEmailByAddress($recipientEmail, $previousCount); - } - - private function countEmailsTo(string $address): int - { - $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? []; - $count = 0; - - foreach ($emails as $email) { - foreach ($email['to'] ?? [] as $recipient) { - if (($recipient['address'] ?? '') === $address) { - $count++; - } - } - } - - return $count; - } - - private function clearMaildev(): void - { - $context = \stream_context_create([ - 'http' => [ - 'method' => 'DELETE', - ], - ]); - - \file_get_contents('http://maildev:1080/email/all', false, $context); - } - - private function getNextEmailByAddress(string $address, int $previousCount): array - { - $result = []; - - $this->assertEventually(function () use (&$result, $address, $previousCount) { - $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? []; - $matches = []; - - foreach ($emails as $email) { - foreach ($email['to'] ?? [] as $recipient) { - if (($recipient['address'] ?? '') === $address) { - $matches[] = $email; - break; - } - } - } - - $this->assertGreaterThan($previousCount, \count($matches), 'Expected a new email for ' . $address); - $result = $matches[\count($matches) - 1]; - }, 15_000, 500); - - return $result; - } - - /** - * @return array{subject: string, text: string, html: string} - */ - private function normalizeMagicUrlEmail(array $email): array - { - return [ - 'subject' => $this->normalizeMagicUrlContent($email['subject'] ?? ''), - 'text' => $this->normalizeMagicUrlContent($email['text'] ?? ''), - 'html' => $this->normalizeMagicUrlContent($email['html'] ?? ''), - ]; - } - - private function normalizeMagicUrlContent(string $content): string - { - $content = \html_entity_decode($content, ENT_QUOTES); - $content = \preg_replace('/([?&](?:secret|expire)=)[^&\s<"]+/', '$1{value}', $content) ?? $content; - - return \trim($content); - } -}