From 1a46fc200613fba8c71693645de8b36a81468666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 16:43:17 +0200 Subject: [PATCH 01/18] Move template APIs under project API --- app/controllers/api/projects.php | 219 ------------------ .../Http/Project/Templates/Email/Delete.php | 101 ++++++++ .../Http/Project/Templates/Email/Get.php | 147 ++++++++++++ .../Http/Project/Templates/Email/Update.php | 121 ++++++++++ .../Modules/Project/Services/Http.php | 8 + 5 files changed, 377 insertions(+), 219 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..b972103224 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -20,7 +20,6 @@ use Utopia\Database\Document; use Utopia\Database\Validator\UID; use Utopia\Emails\Validator\Email; use Utopia\Http\Http; -use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; @@ -833,224 +832,6 @@ Http::post('/v1/projects/:projectId/smtp/tests') $response->noContent(); }); -Http::get('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Get custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'getEmailTemplate', - description: '/docs/references/projects/get-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - if (is_null($template)) { - /** - * different templates, different placeholders. - */ - $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'] - ], - ]; - - // fallback to the base template. - $config = $templateConfigs[$type] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); - - // We use `fromString` due to the replace above - $message = Template::fromString($templateString); - - // Set type-specific parameters - foreach ($config['placeholders'] as $param) { - $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); - - // `useContent: false` will strip new lines! - $message = $message->render(useContent: true); - - $template = [ - 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $type . '.subject'), - 'senderEmail' => '', - 'senderName' => '' - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Update custom email templates') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'updateEmailTemplate', - description: '/docs/references/projects/update-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->param('subject', '', new Text(255), 'Email Subject') - ->param('message', '', new Text(0), 'Template message') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::delete('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Delete custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteEmailTemplate', - description: '/docs/references/projects/delete-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ], - contentType: ContentType::JSON - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->inject('locale') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - - unset($templates['email.' . $type . '-' . $locale]); - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $template['senderName'], - 'senderEmail' => $template['senderEmail'], - 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], - 'message' => $template['message'] - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - Http::patch('/v1/projects/:projectId/auth/session-invalidation') ->desc('Update invalidate session option of the project') ->groups(['api', 'projects']) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php new file mode 100644 index 0000000000..9133971c40 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -0,0 +1,101 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->desc('Delete project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.write') + ->label('event', 'templates.[templateType].delete') + ->label('audits.event', 'project.template.delete') + ->label('audits.resource', 'project.template/{response.type}') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'deleteEmailTemplate', + description: <<param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('locale') + ->callback($this->action(...)); + } + + public function action( + string $type, + string $locale, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Locale $localeObject, + ) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + + $templates = $project->getAttribute('templates', []); + $template = $templates['email.' . $type . '-' . $locale] ?? null; + + if (is_null($template)) { + throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); + } + + unset($templates['email.' . $type . '-' . $locale]); + + $updates = new Document([ + 'templates' => $templates, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('templateType', $type); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php new file mode 100644 index 0000000000..3c5888f8b8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -0,0 +1,147 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->desc('Get project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'getEmailTemplate', + description: <<param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('locale') + ->callback($this->action(...)); + } + + public function action( + string $type, + string $locale, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Locale $localeObject, + ) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + + $templates = $project->getAttribute('templates', []); + $template = $templates['email.' . $type . '-' . $locale] ?? null; + + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + if (is_null($template)) { + /** + * different templates, different placeholders. + */ + $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'] + ], + ]; + + // fallback to the base template. + $config = $templateConfigs[$type] ?? [ + 'file' => 'email-inner-base.tpl', + 'placeholders' => ['buttonText', 'body', 'footer'] + ]; + + $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); + + // We use `fromString` due to the replace above + $message = Template::fromString($templateString); + + // Set type-specific parameters + foreach ($config['placeholders'] as $param) { + $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); + } + + $message + // common placeholders on all the templates + ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); + + // `useContent: false` will strip new lines! + $message = $message->render(useContent: true); + + $template = [ + 'message' => $message, + 'subject' => $localeObj->getText('emails.' . $type . '.subject'), + 'senderEmail' => '', + 'senderName' => '', + 'custom' => false, + ]; + } else { + $template['custom'] = true; + } + + $template['type'] = $type; + $template['locale'] = $locale; + + $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); + } +} 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 new file mode 100644 index 0000000000..ff739f9fe8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -0,0 +1,121 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->desc('Update project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.write') + ->label('event', 'templates.[templateType].update') + ->label('audits.event', 'project.template.update') + ->label('audits.resource', 'project.template/{response.type}') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'updateEmailTemplate', + description: <<param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) + ->param('subject', '', new Text(255), 'Subject of the email template. Can be up to 255 characters.') + ->param('message', '', new Text(10485760), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') + ->param('senderName', '', new Text(255, 0), 'Name of the email sender.', true) + ->param('senderEmail', '', new Email(), 'Email of the sender.', true) + ->param('replyTo', '', new Email(), 'Reply to email.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('locale') + ->callback($this->action(...)); + } + + public function action( + string $type, + string $locale, + string $subject, + string $message, + string $senderName, + string $senderEmail, + string $replyTo, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Locale $localeObject, + ) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + + $template = [ + 'senderName' => $senderName, + 'senderEmail' => $senderEmail, + 'subject' => $subject, + 'replyTo' => $replyTo, + 'message' => $message + ]; + + $templates = $project->getAttribute('templates', []); + $templates['email.' . $type . '-' . $locale] = $template; + + $updates = new Document([ + 'templates' => $templates, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('templateType', $type); + + $response->dynamic(new Document([ + 'type' => $type, + 'locale' => $locale, + 'senderName' => $template['senderName'], + 'senderEmail' => $template['senderEmail'], + 'subject' => $template['subject'], + 'replyTo' => $template['replyTo'], + 'message' => $template['message'], + 'custom' => true, + ]), Response::MODEL_EMAIL_TEMPLATE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index bcab75a8c5..f6c3a2efc2 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -24,6 +24,9 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as U use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol; use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Delete as DeleteTemplate; +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\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -45,6 +48,11 @@ class Http extends Service $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol()); $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); + // Templates + $this->addAction(GetTemplate::getName(), new GetTemplate()); + $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); + $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); + // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); $this->addAction(ListVariables::getName(), new ListVariables()); From 489b2c4e211d442ae3ee41f8ba47f06537a10b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 16:45:04 +0200 Subject: [PATCH 02/18] Add new scopes --- app/config/roles.php | 2 ++ app/config/scopes/project.php | 8 ++++++++ src/Appwrite/Platform/Workers/Migrations.php | 2 ++ tests/e2e/Scopes/ProjectCustom.php | 2 ++ 4 files changed, 14 insertions(+) diff --git a/app/config/roles.php b/app/config/roles.php index 116e8ac932..0903e79b13 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,8 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'templates.read', + 'templates.write', 'projects.write', 'keys.read', 'keys.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 6c7f75c08e..861e292407 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -204,4 +204,12 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s platforms", ], + "templates.read" => [ + "description" => + "Access to read project\'s templates", + ], + "templates.write" => [ + "description" => + "Access to create, update, and delete project\'s templates", + ], ]; diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 118ff7acf9..e69541820b 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -376,6 +376,8 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'templates.read', + 'templates.write', ] ]); diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index a62a1e8ba3..7d9f5eb126 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,8 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'templates.read', + 'templates.write', ], ]); From b01ec03723f828e329b1e9cca8b80fb607ab115c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:01:27 +0200 Subject: [PATCH 03/18] Fix analyze bug --- .../Modules/Project/Http/Project/Templates/Email/Get.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 3c5888f8b8..1a44bf24a5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -6,6 +6,7 @@ use Appwrite\Event\Event as QueueEvent; 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; From e388d2f6a3b950758674f13a1adc9132ca77f898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:22:55 +0200 Subject: [PATCH 04/18] List tempaltes endpoint --- app/init/models.php | 1 + .../Http/Project/Templates/Email/Get.php | 3 +- .../Http/Project/Templates/Email/XList.php | 113 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/TemplateEmail.php | 6 + 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php diff --git a/app/init/models.php b/app/init/models.php index f654c10121..924df52bdd 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -210,6 +210,7 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE)); Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false)); Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); +Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 1a44bf24a5..1855f13d17 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -30,8 +30,7 @@ class Get extends Action public function __construct() { $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/project/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email') + ->setHttpPath('/v1/project/templates/email/:type') ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Get project email template') ->groups(['api', 'project']) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php new file mode 100644 index 0000000000..5cd5184177 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -0,0 +1,113 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->desc('List project email templates') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'listEmailTemplates', + description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('project') + ->inject('response') + ->inject('localeCodes') + ->callback($this->action(...)); + } + + /** + * @param array $queries + * @param array $localeCodes + */ + public function action( + array $queries, + bool $includeTotal, + Document $project, + Response $response, + array $localeCodes, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $grouped = Query::groupByType($queries); + $limit = $grouped['limit'] ?? APP_LIMIT_COUNT; + $offset = $grouped['offset'] ?? 0; + + $types = Config::getParam('locale-templates')['email'] ?? []; + $projectTemplates = $project->getAttribute('templates', []); + + $templates = []; + foreach ($types as $type) { + foreach ($localeCodes as $locale) { + $key = 'email.' . $type . '-' . $locale; + $stored = $projectTemplates[$key] ?? null; + + $templates[] = new Document([ + 'type' => $type, + 'locale' => $locale, + 'message' => $stored['message'] ?? '', + 'subject' => $stored['subject'] ?? '', + 'senderName' => $stored['senderName'] ?? '', + 'senderEmail' => $stored['senderEmail'] ?? '', + 'replyTo' => $stored['replyTo'] ?? '', + 'custom' => !\is_null($stored), + ]); + } + } + + $total = $includeTotal ? \count($templates) : 0; + $templates = \array_slice($templates, $offset, $limit); + + $response->dynamic(new Document([ + 'templates' => $templates, + 'total' => $total, + ]), Response::MODEL_EMAIL_TEMPLATE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index f6c3a2efc2..b91541928c 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -27,6 +27,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProj use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Delete as DeleteTemplate; 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; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -49,6 +50,7 @@ class Http extends Service $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); // Templates + $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index d747373b59..4aecc62fd8 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -266,6 +266,7 @@ class Response extends SwooleResponse public const MODEL_VARIABLE_LIST = 'variableList'; public const MODEL_VCS = 'vcs'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; + public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index ecdf89e774..95cd57a584 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -34,6 +34,12 @@ class TemplateEmail extends Template 'default' => '', 'example' => 'Please verify your email address', ]) + ->addRule('custom', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether the template has been customized for the project. Non-custom templates render from defaults.', + 'default' => false, + 'example' => false, + ]) ; } From dc704fdb5131d7262ee61253419249e31a572f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:27:19 +0200 Subject: [PATCH 05/18] Improved xlist endpoint --- .../Http/Project/Templates/Email/XList.php | 119 +++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 5cd5184177..3a04cf4490 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -8,12 +8,15 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Config\Config; +use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; use Utopia\Database\Validator\Queries; +use Utopia\Database\Validator\Query\Filter; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; +use Utopia\Database\Validator\Query\Order; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -22,6 +25,17 @@ class XList extends Action { use HTTP; + private const ALLOWED_ATTRIBUTES = [ + 'type' => Database::VAR_STRING, + 'locale' => Database::VAR_STRING, + 'subject' => Database::VAR_STRING, + 'message' => Database::VAR_STRING, + 'senderName' => Database::VAR_STRING, + 'senderEmail' => Database::VAR_STRING, + 'replyTo' => Database::VAR_STRING, + 'custom' => Database::VAR_BOOLEAN, + ]; + public static function getName() { return 'listProjectEmailTemplates'; @@ -29,10 +43,25 @@ class XList extends Action public function __construct() { + $attributes = []; + foreach (self::ALLOWED_ATTRIBUTES as $key => $type) { + $attributes[] = new Document([ + 'key' => $key, + 'type' => $type, + 'array' => false, + ]); + } + + $queriesValidator = new Queries([ + new Limit(), + new Offset(), + new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), + new Order($attributes), + ]); + $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/project/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email') ->desc('List project email templates') ->groups(['api', 'project']) ->label('scope', 'templates.read') @@ -51,7 +80,7 @@ class XList extends Action ) ] )) - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset.', true) + ->param('queries', [], $queriesValidator, 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(self::ALLOWED_ATTRIBUTES)), true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('project') ->inject('response') @@ -80,6 +109,13 @@ class XList extends Action $limit = $grouped['limit'] ?? APP_LIMIT_COUNT; $offset = $grouped['offset'] ?? 0; + /** @var array $filters */ + $filters = $grouped['filters'] ?? []; + /** @var array $orderAttributes */ + $orderAttributes = $grouped['orderAttributes'] ?? []; + /** @var array $orderTypes */ + $orderTypes = $grouped['orderTypes'] ?? []; + $types = Config::getParam('locale-templates')['email'] ?? []; $projectTemplates = $project->getAttribute('templates', []); @@ -102,6 +138,9 @@ class XList extends Action } } + $templates = $this->applyFilters($templates, $filters); + $templates = $this->applyOrder($templates, $orderAttributes, $orderTypes); + $total = $includeTotal ? \count($templates) : 0; $templates = \array_slice($templates, $offset, $limit); @@ -110,4 +149,80 @@ class XList extends Action 'total' => $total, ]), Response::MODEL_EMAIL_TEMPLATE_LIST); } + + /** + * @param array $templates + * @param array $filters + * @return array + */ + private function applyFilters(array $templates, array $filters): array + { + if (empty($filters)) { + return $templates; + } + + return \array_values(\array_filter($templates, function (Document $template) use ($filters) { + foreach ($filters as $filter) { + if (!$this->matches($template, $filter)) { + return false; + } + } + return true; + })); + } + + private function matches(Document $template, Query $filter): bool + { + $attribute = $filter->getAttribute(); + $values = $filter->getValues(); + $actual = $template->getAttribute($attribute); + $needle = (string) ($values[0] ?? ''); + + return match ($filter->getMethod()) { + Query::TYPE_EQUAL => \in_array($actual, $values, false), + Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), + Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), + Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), + Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), + Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), + Query::TYPE_CONTAINS => \is_string($actual) && \str_contains($actual, $needle), + Query::TYPE_NOT_CONTAINS => \is_string($actual) && !\str_contains($actual, $needle), + Query::TYPE_SEARCH => \is_string($actual) && \stripos($actual, $needle) !== false, + Query::TYPE_NOT_SEARCH => \is_string($actual) && \stripos($actual, $needle) === false, + Query::TYPE_IS_NULL => $actual === null || $actual === '', + Query::TYPE_IS_NOT_NULL => $actual !== null && $actual !== '', + default => throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Query method not supported for email templates: ' . $filter->getMethod()), + }; + } + + /** + * @param array $templates + * @param array $orderAttributes + * @param array $orderTypes + * @return array + */ + private function applyOrder(array $templates, array $orderAttributes, array $orderTypes): array + { + if (empty($orderAttributes)) { + return $templates; + } + + \usort($templates, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { + foreach ($orderAttributes as $index => $attribute) { + $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); + $valueA = $a->getAttribute($attribute); + $valueB = $b->getAttribute($attribute); + + $cmp = $valueA <=> $valueB; + if ($cmp === 0) { + continue; + } + + return $direction === Database::ORDER_DESC ? -$cmp : $cmp; + } + return 0; + }); + + return $templates; + } } From bb38bf42487b0c59cfc7b09f3a72509d94f57709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:42:46 +0200 Subject: [PATCH 06/18] Improve code quality --- .../Http/Project/Templates/Email/XList.php | 128 +------------- .../Utopia/Database/InMemoryQuery.php | 159 ++++++++++++++++++ .../Validator/Queries/BaseInMemory.php | 41 +++++ .../Validator/Queries/ProjectTemplates.php | 24 +++ 4 files changed, 230 insertions(+), 122 deletions(-) create mode 100644 src/Appwrite/Utopia/Database/InMemoryQuery.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 3a04cf4490..9fdd576af1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -6,17 +6,13 @@ use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\InMemoryQuery; +use Appwrite\Utopia\Database\Validator\Queries\ProjectTemplates; use Appwrite\Utopia\Response; use Utopia\Config\Config; -use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; -use Utopia\Database\Validator\Queries; -use Utopia\Database\Validator\Query\Filter; -use Utopia\Database\Validator\Query\Limit; -use Utopia\Database\Validator\Query\Offset; -use Utopia\Database\Validator\Query\Order; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -25,17 +21,6 @@ class XList extends Action { use HTTP; - private const ALLOWED_ATTRIBUTES = [ - 'type' => Database::VAR_STRING, - 'locale' => Database::VAR_STRING, - 'subject' => Database::VAR_STRING, - 'message' => Database::VAR_STRING, - 'senderName' => Database::VAR_STRING, - 'senderEmail' => Database::VAR_STRING, - 'replyTo' => Database::VAR_STRING, - 'custom' => Database::VAR_BOOLEAN, - ]; - public static function getName() { return 'listProjectEmailTemplates'; @@ -43,22 +28,6 @@ class XList extends Action public function __construct() { - $attributes = []; - foreach (self::ALLOWED_ATTRIBUTES as $key => $type) { - $attributes[] = new Document([ - 'key' => $key, - 'type' => $type, - 'array' => false, - ]); - } - - $queriesValidator = new Queries([ - new Limit(), - new Offset(), - new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), - new Order($attributes), - ]); - $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/project/templates/email') @@ -80,7 +49,7 @@ class XList extends Action ) ] )) - ->param('queries', [], $queriesValidator, 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(self::ALLOWED_ATTRIBUTES)), true) + ->param('queries', [], new ProjectTemplates(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(ProjectTemplates::ALLOWED_ATTRIBUTES)), true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('project') ->inject('response') @@ -106,15 +75,6 @@ class XList extends Action } $grouped = Query::groupByType($queries); - $limit = $grouped['limit'] ?? APP_LIMIT_COUNT; - $offset = $grouped['offset'] ?? 0; - - /** @var array $filters */ - $filters = $grouped['filters'] ?? []; - /** @var array $orderAttributes */ - $orderAttributes = $grouped['orderAttributes'] ?? []; - /** @var array $orderTypes */ - $orderTypes = $grouped['orderTypes'] ?? []; $types = Config::getParam('locale-templates')['email'] ?? []; $projectTemplates = $project->getAttribute('templates', []); @@ -138,91 +98,15 @@ class XList extends Action } } - $templates = $this->applyFilters($templates, $filters); - $templates = $this->applyOrder($templates, $orderAttributes, $orderTypes); + $templates = InMemoryQuery::filter($templates, $grouped['filters']); + $templates = InMemoryQuery::order($templates, $grouped['orderAttributes'], $grouped['orderTypes']); $total = $includeTotal ? \count($templates) : 0; - $templates = \array_slice($templates, $offset, $limit); + $templates = InMemoryQuery::paginate($templates, $grouped['limit'] ?? APP_LIMIT_COUNT, $grouped['offset']); $response->dynamic(new Document([ 'templates' => $templates, 'total' => $total, ]), Response::MODEL_EMAIL_TEMPLATE_LIST); } - - /** - * @param array $templates - * @param array $filters - * @return array - */ - private function applyFilters(array $templates, array $filters): array - { - if (empty($filters)) { - return $templates; - } - - return \array_values(\array_filter($templates, function (Document $template) use ($filters) { - foreach ($filters as $filter) { - if (!$this->matches($template, $filter)) { - return false; - } - } - return true; - })); - } - - private function matches(Document $template, Query $filter): bool - { - $attribute = $filter->getAttribute(); - $values = $filter->getValues(); - $actual = $template->getAttribute($attribute); - $needle = (string) ($values[0] ?? ''); - - return match ($filter->getMethod()) { - Query::TYPE_EQUAL => \in_array($actual, $values, false), - Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), - Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), - Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), - Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), - Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), - Query::TYPE_CONTAINS => \is_string($actual) && \str_contains($actual, $needle), - Query::TYPE_NOT_CONTAINS => \is_string($actual) && !\str_contains($actual, $needle), - Query::TYPE_SEARCH => \is_string($actual) && \stripos($actual, $needle) !== false, - Query::TYPE_NOT_SEARCH => \is_string($actual) && \stripos($actual, $needle) === false, - Query::TYPE_IS_NULL => $actual === null || $actual === '', - Query::TYPE_IS_NOT_NULL => $actual !== null && $actual !== '', - default => throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Query method not supported for email templates: ' . $filter->getMethod()), - }; - } - - /** - * @param array $templates - * @param array $orderAttributes - * @param array $orderTypes - * @return array - */ - private function applyOrder(array $templates, array $orderAttributes, array $orderTypes): array - { - if (empty($orderAttributes)) { - return $templates; - } - - \usort($templates, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { - foreach ($orderAttributes as $index => $attribute) { - $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); - $valueA = $a->getAttribute($attribute); - $valueB = $b->getAttribute($attribute); - - $cmp = $valueA <=> $valueB; - if ($cmp === 0) { - continue; - } - - return $direction === Database::ORDER_DESC ? -$cmp : $cmp; - } - return 0; - }); - - return $templates; - } } diff --git a/src/Appwrite/Utopia/Database/InMemoryQuery.php b/src/Appwrite/Utopia/Database/InMemoryQuery.php new file mode 100644 index 0000000000..c9f930369a --- /dev/null +++ b/src/Appwrite/Utopia/Database/InMemoryQuery.php @@ -0,0 +1,159 @@ + $documents + * @param array $filters + * @return array + */ + public static function filter(array $documents, array $filters): array + { + if (empty($filters)) { + return \array_values($documents); + } + + return \array_values(\array_filter($documents, function (Document $document) use ($filters) { + foreach ($filters as $filter) { + if (!self::matches($document, $filter)) { + return false; + } + } + return true; + })); + } + + /** + * Evaluate a single filter query against a document. + */ + public static function matches(Document $document, Query $filter): bool + { + $attribute = $filter->getAttribute(); + $values = $filter->getValues(); + $actual = $document->getAttribute($attribute); + $needle = (string) ($values[0] ?? ''); + + return match ($filter->getMethod()) { + Query::TYPE_EQUAL => \in_array($actual, $values, false), + Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), + Query::TYPE_LESSER => self::compareScalar($actual, $values[0] ?? null) < 0, + Query::TYPE_LESSER_EQUAL => self::compareScalar($actual, $values[0] ?? null) <= 0, + Query::TYPE_GREATER => self::compareScalar($actual, $values[0] ?? null) > 0, + Query::TYPE_GREATER_EQUAL => self::compareScalar($actual, $values[0] ?? null) >= 0, + Query::TYPE_BETWEEN => self::compareScalar($actual, $values[0] ?? null) >= 0 && self::compareScalar($actual, $values[1] ?? null) <= 0, + Query::TYPE_NOT_BETWEEN => self::compareScalar($actual, $values[0] ?? null) < 0 || self::compareScalar($actual, $values[1] ?? null) > 0, + Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), + Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), + Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), + Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), + Query::TYPE_CONTAINS => self::containsValue($actual, $values), + Query::TYPE_NOT_CONTAINS => !self::containsValue($actual, $values), + Query::TYPE_SEARCH => \is_string($actual) && $needle !== '' && \stripos($actual, $needle) !== false, + Query::TYPE_NOT_SEARCH => \is_string($actual) && ($needle === '' || \stripos($actual, $needle) === false), + Query::TYPE_IS_NULL => $actual === null, + Query::TYPE_IS_NOT_NULL => $actual !== null, + default => throw new \InvalidArgumentException('Unsupported query method: ' . $filter->getMethod()), + }; + } + + /** + * Sort documents by one or more attributes. + * + * @param array $documents + * @param array $orderAttributes + * @param array $orderTypes + * @return array + */ + public static function order(array $documents, array $orderAttributes, array $orderTypes): array + { + if (empty($orderAttributes)) { + return \array_values($documents); + } + + $documents = \array_values($documents); + + \usort($documents, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { + foreach ($orderAttributes as $index => $attribute) { + $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); + $cmp = self::compareScalar($a->getAttribute($attribute), $b->getAttribute($attribute)); + if ($cmp !== 0) { + return $direction === Database::ORDER_DESC ? -$cmp : $cmp; + } + } + return 0; + }); + + return $documents; + } + + /** + * Apply limit and offset. + * + * @param array $documents + * @return array + */ + public static function paginate(array $documents, ?int $limit, ?int $offset): array + { + return \array_slice(\array_values($documents), $offset ?? 0, $limit); + } + + /** + * Compare two scalars in a way that handles null consistently (null sorts before any value). + */ + private static function compareScalar(mixed $a, mixed $b): int + { + if ($a === null && $b === null) { + return 0; + } + if ($a === null) { + return -1; + } + if ($b === null) { + return 1; + } + return $a <=> $b; + } + + /** + * Check if an attribute contains any of the given values. Handles both array attributes + * (checks membership) and string attributes (checks substring). + * + * @param array $values + */ + private static function containsValue(mixed $actual, array $values): bool + { + if (\is_array($actual)) { + foreach ($values as $value) { + if (\in_array($value, $actual, false)) { + return true; + } + } + return false; + } + + if (\is_string($actual)) { + foreach ($values as $value) { + if (\str_contains($actual, (string) $value)) { + return true; + } + } + return false; + } + + return false; + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php new file mode 100644 index 0000000000..c2df415abd --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php @@ -0,0 +1,41 @@ + $allowedAttributes Map of attribute key to Database::VAR_* type + */ + public function __construct(array $allowedAttributes) + { + $attributes = []; + foreach ($allowedAttributes as $key => $type) { + $attributes[] = new Document([ + 'key' => $key, + 'type' => $type, + 'array' => false, + ]); + } + + parent::__construct([ + new Limit(), + new Offset(), + new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), + new Order($attributes), + ]); + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php new file mode 100644 index 0000000000..ea2b7c4cad --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php @@ -0,0 +1,24 @@ + Database::VAR_STRING, + 'locale' => Database::VAR_STRING, + 'subject' => Database::VAR_STRING, + 'message' => Database::VAR_STRING, + 'senderName' => Database::VAR_STRING, + 'senderEmail' => Database::VAR_STRING, + 'replyTo' => Database::VAR_STRING, + 'custom' => Database::VAR_BOOLEAN, + ]; + + public function __construct() + { + parent::__construct(self::ALLOWED_ATTRIBUTES); + } +} From 64d182ac6a109d529b80fecfaef0b1d16ea5a7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:49:20 +0200 Subject: [PATCH 07/18] Add tests for templates --- tests/e2e/Services/Project/TemplatesBase.php | 575 ++++++++++++++++++ .../Project/TemplatesConsoleClientTest.php | 14 + .../Project/TemplatesCustomServerTest.php | 14 + 3 files changed, 603 insertions(+) create mode 100644 tests/e2e/Services/Project/TemplatesBase.php create mode 100644 tests/e2e/Services/Project/TemplatesConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/TemplatesCustomServerTest.php diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php new file mode 100644 index 0000000000..34fe6ee491 --- /dev/null +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -0,0 +1,575 @@ +getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $template['headers']['status-code']); + $this->assertSame('verification', $template['body']['type']); + $this->assertSame('en', $template['body']['locale']); + $this->assertFalse($template['body']['custom']); + $this->assertNotEmpty($template['body']['subject']); + $this->assertNotEmpty($template['body']['message']); + } + + public function testGetEmailTemplateDefaultLocale(): void + { + $template = $this->getEmailTemplate('verification'); + + $this->assertSame(200, $template['headers']['status-code']); + $this->assertSame('verification', $template['body']['type']); + $this->assertSame('en', $template['body']['locale']); + $this->assertFalse($template['body']['custom']); + } + + public function testGetEmailTemplateCustom(): void + { + $update = $this->updateEmailTemplate('magicSession', 'en', 'Magic Subject', 'Magic Body'); + $this->assertSame(200, $update['headers']['status-code']); + + $get = $this->getEmailTemplate('magicSession', 'en'); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('magicSession', $get['body']['type']); + $this->assertSame('en', $get['body']['locale']); + $this->assertTrue($get['body']['custom']); + $this->assertSame('Magic Subject', $get['body']['subject']); + $this->assertSame('Magic Body', $get['body']['message']); + + // Cleanup + $this->deleteEmailTemplate('magicSession', 'en'); + } + + public function testGetEmailTemplateInvalidType(): void + { + $template = $this->getEmailTemplate('notATemplate', 'en'); + + $this->assertSame(400, $template['headers']['status-code']); + } + + public function testGetEmailTemplateInvalidLocale(): void + { + $template = $this->getEmailTemplate('verification', 'not-a-locale'); + + $this->assertSame(400, $template['headers']['status-code']); + } + + public function testGetEmailTemplateWithoutAuthentication(): void + { + $template = $this->getEmailTemplate('verification', 'en', false); + + $this->assertSame(401, $template['headers']['status-code']); + } + + // ========================================================================= + // List email templates tests + // ========================================================================= + + public function testListEmailTemplates(): void + { + $list = $this->listEmailTemplates(null, true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertIsArray($list['body']['templates']); + $this->assertGreaterThan(0, $list['body']['total']); + $this->assertGreaterThan(0, \count($list['body']['templates'])); + + foreach ($list['body']['templates'] as $template) { + $this->assertArrayHasKey('type', $template); + $this->assertArrayHasKey('locale', $template); + $this->assertArrayHasKey('custom', $template); + $this->assertArrayHasKey('subject', $template); + $this->assertArrayHasKey('message', $template); + } + } + + public function testListEmailTemplatesWithLimit(): void + { + $list = $this->listEmailTemplates([ + Query::limit(5)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(5, $list['body']['templates']); + $this->assertGreaterThanOrEqual(5, $list['body']['total']); + } + + public function testListEmailTemplatesWithOffset(): void + { + $first = $this->listEmailTemplates([ + Query::limit(2)->toString(), + ], true); + $this->assertSame(200, $first['headers']['status-code']); + + $second = $this->listEmailTemplates([ + Query::limit(2)->toString(), + Query::offset(2)->toString(), + ], true); + $this->assertSame(200, $second['headers']['status-code']); + + $firstIds = \array_map( + fn ($t) => $t['type'] . '-' . $t['locale'], + $first['body']['templates'] + ); + $secondIds = \array_map( + fn ($t) => $t['type'] . '-' . $t['locale'], + $second['body']['templates'] + ); + + $this->assertEmpty(\array_intersect($firstIds, $secondIds)); + } + + public function testListEmailTemplatesWithoutTotal(): void + { + $list = $this->listEmailTemplates(null, false); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(0, $list['body']['total']); + $this->assertGreaterThan(0, \count($list['body']['templates'])); + } + + public function testListEmailTemplatesFilterByType(): void + { + $list = $this->listEmailTemplates([ + Query::equal('type', ['verification'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThan(0, $list['body']['total']); + + foreach ($list['body']['templates'] as $template) { + $this->assertSame('verification', $template['type']); + } + } + + public function testListEmailTemplatesFilterByLocale(): void + { + $list = $this->listEmailTemplates([ + Query::equal('locale', ['en'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThan(0, $list['body']['total']); + + foreach ($list['body']['templates'] as $template) { + $this->assertSame('en', $template['locale']); + } + } + + public function testListEmailTemplatesFilterByCustom(): void + { + $update = $this->updateEmailTemplate('recovery', 'en', 'Recovery Subject', 'Recovery Body'); + $this->assertSame(200, $update['headers']['status-code']); + + $list = $this->listEmailTemplates([ + Query::equal('custom', [true])->toString(), + Query::limit(100)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + $found = false; + foreach ($list['body']['templates'] as $template) { + $this->assertTrue($template['custom']); + if ($template['type'] === 'recovery' && $template['locale'] === 'en') { + $found = true; + } + } + $this->assertTrue($found, 'Customized template should appear in custom=true filter'); + + // Cleanup + $this->deleteEmailTemplate('recovery', 'en'); + } + + public function testListEmailTemplatesCombinedFilters(): void + { + $list = $this->listEmailTemplates([ + Query::equal('type', ['verification'])->toString(), + Query::equal('locale', ['en'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(1, $list['body']['total']); + $this->assertCount(1, $list['body']['templates']); + $this->assertSame('verification', $list['body']['templates'][0]['type']); + $this->assertSame('en', $list['body']['templates'][0]['locale']); + } + + public function testListEmailTemplatesOrderByType(): void + { + $asc = $this->listEmailTemplates([ + Query::orderAsc('type')->toString(), + Query::limit(100)->toString(), + ], true); + $this->assertSame(200, $asc['headers']['status-code']); + + $ascTypes = \array_map(fn ($t) => $t['type'], $asc['body']['templates']); + $sorted = $ascTypes; + \sort($sorted); + $this->assertSame($sorted, $ascTypes); + + $desc = $this->listEmailTemplates([ + Query::orderDesc('type')->toString(), + Query::limit(100)->toString(), + ], true); + $this->assertSame(200, $desc['headers']['status-code']); + + $descTypes = \array_map(fn ($t) => $t['type'], $desc['body']['templates']); + $sorted = $descTypes; + \rsort($sorted); + $this->assertSame($sorted, $descTypes); + } + + public function testListEmailTemplatesInvalidQuery(): void + { + $list = $this->listEmailTemplates([ + Query::equal('notAnAttribute', ['foo'])->toString(), + ], true); + + $this->assertSame(400, $list['headers']['status-code']); + } + + public function testListEmailTemplatesWithoutAuthentication(): void + { + $list = $this->listEmailTemplates(null, true, false); + + $this->assertSame(401, $list['headers']['status-code']); + } + + // ========================================================================= + // Update email template tests + // ========================================================================= + + public function testUpdateEmailTemplate(): void + { + $update = $this->updateEmailTemplate( + 'verification', + 'en', + 'Please verify your email', + 'Click here to verify: {{url}}', + ); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame('verification', $update['body']['type']); + $this->assertSame('en', $update['body']['locale']); + $this->assertSame('Please verify your email', $update['body']['subject']); + $this->assertSame('Click here to verify: {{url}}', $update['body']['message']); + $this->assertTrue($update['body']['custom']); + + // Verify persisted via GET + $get = $this->getEmailTemplate('verification', 'en'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Please verify your email', $get['body']['subject']); + $this->assertSame('Click here to verify: {{url}}', $get['body']['message']); + $this->assertTrue($get['body']['custom']); + + // Cleanup + $this->deleteEmailTemplate('verification', 'en'); + } + + public function testUpdateEmailTemplateWithOptionalFields(): void + { + $update = $this->updateEmailTemplate( + 'invitation', + 'en', + 'Team invitation', + 'You have been invited', + 'Appwrite Team', + 'team@appwrite.io', + 'reply@appwrite.io', + ); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame('Team invitation', $update['body']['subject']); + $this->assertSame('You have been invited', $update['body']['message']); + $this->assertSame('Appwrite Team', $update['body']['senderName']); + $this->assertSame('team@appwrite.io', $update['body']['senderEmail']); + $this->assertSame('reply@appwrite.io', $update['body']['replyTo']); + + // Cleanup + $this->deleteEmailTemplate('invitation', 'en'); + } + + public function testUpdateEmailTemplateDefaultLocale(): void + { + $update = $this->updateEmailTemplate( + 'sessionAlert', + null, + 'Session alert', + 'Someone signed in', + ); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame('sessionAlert', $update['body']['type']); + $this->assertSame('en', $update['body']['locale']); + + // Cleanup + $this->deleteEmailTemplate('sessionAlert', 'en'); + } + + public function testUpdateEmailTemplateOverwrite(): void + { + $this->updateEmailTemplate('otpSession', 'en', 'First', 'First body'); + + $second = $this->updateEmailTemplate('otpSession', 'en', 'Second', 'Second body'); + + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame('Second', $second['body']['subject']); + $this->assertSame('Second body', $second['body']['message']); + + $get = $this->getEmailTemplate('otpSession', 'en'); + $this->assertSame('Second', $get['body']['subject']); + + // Cleanup + $this->deleteEmailTemplate('otpSession', 'en'); + } + + public function testUpdateEmailTemplateInvalidType(): void + { + $update = $this->updateEmailTemplate('notATemplate', 'en', 'Subject', 'Message'); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateMissingSubject(): void + { + $update = $this->updateEmailTemplate('verification', 'en', null, 'Message only'); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateMissingMessage(): void + { + $update = $this->updateEmailTemplate('verification', 'en', 'Subject only', null); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidSenderEmail(): void + { + $update = $this->updateEmailTemplate( + 'verification', + 'en', + 'Subject', + 'Message', + 'Sender', + 'not-an-email', + ); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidReplyTo(): void + { + $update = $this->updateEmailTemplate( + 'verification', + 'en', + 'Subject', + 'Message', + null, + null, + 'not-an-email', + ); + + $this->assertSame(400, $update['headers']['status-code']); + } + + public function testUpdateEmailTemplateWithoutAuthentication(): void + { + $update = $this->updateEmailTemplate( + 'verification', + 'en', + 'Subject', + 'Message', + null, + null, + null, + false, + ); + + $this->assertSame(401, $update['headers']['status-code']); + } + + // ========================================================================= + // Delete email template tests + // ========================================================================= + + public function testDeleteEmailTemplate(): void + { + $update = $this->updateEmailTemplate('mfaChallenge', 'en', 'MFA', 'Enter code'); + $this->assertSame(200, $update['headers']['status-code']); + + $customBefore = $this->getEmailTemplate('mfaChallenge', 'en'); + $this->assertTrue($customBefore['body']['custom']); + + $delete = $this->deleteEmailTemplate('mfaChallenge', 'en'); + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify reset back to default + $after = $this->getEmailTemplate('mfaChallenge', 'en'); + $this->assertSame(200, $after['headers']['status-code']); + $this->assertFalse($after['body']['custom']); + $this->assertNotSame('MFA', $after['body']['subject']); + } + + public function testDeleteEmailTemplateDefault(): void + { + // Attempt to delete a template that was never customized + $delete = $this->deleteEmailTemplate('verification', 'fr'); + + $this->assertSame(401, $delete['headers']['status-code']); + $this->assertSame('project_template_default_deletion', $delete['body']['type']); + } + + public function testDeleteEmailTemplateInvalidType(): void + { + $delete = $this->deleteEmailTemplate('notATemplate', 'en'); + + $this->assertSame(400, $delete['headers']['status-code']); + } + + public function testDeleteEmailTemplateWithoutAuthentication(): void + { + $update = $this->updateEmailTemplate('recovery', 'en', 'Recovery', 'Reset password'); + $this->assertSame(200, $update['headers']['status-code']); + + $delete = $this->deleteEmailTemplate('recovery', 'en', false); + + $this->assertSame(401, $delete['headers']['status-code']); + + // Verify still customized + $get = $this->getEmailTemplate('recovery', 'en'); + $this->assertTrue($get['body']['custom']); + + // Cleanup + $this->deleteEmailTemplate('recovery', 'en'); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + protected function getEmailTemplate(string $type, ?string $locale = null, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($locale !== null) { + $params['locale'] = $locale; + } + + return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $type, $headers, $params); + } + + /** + * @param array|null $queries + */ + protected function listEmailTemplates(?array $queries, ?bool $total, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($queries !== null) { + $params['queries'] = $queries; + } + if ($total !== null) { + $params['total'] = $total; + } + + return $this->client->call(Client::METHOD_GET, '/project/templates/email', $headers, $params); + } + + protected function updateEmailTemplate( + string $type, + ?string $locale, + ?string $subject, + ?string $message, + ?string $senderName = null, + ?string $senderEmail = null, + ?string $replyTo = null, + bool $authenticated = true, + ): mixed { + $params = [ + 'type' => $type, + ]; + + if ($locale !== null) { + $params['locale'] = $locale; + } + if ($subject !== null) { + $params['subject'] = $subject; + } + if ($message !== null) { + $params['message'] = $message; + } + if ($senderName !== null) { + $params['senderName'] = $senderName; + } + if ($senderEmail !== null) { + $params['senderEmail'] = $senderEmail; + } + if ($replyTo !== null) { + $params['replyTo'] = $replyTo; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', $headers, $params); + } + + protected function deleteEmailTemplate(string $type, ?string $locale = null, bool $authenticated = true): mixed + { + $params = [ + 'type' => $type, + ]; + + if ($locale !== null) { + $params['locale'] = $locale; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_DELETE, '/project/templates/email', $headers, $params); + } +} diff --git a/tests/e2e/Services/Project/TemplatesConsoleClientTest.php b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php new file mode 100644 index 0000000000..d5431074e3 --- /dev/null +++ b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Fri, 17 Apr 2026 18:15:49 +0200 Subject: [PATCH 08/18] Fix bug --- .../Project/Http/Project/Templates/Email/Get.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 1855f13d17..a5a4d8ce8f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -2,16 +2,13 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Templates\Email; -use Appwrite\Event\Event as QueueEvent; 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\Locale\Locale; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -53,9 +50,6 @@ class Get extends Action ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) ->inject('response') - ->inject('queueForEvents') - ->inject('dbForPlatform') - ->inject('authorization') ->inject('project') ->inject('locale') ->callback($this->action(...)); @@ -65,9 +59,6 @@ class Get extends Action string $type, string $locale, Response $response, - QueueEvent $queueForEvents, - Database $dbForPlatform, - Authorization $authorization, Document $project, Locale $localeObject, ) { @@ -108,7 +99,7 @@ class Get extends Action 'placeholders' => ['buttonText', 'body', 'footer'] ]; - $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/../../config/locale/templates/' . $config['file']); // We use `fromString` due to the replace above $message = Template::fromString($templateString); From be5eeb1abac7902053282dcb407325a15a99351e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 19:50:34 +0200 Subject: [PATCH 09/18] Fix failing tests --- .../Modules/Project/Http/Project/Templates/Email/Get.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index a5a4d8ce8f..9725215613 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -99,7 +99,7 @@ class Get extends Action 'placeholders' => ['buttonText', 'body', 'footer'] ]; - $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/../../config/locale/templates/' . $config['file']); + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); // We use `fromString` due to the replace above $message = Template::fromString($templateString); From fcc7a56f4af2354ebaebfa3758a508983e756d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 18 Apr 2026 11:01:26 +0200 Subject: [PATCH 10/18] Fix list endpoint --- .../Http/Project/Templates/Email/XList.php | 81 ++++++++++++++++--- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 9fdd576af1..0128a18e25 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -6,6 +6,7 @@ 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\Database\InMemoryQuery; use Appwrite\Utopia\Database\Validator\Queries\ProjectTemplates; use Appwrite\Utopia\Response; @@ -13,8 +14,10 @@ use Utopia\Config\Config; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; +use Utopia\Locale\Locale; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\System\System; use Utopia\Validator\Boolean; class XList extends Action @@ -85,16 +88,74 @@ class XList extends Action $key = 'email.' . $type . '-' . $locale; $stored = $projectTemplates[$key] ?? null; - $templates[] = new Document([ - 'type' => $type, - 'locale' => $locale, - 'message' => $stored['message'] ?? '', - 'subject' => $stored['subject'] ?? '', - 'senderName' => $stored['senderName'] ?? '', - 'senderEmail' => $stored['senderEmail'] ?? '', - 'replyTo' => $stored['replyTo'] ?? '', - 'custom' => !\is_null($stored), - ]); + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + if (is_null($stored)) { + /** + * different templates, different placeholders. + */ + $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'] + ], + ]; + + // fallback to the base template. + $config = $templateConfigs[$type] ?? [ + 'file' => 'email-inner-base.tpl', + 'placeholders' => ['buttonText', 'body', 'footer'] + ]; + + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); + + // We use `fromString` due to the replace above + $message = Template::fromString($templateString); + + // Set type-specific parameters + foreach ($config['placeholders'] as $param) { + $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); + } + + $message + // common placeholders on all the templates + ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); + + // `useContent: false` will strip new lines! + $message = $message->render(useContent: true); + + $template = [ + 'message' => $message, + 'subject' => $localeObj->getText('emails.' . $type . '.subject'), + 'senderEmail' => '', + 'senderName' => '', + 'custom' => false, + ]; + } else { + $template = $stored; + $template['custom'] = true; + } + + $template['type'] = $type; + $template['locale'] = $locale; + + $templates[] = new Document($template); } } From 0a71efc24494ec15189a426472cd717e79615f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 18 Apr 2026 11:23:53 +0200 Subject: [PATCH 11/18] improve test coverage --- tests/e2e/Services/Project/TemplatesBase.php | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 34fe6ee491..27601eddbe 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -207,6 +207,32 @@ trait TemplatesBase $this->assertSame('en', $list['body']['templates'][0]['locale']); } + public function testListEmailTemplatesDefaultMatchesGet(): void + { + $get = $this->getEmailTemplate('verification', 'en'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertFalse($get['body']['custom']); + + $list = $this->listEmailTemplates([ + Query::equal('type', ['verification'])->toString(), + Query::equal('locale', ['en'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(1, $list['body']['templates']); + + $listed = $list['body']['templates'][0]; + + $this->assertSame($get['body']['type'], $listed['type']); + $this->assertSame($get['body']['locale'], $listed['locale']); + $this->assertSame($get['body']['custom'], $listed['custom']); + $this->assertSame($get['body']['subject'], $listed['subject']); + $this->assertSame($get['body']['message'], $listed['message']); + $this->assertSame($get['body']['senderName'], $listed['senderName']); + $this->assertSame($get['body']['senderEmail'], $listed['senderEmail']); + $this->assertSame($get['body']['replyTo'], $listed['replyTo']); + } + public function testListEmailTemplatesOrderByType(): void { $asc = $this->listEmailTemplates([ From 8a1f8c71b24439433537aaa329a3a6b14b0f15cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:15:48 +0200 Subject: [PATCH 12/18] Temporary removal of listEmailTemplates Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Http/Project/Templates/Email/XList.php | 173 ------------- .../Modules/Project/Services/Http.php | 2 - .../Utopia/Database/InMemoryQuery.php | 159 ------------ .../Validator/Queries/BaseInMemory.php | 41 ---- .../Validator/Queries/ProjectTemplates.php | 24 -- tests/e2e/Services/Project/TemplatesBase.php | 228 ------------------ 6 files changed, 627 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php delete mode 100644 src/Appwrite/Utopia/Database/InMemoryQuery.php delete mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php delete mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php deleted file mode 100644 index 0128a18e25..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ /dev/null @@ -1,173 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/project/templates/email') - ->desc('List project email templates') - ->groups(['api', 'project']) - ->label('scope', 'templates.read') - ->label('sdk', new Method( - namespace: 'project', - group: 'templates', - name: 'listEmailTemplates', - description: <<param('queries', [], new ProjectTemplates(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(ProjectTemplates::ALLOWED_ATTRIBUTES)), true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->inject('project') - ->inject('response') - ->inject('localeCodes') - ->callback($this->action(...)); - } - - /** - * @param array $queries - * @param array $localeCodes - */ - public function action( - array $queries, - bool $includeTotal, - Document $project, - Response $response, - array $localeCodes, - ) { - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $grouped = Query::groupByType($queries); - - $types = Config::getParam('locale-templates')['email'] ?? []; - $projectTemplates = $project->getAttribute('templates', []); - - $templates = []; - foreach ($types as $type) { - foreach ($localeCodes as $locale) { - $key = 'email.' . $type . '-' . $locale; - $stored = $projectTemplates[$key] ?? null; - - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); - - if (is_null($stored)) { - /** - * different templates, different placeholders. - */ - $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'] - ], - ]; - - // fallback to the base template. - $config = $templateConfigs[$type] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); - - // We use `fromString` due to the replace above - $message = Template::fromString($templateString); - - // Set type-specific parameters - foreach ($config['placeholders'] as $param) { - $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); - } - - $message - // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); - - // `useContent: false` will strip new lines! - $message = $message->render(useContent: true); - - $template = [ - 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $type . '.subject'), - 'senderEmail' => '', - 'senderName' => '', - 'custom' => false, - ]; - } else { - $template = $stored; - $template['custom'] = true; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $templates[] = new Document($template); - } - } - - $templates = InMemoryQuery::filter($templates, $grouped['filters']); - $templates = InMemoryQuery::order($templates, $grouped['orderAttributes'], $grouped['orderTypes']); - - $total = $includeTotal ? \count($templates) : 0; - $templates = InMemoryQuery::paginate($templates, $grouped['limit'] ?? APP_LIMIT_COUNT, $grouped['offset']); - - $response->dynamic(new Document([ - 'templates' => $templates, - 'total' => $total, - ]), Response::MODEL_EMAIL_TEMPLATE_LIST); - } -} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index b91541928c..f6c3a2efc2 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -27,7 +27,6 @@ use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProj use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Delete as DeleteTemplate; 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; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -50,7 +49,6 @@ class Http extends Service $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); // Templates - $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); diff --git a/src/Appwrite/Utopia/Database/InMemoryQuery.php b/src/Appwrite/Utopia/Database/InMemoryQuery.php deleted file mode 100644 index c9f930369a..0000000000 --- a/src/Appwrite/Utopia/Database/InMemoryQuery.php +++ /dev/null @@ -1,159 +0,0 @@ - $documents - * @param array $filters - * @return array - */ - public static function filter(array $documents, array $filters): array - { - if (empty($filters)) { - return \array_values($documents); - } - - return \array_values(\array_filter($documents, function (Document $document) use ($filters) { - foreach ($filters as $filter) { - if (!self::matches($document, $filter)) { - return false; - } - } - return true; - })); - } - - /** - * Evaluate a single filter query against a document. - */ - public static function matches(Document $document, Query $filter): bool - { - $attribute = $filter->getAttribute(); - $values = $filter->getValues(); - $actual = $document->getAttribute($attribute); - $needle = (string) ($values[0] ?? ''); - - return match ($filter->getMethod()) { - Query::TYPE_EQUAL => \in_array($actual, $values, false), - Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), - Query::TYPE_LESSER => self::compareScalar($actual, $values[0] ?? null) < 0, - Query::TYPE_LESSER_EQUAL => self::compareScalar($actual, $values[0] ?? null) <= 0, - Query::TYPE_GREATER => self::compareScalar($actual, $values[0] ?? null) > 0, - Query::TYPE_GREATER_EQUAL => self::compareScalar($actual, $values[0] ?? null) >= 0, - Query::TYPE_BETWEEN => self::compareScalar($actual, $values[0] ?? null) >= 0 && self::compareScalar($actual, $values[1] ?? null) <= 0, - Query::TYPE_NOT_BETWEEN => self::compareScalar($actual, $values[0] ?? null) < 0 || self::compareScalar($actual, $values[1] ?? null) > 0, - Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), - Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), - Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), - Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), - Query::TYPE_CONTAINS => self::containsValue($actual, $values), - Query::TYPE_NOT_CONTAINS => !self::containsValue($actual, $values), - Query::TYPE_SEARCH => \is_string($actual) && $needle !== '' && \stripos($actual, $needle) !== false, - Query::TYPE_NOT_SEARCH => \is_string($actual) && ($needle === '' || \stripos($actual, $needle) === false), - Query::TYPE_IS_NULL => $actual === null, - Query::TYPE_IS_NOT_NULL => $actual !== null, - default => throw new \InvalidArgumentException('Unsupported query method: ' . $filter->getMethod()), - }; - } - - /** - * Sort documents by one or more attributes. - * - * @param array $documents - * @param array $orderAttributes - * @param array $orderTypes - * @return array - */ - public static function order(array $documents, array $orderAttributes, array $orderTypes): array - { - if (empty($orderAttributes)) { - return \array_values($documents); - } - - $documents = \array_values($documents); - - \usort($documents, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { - foreach ($orderAttributes as $index => $attribute) { - $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); - $cmp = self::compareScalar($a->getAttribute($attribute), $b->getAttribute($attribute)); - if ($cmp !== 0) { - return $direction === Database::ORDER_DESC ? -$cmp : $cmp; - } - } - return 0; - }); - - return $documents; - } - - /** - * Apply limit and offset. - * - * @param array $documents - * @return array - */ - public static function paginate(array $documents, ?int $limit, ?int $offset): array - { - return \array_slice(\array_values($documents), $offset ?? 0, $limit); - } - - /** - * Compare two scalars in a way that handles null consistently (null sorts before any value). - */ - private static function compareScalar(mixed $a, mixed $b): int - { - if ($a === null && $b === null) { - return 0; - } - if ($a === null) { - return -1; - } - if ($b === null) { - return 1; - } - return $a <=> $b; - } - - /** - * Check if an attribute contains any of the given values. Handles both array attributes - * (checks membership) and string attributes (checks substring). - * - * @param array $values - */ - private static function containsValue(mixed $actual, array $values): bool - { - if (\is_array($actual)) { - foreach ($values as $value) { - if (\in_array($value, $actual, false)) { - return true; - } - } - return false; - } - - if (\is_string($actual)) { - foreach ($values as $value) { - if (\str_contains($actual, (string) $value)) { - return true; - } - } - return false; - } - - return false; - } -} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php deleted file mode 100644 index c2df415abd..0000000000 --- a/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php +++ /dev/null @@ -1,41 +0,0 @@ - $allowedAttributes Map of attribute key to Database::VAR_* type - */ - public function __construct(array $allowedAttributes) - { - $attributes = []; - foreach ($allowedAttributes as $key => $type) { - $attributes[] = new Document([ - 'key' => $key, - 'type' => $type, - 'array' => false, - ]); - } - - parent::__construct([ - new Limit(), - new Offset(), - new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), - new Order($attributes), - ]); - } -} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php deleted file mode 100644 index ea2b7c4cad..0000000000 --- a/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php +++ /dev/null @@ -1,24 +0,0 @@ - Database::VAR_STRING, - 'locale' => Database::VAR_STRING, - 'subject' => Database::VAR_STRING, - 'message' => Database::VAR_STRING, - 'senderName' => Database::VAR_STRING, - 'senderEmail' => Database::VAR_STRING, - 'replyTo' => Database::VAR_STRING, - 'custom' => Database::VAR_BOOLEAN, - ]; - - public function __construct() - { - parent::__construct(self::ALLOWED_ATTRIBUTES); - } -} diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 27601eddbe..45e09779b9 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -3,7 +3,6 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; -use Utopia\Database\Query; trait TemplatesBase { @@ -72,208 +71,6 @@ trait TemplatesBase $this->assertSame(401, $template['headers']['status-code']); } - // ========================================================================= - // List email templates tests - // ========================================================================= - - public function testListEmailTemplates(): void - { - $list = $this->listEmailTemplates(null, true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertIsArray($list['body']['templates']); - $this->assertGreaterThan(0, $list['body']['total']); - $this->assertGreaterThan(0, \count($list['body']['templates'])); - - foreach ($list['body']['templates'] as $template) { - $this->assertArrayHasKey('type', $template); - $this->assertArrayHasKey('locale', $template); - $this->assertArrayHasKey('custom', $template); - $this->assertArrayHasKey('subject', $template); - $this->assertArrayHasKey('message', $template); - } - } - - public function testListEmailTemplatesWithLimit(): void - { - $list = $this->listEmailTemplates([ - Query::limit(5)->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertCount(5, $list['body']['templates']); - $this->assertGreaterThanOrEqual(5, $list['body']['total']); - } - - public function testListEmailTemplatesWithOffset(): void - { - $first = $this->listEmailTemplates([ - Query::limit(2)->toString(), - ], true); - $this->assertSame(200, $first['headers']['status-code']); - - $second = $this->listEmailTemplates([ - Query::limit(2)->toString(), - Query::offset(2)->toString(), - ], true); - $this->assertSame(200, $second['headers']['status-code']); - - $firstIds = \array_map( - fn ($t) => $t['type'] . '-' . $t['locale'], - $first['body']['templates'] - ); - $secondIds = \array_map( - fn ($t) => $t['type'] . '-' . $t['locale'], - $second['body']['templates'] - ); - - $this->assertEmpty(\array_intersect($firstIds, $secondIds)); - } - - public function testListEmailTemplatesWithoutTotal(): void - { - $list = $this->listEmailTemplates(null, false); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertSame(0, $list['body']['total']); - $this->assertGreaterThan(0, \count($list['body']['templates'])); - } - - public function testListEmailTemplatesFilterByType(): void - { - $list = $this->listEmailTemplates([ - Query::equal('type', ['verification'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertGreaterThan(0, $list['body']['total']); - - foreach ($list['body']['templates'] as $template) { - $this->assertSame('verification', $template['type']); - } - } - - public function testListEmailTemplatesFilterByLocale(): void - { - $list = $this->listEmailTemplates([ - Query::equal('locale', ['en'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertGreaterThan(0, $list['body']['total']); - - foreach ($list['body']['templates'] as $template) { - $this->assertSame('en', $template['locale']); - } - } - - public function testListEmailTemplatesFilterByCustom(): void - { - $update = $this->updateEmailTemplate('recovery', 'en', 'Recovery Subject', 'Recovery Body'); - $this->assertSame(200, $update['headers']['status-code']); - - $list = $this->listEmailTemplates([ - Query::equal('custom', [true])->toString(), - Query::limit(100)->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertGreaterThanOrEqual(1, $list['body']['total']); - - $found = false; - foreach ($list['body']['templates'] as $template) { - $this->assertTrue($template['custom']); - if ($template['type'] === 'recovery' && $template['locale'] === 'en') { - $found = true; - } - } - $this->assertTrue($found, 'Customized template should appear in custom=true filter'); - - // Cleanup - $this->deleteEmailTemplate('recovery', 'en'); - } - - public function testListEmailTemplatesCombinedFilters(): void - { - $list = $this->listEmailTemplates([ - Query::equal('type', ['verification'])->toString(), - Query::equal('locale', ['en'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertSame(1, $list['body']['total']); - $this->assertCount(1, $list['body']['templates']); - $this->assertSame('verification', $list['body']['templates'][0]['type']); - $this->assertSame('en', $list['body']['templates'][0]['locale']); - } - - public function testListEmailTemplatesDefaultMatchesGet(): void - { - $get = $this->getEmailTemplate('verification', 'en'); - $this->assertSame(200, $get['headers']['status-code']); - $this->assertFalse($get['body']['custom']); - - $list = $this->listEmailTemplates([ - Query::equal('type', ['verification'])->toString(), - Query::equal('locale', ['en'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertCount(1, $list['body']['templates']); - - $listed = $list['body']['templates'][0]; - - $this->assertSame($get['body']['type'], $listed['type']); - $this->assertSame($get['body']['locale'], $listed['locale']); - $this->assertSame($get['body']['custom'], $listed['custom']); - $this->assertSame($get['body']['subject'], $listed['subject']); - $this->assertSame($get['body']['message'], $listed['message']); - $this->assertSame($get['body']['senderName'], $listed['senderName']); - $this->assertSame($get['body']['senderEmail'], $listed['senderEmail']); - $this->assertSame($get['body']['replyTo'], $listed['replyTo']); - } - - public function testListEmailTemplatesOrderByType(): void - { - $asc = $this->listEmailTemplates([ - Query::orderAsc('type')->toString(), - Query::limit(100)->toString(), - ], true); - $this->assertSame(200, $asc['headers']['status-code']); - - $ascTypes = \array_map(fn ($t) => $t['type'], $asc['body']['templates']); - $sorted = $ascTypes; - \sort($sorted); - $this->assertSame($sorted, $ascTypes); - - $desc = $this->listEmailTemplates([ - Query::orderDesc('type')->toString(), - Query::limit(100)->toString(), - ], true); - $this->assertSame(200, $desc['headers']['status-code']); - - $descTypes = \array_map(fn ($t) => $t['type'], $desc['body']['templates']); - $sorted = $descTypes; - \rsort($sorted); - $this->assertSame($sorted, $descTypes); - } - - public function testListEmailTemplatesInvalidQuery(): void - { - $list = $this->listEmailTemplates([ - Query::equal('notAnAttribute', ['foo'])->toString(), - ], true); - - $this->assertSame(400, $list['headers']['status-code']); - } - - public function testListEmailTemplatesWithoutAuthentication(): void - { - $list = $this->listEmailTemplates(null, true, false); - - $this->assertSame(401, $list['headers']['status-code']); - } - // ========================================================================= // Update email template tests // ========================================================================= @@ -507,31 +304,6 @@ trait TemplatesBase return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $type, $headers, $params); } - /** - * @param array|null $queries - */ - protected function listEmailTemplates(?array $queries, ?bool $total, bool $authenticated = true): mixed - { - $headers = [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ]; - - if ($authenticated) { - $headers = \array_merge($headers, $this->getHeaders()); - } - - $params = []; - if ($queries !== null) { - $params['queries'] = $queries; - } - if ($total !== null) { - $params['total'] = $total; - } - - return $this->client->call(Client::METHOD_GET, '/project/templates/email', $headers, $params); - } - protected function updateEmailTemplate( string $type, ?string $locale, From 2a95cfd5a3a6d973970e03ae03216eb579b313a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:35:57 +0200 Subject: [PATCH 13/18] Final template API rework --- .github/workflows/ci.yml | 3 +- CHANGES.md | 8 ++ README-CN.md | 6 +- README.md | 6 +- app/controllers/general.php | 8 ++ app/init/constants.php | 4 +- src/Appwrite/Migration/Migration.php | 1 + .../Http/Project/Templates/Email/Delete.php | 18 +-- .../Http/Project/Templates/Email/Get.php | 28 ++--- .../Http/Project/Templates/Email/Update.php | 18 +-- src/Appwrite/Utopia/Request/Filters/V23.php | 31 +++++ src/Appwrite/Utopia/Response/Filters/V23.php | 28 +++++ .../Utopia/Response/Model/Template.php | 32 ----- .../Utopia/Response/Model/TemplateEmail.php | 22 +++- tests/e2e/Services/Project/TemplatesBase.php | 113 ++++++++++++++++-- 15 files changed, 243 insertions(+), 83 deletions(-) create mode 100644 src/Appwrite/Utopia/Request/Filters/V23.php create mode 100644 src/Appwrite/Utopia/Response/Filters/V23.php delete mode 100644 src/Appwrite/Utopia/Response/Model/Template.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8256ddc7a..48c8ea901b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -701,9 +701,8 @@ jobs: - name: Installing latest version run: | - rm docker-compose.yml + # TODO: Minify docker-compose; remove development tooling rm .env - curl https://appwrite.io/install/compose -o docker-compose.yml curl https://appwrite.io/install/env -o .env sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env docker compose up -d diff --git a/CHANGES.md b/CHANGES.md index 548c0d72b0..d717de4668 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,11 @@ +# Version 1.9.2 + +### Notable changes + +### Fixes + +### Miscellaneous + # Version 1.9.0 ## What's Changed diff --git a/README-CN.md b/README-CN.md index 2c7402f1ef..7f758bc247 100644 --- a/README-CN.md +++ b/README-CN.md @@ -72,7 +72,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.2 ``` ### Windows @@ -84,7 +84,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.2 ``` #### PowerShell @@ -94,7 +94,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.2 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 31076ffa31..b0926bbeeb 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.2 ``` ### Windows @@ -88,7 +88,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.2 ``` #### PowerShell @@ -99,7 +99,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.1 + appwrite/appwrite:1.9.2 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. diff --git a/app/controllers/general.php b/app/controllers/general.php index b4f4a5c1d1..596fbd0926 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -26,6 +26,7 @@ use Appwrite\Utopia\Request\Filters\V19 as RequestV19; use Appwrite\Utopia\Request\Filters\V20 as RequestV20; use Appwrite\Utopia\Request\Filters\V21 as RequestV21; use Appwrite\Utopia\Request\Filters\V22 as RequestV22; +use Appwrite\Utopia\Request\Filters\V23 as RequestV23; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; @@ -34,6 +35,7 @@ use Appwrite\Utopia\Response\Filters\V19 as ResponseV19; use Appwrite\Utopia\Response\Filters\V20 as ResponseV20; use Appwrite\Utopia\Response\Filters\V21 as ResponseV21; use Appwrite\Utopia\Response\Filters\V22 as ResponseV22; +use Appwrite\Utopia\Response\Filters\V23 as ResponseV23; use Appwrite\Utopia\View; use Executor\Executor; use MaxMind\Db\Reader; @@ -897,6 +899,9 @@ Http::init() if (version_compare($requestFormat, '1.9.1', '<')) { $request->addFilter(new RequestV22()); } + if (version_compare($requestFormat, '1.9.2', '<')) { + $request->addFilter(new RequestV23()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -921,6 +926,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.2', '<')) { + $response->addFilter(new ResponseV23()); + } if (version_compare($responseFormat, '1.9.1', '<')) { $response->addFilter(new ResponseV22()); } diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..54a94652fb 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -46,8 +46,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4322; -const APP_VERSION_STABLE = '1.9.1'; +const APP_CACHE_BUSTER = 4323; +const APP_VERSION_STABLE = '1.9.2'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index a01031de9b..ef0dd9f8b5 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -94,6 +94,7 @@ abstract class Migration '1.8.1' => 'V23', '1.9.0' => 'V24', '1.9.1' => 'V24', + '1.9.2' => 'V24', ]; /** diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php index 9133971c40..cf02704d47 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -33,13 +33,13 @@ class Delete extends Action $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) ->setHttpPath('/v1/project/templates/email') ->httpAlias('/v1/projects/:projectId/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') ->desc('Delete project email template') ->groups(['api', 'project']) ->label('scope', 'templates.write') ->label('event', 'templates.[templateType].delete') ->label('audits.event', 'project.template.delete') - ->label('audits.resource', 'project.template/{response.type}') + ->label('audits.resource', 'project.template/{response.templateId}') ->label('sdk', new Method( namespace: 'project', group: 'templates', @@ -56,8 +56,8 @@ class Delete extends Action ], contentType: ContentType::NONE )) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) + ->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) ->inject('response') ->inject('queueForEvents') ->inject('dbForPlatform') @@ -68,7 +68,7 @@ class Delete extends Action } public function action( - string $type, + string $templateId, string $locale, Response $response, QueueEvent $queueForEvents, @@ -77,16 +77,16 @@ class Delete extends Action Document $project, Locale $localeObject, ) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; + $template = $templates['email.' . $templateId . '-' . $locale] ?? null; if (is_null($template)) { throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); } - unset($templates['email.' . $type . '-' . $locale]); + unset($templates['email.' . $templateId . '-' . $locale]); $updates = new Document([ 'templates' => $templates, @@ -94,7 +94,7 @@ class Delete extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $queueForEvents->setParam('templateType', $type); + $queueForEvents->setParam('templateType', $templateId); $response->noContent(); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 9725215613..115b10f7dd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -27,8 +27,8 @@ class Get extends Action public function __construct() { $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/project/templates/email/:type') - ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->setHttpPath('/v1/project/templates/email/:templateId') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') ->desc('Get project email template') ->groups(['api', 'project']) ->label('scope', 'templates.read') @@ -47,8 +47,8 @@ class Get extends Action ) ] )) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) + ->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) ->inject('response') ->inject('project') ->inject('locale') @@ -56,16 +56,16 @@ class Get extends Action } public function action( - string $type, + string $templateId, string $locale, Response $response, Document $project, Locale $localeObject, ) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; + $template = $templates['email.' . $templateId . '-' . $locale] ?? null; $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); @@ -94,7 +94,7 @@ class Get extends Action ]; // fallback to the base template. - $config = $templateConfigs[$type] ?? [ + $config = $templateConfigs[$templateId] ?? [ 'file' => 'email-inner-base.tpl', 'placeholders' => ['buttonText', 'body', 'footer'] ]; @@ -107,21 +107,21 @@ class Get extends Action // Set type-specific parameters foreach ($config['placeholders'] as $param) { $escapeHtml = !in_array($param, ['clientInfo', 'body', 'footer', 'description']); - $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$type}.{$param}"), escapeHtml: $escapeHtml); + $message->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$param}"), escapeHtml: $escapeHtml); } $message // common placeholders on all the templates - ->setParam('{{hello}}', $localeObj->getText("emails.{$type}.hello")) - ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); + ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); // `useContent: false` will strip new lines! $message = $message->render(useContent: true); $template = [ 'message' => $message, - 'subject' => $localeObj->getText('emails.' . $type . '.subject'), + 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), 'senderEmail' => '', 'senderName' => '', 'custom' => false, @@ -130,7 +130,7 @@ class Get extends Action $template['custom'] = true; } - $template['type'] = $type; + $template['templateId'] = $templateId; $template['locale'] = $locale; $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); 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 ff739f9fe8..f17be381f6 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 @@ -33,13 +33,13 @@ class Update extends Action $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) ->setHttpPath('/v1/project/templates/email') ->httpAlias('/v1/projects/:projectId/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->httpAlias('/v1/projects/:projectId/templates/email/:templateId/:locale') ->desc('Update project email template') ->groups(['api', 'project']) ->label('scope', 'templates.write') ->label('event', 'templates.[templateType].update') ->label('audits.event', 'project.template.update') - ->label('audits.resource', 'project.template/{response.type}') + ->label('audits.resource', 'project.template/{response.templateId}') ->label('sdk', new Method( namespace: 'project', group: 'templates', @@ -55,8 +55,8 @@ class Update extends Action ) ] )) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale.', optional: true, injections: ['localeCodes']) + ->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) ->param('subject', '', new Text(255), 'Subject of the email template. Can be up to 255 characters.') ->param('message', '', new Text(10485760), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') ->param('senderName', '', new Text(255, 0), 'Name of the email sender.', true) @@ -72,7 +72,7 @@ class Update extends Action } public function action( - string $type, + string $templateId, string $locale, string $subject, string $message, @@ -86,7 +86,7 @@ class Update extends Action Document $project, Locale $localeObject, ) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $template = [ 'senderName' => $senderName, @@ -97,7 +97,7 @@ class Update extends Action ]; $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = $template; + $templates['email.' . $templateId . '-' . $locale] = $template; $updates = new Document([ 'templates' => $templates, @@ -105,10 +105,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $queueForEvents->setParam('templateType', $type); + $queueForEvents->setParam('templateType', $templateId); $response->dynamic(new Document([ - 'type' => $type, + 'templateId' => $templateId, 'locale' => $locale, 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php new file mode 100644 index 0000000000..adb5d69aea --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -0,0 +1,31 @@ +parseEmailTemplate($content); + break; + } + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php new file mode 100644 index 0000000000..54fcf8459f --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -0,0 +1,28 @@ + $this->parseEmailTemplate($content), + default => $content, + }; + } + + private function parseEmailTemplate(array $content): array + { + if (isset($content['templateId'])) { + $content['type'] = $content['templateId']; + unset($content['templateId']); + } + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php deleted file mode 100644 index 3ce9cacdb3..0000000000 --- a/src/Appwrite/Utopia/Response/Model/Template.php +++ /dev/null @@ -1,32 +0,0 @@ -addRule('type', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template type', - 'default' => '', - 'example' => 'verification', - ]) - ->addRule('locale', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template locale', - 'default' => '', - 'example' => 'en_us', - ]) - ->addRule('message', [ - 'type' => self::TYPE_STRING, - 'description' => 'Template message', - 'default' => '', - 'example' => 'Click on the link to verify your account.', - ]) - ; - } -} diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index 95cd57a584..9b77617b8c 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -3,13 +3,31 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; +use Appwrite\Utopia\Response\Model; -class TemplateEmail extends Template +class TemplateEmail extends Model { public function __construct() { - parent::__construct(); $this + ->addRule('templateId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template type', + 'default' => '', + 'example' => 'verification', + ]) + ->addRule('locale', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template locale', + 'default' => '', + 'example' => 'en_us', + ]) + ->addRule('message', [ + 'type' => self::TYPE_STRING, + 'description' => 'Template message', + 'default' => '', + 'example' => 'Click on the link to verify your account.', + ]) ->addRule('senderName', [ 'type' => self::TYPE_STRING, 'description' => 'Name of the sender', diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 45e09779b9..b61f85aeef 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -15,7 +15,7 @@ trait TemplatesBase $template = $this->getEmailTemplate('verification', 'en'); $this->assertSame(200, $template['headers']['status-code']); - $this->assertSame('verification', $template['body']['type']); + $this->assertSame('verification', $template['body']['templateId']); $this->assertSame('en', $template['body']['locale']); $this->assertFalse($template['body']['custom']); $this->assertNotEmpty($template['body']['subject']); @@ -27,7 +27,7 @@ trait TemplatesBase $template = $this->getEmailTemplate('verification'); $this->assertSame(200, $template['headers']['status-code']); - $this->assertSame('verification', $template['body']['type']); + $this->assertSame('verification', $template['body']['templateId']); $this->assertSame('en', $template['body']['locale']); $this->assertFalse($template['body']['custom']); } @@ -40,7 +40,7 @@ trait TemplatesBase $get = $this->getEmailTemplate('magicSession', 'en'); $this->assertSame(200, $get['headers']['status-code']); - $this->assertSame('magicSession', $get['body']['type']); + $this->assertSame('magicSession', $get['body']['templateId']); $this->assertSame('en', $get['body']['locale']); $this->assertTrue($get['body']['custom']); $this->assertSame('Magic Subject', $get['body']['subject']); @@ -85,7 +85,7 @@ trait TemplatesBase ); $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('verification', $update['body']['type']); + $this->assertSame('verification', $update['body']['templateId']); $this->assertSame('en', $update['body']['locale']); $this->assertSame('Please verify your email', $update['body']['subject']); $this->assertSame('Click here to verify: {{url}}', $update['body']['message']); @@ -135,7 +135,7 @@ trait TemplatesBase ); $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('sessionAlert', $update['body']['type']); + $this->assertSame('sessionAlert', $update['body']['templateId']); $this->assertSame('en', $update['body']['locale']); // Cleanup @@ -281,6 +281,105 @@ trait TemplatesBase $this->deleteEmailTemplate('recovery', 'en'); } + // ========================================================================= + // Legacy response format tests (request + response filters) + // ========================================================================= + + public function testGetEmailTemplateLegacyResponseFormat(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $template = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/verification', + $headers, + ); + + $this->assertSame(200, $template['headers']['status-code']); + // Response filter should rename templateId -> type for < 1.9.2 clients. + $this->assertArrayHasKey('type', $template['body']); + $this->assertArrayNotHasKey('templateId', $template['body']); + $this->assertSame('verification', $template['body']['type']); + $this->assertSame('en', $template['body']['locale']); + } + + public function testUpdateEmailTemplateLegacyResponseFormat(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Request filter should accept legacy `type` and map it to `templateId`. + $update = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + $headers, + [ + 'type' => 'magicSession', + 'locale' => 'en', + 'subject' => 'Legacy Subject', + 'message' => 'Legacy Body', + ], + ); + + $this->assertSame(200, $update['headers']['status-code']); + // Response filter should rename templateId -> type for < 1.9.2 clients. + $this->assertArrayHasKey('type', $update['body']); + $this->assertArrayNotHasKey('templateId', $update['body']); + $this->assertSame('magicSession', $update['body']['type']); + $this->assertSame('Legacy Subject', $update['body']['subject']); + $this->assertSame('Legacy Body', $update['body']['message']); + $this->assertTrue($update['body']['custom']); + + // Verify persisted, then cleanup via legacy DELETE with `type`. + $get = $this->getEmailTemplate('magicSession', 'en'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['custom']); + + $delete = $this->client->call( + Client::METHOD_DELETE, + '/project/templates/email', + $headers, + [ + 'type' => 'magicSession', + 'locale' => 'en', + ], + ); + $this->assertSame(204, $delete['headers']['status-code']); + + $after = $this->getEmailTemplate('magicSession', 'en'); + $this->assertFalse($after['body']['custom']); + } + + public function testUpdateEmailTemplateLegacyInvalidType(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $update = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + $headers, + [ + 'type' => 'notATemplate', + 'locale' => 'en', + 'subject' => 'Subject', + 'message' => 'Message', + ], + ); + + $this->assertSame(400, $update['headers']['status-code']); + } + // ========================================================================= // Helpers // ========================================================================= @@ -315,7 +414,7 @@ trait TemplatesBase bool $authenticated = true, ): mixed { $params = [ - 'type' => $type, + 'templateId' => $type, ]; if ($locale !== null) { @@ -352,7 +451,7 @@ trait TemplatesBase protected function deleteEmailTemplate(string $type, ?string $locale = null, bool $authenticated = true): mixed { $params = [ - 'type' => $type, + 'templateId' => $type, ]; if ($locale !== null) { From afab349a7792b1d970a5fcdbb8cc57a2f9101be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:43:57 +0200 Subject: [PATCH 14/18] self review fixes --- CHANGES.md | 8 --- README-CN.md | 6 +-- README.md | 6 +-- tests/e2e/Services/Project/TemplatesBase.php | 54 ++++++++++++++++++++ 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d717de4668..548c0d72b0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,11 +1,3 @@ -# Version 1.9.2 - -### Notable changes - -### Fixes - -### Miscellaneous - # Version 1.9.0 ## What's Changed diff --git a/README-CN.md b/README-CN.md index 7f758bc247..212b5bb08d 100644 --- a/README-CN.md +++ b/README-CN.md @@ -72,7 +72,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.2 + appwrite/appwrite:1.9.0 ``` ### Windows @@ -84,7 +84,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.2 + appwrite/appwrite:1.9.0 ``` #### PowerShell @@ -94,7 +94,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.2 + appwrite/appwrite:1.9.0 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index b0926bbeeb..88d527f060 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.2 + appwrite/appwrite:1.9.0 ``` ### Windows @@ -88,7 +88,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.2 + appwrite/appwrite:1.9.0 ``` #### PowerShell @@ -99,7 +99,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.2 + appwrite/appwrite:1.9.0 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index b61f85aeef..4f78992079 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -357,6 +357,60 @@ trait TemplatesBase $this->assertFalse($after['body']['custom']); } + public function testDeleteEmailTemplateLegacyResponseFormat(): void + { + // Seed a custom template using the current API. + $update = $this->updateEmailTemplate('otpSession', 'en', 'Legacy OTP', 'Legacy OTP body'); + $this->assertSame(200, $update['headers']['status-code']); + + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Request filter should accept legacy `type` and map it to `templateId`. + $delete = $this->client->call( + Client::METHOD_DELETE, + '/project/templates/email', + $headers, + [ + 'type' => 'otpSession', + 'locale' => 'en', + ], + ); + + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify reset back to default. + $after = $this->getEmailTemplate('otpSession', 'en'); + $this->assertSame(200, $after['headers']['status-code']); + $this->assertFalse($after['body']['custom']); + $this->assertNotSame('Legacy OTP', $after['body']['subject']); + } + + public function testDeleteEmailTemplateLegacyInvalidType(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $delete = $this->client->call( + Client::METHOD_DELETE, + '/project/templates/email', + $headers, + [ + 'type' => 'notATemplate', + 'locale' => 'en', + ], + ); + + $this->assertSame(400, $delete['headers']['status-code']); + } + public function testUpdateEmailTemplateLegacyInvalidType(): void { $headers = \array_merge([ From 447375dcbfec003feac0bea27ae7ba4e66cf01a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:53:11 +0200 Subject: [PATCH 15/18] Fix tests --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 59ff5e353c..4400c337ac 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1121,6 +1121,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); @@ -1133,6 +1134,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'subject' => 'Please verify your email', 'message' => 'Please verify your email {{url}}', @@ -1152,6 +1154,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); @@ -1219,6 +1222,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'type' => 'sessionAlert', // Intentionally no locale @@ -1237,6 +1241,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'type' => 'sessionAlert', 'locale' => 'sk', From 69d53cb2d4553ad074f2feb7c04bfb38a351cc18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 11:03:36 +0200 Subject: [PATCH 16/18] Remove unused email template list response model Co-Authored-By: Claude Opus 4.7 (1M context) --- app/init/models.php | 1 - src/Appwrite/Utopia/Response.php | 1 - 2 files changed, 2 deletions(-) diff --git a/app/init/models.php b/app/init/models.php index 924df52bdd..f654c10121 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -210,7 +210,6 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE)); Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false)); Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); -Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 4aecc62fd8..d747373b59 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -266,7 +266,6 @@ class Response extends SwooleResponse public const MODEL_VARIABLE_LIST = 'variableList'; public const MODEL_VCS = 'vcs'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; - public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; From 6b66923f1861d2f93e3f2802f07f653d44f593d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 19:36:24 +0200 Subject: [PATCH 17/18] Fix delete response placeholder audit label --- app/controllers/api/account.php | 4 ++-- app/controllers/api/users.php | 4 ++-- .../Account/Http/Account/MFA/Authenticators/Delete.php | 4 ++-- .../Modules/Project/Http/Project/Platforms/Delete.php | 2 +- .../Modules/Project/Http/Project/Templates/Email/Delete.php | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index ffe2b54c5b..b26f6bc5c6 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -453,7 +453,7 @@ Http::delete('/v1/account') ->groups(['api', 'account']) ->label('scope', 'account') ->label('audits.event', 'user.delete') - ->label('audits.resource', 'user/{response.$id}') + ->label('audits.resource', 'user/{user.$id}') ->label('sdk', new Method( namespace: 'account', group: 'account', @@ -4618,7 +4618,7 @@ Http::delete('/v1/account/targets/:targetId/push') ->groups(['api', 'account']) ->label('scope', 'targets.write') ->label('audits.event', 'target.delete') - ->label('audits.resource', 'target/response.$id') + ->label('audits.resource', 'target/{request.targetId}') ->label('event', 'users.[userId].targets.[targetId].delete') ->label('sdk', new Method( namespace: 'account', diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index a8875fc442..c922b2a1f6 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -2252,8 +2252,8 @@ Http::delete('/v1/users/:userId/mfa/authenticators/:type') ->label('event', 'users.[userId].delete.mfa') ->label('scope', 'users.write') ->label('audits.event', 'user.update') - ->label('audits.resource', 'user/{response.$id}') - ->label('audits.userId', '{response.$id}') + ->label('audits.resource', 'user/{request.userId}') + ->label('audits.userId', '{request.userId}') ->label('usage.metric', 'users.{scope}.requests.update') ->label('sdk', [ new Method( diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php index 754255be15..5765c5bf6e 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php @@ -37,8 +37,8 @@ class Delete extends Action ->label('event', 'users.[userId].delete.mfa') ->label('scope', 'account') ->label('audits.event', 'user.update') - ->label('audits.resource', 'user/{response.$id}') - ->label('audits.userId', '{response.$id}') + ->label('audits.resource', 'user/{user.$id}') + ->label('audits.userId', '{user.$id}') ->label('sdk', [ new Method( namespace: 'account', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php index 4b58766751..24669b02b2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php @@ -36,7 +36,7 @@ class Delete extends Action ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].delete') ->label('audits.event', 'project.platform.delete') - ->label('audits.resource', 'project.platform/{response.$id}') + ->label('audits.resource', 'project.platform/{request.platformId}') ->label('sdk', new Method( namespace: 'project', group: 'platforms', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php index cf02704d47..176e8d7d63 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -39,7 +39,7 @@ class Delete extends Action ->label('scope', 'templates.write') ->label('event', 'templates.[templateType].delete') ->label('audits.event', 'project.template.delete') - ->label('audits.resource', 'project.template/{response.templateId}') + ->label('audits.resource', 'project.template/{request.templateId}') ->label('sdk', new Method( namespace: 'project', group: 'templates', From 2097b0a0b0d7a5f968e72baec6769c4be617d14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:04:53 +0200 Subject: [PATCH 18/18] Better support for post-smtp changes --- .../Http/Project/Templates/Email/Delete.php | 20 +++++++++++++------ .../Http/Project/Templates/Email/Get.php | 8 ++++++++ .../Http/Project/Templates/Email/Update.php | 12 +++++++---- .../Utopia/Response/Model/TemplateEmail.php | 8 +++++++- tests/e2e/Services/Project/TemplatesBase.php | 12 +++++++---- 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php index 176e8d7d63..7928486192 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -5,7 +5,6 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Templates\Email; use Appwrite\Event\Event as QueueEvent; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; -use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; @@ -50,11 +49,10 @@ class Delete extends Action auth: [AuthType::ADMIN, AuthType::KEY], responses: [ new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, + code: Response::STATUS_CODE_OK, + model: Response::MODEL_EMAIL_TEMPLATE, ) - ], - contentType: ContentType::NONE + ] )) ->param('templateId', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Custom email template type. Can be one of: '.\implode(', ', Config::getParam('locale-templates')['email'] ?? [])) ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Custom email template locale. If left empty, the fallback locale (en) will be used.', optional: true, injections: ['localeCodes']) @@ -96,6 +94,16 @@ class Delete extends Action $queueForEvents->setParam('templateType', $templateId); - $response->noContent(); + $response->dynamic(new Document([ + 'templateId' => $templateId, + 'locale' => $locale, + 'senderName' => $template['senderName'] ?? '', + 'senderEmail' => $template['senderEmail'] ?? '', + 'subject' => $template['subject'] ?? '', + 'replyToEmail' => $template['replyToEmail'] ?? $template['replyTo'] ?? '', // Includes backwards compatibility + 'replyToName' => $template['replyToName'] ?? '', + 'message' => $template['message'] ?? '', + 'custom' => true, + ]), Response::MODEL_EMAIL_TEMPLATE); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 115b10f7dd..6e2b2ef56d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -67,6 +67,12 @@ class Get extends Action $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $templateId . '-' . $locale] ?? null; + // Includes backwards compatibility: fall back to legacy `replyTo` key + if (!is_null($template)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + $template['replyToName'] = $template['replyToName'] ?? ''; + } + $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); @@ -124,6 +130,8 @@ class Get extends Action 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), 'senderEmail' => '', 'senderName' => '', + 'replyToEmail' => '', + 'replyToName' => '', 'custom' => false, ]; } else { 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 f17be381f6..93cfa7a3fb 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 @@ -61,7 +61,8 @@ class Update extends Action ->param('message', '', new Text(10485760), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') ->param('senderName', '', new Text(255, 0), 'Name of the email sender.', true) ->param('senderEmail', '', new Email(), 'Email of the sender.', true) - ->param('replyTo', '', new Email(), 'Reply to email.', true) + ->param('replyToEmail', '', new Email(), 'Reply to email.', true) + ->param('replyToName', '', new Text(255, 0), 'Reply to name.', true) ->inject('response') ->inject('queueForEvents') ->inject('dbForPlatform') @@ -78,7 +79,8 @@ class Update extends Action string $message, string $senderName, string $senderEmail, - string $replyTo, + string $replyToEmail, + string $replyToName, Response $response, QueueEvent $queueForEvents, Database $dbForPlatform, @@ -92,7 +94,8 @@ class Update extends Action 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, - 'replyTo' => $replyTo, + 'replyToEmail' => $replyToEmail, + 'replyToName' => $replyToName, 'message' => $message ]; @@ -113,7 +116,8 @@ class Update extends Action 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], + 'replyToEmail' => $template['replyToEmail'], + 'replyToName' => $template['replyToName'], 'message' => $template['message'], 'custom' => true, ]), Response::MODEL_EMAIL_TEMPLATE); diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index 9b77617b8c..48cd0ba556 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -40,12 +40,18 @@ class TemplateEmail extends Model 'default' => '', 'example' => 'mail@appwrite.io', ]) - ->addRule('replyTo', [ + ->addRule('replyToEmail', [ 'type' => self::TYPE_STRING, 'description' => 'Reply to email address', 'default' => '', 'example' => 'emails@appwrite.io', ]) + ->addRule('replyToName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Reply to name', + 'default' => '', + 'example' => 'My User', + ]) ->addRule('subject', [ 'type' => self::TYPE_STRING, 'description' => 'Email subject', diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 4f78992079..422f890785 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -119,7 +119,7 @@ trait TemplatesBase $this->assertSame('You have been invited', $update['body']['message']); $this->assertSame('Appwrite Team', $update['body']['senderName']); $this->assertSame('team@appwrite.io', $update['body']['senderEmail']); - $this->assertSame('reply@appwrite.io', $update['body']['replyTo']); + $this->assertSame('reply@appwrite.io', $update['body']['replyToEmail']); // Cleanup $this->deleteEmailTemplate('invitation', 'en'); @@ -464,7 +464,8 @@ trait TemplatesBase ?string $message, ?string $senderName = null, ?string $senderEmail = null, - ?string $replyTo = null, + ?string $replyToEmail = null, + ?string $replyToName = null, bool $authenticated = true, ): mixed { $params = [ @@ -486,8 +487,11 @@ trait TemplatesBase if ($senderEmail !== null) { $params['senderEmail'] = $senderEmail; } - if ($replyTo !== null) { - $params['replyTo'] = $replyTo; + if ($replyToEmail !== null) { + $params['replyToEmail'] = $replyToEmail; + } + if ($replyToName !== null) { + $params['replyToName'] = $replyToName; } $headers = [