Rework email templates PR after discussions

This commit is contained in:
Matej Bačo
2026-04-17 10:52:21 +02:00
parent 4cf375de6d
commit 11f23fdcfa
7 changed files with 25 additions and 273 deletions
+4 -4
View File
@@ -2268,7 +2268,7 @@ Http::post('/v1/account/tokens/magic-url')
$customTemplate =
$project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ??
$project->getAttribute('templates', [])['email.magicSession-worldwide'] ?? [];
$project->getAttribute('templates', [])['email.magicSession-' . $locale->fallback] ?? [];
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$agentOs = $detector->getOS();
@@ -2580,7 +2580,7 @@ Http::post('/v1/account/tokens/email')
$customTemplate =
$project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ??
$project->getAttribute('templates', [])['email.otpSession-worldwide'] ?? [];
$project->getAttribute('templates', [])['email.otpSession-' . $locale->fallback] ?? [];
$smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
$validator = new FileName();
@@ -3728,7 +3728,7 @@ Http::post('/v1/account/recovery')
$preview = $locale->getText("emails.recovery.preview");
$customTemplate =
$project->getAttribute('templates', [])['email.recovery-' . $locale->default] ??
$project->getAttribute('templates', [])['email.recovery-worldwide'] ?? [];
$project->getAttribute('templates', [])['email.recovery-' . $locale->fallback] ?? [];
$message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl');
$message
@@ -4038,7 +4038,7 @@ Http::post('/v1/account/verifications/email')
$customTemplate =
$project->getAttribute('templates', [])['email.verification-' . $locale->default] ??
$project->getAttribute('templates', [])['email.verification-worldwide'] ?? [];
$project->getAttribute('templates', [])['email.verification-' . $locale->fallback] ?? [];
$smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
$validator = new FileName();
+17 -18
View File
@@ -852,13 +852,13 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale')
))
->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', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([
...$localeCodes,
'worldwide'
])), 'Template locale', true, ['localeCodes'])
->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes'])
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, string $type, string $locale, Response $response, Database $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()) {
@@ -868,9 +868,8 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale')
$templates = $project->getAttribute('templates', []);
$template = $templates['email.' . $type . '-' . $locale] ?? null;
$fallbackLocale = System::getEnv('_APP_LOCALE', 'en');
$localeObj = new Locale($locale === 'worldwide' ? $fallbackLocale : $locale);
$localeObj->setFallback($fallbackLocale);
$localeObj = new Locale($locale);
$localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en'));
if (is_null($template)) {
/**
@@ -954,10 +953,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale')
))
->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', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([
...$localeCodes,
'worldwide'
])), 'Template locale', true, ['localeCodes'])
->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)
@@ -965,7 +961,10 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale')
->param('replyTo', '', new Email(), 'Reply to email', true)
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $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()) {
@@ -1014,13 +1013,13 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale')
))
->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', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([
...$localeCodes,
'worldwide'
])), 'Template locale', true, ['localeCodes'])
->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes'])
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, string $type, string $locale, Response $response, Database $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()) {
+1 -1
View File
@@ -73,7 +73,7 @@ class Mails extends Listener
$customTemplate =
$project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ??
$project->getAttribute('templates', [])['email.sessionAlert-worldwide'] ?? [];
$project->getAttribute('templates', [])['email.sessionAlert-' . $locale->fallback] ?? [];
$isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE;
$subject = $customTemplate['subject'] ?? $locale->getText('emails.sessionAlert.subject');
@@ -220,7 +220,7 @@ class Create extends Action
$customTemplate =
$project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ??
$project->getAttribute('templates', [])['email.mfaChallenge-worldwide'] ?? [];
$project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->fallback] ?? [];
$smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base');
$validator = new FileName();
@@ -326,7 +326,7 @@ class Create extends Action
$subject = $locale->getText('emails.invitation.subject');
$customTemplate =
$project->getAttribute('templates', [])['email.invitation-' . $locale->default] ??
$project->getAttribute('templates', [])['email.invitation-worldwide'] ?? [];
$project->getAttribute('templates', [])['email.invitation-' . $locale->fallback] ?? [];
$message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-inner-base.tpl');
$message
@@ -19,7 +19,7 @@ abstract class Template extends Model
'type' => self::TYPE_STRING,
'description' => 'Template locale',
'default' => '',
'example' => 'worldwide',
'example' => 'en_us',
])
->addRule('message', [
'type' => self::TYPE_STRING,
@@ -1163,253 +1163,6 @@ class ProjectsConsoleClientTest extends Scope
$this->assertEquals('Please verify your email {{url}}', $response['body']['message']);
}
#[Group('smtpAndTemplates')]
public function testWorldwideTemplates(): void
{
$data = $this->setupProjectData();
$id = $data['projectId'];
/** Get default template without locale (should default to worldwide) */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('verification', $response['body']['type']);
$this->assertEquals('worldwide', $response['body']['locale']);
/** Get default template with explicit worldwide locale */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('verification', $response['body']['type']);
$this->assertEquals('worldwide', $response['body']['locale']);
/** Set a worldwide email template */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'subject' => 'Worldwide verify subject',
'message' => 'Worldwide verify message {{url}}',
'senderName' => 'Worldwide Sender',
'senderEmail' => 'worldwide@appwrite.io',
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('Worldwide verify subject', $response['body']['subject']);
$this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']);
$this->assertEquals('Worldwide Sender', $response['body']['senderName']);
$this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']);
$this->assertEquals('verification', $response['body']['type']);
/** Get the worldwide template back */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('Worldwide verify subject', $response['body']['subject']);
$this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']);
$this->assertEquals('Worldwide Sender', $response['body']['senderName']);
$this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']);
$this->assertEquals('verification', $response['body']['type']);
$this->assertEquals('worldwide', $response['body']['locale']);
/** Locale-specific template should not return the worldwide custom template */
$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'],
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('verification', $response['body']['type']);
$this->assertEquals('en-us', $response['body']['locale']);
// en-us should NOT return the worldwide custom subject
$this->assertNotEquals('Worldwide verify subject', $response['body']['subject']);
/** Delete the worldwide template */
$response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
/** After deletion, worldwide GET should return default template */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('verification', $response['body']['type']);
$this->assertEquals('worldwide', $response['body']['locale']);
// Should be back to default (no custom subject)
$this->assertNotEquals('Worldwide verify subject', $response['body']['subject']);
}
#[Group('smtpAndTemplates')]
public function testWorldwideFallbackOnMagicURL(): void
{
$smtpHost = 'maildev';
$smtpPort = 1025;
$smtpUsername = 'user';
$smtpPassword = 'password';
/** Create a dedicated project for this test */
$team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'teamId' => ID::unique(),
'name' => 'Worldwide Fallback Test Team',
]);
$this->assertEquals(201, $team['headers']['status-code']);
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'projectId' => ID::unique(),
'name' => 'Worldwide Fallback Test',
'teamId' => $team['body']['$id'],
'region' => System::getEnv('_APP_REGION', 'default'),
]);
$this->assertEquals(201, $project['headers']['status-code']);
$projectId = $project['body']['$id'];
/** Enable SMTP on the project pointing to maildev */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/smtp', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'enabled' => true,
'senderEmail' => 'mailer@appwrite.io',
'senderName' => 'Mailer',
'host' => $smtpHost,
'port' => $smtpPort,
'username' => $smtpUsername,
'password' => $smtpPassword,
]);
$this->assertEquals(200, $response['headers']['status-code']);
/** Set worldwide magicSession template */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'subject' => 'Worldwide Magic Login',
'message' => 'Worldwide magic link: {{url}}',
'senderName' => 'Worldwide Mailer',
'senderEmail' => 'worldwide@appwrite.io',
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('Worldwide Magic Login', $response['body']['subject']);
/** Set German (de) magicSession template */
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'subject' => 'German Magic Login',
'message' => 'German magic link: {{url}}',
'senderName' => 'German Mailer',
'senderEmail' => 'german@appwrite.io',
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('German Magic Login', $response['body']['subject']);
/** Verify worldwide template is stored correctly */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('Worldwide Magic Login', $response['body']['subject']);
/** Verify German template is stored correctly */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('German Magic Login', $response['body']['subject']);
/** Verify SMTP is enabled on the project */
$response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertTrue($response['body']['smtpEnabled']);
/** Trigger magic URL with English locale — should use worldwide fallback */
$emailEn = 'magic-en-' . uniqid() . '@appwrite.io';
$response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-locale' => 'en',
], [
'userId' => ID::unique(),
'email' => $emailEn,
]);
$this->assertEquals(201, $response['headers']['status-code']);
/** Trigger magic URL with German locale — should use German template */
$emailDe = 'magic-de-' . uniqid() . '@appwrite.io';
$response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-locale' => 'de',
], [
'userId' => ID::unique(),
'email' => $emailDe,
]);
$this->assertEquals(201, $response['headers']['status-code']);
/** Trigger magic URL with Polish locale — should use worldwide fallback */
$emailPl = 'magic-pl-' . uniqid() . '@appwrite.io';
$response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-locale' => 'pl',
], [
'userId' => ID::unique(),
'email' => $emailPl,
]);
$this->assertEquals(201, $response['headers']['status-code']);
/** Verify English email uses worldwide fallback template */
$lastEmailEn = $this->getLastEmailByAddress($emailEn);
$this->assertEquals('Worldwide Magic Login', $lastEmailEn['subject']);
$this->assertEquals('worldwide@appwrite.io', $lastEmailEn['from'][0]['address']);
$this->assertEquals('Worldwide Mailer', $lastEmailEn['from'][0]['name']);
$this->assertStringContainsString('Worldwide magic link:', $lastEmailEn['html']);
/** Verify German email uses the German-specific template */
$lastEmailDe = $this->getLastEmailByAddress($emailDe);
$this->assertEquals('German Magic Login', $lastEmailDe['subject']);
$this->assertEquals('german@appwrite.io', $lastEmailDe['from'][0]['address']);
$this->assertEquals('German Mailer', $lastEmailDe['from'][0]['name']);
$this->assertStringContainsString('German magic link:', $lastEmailDe['html']);
/** Verify Polish email uses worldwide fallback template */
$lastEmailPl = $this->getLastEmailByAddress($emailPl);
$this->assertEquals('Worldwide Magic Login', $lastEmailPl['subject']);
$this->assertEquals('worldwide@appwrite.io', $lastEmailPl['from'][0]['address']);
$this->assertEquals('Worldwide Mailer', $lastEmailPl['from'][0]['name']);
$this->assertStringContainsString('Worldwide magic link:', $lastEmailPl['html']);
}
public function testUpdateProjectAuthDuration(): void
{
$data = $this->setupProjectData();