From 11f23fdcfa3aa90cd862609e869ca430fb14f209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 10:52:21 +0200 Subject: [PATCH] Rework email templates PR after discussions --- app/controllers/api/account.php | 8 +- app/controllers/api/projects.php | 35 ++- src/Appwrite/Bus/Listeners/Mails.php | 2 +- .../Http/Account/MFA/Challenges/Create.php | 2 +- .../Modules/Teams/Http/Memberships/Create.php | 2 +- .../Utopia/Response/Model/Template.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 247 ------------------ 7 files changed, 25 insertions(+), 273 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 03526bd49f..7511f7d31f 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -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(); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 46806f79ed..23d3af075a 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -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()) { diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 7b33baced5..e59bbe3536 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -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'); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 319e080f25..14dc4e3237 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -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(); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 161e817aed..aa4ee2c66c 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -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 diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php index b0e127e07f..3ce9cacdb3 100644 --- a/src/Appwrite/Utopia/Response/Model/Template.php +++ b/src/Appwrite/Utopia/Response/Model/Template.php @@ -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, diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e4dfb8d85b..597030413e 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -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();