Merge branch 'feat-project-templates-api' into feat-project-smtp-endpoints

This commit is contained in:
Matej Bačo
2026-04-20 14:49:04 +02:00
26 changed files with 1074 additions and 51 deletions
+1 -2
View File
@@ -692,9 +692,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
+3 -3
View File
@@ -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.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.1
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.1
appwrite/appwrite:1.9.0
```
运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。
+3 -3
View File
@@ -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.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.1
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.1
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.
+2
View File
@@ -55,6 +55,8 @@ $admins = [
'tables.write',
'platforms.read',
'platforms.write',
'templates.read',
'templates.write',
'projects.write',
'keys.read',
'keys.write',
+8
View File
@@ -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",
],
];
+2 -2
View File
@@ -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',
@@ -4665,7 +4665,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',
+2 -2
View File
@@ -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(
+8
View File
@@ -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());
}
+2 -2
View File
@@ -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';
+1
View File
@@ -94,6 +94,7 @@ abstract class Migration
'1.8.1' => 'V23',
'1.9.0' => 'V24',
'1.9.1' => 'V24',
'1.9.2' => 'V24',
];
/**
@@ -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',
@@ -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',
@@ -0,0 +1,109 @@
<?php
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\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\Validator\Authorization;
use Utopia\Locale\Locale;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\WhiteList;
class Delete extends Action
{
use HTTP;
public static function getName()
{
return 'deleteProjectEmailTemplate';
}
public function __construct()
{
$this->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/project/templates/email')
->httpAlias('/v1/projects/:projectId/templates/email')
->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/{request.templateId}')
->label('sdk', new Method(
namespace: 'project',
group: 'templates',
name: 'deleteEmailTemplate',
description: <<<EOT
Reset a custom email template to its default value. This endpoint removes any custom content and restores the template to its original state.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_EMAIL_TEMPLATE,
)
]
))
->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')
->inject('authorization')
->inject('project')
->inject('locale')
->callback($this->action(...));
}
public function action(
string $templateId,
string $locale,
Response $response,
QueueEvent $queueForEvents,
Database $dbForPlatform,
Authorization $authorization,
Document $project,
Locale $localeObject,
) {
$locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
$templates = $project->getAttribute('templates', []);
$template = $templates['email.' . $templateId . '-' . $locale] ?? null;
if (is_null($template)) {
throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION);
}
unset($templates['email.' . $templateId . '-' . $locale]);
$updates = new Document([
'templates' => $templates,
]);
$project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
$queueForEvents->setParam('templateType', $templateId);
$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);
}
}
@@ -0,0 +1,146 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Templates\Email;
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\Document;
use Utopia\Locale\Locale;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\WhiteList;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getProjectEmailTemplate';
}
public function __construct()
{
$this->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->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')
->label('sdk', new Method(
namespace: 'project',
group: 'templates',
name: 'getEmailTemplate',
description: <<<EOT
Get a custom email template for the specified locale and type. This endpoint returns the template content, subject, and other configuration details.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_EMAIL_TEMPLATE,
)
]
))
->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')
->callback($this->action(...));
}
public function action(
string $templateId,
string $locale,
Response $response,
Document $project,
Locale $localeObject,
) {
$locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
$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'));
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[$templateId] ?? [
'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.{$templateId}.{$param}"), escapeHtml: $escapeHtml);
}
$message
// common placeholders on all the templates
->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.' . $templateId . '.subject'),
'senderEmail' => '',
'senderName' => '',
'replyToEmail' => '',
'replyToName' => '',
'custom' => false,
];
} else {
$template['custom'] = true;
}
$template['templateId'] = $templateId;
$template['locale'] = $locale;
$response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE);
}
}
@@ -0,0 +1,125 @@
<?php
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\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Emails\Validator\Email;
use Utopia\Locale\Locale;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateProjectEmailTemplate';
}
public function __construct()
{
$this->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/project/templates/email')
->httpAlias('/v1/projects/:projectId/templates/email')
->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.templateId}')
->label('sdk', new Method(
namespace: 'project',
group: 'templates',
name: 'updateEmailTemplate',
description: <<<EOT
Update a custom email template for the specified locale and type. Use this endpoint to modify the content of your email templates.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_EMAIL_TEMPLATE,
)
]
))
->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)
->param('senderEmail', '', new Email(), 'Email of the sender.', 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')
->inject('authorization')
->inject('project')
->inject('locale')
->callback($this->action(...));
}
public function action(
string $templateId,
string $locale,
string $subject,
string $message,
string $senderName,
string $senderEmail,
string $replyToEmail,
string $replyToName,
Response $response,
QueueEvent $queueForEvents,
Database $dbForPlatform,
Authorization $authorization,
Document $project,
Locale $localeObject,
) {
$locale = $locale ?: System::getEnv('_APP_LOCALE', 'en');
$template = [
'senderName' => $senderName,
'senderEmail' => $senderEmail,
'subject' => $subject,
'replyToEmail' => $replyToEmail,
'replyToName' => $replyToName,
'message' => $message
];
$templates = $project->getAttribute('templates', []);
$templates['email.' . $templateId . '-' . $locale] = $template;
$updates = new Document([
'templates' => $templates,
]);
$project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates));
$queueForEvents->setParam('templateType', $templateId);
$response->dynamic(new Document([
'templateId' => $templateId,
'locale' => $locale,
'senderName' => $template['senderName'],
'senderEmail' => $template['senderEmail'],
'subject' => $template['subject'],
'replyToEmail' => $template['replyToEmail'],
'replyToName' => $template['replyToName'],
'message' => $template['message'],
'custom' => true,
]), Response::MODEL_EMAIL_TEMPLATE);
}
}
@@ -26,6 +26,9 @@ use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdatePro
use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService;
use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest;
use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP;
use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\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;
@@ -50,6 +53,10 @@ class Http extends Service
// SMTP
$this->addAction(UpdateSMTP::getName(), new UpdateSMTP());
$this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest());
// 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());
@@ -376,6 +376,8 @@ class Migrations extends Action
'keys.write',
'platforms.read',
'platforms.write',
'templates.read',
'templates.write',
]
]);
@@ -0,0 +1,31 @@
<?php
namespace Appwrite\Utopia\Request\Filters;
use Appwrite\Utopia\Request\Filter;
class V23 extends Filter
{
// Convert 1.9.1 params to 1.9.2
protected function parseEmailTemplate(array $content): array
{
if (isset($content['type'])) {
$content['templateId'] = $content['type'];
unset($content['type']);
}
return $content;
}
public function parse(array $content, string $model): array
{
switch ($model) {
case 'project.getEmailTemplate':
case 'project.updateEmailTemplate':
case 'project.deleteEmailTemplate':
$content = $this->parseEmailTemplate($content);
break;
}
return $content;
}
}
@@ -0,0 +1,28 @@
<?php
namespace Appwrite\Utopia\Response\Filters;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filter;
// Convert 1.9.2 Data format to 1.9.1 format
class V23 extends Filter
{
public function parse(array $content, string $model): array
{
return match ($model) {
Response::MODEL_EMAIL_TEMPLATE => $this->parseEmailTemplate($content),
default => $content,
};
}
private function parseEmailTemplate(array $content): array
{
if (isset($content['templateId'])) {
$content['type'] = $content['templateId'];
unset($content['templateId']);
}
return $content;
}
}
@@ -1,32 +0,0 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response\Model;
abstract class Template extends Model
{
public function __construct()
{
$this
->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.',
])
;
}
}
@@ -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',
@@ -40,6 +58,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,
])
;
}
+2
View File
@@ -169,6 +169,8 @@ trait ProjectCustom
'keys.write',
'platforms.read',
'platforms.write',
'templates.read',
'templates.write',
],
]);
@@ -0,0 +1,530 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Client;
trait TemplatesBase
{
// =========================================================================
// Get email template tests
// =========================================================================
public function testGetEmailTemplateDefault(): void
{
$template = $this->getEmailTemplate('verification', 'en');
$this->assertSame(200, $template['headers']['status-code']);
$this->assertSame('verification', $template['body']['templateId']);
$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']['templateId']);
$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']['templateId']);
$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']);
}
// =========================================================================
// 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']['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']);
$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']['replyToEmail']);
// 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']['templateId']);
$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');
}
// =========================================================================
// 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 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([
'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
// =========================================================================
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);
}
protected function updateEmailTemplate(
string $type,
?string $locale,
?string $subject,
?string $message,
?string $senderName = null,
?string $senderEmail = null,
?string $replyToEmail = null,
?string $replyToName = null,
bool $authenticated = true,
): mixed {
$params = [
'templateId' => $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 ($replyToEmail !== null) {
$params['replyToEmail'] = $replyToEmail;
}
if ($replyToName !== null) {
$params['replyToName'] = $replyToName;
}
$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 = [
'templateId' => $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);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideConsole;
class TemplatesConsoleClientTest extends Scope
{
use TemplatesBase;
use ProjectCustom;
use SideConsole;
}
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
class TemplatesCustomServerTest extends Scope
{
use TemplatesBase;
use ProjectCustom;
use SideServer;
}
@@ -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',