Skip to content

Feat: list email templates#11956

Draft
Meldiron wants to merge 3 commits into
feat-project-templates-apifrom
feat-project-templates-api-with-list-endpoint
Draft

Feat: list email templates#11956
Meldiron wants to merge 3 commits into
feat-project-templates-apifrom
feat-project-templates-api-with-list-endpoint

Conversation

@Meldiron
Copy link
Copy Markdown
Contributor

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com<!--
Thank you for sending the PR! We appreciate you spending the time to work on these changes.

Help us understand your motivation by explaining why you decided to make this change.

You can learn more about contributing to appwrite here: https://github.com/appwrite/appwrite/blob/master/CONTRIBUTING.md

Happy contributing!

-->

What does this PR do?

(Provide a description of what this PR does and why it's needed.)

Test Plan

(Write your test plan here. If you changed any code, please provide us with clear instructions on how you verified your changes work. Screenshots may also be helpful.)

Related PRs and Issues

  • (Related PR or issue)

Checklist

  • Have you read the Contributing Guidelines on issues?
  • If the PR includes a change to an API's metadata (desc, label, params, etc.), does it also include updated API specs and example docs?

Meldiron and others added 3 commits April 19, 2026 10:16
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Meldiron Meldiron marked this pull request as draft April 20, 2026 07:58
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 20, 2026

Greptile Summary

This PR adds a GET /v1/project/templates/email list endpoint that materializes all email templates from config and project attributes, applies in-memory filtering/ordering/pagination via new InMemoryQuery and BaseInMemory utilities, and returns them as an emailTemplateList response.

  • P1 — document key mismatch: XList.php sets $template['type'] = $type but the TemplateEmail model's field key is templateId (the Get endpoint correctly uses $template['templateId']). This means templateId always serializes as '' in list items.
  • P1 — broken test: testListEmailTemplatesDefaultMatchesGet accesses $get['body']['type'], but the GET endpoint returns templateId, not type, so this comparison will fail.

Confidence Score: 3/5

Not safe to merge — the list endpoint exposes an empty templateId field and a test that will fail due to the type/templateId key mismatch.

Two P1 findings share the same root cause: XList.php uses type as the document key while the model and GET endpoint both use templateId. This breaks the primary user path and causes a test failure.

src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php (key mismatch) and tests/e2e/Services/Project/TemplatesBase.php (test assertion using wrong key).

Important Files Changed

Filename Overview
src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php New list endpoint for email templates; uses type as document key instead of templateId (model expects templateId), causing an empty templateId in responses; also materializes all templates before filtering.
src/Appwrite/Utopia/Database/InMemoryQuery.php New utility class for in-memory filtering, ordering, and pagination of Document arrays; logic is correct and well-structured.
src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php New base validator for in-memory query validation; correctly wires Limit, Offset, Filter, and Order validators against a typed attribute map.
src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php Defines allowed query attributes for the email template list endpoint; attribute set and types look appropriate.
tests/e2e/Services/Project/TemplatesBase.php Comprehensive e2e test coverage for all template operations; testListEmailTemplatesDefaultMatchesGet compares $get['body']['type'] which is null since GET returns templateId, not type.
src/Appwrite/Platform/Modules/Project/Services/Http.php Registers the new ListTemplates action; wiring looks correct.
app/init/models.php Registers EMAIL_TEMPLATE_LIST model via BaseList; addition is correct and consistent with other list models.
src/Appwrite/Utopia/Response.php Adds MODEL_EMAIL_TEMPLATE_LIST constant; no issues.

Reviews (1): Last reviewed commit: "Add email template list response model" | Re-trigger Greptile

$template['custom'] = true;
}

$template['type'] = $type;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Document key mismatch: type vs templateId

The list endpoint stores the template type under the type key, but the TemplateEmail model defines templateId as its field (with a default of ''). The Get endpoint correctly uses $template['templateId'] = $templateId. This means templateId will always serialize as an empty string in list responses, and testListEmailTemplatesDefaultMatchesGet will fail because $get['body']['type'] is null (GET doesn't set a type attribute) while $listed['type'] is non-null.

Suggested change
$template['type'] = $type;
$template['templateId'] = $type;

Comment on lines +85 to +160
$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);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Full materialization before filtering is expensive

Every request renders all types × localeCodes templates — including file_get_contents() and full HTML rendering — before any filter or pagination is applied. With a large locale code set this can mean hundreds of disk reads and template renders for a request that only needs a few results. Consider applying type/locale filters first (which can be extracted cheaply from the query before rendering) and rendering lazily, or at minimum caching the template file strings outside the inner loop.

Comment on lines +226 to +233
$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']);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Test compares type from GET response that doesn't set type

testListEmailTemplatesDefaultMatchesGet accesses $get['body']['type'] on line 226, but the GET endpoint stores the field as templateId (not type) in the document and serializes it through the TemplateEmail model which has a templateId rule — there is no type attribute in the GET response. $get['body']['type'] will be null, so assertSame(null, 'verification') will fail. Once the list endpoint is corrected to use templateId, this comparison should be updated to use templateId as well:

Suggested change
$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']);
$this->assertSame($get['body']['templateId'], $listed['templateId']);
$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']);

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 20, 2026

🔄 PHP-Retry Summary

Flaky tests detected across commits:

Commit 649e9bd - 28 flaky tests
Test Retries Total Time Details
UsageTest::testFunctionsStats 1 10.26s Logs
UsageTest::testPrepareSitesStats 1 7ms Logs
UsageTest::testEmbeddingsTextUsageDoesNotBreakProjectUsage 1 6ms Logs
WebhooksCustomServerTest::testDeleteDeployment 1 13ms Logs
WebhooksCustomServerTest::testDeleteFunction 1 7ms Logs
WebhooksCustomServerTest::testCreateCollection 1 8ms Logs
WebhooksCustomServerTest::testCreateAttributes 1 9ms Logs
WebhooksCustomServerTest::testCreateDocument 1 37ms Logs
WebhooksCustomServerTest::testUpdateDocument 1 18ms Logs
WebhooksCustomServerTest::testDeleteDocument 1 13ms Logs
WebhooksCustomServerTest::testCreateTable 1 11ms Logs
WebhooksCustomServerTest::testCreateColumns 1 73ms Logs
WebhooksCustomServerTest::testCreateRow 1 19ms Logs
WebhooksCustomServerTest::testUpdateRow 1 15ms Logs
WebhooksCustomServerTest::testDeleteRow 1 17ms Logs
WebhooksCustomServerTest::testCreateStorageBucket 1 7ms Logs
WebhooksCustomServerTest::testUpdateStorageBucket 1 15ms Logs
WebhooksCustomServerTest::testCreateBucketFile 1 14ms Logs
WebhooksCustomServerTest::testUpdateBucketFile 1 16ms Logs
WebhooksCustomServerTest::testDeleteBucketFile 1 7ms Logs
WebhooksCustomServerTest::testDeleteStorageBucket 1 14ms Logs
WebhooksCustomServerTest::testCreateTeam 1 5ms Logs
WebhooksCustomServerTest::testUpdateTeam 1 13ms Logs
WebhooksCustomServerTest::testUpdateTeamPrefs 1 15ms Logs
WebhooksCustomServerTest::testDeleteTeam 1 4ms Logs
WebhooksCustomServerTest::testCreateTeamMembership 1 12ms Logs
WebhooksCustomServerTest::testDeleteTeamMembership 1 10ms Logs
WebhooksCustomServerTest::testWebhookAutoDisable 1 24ms Logs

@github-actions
Copy link
Copy Markdown

✨ Benchmark results

  • Requests per second: 2,269
  • Requests with 200 status code: 408,428
  • P99 latency: 0.084711287

⚡ Benchmark Comparison

Metric This PR Latest version
RPS 2,269 19,951
200 408,428 n,ull
P99 0.084711287 0.009110711

@blacksmith-sh
Copy link
Copy Markdown

blacksmith-sh Bot commented Apr 20, 2026

Found 1 test failure on Blacksmith runners:

Failure

Test View Logs
› Tests\E2E\Services\TablesDB\Transactions\TablesDBTransactionsCustomServerTest/
testBulkUpdateWithDependentDocuments
View Logs

Fix in Cursor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant