Add missing endpoint in email templates

This commit is contained in:
Matej Bačo
2026-04-22 11:36:54 +02:00
parent 2e42633e12
commit eeadba3b59
5 changed files with 341 additions and 0 deletions
+1
View File
@@ -211,6 +211,7 @@ Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phon
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('Mock Numbers List', Response::MODEL_MOCK_NUMBER_LIST, 'mockNumbers', Response::MODEL_MOCK_NUMBER));
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));
@@ -0,0 +1,91 @@
<?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\Utopia\Response;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
class XList extends Action
{
use HTTP;
public static function getName()
{
return 'listProjectEmailTemplates';
}
public function __construct()
{
$this
->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: <<<EOT
Get a list of all custom email templates configured for the project. This endpoint returns an array of all configured email templates and their locales.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_EMAIL_TEMPLATE_LIST,
)
]
))
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('project')
->callback($this->action(...));
}
public function action(
bool $includeTotal,
Response $response,
Document $project,
) {
$templates = $project->getAttribute('templates', []);
$emailTemplates = [];
foreach ($templates as $key => $template) {
if (!\str_starts_with($key, 'email.')) {
continue;
}
$suffix = \substr($key, \strlen('email.'));
$parts = \explode('-', $suffix, 2);
if (\count($parts) !== 2) {
continue;
}
[$templateId, $locale] = $parts;
$template['templateId'] = $templateId;
$template['locale'] = $locale;
// Backwards compatibility
if (!\is_null($template['replyTo'] ?? null)) {
$template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? '';
}
$emailTemplates[] = new Document($template);
}
$total = $includeTotal ? \count($emailTemplates) : 0;
$response->dynamic(new Document([
'templates' => $emailTemplates,
'total' => $total,
]), Response::MODEL_EMAIL_TEMPLATE_LIST);
}
}
@@ -42,6 +42,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSM
use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP;
use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate;
use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\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;
@@ -68,6 +69,7 @@ class Http extends Service
$this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest());
// Templates
$this->addAction(ListTemplates::getName(), new ListTemplates());
$this->addAction(GetTemplate::getName(), new GetTemplate());
$this->addAction(UpdateTemplate::getName(), new UpdateTemplate());
+1
View File
@@ -267,6 +267,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';
@@ -579,6 +579,233 @@ trait TemplatesBase
}
}
// List email template tests
public function testListEmailTemplatesReturnsSeededTemplate(): void
{
$this->ensureSMTPEnabled();
$subject = 'List subject ' . \uniqid();
$seed = $this->updateEmailTemplate(
templateId: 'verification',
locale: 'en',
subject: $subject,
message: 'List body',
);
$this->assertSame(200, $seed['headers']['status-code']);
$response = $this->listEmailTemplates();
$this->assertSame(200, $response['headers']['status-code']);
$this->assertArrayHasKey('templates', $response['body']);
$this->assertArrayHasKey('total', $response['body']);
$this->assertIsArray($response['body']['templates']);
$this->assertIsInt($response['body']['total']);
$this->assertGreaterThanOrEqual(1, $response['body']['total']);
$found = null;
foreach ($response['body']['templates'] as $template) {
if (
$template['templateId'] === 'verification'
&& $template['locale'] === 'en'
&& $template['subject'] === $subject
) {
$found = $template;
break;
}
}
$this->assertNotNull($found, 'seeded verification/en template must appear in the list');
}
public function testListEmailTemplatesResponseModel(): void
{
$this->ensureSMTPEnabled();
$seed = $this->updateEmailTemplate(
templateId: 'invitation',
locale: 'en',
subject: 'Shape subject ' . \uniqid(),
message: 'Shape body',
senderName: 'Shape Sender',
senderEmail: 'shape@appwrite.io',
replyToEmail: 'shape-reply@appwrite.io',
replyToName: 'Shape Reply',
);
$this->assertSame(200, $seed['headers']['status-code']);
$response = $this->listEmailTemplates();
$this->assertSame(200, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['templates']);
foreach ($response['body']['templates'] as $template) {
$this->assertArrayHasKey('templateId', $template);
$this->assertArrayHasKey('locale', $template);
$this->assertArrayHasKey('subject', $template);
$this->assertArrayHasKey('message', $template);
$this->assertArrayHasKey('senderName', $template);
$this->assertArrayHasKey('senderEmail', $template);
$this->assertArrayHasKey('replyToEmail', $template);
$this->assertArrayHasKey('replyToName', $template);
}
}
public function testListEmailTemplatesSeparatesLocales(): void
{
$this->ensureSMTPEnabled();
$runId = \uniqid();
$enSubject = "Multi-locale EN {$runId}";
$frSubject = "Multi-locale FR {$runId}";
$this->assertSame(200, $this->updateEmailTemplate(
templateId: 'recovery',
locale: 'en',
subject: $enSubject,
message: 'EN body',
)['headers']['status-code']);
$this->assertSame(200, $this->updateEmailTemplate(
templateId: 'recovery',
locale: 'fr',
subject: $frSubject,
message: 'FR body',
)['headers']['status-code']);
$response = $this->listEmailTemplates();
$this->assertSame(200, $response['headers']['status-code']);
$foundEn = false;
$foundFr = false;
foreach ($response['body']['templates'] as $template) {
if ($template['templateId'] === 'recovery' && $template['locale'] === 'en' && $template['subject'] === $enSubject) {
$foundEn = true;
}
if ($template['templateId'] === 'recovery' && $template['locale'] === 'fr' && $template['subject'] === $frSubject) {
$foundFr = true;
}
}
$this->assertTrue($foundEn, 'recovery/en must appear in the list');
$this->assertTrue($foundFr, 'recovery/fr must appear in the list');
}
public function testListEmailTemplatesUpdateDoesNotDuplicate(): void
{
$this->ensureSMTPEnabled();
$runId = \uniqid();
$firstSubject = "First {$runId}";
$secondSubject = "Second {$runId}";
$this->assertSame(200, $this->updateEmailTemplate(
templateId: 'mfaChallenge',
locale: 'en',
subject: $firstSubject,
message: 'Body',
)['headers']['status-code']);
$before = $this->listEmailTemplates();
$this->assertSame(200, $before['headers']['status-code']);
$beforeTotal = $before['body']['total'];
$this->assertSame(200, $this->updateEmailTemplate(
templateId: 'mfaChallenge',
locale: 'en',
subject: $secondSubject,
message: 'Body',
)['headers']['status-code']);
$after = $this->listEmailTemplates();
$this->assertSame(200, $after['headers']['status-code']);
// Same templateId/locale must remain a single entry, not accumulate.
$this->assertSame($beforeTotal, $after['body']['total']);
$matches = \array_values(\array_filter(
$after['body']['templates'],
fn ($t) => $t['templateId'] === 'mfaChallenge' && $t['locale'] === 'en',
));
$this->assertCount(1, $matches);
$this->assertSame($secondSubject, $matches[0]['subject']);
}
public function testListEmailTemplatesTotalFalse(): void
{
$this->ensureSMTPEnabled();
// Ensure at least one template exists so `templates` is non-empty.
$this->assertSame(200, $this->updateEmailTemplate(
templateId: 'verification',
locale: 'en',
subject: 'Total-false subject',
message: 'Body',
)['headers']['status-code']);
$response = $this->listEmailTemplates(total: false);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertIsInt($response['body']['total']);
$this->assertSame(0, $response['body']['total']);
$this->assertNotEmpty($response['body']['templates']);
}
public function testListEmailTemplatesTotalMatchesCount(): void
{
$this->ensureSMTPEnabled();
$this->assertSame(200, $this->updateEmailTemplate(
templateId: 'verification',
locale: 'en',
subject: 'Match subject',
message: 'Body',
)['headers']['status-code']);
$response = $this->listEmailTemplates();
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame(\count($response['body']['templates']), $response['body']['total']);
}
public function testListEmailTemplatesOnlyReturnsCustomizedTemplates(): void
{
$this->ensureSMTPEnabled();
// Seed exactly one template so we have a stable marker to count against.
$marker = 'Customized-only ' . \uniqid();
$this->assertSame(200, $this->updateEmailTemplate(
templateId: 'otpSession',
locale: 'en',
subject: $marker,
message: 'Body',
)['headers']['status-code']);
$response = $this->listEmailTemplates();
$this->assertSame(200, $response['headers']['status-code']);
// Every returned entry must be a real stored template (has templateId+locale set,
// not a synthesized default row for every possible type).
foreach ($response['body']['templates'] as $template) {
$this->assertNotEmpty($template['templateId']);
$this->assertNotEmpty($template['locale']);
}
// A `(templateId, locale)` pair that has never been customized in this test
// run must NOT show up. 'otpSession'/'pt-br' has no writer anywhere in the file.
$uncustomized = \array_filter(
$response['body']['templates'],
fn ($t) => $t['templateId'] === 'otpSession' && $t['locale'] === 'pt-br',
);
$this->assertEmpty($uncustomized, 'uncustomized (templateId, locale) pairs must not appear');
}
public function testListEmailTemplatesWithoutAuthentication(): void
{
$response = $this->listEmailTemplates(authenticated: false);
$this->assertSame(401, $response['headers']['status-code']);
}
// Backwards compatibility (x-appwrite-response-format: 1.9.1)
public function testGetEmailTemplateLegacyResponseFormat(): void
@@ -804,6 +1031,25 @@ trait TemplatesBase
return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $templateId, $headers, $params);
}
protected function listEmailTemplates(?bool $total = 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 ($total !== null) {
$params['total'] = $total;
}
return $this->client->call(Client::METHOD_GET, '/project/templates/email', $headers, $params);
}
protected function updateEmailTemplate(
string $templateId,
?string $locale = null,