From 193beb76fe3ada995af20e5d1db43c36f05c2bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 16:50:07 +0200 Subject: [PATCH 01/81] add SMTP endpoints --- app/controllers/api/projects.php | 218 ------------------ .../Project/Http/Project/Labels/Update.php | 7 +- .../Http/Project/SMTP/Status/Update.php | 74 ++++++ .../Project/Http/Project/SMTP/Test/Create.php | 142 ++++++++++++ .../Project/Http/Project/SMTP/Update.php | 142 ++++++++++++ 5 files changed, 363 insertions(+), 220 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 5b82e6c1a3..e964933686 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -3,7 +3,6 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Validator\MockNumber; use Appwrite\Event\Delete; -use Appwrite\Event\Mail; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -13,7 +12,6 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; use Appwrite\Utopia\Database\Validator\Queries\Keys; use Appwrite\Utopia\Response; -use PHPMailer\PHPMailer\PHPMailer; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; @@ -24,8 +22,6 @@ use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; -use Utopia\Validator\Hostname; -use Utopia\Validator\Integer; use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; @@ -619,220 +615,6 @@ Http::post('/v1/projects/:projectId/jwts') ])]), Response::MODEL_JWT); }); -// CUSTOM SMTP and Templates -Http::patch('/v1/projects/:projectId/smtp') - ->desc('Update SMTP') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSmtp', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateSMTP', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSMTP', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(), 'Enable custom SMTP service') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name', true) - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - // Ensure required params for when enabling SMTP - if ($enabled) { - if (empty($senderName)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender name is required when enabling SMTP.'); - } elseif (empty($senderEmail)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender email is required when enabling SMTP.'); - } elseif (empty($host)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Host is required when enabling SMTP.'); - } elseif (empty($port)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Port is required when enabling SMTP.'); - } - } - - // validate SMTP settings - if ($enabled) { - $mail = new PHPMailer(true); - $mail->isSMTP(); - $mail->SMTPAuth = (!empty($username) && !empty($password)); - $mail->Username = $username; - $mail->Password = $password; - $mail->Host = $host; - $mail->Port = $port; - $mail->SMTPSecure = $secure; - $mail->SMTPAutoTLS = false; - $mail->Timeout = 5; - - try { - $valid = $mail->SmtpConnect(); - - if (!$valid) { - throw new Exception('Connection is not valid.'); - } - } catch (Throwable $error) { - throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); - } - } - - // Save SMTP settings - if ($enabled) { - $smtp = [ - 'enabled' => $enabled, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'replyTo' => $replyTo, - 'host' => $host, - 'port' => $port, - 'username' => $username, - 'password' => $password, - 'secure' => $secure, - ]; - } else { - $smtp = [ - 'enabled' => false - ]; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::post('/v1/projects/:projectId/smtp/tests') - ->desc('Create SMTP test') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSmtpTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.createSMTPTest', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSMTPTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') - ->param('senderName', System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'), new Text(255, 0), 'Name of the email sender') - ->param('senderEmail', System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM), new Email(), 'Email of the sender') - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name') - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('queueForMails') - ->inject('plan') - ->action(function (string $projectId, array $emails, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform, Mail $queueForMails, array $plan) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; - - $subject = 'Custom SMTP email sample'; - $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl'); - $template - ->setParam('{{from}}', "{$senderName} ({$senderEmail})") - ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") - ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) - ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) - ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) - ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD) - ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE) - ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL) - ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL); - - foreach ($emails as $email) { - $queueForMails - ->setSmtpHost($host) - ->setSmtpPort($port) - ->setSmtpUsername($username) - ->setSmtpPassword($password) - ->setSmtpSecure($secure) - ->setSmtpReplyTo($replyTo) - ->setSmtpSenderEmail($senderEmail) - ->setSmtpSenderName($senderName) - ->setRecipient($email) - ->setName('') - ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') - ->setBody($template->render()) - ->setVariables([]) - ->setSubject($subject) - ->trigger(); - } - - $response->noContent(); - }); - Http::get('/v1/projects/:projectId/templates/sms/:type/:locale') ->desc('Get custom SMS template') ->groups(['api', 'projects']) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php index 24d1c48cf1..304d9dc8a6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php @@ -9,6 +9,7 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Validator\Authorization; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; use Utopia\Validator\Text; @@ -53,6 +54,7 @@ class Update extends Action ->inject('response') ->inject('dbForPlatform') ->inject('project') + ->inject('authorization') ->callback($this->action(...)); } @@ -63,11 +65,12 @@ class Update extends Action array $labels, Response $response, Database $dbForPlatform, - Document $project + Document $project, + Authorization $authorization ): void { $labels = (array) \array_values(\array_unique($labels)); - $project = $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels])); + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document(['labels' => $labels]))); $response->dynamic($project, Response::MODEL_PROJECT); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php new file mode 100644 index 0000000000..0669f6344f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php @@ -0,0 +1,74 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/smtp/status') + ->desc('Update project SMTP status') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'smtp.*.update') + ->label('audits.event', 'project.smtp.update') + ->label('audits.resource', 'project.smtp/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'updateSMTPStatus', + description: <<param('enabled', null, new Boolean(), 'SMTP status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $smtp = $project->getAttribute('smtp', []); + + $smtp['enabled'] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php new file mode 100644 index 0000000000..a9a59cba85 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php @@ -0,0 +1,142 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/smtp/tests') + ->httpAlias('/v1/projects/:projectId/smtp/tests') + ->desc('Create project SMTP test') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'smtp.*.update') + ->label('audits.event', 'project.smtp.update') + ->label('audits.resource', 'project.smtp/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'createSMTPTest', + description: <<param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') + ->inject('response') + ->inject('project') + ->inject('queueForMails') + ->callback($this->action(...)); + } + + /** + * @param array $emails + */ + public function action( + array $emails, + Response $response, + Document $project, + Mail $queueForMails + ): void { + + $smtp = $project->getAttribute('smtp', []); + + if ($smtp['enabled'] !== true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to send a test email.'); + } + + $senderName = $smtp['senderName'] ?? ''; + $senderEmail = $smtp['senderEmail'] ?? ''; + $replyTo = $smtp['replyTo'] ?? ''; + $host = $smtp['host'] ?? ''; + $port = $smtp['port'] ?? ''; + $username = $smtp['username'] ?? ''; + $password = $smtp['password'] ?? ''; + $secure = $smtp['secure'] ?? ''; + + if (empty($senderName)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender name must be configured on the project to send a test email.'); + } + + if (empty($senderEmail)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender email must be configured on the project to send a test email.'); + } + + if (empty($host)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP host must be configured on the project to send a test email.'); + } + + if (empty($port)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP port must be configured on the project to send a test email.'); + } + + $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; + + $subject = 'Custom SMTP email sample'; + $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl'); + $template + ->setParam('{{from}}', "{$senderName} ({$senderEmail})") + ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") + ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) + ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) + ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) + ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD) + ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE) + ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL) + ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL); + + foreach ($emails as $email) { + $queueForMails + ->setSmtpHost($host) + ->setSmtpPort($port) + ->setSmtpUsername($username) + ->setSmtpPassword($password) + ->setSmtpSecure($secure) + ->setSmtpReplyTo($replyTo) + ->setSmtpSenderEmail($senderEmail) + ->setSmtpSenderName($senderName) + ->setRecipient($email) + ->setName('') + ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') + ->setBody($template->render()) + ->setVariables([]) + ->setSubject($subject) + ->trigger(); + } + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php new file mode 100644 index 0000000000..027ced5664 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -0,0 +1,142 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/smtp') + ->httpAlias('/v1/projects/:projectId/smtp') + ->desc('Update project SMTP details') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'smtp.*.update') + ->label('audits.event', 'project.smtp.update') + ->label('audits.resource', 'project.smtp/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'updateSMTP', + description: <<param('senderName', '', new Text(255, 0), 'Name of the email sender') + ->param('senderEmail', '', new Email(), 'Email of the sender') + ->param('replyTo', '', new Email(), 'Reply to email', true) + ->param('host', '', new Hostname(), 'SMTP server host name') + ->param('port', 587, new Integer(), 'SMTP server port') + ->param('username', '', new Text(0, 0), 'SMTP server username', true) + ->param('password', '', new Text(0, 0), 'SMTP server password', true) + ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) + ->param('enabled', null, new Nullable(new Boolean()), 'Enable custom SMTP service', optional: true, deprecated: true) // Backwards compatibility + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + + public function action( + string $senderName, + string $senderEmail, + string $replyTo, + string $host, + int $port, + string $username, + string $password, + string $secure, + ?bool $enabled, // Backwards compatibility + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + // Backwards compatibility + if (!\is_null($enabled) && $enabled === false) { + $smtp = $project->getAttribute('smtp', []); + + $smtp['enabled'] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } + + // Validate SMTP settings + $mail = new PHPMailer(true); + $mail->isSMTP(); + $mail->SMTPAuth = (!empty($username) && !empty($password)); + $mail->Username = $username; + $mail->Password = $password; + $mail->Host = $host; + $mail->Port = $port; + $mail->SMTPSecure = $secure; + $mail->SMTPAutoTLS = false; + $mail->Timeout = 5; + + try { + $valid = $mail->SmtpConnect(); + + if (!$valid) { + throw new \Exception('Connection is not valid.'); + } + } catch (Throwable $error) { + throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); + } + + $smtp = [ + 'enabled' => true, + 'senderName' => $senderName, + 'senderEmail' => $senderEmail, + 'replyTo' => $replyTo, + 'host' => $host, + 'port' => $port, + 'username' => $username, + 'password' => $password, + 'secure' => $secure, + ]; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} From 556ca10ed9f2774eb20d044c21a5e61be5651df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 16:52:38 +0200 Subject: [PATCH 02/81] Register endpoints --- src/Appwrite/Platform/Modules/Project/Services/Http.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index a2c94928e2..d63be56632 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -23,6 +23,9 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as C use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Status\Update as UpdateSMTPStatus; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Test\Create as CreateSMTPTest; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; @@ -45,6 +48,11 @@ class Http extends Service $this->addAction(UpdateProjectProtocolStatus::getName(), new UpdateProjectProtocolStatus()); $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); + // SMTP + $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); + $this->addAction(UpdateSMTPStatus::getName(), new UpdateSMTPStatus()); + $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); + // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); $this->addAction(ListVariables::getName(), new ListVariables()); From ffdfe4bbf802d4c48fe86a16906eccbffff0b1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 16:55:39 +0200 Subject: [PATCH 03/81] Add new tests --- tests/e2e/Services/Project/SMTPBase.php | 572 ++++++++++++++++++ .../Project/SMTPConsoleClientTest.php | 14 + .../Services/Project/SMTPCustomServerTest.php | 14 + 3 files changed, 600 insertions(+) create mode 100644 tests/e2e/Services/Project/SMTPBase.php create mode 100644 tests/e2e/Services/Project/SMTPConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/SMTPCustomServerTest.php diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php new file mode 100644 index 0000000000..7089162e39 --- /dev/null +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -0,0 +1,572 @@ +updateSMTPStatus(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPStatusDisable(): void + { + $this->updateSMTPStatus(true); + + $response = $this->updateSMTPStatus(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['smtpEnabled']); + } + + public function testUpdateSMTPStatusEnableIdempotent(): void + { + $first = $this->updateSMTPStatus(true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['smtpEnabled']); + + $second = $this->updateSMTPStatus(true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPStatusDisableIdempotent(): void + { + $first = $this->updateSMTPStatus(false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['smtpEnabled']); + + $second = $this->updateSMTPStatus(false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['smtpEnabled']); + } + + public function testUpdateSMTPStatusResponseModel(): void + { + $response = $this->updateSMTPStatus(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('smtpEnabled', $response['body']); + $this->assertArrayHasKey('smtpSenderName', $response['body']); + $this->assertArrayHasKey('smtpSenderEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyTo', $response['body']); + $this->assertArrayHasKey('smtpHost', $response['body']); + $this->assertArrayHasKey('smtpPort', $response['body']); + $this->assertArrayHasKey('smtpUsername', $response['body']); + $this->assertArrayHasKey('smtpPassword', $response['body']); + $this->assertArrayHasKey('smtpSecure', $response['body']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPStatusWithoutAuthentication(): void + { + $response = $this->updateSMTPStatus(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // Update SMTP tests + + public function testUpdateSMTP(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Test Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPWithAllOptionalFields(): void + { + $response = $this->updateSMTP( + senderName: 'Full Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + replyTo: 'reply@example.com', + username: 'smtpuser', + password: 'smtppass', + secure: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Full Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('reply@example.com', $response['body']['smtpReplyTo']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + $this->assertSame('smtpuser', $response['body']['smtpUsername']); + $this->assertSame('smtppass', $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPOverwritesPreviousSettings(): void + { + $this->updateSMTP( + senderName: 'First Sender', + senderEmail: 'first@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->updateSMTP( + senderName: 'Second Sender', + senderEmail: 'second@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Second Sender', $response['body']['smtpSenderName']); + $this->assertSame('second@example.com', $response['body']['smtpSenderEmail']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPEnablesSMTP(): void + { + // Ensure SMTP is disabled + $this->updateSMTPStatus(false); + + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPResponseModel(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('smtpEnabled', $response['body']); + $this->assertArrayHasKey('smtpSenderName', $response['body']); + $this->assertArrayHasKey('smtpSenderEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyTo', $response['body']); + $this->assertArrayHasKey('smtpHost', $response['body']); + $this->assertArrayHasKey('smtpPort', $response['body']); + $this->assertArrayHasKey('smtpUsername', $response['body']); + $this->assertArrayHasKey('smtpPassword', $response['body']); + $this->assertArrayHasKey('smtpSecure', $response['body']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPWithoutAuthentication(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + authenticated: false, + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidSenderEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'not-an-email', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptySenderName(): void + { + $response = $this->updateSMTP( + senderName: '', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptySenderEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: '', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPEmptyHost(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: '', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidHost(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'not a valid host!@#', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidReplyToEmail(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + replyTo: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPInvalidSecure(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + secure: 'invalid', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPValidSecureTLS(): void + { + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + secure: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['smtpSecure']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPInvalidConnectionRefused(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_smtp_config_invalid', $response['body']['type']); + } + + public function testUpdateSMTPBackwardsCompatibilityDisable(): void + { + // First enable SMTP + $this->updateSMTPStatus(true); + + // Use the deprecated enabled=false parameter to disable + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + } + + // Create SMTP test tests + + public function testCreateSMTPTest(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest(['recipient@example.com']); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testCreateSMTPTestMultipleRecipients(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest([ + 'recipient1@example.com', + 'recipient2@example.com', + 'recipient3@example.com', + ]); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testCreateSMTPTestWhenSMTPDisabled(): void + { + // Ensure SMTP is disabled + $this->updateSMTPStatus(false); + + $response = $this->createSMTPTest(['recipient@example.com']); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateSMTPTestWithoutAuthentication(): void + { + $response = $this->createSMTPTest(['recipient@example.com'], false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateSMTPTestEmptyEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest([]); + + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testCreateSMTPTestInvalidEmail(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $response = $this->createSMTPTest(['not-an-email']); + + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testCreateSMTPTestExceedsMaxEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $emails = []; + for ($i = 1; $i <= 11; $i++) { + $emails[] = "recipient{$i}@example.com"; + } + + $response = $this->createSMTPTest($emails); + + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testCreateSMTPTestMaxEmails(): void + { + // First configure SMTP + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $emails = []; + for ($i = 1; $i <= 10; $i++) { + $emails[] = "recipient{$i}@example.com"; + } + + $response = $this->createSMTPTest($emails); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + // Helpers + + protected function updateSMTPStatus(bool $enabled, bool $authenticated = true): mixed + { + $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/smtp/status', $headers, [ + 'enabled' => $enabled, + ]); + } + + protected function updateSMTP( + string $senderName = '', + string $senderEmail = '', + string $host = '', + int $port = 587, + string $replyTo = '', + string $username = '', + string $password = '', + string $secure = '', + ?bool $enabled = 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 = [ + 'senderName' => $senderName, + 'senderEmail' => $senderEmail, + 'host' => $host, + 'port' => $port, + 'replyTo' => $replyTo, + 'username' => $username, + 'password' => $password, + 'secure' => $secure, + ]; + + if (!\is_null($enabled)) { + $params['enabled'] = $enabled; + } + + return $this->client->call(Client::METHOD_PATCH, '/project/smtp', $headers, $params); + } + + /** + * @param array $emails + */ + protected function createSMTPTest(array $emails, bool $authenticated = true): mixed + { + $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_POST, '/project/smtp/tests', $headers, [ + 'emails' => $emails, + ]); + } +} diff --git a/tests/e2e/Services/Project/SMTPConsoleClientTest.php b/tests/e2e/Services/Project/SMTPConsoleClientTest.php new file mode 100644 index 0000000000..e5962c0960 --- /dev/null +++ b/tests/e2e/Services/Project/SMTPConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Tue, 14 Apr 2026 17:01:46 +0200 Subject: [PATCH 04/81] Fix bugs --- .../Modules/Project/Http/Project/SMTP/Test/Create.php | 8 +++++--- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php index a9a59cba85..fb93806983 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php @@ -59,6 +59,7 @@ class Create extends Action ->inject('response') ->inject('project') ->inject('queueForMails') + ->inject('plan') ->callback($this->action(...)); } @@ -69,7 +70,8 @@ class Create extends Action array $emails, Response $response, Document $project, - Mail $queueForMails + Mail $queueForMails, + array $plan ): void { $smtp = $project->getAttribute('smtp', []); @@ -106,7 +108,7 @@ class Create extends Action $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; $subject = 'Custom SMTP email sample'; - $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl'); + $template = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-smtp-test.tpl'); $template ->setParam('{{from}}', "{$senderName} ({$senderEmail})") ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") @@ -130,7 +132,7 @@ class Create extends Action ->setSmtpSenderName($senderName) ->setRecipient($email) ->setName('') - ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') + ->setBodyTemplate(APP_CE_CONFIG_DIR . '/locale/templates/email-base-styled.tpl') ->setBody($template->render()) ->setVariables([]) ->setSubject($subject) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 027ced5664..43bedb87c2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -99,6 +99,8 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); $response->dynamic($project, Response::MODEL_PROJECT); + + return; } // Validate SMTP settings From 56bcc0d09f15439726f04244b33b43104ee98075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:01:56 +0200 Subject: [PATCH 05/81] Fix tests --- tests/e2e/Services/Project/SMTPBase.php | 32 ++++++++++++++++--------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 7089162e39..ee341e583f 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -117,7 +117,6 @@ trait SMTPBase replyTo: 'reply@example.com', username: 'smtpuser', password: 'smtppass', - secure: '', ); $this->assertSame(200, $response['headers']['status-code']); @@ -302,14 +301,13 @@ trait SMTPBase $this->assertSame(400, $response['headers']['status-code']); } - public function testUpdateSMTPValidSecureTLS(): void + public function testUpdateSMTPWithoutSecure(): void { $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', port: 1025, - secure: '', ); $this->assertSame(200, $response['headers']['status-code']); @@ -517,10 +515,10 @@ trait SMTPBase string $senderEmail = '', string $host = '', int $port = 587, - string $replyTo = '', - string $username = '', - string $password = '', - string $secure = '', + ?string $replyTo = null, + ?string $username = null, + ?string $password = null, + ?string $secure = null, ?bool $enabled = null, bool $authenticated = true, ): mixed { @@ -538,12 +536,24 @@ trait SMTPBase 'senderEmail' => $senderEmail, 'host' => $host, 'port' => $port, - 'replyTo' => $replyTo, - 'username' => $username, - 'password' => $password, - 'secure' => $secure, ]; + if (!\is_null($replyTo)) { + $params['replyTo'] = $replyTo; + } + + if (!\is_null($username)) { + $params['username'] = $username; + } + + if (!\is_null($password)) { + $params['password'] = $password; + } + + if (!\is_null($secure)) { + $params['secure'] = $secure; + } + if (!\is_null($enabled)) { $params['enabled'] = $enabled; } From d7f8ca3f0146db1a8f5285563be1c401bd58e11a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:04:22 +0200 Subject: [PATCH 06/81] Improve endpoint quality --- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 43bedb87c2..28c4929694 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -58,13 +58,13 @@ class Update extends Action ) ], )) - ->param('senderName', '', new Text(255, 0), 'Name of the email sender') + ->param('senderName', '', new Text(256), 'Name of the email sender') ->param('senderEmail', '', new Email(), 'Email of the sender') ->param('replyTo', '', new Email(), 'Reply to email', true) ->param('host', '', new Hostname(), 'SMTP server host name') ->param('port', 587, new Integer(), 'SMTP server port') - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) + ->param('username', '', new Text(256), 'SMTP server username', true) + ->param('password', '', new Text(256), 'SMTP server password', true) ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) ->param('enabled', null, new Nullable(new Boolean()), 'Enable custom SMTP service', optional: true, deprecated: true) // Backwards compatibility ->inject('response') From 9d6428d5d52b02d062f61e983c8df14813484811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:06:57 +0200 Subject: [PATCH 07/81] Improve tests --- tests/e2e/Services/Project/SMTPBase.php | 176 +++++++++++++++++++++++- 1 file changed, 170 insertions(+), 6 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index ee341e583f..7eee0ccaa1 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -107,7 +107,7 @@ trait SMTPBase $this->updateSMTPStatus(false); } - public function testUpdateSMTPWithAllOptionalFields(): void + public function testUpdateSMTPWithOptionalReplyTo(): void { $response = $this->updateSMTP( senderName: 'Full Sender', @@ -115,8 +115,6 @@ trait SMTPBase host: 'maildev', port: 1025, replyTo: 'reply@example.com', - username: 'smtpuser', - password: 'smtppass', ); $this->assertSame(200, $response['headers']['status-code']); @@ -126,8 +124,6 @@ trait SMTPBase $this->assertSame('reply@example.com', $response['body']['smtpReplyTo']); $this->assertSame('maildev', $response['body']['smtpHost']); $this->assertSame(1025, $response['body']['smtpPort']); - $this->assertSame('smtpuser', $response['body']['smtpUsername']); - $this->assertSame('smtppass', $response['body']['smtpPassword']); // Cleanup $this->updateSMTPStatus(false); @@ -301,6 +297,173 @@ trait SMTPBase $this->assertSame(400, $response['headers']['status-code']); } + public function testUpdateSMTPSenderNameMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'A', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('A', $response['body']['smtpSenderName']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPSenderNameMaxLength(): void + { + $name = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: $name, + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($name, $response['body']['smtpSenderName']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPSenderNameTooLong(): void + { + $response = $this->updateSMTP( + senderName: str_repeat('a', 257), + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPUsernameMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'u', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('u', $response['body']['smtpUsername']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPUsernameMaxLength(): void + { + $username = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: $username, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($username, $response['body']['smtpUsername']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPUsernameTooLong(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: str_repeat('a', 257), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPUsernameEmpty(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPPasswordMinLength(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: 'p', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('p', $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPPasswordMaxLength(): void + { + $password = str_repeat('a', 256); + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: $password, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($password, $response['body']['smtpPassword']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testUpdateSMTPPasswordTooLong(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: str_repeat('a', 257), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSMTPPasswordEmpty(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + password: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + public function testUpdateSMTPWithoutSecure(): void { $response = $this->updateSMTP( @@ -421,7 +584,8 @@ trait SMTPBase $response = $this->createSMTPTest([]); - $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); // Cleanup $this->updateSMTPStatus(false); From a3a8ad88fe5e0f3833c996bb164870a618c47ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:07:06 +0200 Subject: [PATCH 08/81] formatting fix --- src/Appwrite/Platform/Modules/Project/Services/Http.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index d63be56632..54787c2782 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -23,10 +23,10 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as C use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; +use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Status\Update as UpdateSMTPStatus; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Test\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; -use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; 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; From b5e46c1a60a5eae54e5f916b6b347a4b501415e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:09:37 +0200 Subject: [PATCH 09/81] E2E integration tests --- tests/e2e/Services/Project/SMTPBase.php | 86 +++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 7eee0ccaa1..2f3762cfa8 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; +use Utopia\Database\Helpers\ID; trait SMTPBase { @@ -656,6 +657,91 @@ trait SMTPBase $this->updateSMTPStatus(false); } + // Integration tests + + public function testCreateSMTPTestEmailDelivery(): void + { + $senderName = 'SMTP Test Sender'; + $senderEmail = 'smtptest@appwrite.io'; + $replyToEmail = 'smtpreply@appwrite.io'; + $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; + + // Configure SMTP with replyTo + $response = $this->updateSMTP( + senderName: $senderName, + senderEmail: $senderEmail, + host: 'maildev', + port: 1025, + replyTo: $replyToEmail, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Trigger test email + $response = $this->createSMTPTest([$recipientEmail]); + + $this->assertSame(204, $response['headers']['status-code']); + + // Verify email arrived via maildev + $email = $this->getLastEmailByAddress($recipientEmail, function ($email) { + $this->assertSame('Custom SMTP email sample', $email['subject']); + }); + + $this->assertSame($senderEmail, $email['from'][0]['address']); + $this->assertSame($senderName, $email['from'][0]['name']); + $this->assertSame($replyToEmail, $email['replyTo'][0]['address']); + $this->assertSame($senderName, $email['replyTo'][0]['name']); + $this->assertSame('Custom SMTP email sample', $email['subject']); + $this->assertStringContainsStringIgnoringCase('working correctly', $email['text']); + $this->assertStringContainsStringIgnoringCase('working correctly', $email['html']); + + // Cleanup + $this->updateSMTPStatus(false); + } + + public function testMagicURLLoginUsesCustomSMTP(): void + { + $senderName = 'Custom Auth Mailer'; + $senderEmail = 'authmailer@appwrite.io'; + $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; + + // Configure custom SMTP + $response = $this->updateSMTP( + senderName: $senderName, + senderEmail: $senderEmail, + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Trigger MagicURL login as a client (no auth headers needed) + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => ID::unique(), + 'email' => $recipientEmail, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + + // Verify the email arrived with custom SMTP sender details + $email = $this->getLastEmailByAddress($recipientEmail, function ($email) { + $this->assertStringContainsString('Login', $email['subject']); + }); + + $this->assertSame($senderEmail, $email['from'][0]['address']); + $this->assertSame($senderName, $email['from'][0]['name']); + $this->assertSame($this->getProject()['name'] . ' Login', $email['subject']); + + // Cleanup + $this->updateSMTPStatus(false); + } + // Helpers protected function updateSMTPStatus(bool $enabled, bool $authenticated = true): mixed From 905d2a8eaa69bc5810fe3f914f1ec14d459aa6e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:20:03 +0200 Subject: [PATCH 10/81] Fix tests --- tests/e2e/Services/Project/SMTPBase.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 2f3762cfa8..da1325fac3 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -666,13 +666,15 @@ trait SMTPBase $replyToEmail = 'smtpreply@appwrite.io'; $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; - // Configure SMTP with replyTo + // Configure SMTP with replyTo and auth credentials $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', port: 1025, replyTo: $replyToEmail, + username: 'user', + password: 'password', ); $this->assertSame(200, $response['headers']['status-code']); @@ -706,12 +708,14 @@ trait SMTPBase $senderEmail = 'authmailer@appwrite.io'; $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; - // Configure custom SMTP + // Configure custom SMTP with auth credentials $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, - host: 'maildev', - port: 1025, + host: $smtpHost, + port: $smtpPort, + username: $smtpUsername, + password: $smtpPassword, ); $this->assertSame(200, $response['headers']['status-code']); From d3dcbfe567b8e7ec719277af9df3e43f1ef05b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:20:22 +0200 Subject: [PATCH 11/81] Leftover chaneges, --- tests/e2e/Services/Project/SMTPBase.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index da1325fac3..da5be3869d 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -712,10 +712,10 @@ trait SMTPBase $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, - host: $smtpHost, - port: $smtpPort, - username: $smtpUsername, - password: $smtpPassword, + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', ); $this->assertSame(200, $response['headers']['status-code']); From 732538504dde3e6e3b6a9d31b1d5c5f73c37f93f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 Apr 2026 17:42:26 +0200 Subject: [PATCH 12/81] Fix backwards compatibility --- .../Project/Http/Project/SMTP/Test/Create.php | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php index fb93806983..e1d4b3f847 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Test/Create.php @@ -17,6 +17,10 @@ use Utopia\Emails\Validator\Email; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\ArrayList; +use Utopia\Validator\Hostname; +use Utopia\Validator\Integer; +use Utopia\Validator\Text; +use Utopia\Validator\WhiteList; class Create extends Action { @@ -56,6 +60,14 @@ class Create extends Action contentType: ContentType::NONE, )) ->param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') + ->param('senderName', '', new Text(256), 'Name of the email sender', optional: true, deprecated: true) // Backwards compatibility + ->param('senderEmail', '', new Email(), 'Email of the sender', optional: true, deprecated: true) // Backwards compatibility + ->param('replyTo', '', new Email(), 'Reply to email', optional: true, deprecated: true) // Backwards compatibility + ->param('host', '', new Hostname(), 'SMTP server host name', optional: true, deprecated: true) // Backwards compatibility + ->param('port', null, new Integer(), 'SMTP server port', optional: true, deprecated: true) // Backwards compatibility + ->param('username', '', new Text(256), 'SMTP server username', optional: true, deprecated: true) // Backwards compatibility + ->param('password', '', new Text(256), 'SMTP server password', optional: true, deprecated: true) // Backwards compatibility + ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', optional: true, deprecated: true) // Backwards compatibility ->inject('response') ->inject('project') ->inject('queueForMails') @@ -68,26 +80,36 @@ class Create extends Action */ public function action( array $emails, + string $paramSenderName, // Backwards compatibility + string $paramSenderEmail, // Backwards compatibility + string $paramReplyTo, // Backwards compatibility + string $paramHost, // Backwards compatibility + ?int $paramPort, // Backwards compatibility + string $paramUsername, // Backwards compatibility + string $paramPassword, // Backwards compatibility + string $paramSecure, // Backwards compatibility Response $response, Document $project, Mail $queueForMails, array $plan ): void { + // Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config + $hasInlineParams = !empty($paramHost); $smtp = $project->getAttribute('smtp', []); - if ($smtp['enabled'] !== true) { + if (!$hasInlineParams && ($smtp['enabled'] ?? false) !== true) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to send a test email.'); } - $senderName = $smtp['senderName'] ?? ''; - $senderEmail = $smtp['senderEmail'] ?? ''; - $replyTo = $smtp['replyTo'] ?? ''; - $host = $smtp['host'] ?? ''; - $port = $smtp['port'] ?? ''; - $username = $smtp['username'] ?? ''; - $password = $smtp['password'] ?? ''; - $secure = $smtp['secure'] ?? ''; + $senderName = $paramSenderName ?: ($smtp['senderName'] ?? ''); + $senderEmail = $paramSenderEmail ?: ($smtp['senderEmail'] ?? ''); + $replyTo = $paramReplyTo ?: ($smtp['replyTo'] ?? ''); + $host = $paramHost ?: ($smtp['host'] ?? ''); + $port = $paramPort ?? ($smtp['port'] ?? ''); + $username = $paramUsername ?: ($smtp['username'] ?? ''); + $password = $paramPassword ?: ($smtp['password'] ?? ''); + $secure = $paramSecure ?: ($smtp['secure'] ?? ''); if (empty($senderName)) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender name must be configured on the project to send a test email.'); From 7fe65eec5751116f86689f16efb62f5a8f501c21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 16 Apr 2026 10:23:31 +0200 Subject: [PATCH 13/81] Restruture endpoints --- .../Project/SMTP/{ => Credentials}/Update.php | 8 +- .../Project/SMTP/{Test => Tests}/Create.php | 2 +- .../Modules/Project/Services/Http.php | 6 +- tests/e2e/Services/Project/SMTPBase.php | 78 +++++++++---------- 4 files changed, 47 insertions(+), 47 deletions(-) rename src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/{ => Credentials}/Update.php (95%) rename src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/{Test => Tests}/Create.php (99%) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php similarity index 95% rename from src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php index 28c4929694..9117554622 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/smtp') + ->setHttpPath('/v1/project/smtp/credentials') ->httpAlias('/v1/projects/:projectId/smtp') ->desc('Update project SMTP details') ->groups(['api', 'project']) @@ -46,7 +46,7 @@ class Update extends Action ->label('sdk', new Method( namespace: 'project', group: 'smtp', - name: 'updateSMTP', + name: 'updateSMTPCredentials', description: <<addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); // SMTP - $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); + $this->addAction(UpdateSMTPCredentials::getName(), new UpdateSMTPCredentials()); $this->addAction(UpdateSMTPStatus::getName(), new UpdateSMTPStatus()); $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index da5be3869d..51e2e5e8a9 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -87,9 +87,9 @@ trait SMTPBase // Update SMTP tests - public function testUpdateSMTP(): void + public function testUpdateSMTPCredentials(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -110,7 +110,7 @@ trait SMTPBase public function testUpdateSMTPWithOptionalReplyTo(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Full Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -132,14 +132,14 @@ trait SMTPBase public function testUpdateSMTPOverwritesPreviousSettings(): void { - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'First Sender', senderEmail: 'first@example.com', host: 'maildev', port: 1025, ); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Second Sender', senderEmail: 'second@example.com', host: 'maildev', @@ -159,7 +159,7 @@ trait SMTPBase // Ensure SMTP is disabled $this->updateSMTPStatus(false); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -175,7 +175,7 @@ trait SMTPBase public function testUpdateSMTPResponseModel(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -201,7 +201,7 @@ trait SMTPBase public function testUpdateSMTPWithoutAuthentication(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -214,7 +214,7 @@ trait SMTPBase public function testUpdateSMTPInvalidSenderEmail(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'not-an-email', host: 'maildev', @@ -226,7 +226,7 @@ trait SMTPBase public function testUpdateSMTPEmptySenderName(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: '', senderEmail: 'sender@example.com', host: 'maildev', @@ -238,7 +238,7 @@ trait SMTPBase public function testUpdateSMTPEmptySenderEmail(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: '', host: 'maildev', @@ -250,7 +250,7 @@ trait SMTPBase public function testUpdateSMTPEmptyHost(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: '', @@ -262,7 +262,7 @@ trait SMTPBase public function testUpdateSMTPInvalidHost(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'not a valid host!@#', @@ -274,7 +274,7 @@ trait SMTPBase public function testUpdateSMTPInvalidReplyToEmail(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -287,7 +287,7 @@ trait SMTPBase public function testUpdateSMTPInvalidSecure(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -300,7 +300,7 @@ trait SMTPBase public function testUpdateSMTPSenderNameMinLength(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'A', senderEmail: 'sender@example.com', host: 'maildev', @@ -317,7 +317,7 @@ trait SMTPBase public function testUpdateSMTPSenderNameMaxLength(): void { $name = str_repeat('a', 256); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: $name, senderEmail: 'sender@example.com', host: 'maildev', @@ -333,7 +333,7 @@ trait SMTPBase public function testUpdateSMTPSenderNameTooLong(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: str_repeat('a', 257), senderEmail: 'sender@example.com', host: 'maildev', @@ -345,7 +345,7 @@ trait SMTPBase public function testUpdateSMTPUsernameMinLength(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -363,7 +363,7 @@ trait SMTPBase public function testUpdateSMTPUsernameMaxLength(): void { $username = str_repeat('a', 256); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -380,7 +380,7 @@ trait SMTPBase public function testUpdateSMTPUsernameTooLong(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -393,7 +393,7 @@ trait SMTPBase public function testUpdateSMTPUsernameEmpty(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -406,7 +406,7 @@ trait SMTPBase public function testUpdateSMTPPasswordMinLength(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -424,7 +424,7 @@ trait SMTPBase public function testUpdateSMTPPasswordMaxLength(): void { $password = str_repeat('a', 256); - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -441,7 +441,7 @@ trait SMTPBase public function testUpdateSMTPPasswordTooLong(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -454,7 +454,7 @@ trait SMTPBase public function testUpdateSMTPPasswordEmpty(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -467,7 +467,7 @@ trait SMTPBase public function testUpdateSMTPWithoutSecure(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -483,7 +483,7 @@ trait SMTPBase public function testUpdateSMTPInvalidConnectionRefused(): void { - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'localhost', @@ -500,7 +500,7 @@ trait SMTPBase $this->updateSMTPStatus(true); // Use the deprecated enabled=false parameter to disable - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -517,7 +517,7 @@ trait SMTPBase public function testCreateSMTPTest(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -536,7 +536,7 @@ trait SMTPBase public function testCreateSMTPTestMultipleRecipients(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -576,7 +576,7 @@ trait SMTPBase public function testCreateSMTPTestEmptyEmails(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -595,7 +595,7 @@ trait SMTPBase public function testCreateSMTPTestInvalidEmail(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -613,7 +613,7 @@ trait SMTPBase public function testCreateSMTPTestExceedsMaxEmails(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -636,7 +636,7 @@ trait SMTPBase public function testCreateSMTPTestMaxEmails(): void { // First configure SMTP - $this->updateSMTP( + $this->updateSMTPCredentials( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -667,7 +667,7 @@ trait SMTPBase $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; // Configure SMTP with replyTo and auth credentials - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', @@ -709,7 +709,7 @@ trait SMTPBase $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; // Configure custom SMTP with auth credentials - $response = $this->updateSMTP( + $response = $this->updateSMTPCredentials( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', @@ -764,7 +764,7 @@ trait SMTPBase ]); } - protected function updateSMTP( + protected function updateSMTPCredentials( string $senderName = '', string $senderEmail = '', string $host = '', @@ -812,7 +812,7 @@ trait SMTPBase $params['enabled'] = $enabled; } - return $this->client->call(Client::METHOD_PATCH, '/project/smtp', $headers, $params); + return $this->client->call(Client::METHOD_PATCH, '/project/smtp/credentials', $headers, $params); } /** From 1a46fc200613fba8c71693645de8b36a81468666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 16:43:17 +0200 Subject: [PATCH 14/81] Move template APIs under project API --- app/controllers/api/projects.php | 219 ------------------ .../Http/Project/Templates/Email/Delete.php | 101 ++++++++ .../Http/Project/Templates/Email/Get.php | 147 ++++++++++++ .../Http/Project/Templates/Email/Update.php | 121 ++++++++++ .../Modules/Project/Services/Http.php | 8 + 5 files changed, 377 insertions(+), 219 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..b972103224 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -20,7 +20,6 @@ use Utopia\Database\Document; use Utopia\Database\Validator\UID; use Utopia\Emails\Validator\Email; use Utopia\Http\Http; -use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; @@ -833,224 +832,6 @@ Http::post('/v1/projects/:projectId/smtp/tests') $response->noContent(); }); -Http::get('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Get custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'getEmailTemplate', - description: '/docs/references/projects/get-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->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', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('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()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - $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[$type] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(__DIR__ . '/../../config/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' => '' - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Update custom email templates') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'updateEmailTemplate', - description: '/docs/references/projects/update-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->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', '', 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) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->inject('response') - ->inject('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()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::delete('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Delete custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteEmailTemplate', - description: '/docs/references/projects/delete-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ], - contentType: ContentType::JSON - )) - ->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', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('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()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - - unset($templates['email.' . $type . '-' . $locale]); - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $template['senderName'], - 'senderEmail' => $template['senderEmail'], - 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], - 'message' => $template['message'] - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - Http::patch('/v1/projects/:projectId/auth/session-invalidation') ->desc('Update invalidate session option of the project') ->groups(['api', 'projects']) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php new file mode 100644 index 0000000000..9133971c40 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -0,0 +1,101 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:type/: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/{response.type}') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'deleteEmailTemplate', + description: <<param('type', '', 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.', optional: true, injections: ['localeCodes']) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('locale') + ->callback($this->action(...)); + } + + public function action( + string $type, + string $locale, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Locale $localeObject, + ) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + + $templates = $project->getAttribute('templates', []); + $template = $templates['email.' . $type . '-' . $locale] ?? null; + + if (is_null($template)) { + throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); + } + + unset($templates['email.' . $type . '-' . $locale]); + + $updates = new Document([ + 'templates' => $templates, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('templateType', $type); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php new file mode 100644 index 0000000000..3c5888f8b8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -0,0 +1,147 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->desc('Get project email template') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'getEmailTemplate', + description: <<param('type', '', 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.', optional: true, injections: ['localeCodes']) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('locale') + ->callback($this->action(...)); + } + + public function action( + string $type, + string $locale, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Locale $localeObject, + ) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + + $templates = $project->getAttribute('templates', []); + $template = $templates['email.' . $type . '-' . $locale] ?? null; + + $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[$type] ?? [ + 'file' => 'email-inner-base.tpl', + 'placeholders' => ['buttonText', 'body', 'footer'] + ]; + + $templateString = file_get_contents(__DIR__ . '/../../config/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['custom'] = true; + } + + $template['type'] = $type; + $template['locale'] = $locale; + + $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php new file mode 100644 index 0000000000..ff739f9fe8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -0,0 +1,121 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email') + ->httpAlias('/v1/projects/:projectId/templates/email/:type/: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.type}') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'updateEmailTemplate', + description: <<param('type', '', 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.', 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('replyTo', '', new Email(), 'Reply to email.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('locale') + ->callback($this->action(...)); + } + + public function action( + string $type, + string $locale, + string $subject, + string $message, + string $senderName, + string $senderEmail, + string $replyTo, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + Locale $localeObject, + ) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + + $template = [ + 'senderName' => $senderName, + 'senderEmail' => $senderEmail, + 'subject' => $subject, + 'replyTo' => $replyTo, + 'message' => $message + ]; + + $templates = $project->getAttribute('templates', []); + $templates['email.' . $type . '-' . $locale] = $template; + + $updates = new Document([ + 'templates' => $templates, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('templateType', $type); + + $response->dynamic(new Document([ + 'type' => $type, + 'locale' => $locale, + 'senderName' => $template['senderName'], + 'senderEmail' => $template['senderEmail'], + 'subject' => $template['subject'], + 'replyTo' => $template['replyTo'], + 'message' => $template['message'], + 'custom' => true, + ]), Response::MODEL_EMAIL_TEMPLATE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index bcab75a8c5..f6c3a2efc2 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -24,6 +24,9 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as U use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol; use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; +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; @@ -45,6 +48,11 @@ class Http extends Service $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol()); $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); + // 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()); $this->addAction(ListVariables::getName(), new ListVariables()); From 489b2c4e211d442ae3ee41f8ba47f06537a10b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 16:45:04 +0200 Subject: [PATCH 15/81] Add new scopes --- app/config/roles.php | 2 ++ app/config/scopes/project.php | 8 ++++++++ src/Appwrite/Platform/Workers/Migrations.php | 2 ++ tests/e2e/Scopes/ProjectCustom.php | 2 ++ 4 files changed, 14 insertions(+) diff --git a/app/config/roles.php b/app/config/roles.php index 116e8ac932..0903e79b13 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,8 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'templates.read', + 'templates.write', 'projects.write', 'keys.read', 'keys.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 6c7f75c08e..861e292407 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -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", + ], ]; diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 118ff7acf9..e69541820b 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -376,6 +376,8 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'templates.read', + 'templates.write', ] ]); diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index a62a1e8ba3..7d9f5eb126 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,8 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'templates.read', + 'templates.write', ], ]); From b01ec03723f828e329b1e9cca8b80fb607ab115c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:01:27 +0200 Subject: [PATCH 16/81] Fix analyze bug --- .../Modules/Project/Http/Project/Templates/Email/Get.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 3c5888f8b8..1a44bf24a5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -6,6 +6,7 @@ use Appwrite\Event\Event as QueueEvent; 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\Database; From e388d2f6a3b950758674f13a1adc9132ca77f898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:22:55 +0200 Subject: [PATCH 17/81] List tempaltes endpoint --- app/init/models.php | 1 + .../Http/Project/Templates/Email/Get.php | 3 +- .../Http/Project/Templates/Email/XList.php | 113 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Utopia/Response/Model/TemplateEmail.php | 6 + 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php diff --git a/app/init/models.php b/app/init/models.php index f654c10121..924df52bdd 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -210,6 +210,7 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE)); 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('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)); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 1a44bf24a5..1855f13d17 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -30,8 +30,7 @@ class Get extends Action public function __construct() { $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/project/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email') + ->setHttpPath('/v1/project/templates/email/:type') ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Get project email template') ->groups(['api', 'project']) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php new file mode 100644 index 0000000000..5cd5184177 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -0,0 +1,113 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email') + ->httpAlias('/v1/projects/:projectId/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: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('project') + ->inject('response') + ->inject('localeCodes') + ->callback($this->action(...)); + } + + /** + * @param array $queries + * @param array $localeCodes + */ + public function action( + array $queries, + bool $includeTotal, + Document $project, + Response $response, + array $localeCodes, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $grouped = Query::groupByType($queries); + $limit = $grouped['limit'] ?? APP_LIMIT_COUNT; + $offset = $grouped['offset'] ?? 0; + + $types = Config::getParam('locale-templates')['email'] ?? []; + $projectTemplates = $project->getAttribute('templates', []); + + $templates = []; + foreach ($types as $type) { + foreach ($localeCodes as $locale) { + $key = 'email.' . $type . '-' . $locale; + $stored = $projectTemplates[$key] ?? null; + + $templates[] = new Document([ + 'type' => $type, + 'locale' => $locale, + 'message' => $stored['message'] ?? '', + 'subject' => $stored['subject'] ?? '', + 'senderName' => $stored['senderName'] ?? '', + 'senderEmail' => $stored['senderEmail'] ?? '', + 'replyTo' => $stored['replyTo'] ?? '', + 'custom' => !\is_null($stored), + ]); + } + } + + $total = $includeTotal ? \count($templates) : 0; + $templates = \array_slice($templates, $offset, $limit); + + $response->dynamic(new Document([ + 'templates' => $templates, + 'total' => $total, + ]), Response::MODEL_EMAIL_TEMPLATE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index f6c3a2efc2..b91541928c 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -27,6 +27,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProj 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\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; @@ -49,6 +50,7 @@ class Http extends Service $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); // Templates + $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index d747373b59..4aecc62fd8 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -266,6 +266,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'; diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index ecdf89e774..95cd57a584 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -34,6 +34,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, + ]) ; } From dc704fdb5131d7262ee61253419249e31a572f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:27:19 +0200 Subject: [PATCH 18/81] Improved xlist endpoint --- .../Http/Project/Templates/Email/XList.php | 119 +++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 5cd5184177..3a04cf4490 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -8,12 +8,15 @@ 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\Exception\Query as QueryException; use Utopia\Database\Query; use Utopia\Database\Validator\Queries; +use Utopia\Database\Validator\Query\Filter; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; +use Utopia\Database\Validator\Query\Order; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -22,6 +25,17 @@ class XList extends Action { use HTTP; + private const ALLOWED_ATTRIBUTES = [ + 'type' => Database::VAR_STRING, + 'locale' => Database::VAR_STRING, + 'subject' => Database::VAR_STRING, + 'message' => Database::VAR_STRING, + 'senderName' => Database::VAR_STRING, + 'senderEmail' => Database::VAR_STRING, + 'replyTo' => Database::VAR_STRING, + 'custom' => Database::VAR_BOOLEAN, + ]; + public static function getName() { return 'listProjectEmailTemplates'; @@ -29,10 +43,25 @@ class XList extends Action public function __construct() { + $attributes = []; + foreach (self::ALLOWED_ATTRIBUTES as $key => $type) { + $attributes[] = new Document([ + 'key' => $key, + 'type' => $type, + 'array' => false, + ]); + } + + $queriesValidator = new Queries([ + new Limit(), + new Offset(), + new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), + new Order($attributes), + ]); + $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/project/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email') ->desc('List project email templates') ->groups(['api', 'project']) ->label('scope', 'templates.read') @@ -51,7 +80,7 @@ class XList extends Action ) ] )) - ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset.', true) + ->param('queries', [], $queriesValidator, 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(self::ALLOWED_ATTRIBUTES)), true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('project') ->inject('response') @@ -80,6 +109,13 @@ class XList extends Action $limit = $grouped['limit'] ?? APP_LIMIT_COUNT; $offset = $grouped['offset'] ?? 0; + /** @var array $filters */ + $filters = $grouped['filters'] ?? []; + /** @var array $orderAttributes */ + $orderAttributes = $grouped['orderAttributes'] ?? []; + /** @var array $orderTypes */ + $orderTypes = $grouped['orderTypes'] ?? []; + $types = Config::getParam('locale-templates')['email'] ?? []; $projectTemplates = $project->getAttribute('templates', []); @@ -102,6 +138,9 @@ class XList extends Action } } + $templates = $this->applyFilters($templates, $filters); + $templates = $this->applyOrder($templates, $orderAttributes, $orderTypes); + $total = $includeTotal ? \count($templates) : 0; $templates = \array_slice($templates, $offset, $limit); @@ -110,4 +149,80 @@ class XList extends Action 'total' => $total, ]), Response::MODEL_EMAIL_TEMPLATE_LIST); } + + /** + * @param array $templates + * @param array $filters + * @return array + */ + private function applyFilters(array $templates, array $filters): array + { + if (empty($filters)) { + return $templates; + } + + return \array_values(\array_filter($templates, function (Document $template) use ($filters) { + foreach ($filters as $filter) { + if (!$this->matches($template, $filter)) { + return false; + } + } + return true; + })); + } + + private function matches(Document $template, Query $filter): bool + { + $attribute = $filter->getAttribute(); + $values = $filter->getValues(); + $actual = $template->getAttribute($attribute); + $needle = (string) ($values[0] ?? ''); + + return match ($filter->getMethod()) { + Query::TYPE_EQUAL => \in_array($actual, $values, false), + Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), + Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), + Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), + Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), + Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), + Query::TYPE_CONTAINS => \is_string($actual) && \str_contains($actual, $needle), + Query::TYPE_NOT_CONTAINS => \is_string($actual) && !\str_contains($actual, $needle), + Query::TYPE_SEARCH => \is_string($actual) && \stripos($actual, $needle) !== false, + Query::TYPE_NOT_SEARCH => \is_string($actual) && \stripos($actual, $needle) === false, + Query::TYPE_IS_NULL => $actual === null || $actual === '', + Query::TYPE_IS_NOT_NULL => $actual !== null && $actual !== '', + default => throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Query method not supported for email templates: ' . $filter->getMethod()), + }; + } + + /** + * @param array $templates + * @param array $orderAttributes + * @param array $orderTypes + * @return array + */ + private function applyOrder(array $templates, array $orderAttributes, array $orderTypes): array + { + if (empty($orderAttributes)) { + return $templates; + } + + \usort($templates, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { + foreach ($orderAttributes as $index => $attribute) { + $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); + $valueA = $a->getAttribute($attribute); + $valueB = $b->getAttribute($attribute); + + $cmp = $valueA <=> $valueB; + if ($cmp === 0) { + continue; + } + + return $direction === Database::ORDER_DESC ? -$cmp : $cmp; + } + return 0; + }); + + return $templates; + } } From bb38bf42487b0c59cfc7b09f3a72509d94f57709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:42:46 +0200 Subject: [PATCH 19/81] Improve code quality --- .../Http/Project/Templates/Email/XList.php | 128 +------------- .../Utopia/Database/InMemoryQuery.php | 159 ++++++++++++++++++ .../Validator/Queries/BaseInMemory.php | 41 +++++ .../Validator/Queries/ProjectTemplates.php | 24 +++ 4 files changed, 230 insertions(+), 122 deletions(-) create mode 100644 src/Appwrite/Utopia/Database/InMemoryQuery.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 3a04cf4490..9fdd576af1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -6,17 +6,13 @@ use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\InMemoryQuery; +use Appwrite\Utopia\Database\Validator\Queries\ProjectTemplates; use Appwrite\Utopia\Response; use Utopia\Config\Config; -use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; -use Utopia\Database\Validator\Queries; -use Utopia\Database\Validator\Query\Filter; -use Utopia\Database\Validator\Query\Limit; -use Utopia\Database\Validator\Query\Offset; -use Utopia\Database\Validator\Query\Order; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -25,17 +21,6 @@ class XList extends Action { use HTTP; - private const ALLOWED_ATTRIBUTES = [ - 'type' => Database::VAR_STRING, - 'locale' => Database::VAR_STRING, - 'subject' => Database::VAR_STRING, - 'message' => Database::VAR_STRING, - 'senderName' => Database::VAR_STRING, - 'senderEmail' => Database::VAR_STRING, - 'replyTo' => Database::VAR_STRING, - 'custom' => Database::VAR_BOOLEAN, - ]; - public static function getName() { return 'listProjectEmailTemplates'; @@ -43,22 +28,6 @@ class XList extends Action public function __construct() { - $attributes = []; - foreach (self::ALLOWED_ATTRIBUTES as $key => $type) { - $attributes[] = new Document([ - 'key' => $key, - 'type' => $type, - 'array' => false, - ]); - } - - $queriesValidator = new Queries([ - new Limit(), - new Offset(), - new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), - new Order($attributes), - ]); - $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/project/templates/email') @@ -80,7 +49,7 @@ class XList extends Action ) ] )) - ->param('queries', [], $queriesValidator, 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(self::ALLOWED_ATTRIBUTES)), true) + ->param('queries', [], new ProjectTemplates(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(ProjectTemplates::ALLOWED_ATTRIBUTES)), true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('project') ->inject('response') @@ -106,15 +75,6 @@ class XList extends Action } $grouped = Query::groupByType($queries); - $limit = $grouped['limit'] ?? APP_LIMIT_COUNT; - $offset = $grouped['offset'] ?? 0; - - /** @var array $filters */ - $filters = $grouped['filters'] ?? []; - /** @var array $orderAttributes */ - $orderAttributes = $grouped['orderAttributes'] ?? []; - /** @var array $orderTypes */ - $orderTypes = $grouped['orderTypes'] ?? []; $types = Config::getParam('locale-templates')['email'] ?? []; $projectTemplates = $project->getAttribute('templates', []); @@ -138,91 +98,15 @@ class XList extends Action } } - $templates = $this->applyFilters($templates, $filters); - $templates = $this->applyOrder($templates, $orderAttributes, $orderTypes); + $templates = InMemoryQuery::filter($templates, $grouped['filters']); + $templates = InMemoryQuery::order($templates, $grouped['orderAttributes'], $grouped['orderTypes']); $total = $includeTotal ? \count($templates) : 0; - $templates = \array_slice($templates, $offset, $limit); + $templates = InMemoryQuery::paginate($templates, $grouped['limit'] ?? APP_LIMIT_COUNT, $grouped['offset']); $response->dynamic(new Document([ 'templates' => $templates, 'total' => $total, ]), Response::MODEL_EMAIL_TEMPLATE_LIST); } - - /** - * @param array $templates - * @param array $filters - * @return array - */ - private function applyFilters(array $templates, array $filters): array - { - if (empty($filters)) { - return $templates; - } - - return \array_values(\array_filter($templates, function (Document $template) use ($filters) { - foreach ($filters as $filter) { - if (!$this->matches($template, $filter)) { - return false; - } - } - return true; - })); - } - - private function matches(Document $template, Query $filter): bool - { - $attribute = $filter->getAttribute(); - $values = $filter->getValues(); - $actual = $template->getAttribute($attribute); - $needle = (string) ($values[0] ?? ''); - - return match ($filter->getMethod()) { - Query::TYPE_EQUAL => \in_array($actual, $values, false), - Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), - Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), - Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), - Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), - Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), - Query::TYPE_CONTAINS => \is_string($actual) && \str_contains($actual, $needle), - Query::TYPE_NOT_CONTAINS => \is_string($actual) && !\str_contains($actual, $needle), - Query::TYPE_SEARCH => \is_string($actual) && \stripos($actual, $needle) !== false, - Query::TYPE_NOT_SEARCH => \is_string($actual) && \stripos($actual, $needle) === false, - Query::TYPE_IS_NULL => $actual === null || $actual === '', - Query::TYPE_IS_NOT_NULL => $actual !== null && $actual !== '', - default => throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Query method not supported for email templates: ' . $filter->getMethod()), - }; - } - - /** - * @param array $templates - * @param array $orderAttributes - * @param array $orderTypes - * @return array - */ - private function applyOrder(array $templates, array $orderAttributes, array $orderTypes): array - { - if (empty($orderAttributes)) { - return $templates; - } - - \usort($templates, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { - foreach ($orderAttributes as $index => $attribute) { - $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); - $valueA = $a->getAttribute($attribute); - $valueB = $b->getAttribute($attribute); - - $cmp = $valueA <=> $valueB; - if ($cmp === 0) { - continue; - } - - return $direction === Database::ORDER_DESC ? -$cmp : $cmp; - } - return 0; - }); - - return $templates; - } } diff --git a/src/Appwrite/Utopia/Database/InMemoryQuery.php b/src/Appwrite/Utopia/Database/InMemoryQuery.php new file mode 100644 index 0000000000..c9f930369a --- /dev/null +++ b/src/Appwrite/Utopia/Database/InMemoryQuery.php @@ -0,0 +1,159 @@ + $documents + * @param array $filters + * @return array + */ + public static function filter(array $documents, array $filters): array + { + if (empty($filters)) { + return \array_values($documents); + } + + return \array_values(\array_filter($documents, function (Document $document) use ($filters) { + foreach ($filters as $filter) { + if (!self::matches($document, $filter)) { + return false; + } + } + return true; + })); + } + + /** + * Evaluate a single filter query against a document. + */ + public static function matches(Document $document, Query $filter): bool + { + $attribute = $filter->getAttribute(); + $values = $filter->getValues(); + $actual = $document->getAttribute($attribute); + $needle = (string) ($values[0] ?? ''); + + return match ($filter->getMethod()) { + Query::TYPE_EQUAL => \in_array($actual, $values, false), + Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), + Query::TYPE_LESSER => self::compareScalar($actual, $values[0] ?? null) < 0, + Query::TYPE_LESSER_EQUAL => self::compareScalar($actual, $values[0] ?? null) <= 0, + Query::TYPE_GREATER => self::compareScalar($actual, $values[0] ?? null) > 0, + Query::TYPE_GREATER_EQUAL => self::compareScalar($actual, $values[0] ?? null) >= 0, + Query::TYPE_BETWEEN => self::compareScalar($actual, $values[0] ?? null) >= 0 && self::compareScalar($actual, $values[1] ?? null) <= 0, + Query::TYPE_NOT_BETWEEN => self::compareScalar($actual, $values[0] ?? null) < 0 || self::compareScalar($actual, $values[1] ?? null) > 0, + Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), + Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), + Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), + Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), + Query::TYPE_CONTAINS => self::containsValue($actual, $values), + Query::TYPE_NOT_CONTAINS => !self::containsValue($actual, $values), + Query::TYPE_SEARCH => \is_string($actual) && $needle !== '' && \stripos($actual, $needle) !== false, + Query::TYPE_NOT_SEARCH => \is_string($actual) && ($needle === '' || \stripos($actual, $needle) === false), + Query::TYPE_IS_NULL => $actual === null, + Query::TYPE_IS_NOT_NULL => $actual !== null, + default => throw new \InvalidArgumentException('Unsupported query method: ' . $filter->getMethod()), + }; + } + + /** + * Sort documents by one or more attributes. + * + * @param array $documents + * @param array $orderAttributes + * @param array $orderTypes + * @return array + */ + public static function order(array $documents, array $orderAttributes, array $orderTypes): array + { + if (empty($orderAttributes)) { + return \array_values($documents); + } + + $documents = \array_values($documents); + + \usort($documents, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { + foreach ($orderAttributes as $index => $attribute) { + $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); + $cmp = self::compareScalar($a->getAttribute($attribute), $b->getAttribute($attribute)); + if ($cmp !== 0) { + return $direction === Database::ORDER_DESC ? -$cmp : $cmp; + } + } + return 0; + }); + + return $documents; + } + + /** + * Apply limit and offset. + * + * @param array $documents + * @return array + */ + public static function paginate(array $documents, ?int $limit, ?int $offset): array + { + return \array_slice(\array_values($documents), $offset ?? 0, $limit); + } + + /** + * Compare two scalars in a way that handles null consistently (null sorts before any value). + */ + private static function compareScalar(mixed $a, mixed $b): int + { + if ($a === null && $b === null) { + return 0; + } + if ($a === null) { + return -1; + } + if ($b === null) { + return 1; + } + return $a <=> $b; + } + + /** + * Check if an attribute contains any of the given values. Handles both array attributes + * (checks membership) and string attributes (checks substring). + * + * @param array $values + */ + private static function containsValue(mixed $actual, array $values): bool + { + if (\is_array($actual)) { + foreach ($values as $value) { + if (\in_array($value, $actual, false)) { + return true; + } + } + return false; + } + + if (\is_string($actual)) { + foreach ($values as $value) { + if (\str_contains($actual, (string) $value)) { + return true; + } + } + return false; + } + + return false; + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php new file mode 100644 index 0000000000..c2df415abd --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php @@ -0,0 +1,41 @@ + $allowedAttributes Map of attribute key to Database::VAR_* type + */ + public function __construct(array $allowedAttributes) + { + $attributes = []; + foreach ($allowedAttributes as $key => $type) { + $attributes[] = new Document([ + 'key' => $key, + 'type' => $type, + 'array' => false, + ]); + } + + parent::__construct([ + new Limit(), + new Offset(), + new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), + new Order($attributes), + ]); + } +} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php new file mode 100644 index 0000000000..ea2b7c4cad --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php @@ -0,0 +1,24 @@ + Database::VAR_STRING, + 'locale' => Database::VAR_STRING, + 'subject' => Database::VAR_STRING, + 'message' => Database::VAR_STRING, + 'senderName' => Database::VAR_STRING, + 'senderEmail' => Database::VAR_STRING, + 'replyTo' => Database::VAR_STRING, + 'custom' => Database::VAR_BOOLEAN, + ]; + + public function __construct() + { + parent::__construct(self::ALLOWED_ATTRIBUTES); + } +} From 64d182ac6a109d529b80fecfaef0b1d16ea5a7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 17:49:20 +0200 Subject: [PATCH 20/81] Add tests for templates --- tests/e2e/Services/Project/TemplatesBase.php | 575 ++++++++++++++++++ .../Project/TemplatesConsoleClientTest.php | 14 + .../Project/TemplatesCustomServerTest.php | 14 + 3 files changed, 603 insertions(+) create mode 100644 tests/e2e/Services/Project/TemplatesBase.php create mode 100644 tests/e2e/Services/Project/TemplatesConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/TemplatesCustomServerTest.php diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php new file mode 100644 index 0000000000..34fe6ee491 --- /dev/null +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -0,0 +1,575 @@ +getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $template['headers']['status-code']); + $this->assertSame('verification', $template['body']['type']); + $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']['type']); + $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']['type']); + $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']); + } + + // ========================================================================= + // List email templates tests + // ========================================================================= + + public function testListEmailTemplates(): void + { + $list = $this->listEmailTemplates(null, true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertIsArray($list['body']['templates']); + $this->assertGreaterThan(0, $list['body']['total']); + $this->assertGreaterThan(0, \count($list['body']['templates'])); + + foreach ($list['body']['templates'] as $template) { + $this->assertArrayHasKey('type', $template); + $this->assertArrayHasKey('locale', $template); + $this->assertArrayHasKey('custom', $template); + $this->assertArrayHasKey('subject', $template); + $this->assertArrayHasKey('message', $template); + } + } + + public function testListEmailTemplatesWithLimit(): void + { + $list = $this->listEmailTemplates([ + Query::limit(5)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(5, $list['body']['templates']); + $this->assertGreaterThanOrEqual(5, $list['body']['total']); + } + + public function testListEmailTemplatesWithOffset(): void + { + $first = $this->listEmailTemplates([ + Query::limit(2)->toString(), + ], true); + $this->assertSame(200, $first['headers']['status-code']); + + $second = $this->listEmailTemplates([ + Query::limit(2)->toString(), + Query::offset(2)->toString(), + ], true); + $this->assertSame(200, $second['headers']['status-code']); + + $firstIds = \array_map( + fn ($t) => $t['type'] . '-' . $t['locale'], + $first['body']['templates'] + ); + $secondIds = \array_map( + fn ($t) => $t['type'] . '-' . $t['locale'], + $second['body']['templates'] + ); + + $this->assertEmpty(\array_intersect($firstIds, $secondIds)); + } + + public function testListEmailTemplatesWithoutTotal(): void + { + $list = $this->listEmailTemplates(null, false); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(0, $list['body']['total']); + $this->assertGreaterThan(0, \count($list['body']['templates'])); + } + + public function testListEmailTemplatesFilterByType(): void + { + $list = $this->listEmailTemplates([ + Query::equal('type', ['verification'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThan(0, $list['body']['total']); + + foreach ($list['body']['templates'] as $template) { + $this->assertSame('verification', $template['type']); + } + } + + public function testListEmailTemplatesFilterByLocale(): void + { + $list = $this->listEmailTemplates([ + Query::equal('locale', ['en'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThan(0, $list['body']['total']); + + foreach ($list['body']['templates'] as $template) { + $this->assertSame('en', $template['locale']); + } + } + + public function testListEmailTemplatesFilterByCustom(): void + { + $update = $this->updateEmailTemplate('recovery', 'en', 'Recovery Subject', 'Recovery Body'); + $this->assertSame(200, $update['headers']['status-code']); + + $list = $this->listEmailTemplates([ + Query::equal('custom', [true])->toString(), + Query::limit(100)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + $found = false; + foreach ($list['body']['templates'] as $template) { + $this->assertTrue($template['custom']); + if ($template['type'] === 'recovery' && $template['locale'] === 'en') { + $found = true; + } + } + $this->assertTrue($found, 'Customized template should appear in custom=true filter'); + + // Cleanup + $this->deleteEmailTemplate('recovery', 'en'); + } + + public function testListEmailTemplatesCombinedFilters(): void + { + $list = $this->listEmailTemplates([ + Query::equal('type', ['verification'])->toString(), + Query::equal('locale', ['en'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(1, $list['body']['total']); + $this->assertCount(1, $list['body']['templates']); + $this->assertSame('verification', $list['body']['templates'][0]['type']); + $this->assertSame('en', $list['body']['templates'][0]['locale']); + } + + public function testListEmailTemplatesOrderByType(): void + { + $asc = $this->listEmailTemplates([ + Query::orderAsc('type')->toString(), + Query::limit(100)->toString(), + ], true); + $this->assertSame(200, $asc['headers']['status-code']); + + $ascTypes = \array_map(fn ($t) => $t['type'], $asc['body']['templates']); + $sorted = $ascTypes; + \sort($sorted); + $this->assertSame($sorted, $ascTypes); + + $desc = $this->listEmailTemplates([ + Query::orderDesc('type')->toString(), + Query::limit(100)->toString(), + ], true); + $this->assertSame(200, $desc['headers']['status-code']); + + $descTypes = \array_map(fn ($t) => $t['type'], $desc['body']['templates']); + $sorted = $descTypes; + \rsort($sorted); + $this->assertSame($sorted, $descTypes); + } + + public function testListEmailTemplatesInvalidQuery(): void + { + $list = $this->listEmailTemplates([ + Query::equal('notAnAttribute', ['foo'])->toString(), + ], true); + + $this->assertSame(400, $list['headers']['status-code']); + } + + public function testListEmailTemplatesWithoutAuthentication(): void + { + $list = $this->listEmailTemplates(null, true, false); + + $this->assertSame(401, $list['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']['type']); + $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']['replyTo']); + + // 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']['type']); + $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'); + } + + // ========================================================================= + // 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); + } + + /** + * @param array|null $queries + */ + protected function listEmailTemplates(?array $queries, ?bool $total, 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 ($queries !== null) { + $params['queries'] = $queries; + } + if ($total !== null) { + $params['total'] = $total; + } + + return $this->client->call(Client::METHOD_GET, '/project/templates/email', $headers, $params); + } + + protected function updateEmailTemplate( + string $type, + ?string $locale, + ?string $subject, + ?string $message, + ?string $senderName = null, + ?string $senderEmail = null, + ?string $replyTo = null, + bool $authenticated = true, + ): mixed { + $params = [ + 'type' => $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 ($replyTo !== null) { + $params['replyTo'] = $replyTo; + } + + $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 = [ + 'type' => $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); + } +} diff --git a/tests/e2e/Services/Project/TemplatesConsoleClientTest.php b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php new file mode 100644 index 0000000000..d5431074e3 --- /dev/null +++ b/tests/e2e/Services/Project/TemplatesConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Fri, 17 Apr 2026 18:15:49 +0200 Subject: [PATCH 21/81] Fix bug --- .../Project/Http/Project/Templates/Email/Get.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 1855f13d17..a5a4d8ce8f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -2,16 +2,13 @@ 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\Template\Template; 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; @@ -53,9 +50,6 @@ class Get extends Action ->param('type', '', 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.', optional: true, injections: ['localeCodes']) ->inject('response') - ->inject('queueForEvents') - ->inject('dbForPlatform') - ->inject('authorization') ->inject('project') ->inject('locale') ->callback($this->action(...)); @@ -65,9 +59,6 @@ class Get extends Action string $type, string $locale, Response $response, - QueueEvent $queueForEvents, - Database $dbForPlatform, - Authorization $authorization, Document $project, Locale $localeObject, ) { @@ -108,7 +99,7 @@ class Get extends Action 'placeholders' => ['buttonText', 'body', 'footer'] ]; - $templateString = file_get_contents(__DIR__ . '/../../config/locale/templates/' . $config['file']); + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/../../config/locale/templates/' . $config['file']); // We use `fromString` due to the replace above $message = Template::fromString($templateString); From be5eeb1abac7902053282dcb407325a15a99351e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 19:50:34 +0200 Subject: [PATCH 22/81] Fix failing tests --- .../Modules/Project/Http/Project/Templates/Email/Get.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index a5a4d8ce8f..9725215613 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -99,7 +99,7 @@ class Get extends Action 'placeholders' => ['buttonText', 'body', 'footer'] ]; - $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/../../config/locale/templates/' . $config['file']); + $templateString = file_get_contents(APP_CE_CONFIG_DIR . '/locale/templates/' . $config['file']); // We use `fromString` due to the replace above $message = Template::fromString($templateString); From fcc7a56f4af2354ebaebfa3758a508983e756d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 18 Apr 2026 11:01:26 +0200 Subject: [PATCH 23/81] Fix list endpoint --- .../Http/Project/Templates/Email/XList.php | 81 ++++++++++++++++--- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 9fdd576af1..0128a18e25 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -6,6 +6,7 @@ use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Template\Template; use Appwrite\Utopia\Database\InMemoryQuery; use Appwrite\Utopia\Database\Validator\Queries\ProjectTemplates; use Appwrite\Utopia\Response; @@ -13,8 +14,10 @@ use Utopia\Config\Config; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; +use Utopia\Locale\Locale; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; +use Utopia\System\System; use Utopia\Validator\Boolean; class XList extends Action @@ -85,16 +88,74 @@ class XList extends Action $key = 'email.' . $type . '-' . $locale; $stored = $projectTemplates[$key] ?? null; - $templates[] = new Document([ - 'type' => $type, - 'locale' => $locale, - 'message' => $stored['message'] ?? '', - 'subject' => $stored['subject'] ?? '', - 'senderName' => $stored['senderName'] ?? '', - 'senderEmail' => $stored['senderEmail'] ?? '', - 'replyTo' => $stored['replyTo'] ?? '', - 'custom' => !\is_null($stored), - ]); + $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); } } From 0a71efc24494ec15189a426472cd717e79615f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 18 Apr 2026 11:23:53 +0200 Subject: [PATCH 24/81] improve test coverage --- tests/e2e/Services/Project/TemplatesBase.php | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 34fe6ee491..27601eddbe 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -207,6 +207,32 @@ trait TemplatesBase $this->assertSame('en', $list['body']['templates'][0]['locale']); } + public function testListEmailTemplatesDefaultMatchesGet(): void + { + $get = $this->getEmailTemplate('verification', 'en'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertFalse($get['body']['custom']); + + $list = $this->listEmailTemplates([ + Query::equal('type', ['verification'])->toString(), + Query::equal('locale', ['en'])->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(1, $list['body']['templates']); + + $listed = $list['body']['templates'][0]; + + $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']); + } + public function testListEmailTemplatesOrderByType(): void { $asc = $this->listEmailTemplates([ From 8a1f8c71b24439433537aaa329a3a6b14b0f15cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:15:48 +0200 Subject: [PATCH 25/81] Temporary removal of listEmailTemplates Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Http/Project/Templates/Email/XList.php | 173 ------------- .../Modules/Project/Services/Http.php | 2 - .../Utopia/Database/InMemoryQuery.php | 159 ------------ .../Validator/Queries/BaseInMemory.php | 41 ---- .../Validator/Queries/ProjectTemplates.php | 24 -- tests/e2e/Services/Project/TemplatesBase.php | 228 ------------------ 6 files changed, 627 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php delete mode 100644 src/Appwrite/Utopia/Database/InMemoryQuery.php delete mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php delete mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php deleted file mode 100644 index 0128a18e25..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ /dev/null @@ -1,173 +0,0 @@ -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: <<param('queries', [], new ProjectTemplates(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter and order on the following attributes: ' . implode(', ', array_keys(ProjectTemplates::ALLOWED_ATTRIBUTES)), true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->inject('project') - ->inject('response') - ->inject('localeCodes') - ->callback($this->action(...)); - } - - /** - * @param array $queries - * @param array $localeCodes - */ - public function action( - array $queries, - bool $includeTotal, - Document $project, - Response $response, - array $localeCodes, - ) { - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - $grouped = Query::groupByType($queries); - - $types = Config::getParam('locale-templates')['email'] ?? []; - $projectTemplates = $project->getAttribute('templates', []); - - $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); - } - } - - $templates = InMemoryQuery::filter($templates, $grouped['filters']); - $templates = InMemoryQuery::order($templates, $grouped['orderAttributes'], $grouped['orderTypes']); - - $total = $includeTotal ? \count($templates) : 0; - $templates = InMemoryQuery::paginate($templates, $grouped['limit'] ?? APP_LIMIT_COUNT, $grouped['offset']); - - $response->dynamic(new Document([ - 'templates' => $templates, - 'total' => $total, - ]), Response::MODEL_EMAIL_TEMPLATE_LIST); - } -} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index b91541928c..f6c3a2efc2 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -27,7 +27,6 @@ use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProj 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\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; @@ -50,7 +49,6 @@ class Http extends Service $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); // Templates - $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); diff --git a/src/Appwrite/Utopia/Database/InMemoryQuery.php b/src/Appwrite/Utopia/Database/InMemoryQuery.php deleted file mode 100644 index c9f930369a..0000000000 --- a/src/Appwrite/Utopia/Database/InMemoryQuery.php +++ /dev/null @@ -1,159 +0,0 @@ - $documents - * @param array $filters - * @return array - */ - public static function filter(array $documents, array $filters): array - { - if (empty($filters)) { - return \array_values($documents); - } - - return \array_values(\array_filter($documents, function (Document $document) use ($filters) { - foreach ($filters as $filter) { - if (!self::matches($document, $filter)) { - return false; - } - } - return true; - })); - } - - /** - * Evaluate a single filter query against a document. - */ - public static function matches(Document $document, Query $filter): bool - { - $attribute = $filter->getAttribute(); - $values = $filter->getValues(); - $actual = $document->getAttribute($attribute); - $needle = (string) ($values[0] ?? ''); - - return match ($filter->getMethod()) { - Query::TYPE_EQUAL => \in_array($actual, $values, false), - Query::TYPE_NOT_EQUAL => !\in_array($actual, $values, false), - Query::TYPE_LESSER => self::compareScalar($actual, $values[0] ?? null) < 0, - Query::TYPE_LESSER_EQUAL => self::compareScalar($actual, $values[0] ?? null) <= 0, - Query::TYPE_GREATER => self::compareScalar($actual, $values[0] ?? null) > 0, - Query::TYPE_GREATER_EQUAL => self::compareScalar($actual, $values[0] ?? null) >= 0, - Query::TYPE_BETWEEN => self::compareScalar($actual, $values[0] ?? null) >= 0 && self::compareScalar($actual, $values[1] ?? null) <= 0, - Query::TYPE_NOT_BETWEEN => self::compareScalar($actual, $values[0] ?? null) < 0 || self::compareScalar($actual, $values[1] ?? null) > 0, - Query::TYPE_STARTS_WITH => \is_string($actual) && \str_starts_with($actual, $needle), - Query::TYPE_NOT_STARTS_WITH => \is_string($actual) && !\str_starts_with($actual, $needle), - Query::TYPE_ENDS_WITH => \is_string($actual) && \str_ends_with($actual, $needle), - Query::TYPE_NOT_ENDS_WITH => \is_string($actual) && !\str_ends_with($actual, $needle), - Query::TYPE_CONTAINS => self::containsValue($actual, $values), - Query::TYPE_NOT_CONTAINS => !self::containsValue($actual, $values), - Query::TYPE_SEARCH => \is_string($actual) && $needle !== '' && \stripos($actual, $needle) !== false, - Query::TYPE_NOT_SEARCH => \is_string($actual) && ($needle === '' || \stripos($actual, $needle) === false), - Query::TYPE_IS_NULL => $actual === null, - Query::TYPE_IS_NOT_NULL => $actual !== null, - default => throw new \InvalidArgumentException('Unsupported query method: ' . $filter->getMethod()), - }; - } - - /** - * Sort documents by one or more attributes. - * - * @param array $documents - * @param array $orderAttributes - * @param array $orderTypes - * @return array - */ - public static function order(array $documents, array $orderAttributes, array $orderTypes): array - { - if (empty($orderAttributes)) { - return \array_values($documents); - } - - $documents = \array_values($documents); - - \usort($documents, function (Document $a, Document $b) use ($orderAttributes, $orderTypes) { - foreach ($orderAttributes as $index => $attribute) { - $direction = \strtoupper($orderTypes[$index] ?? Database::ORDER_ASC); - $cmp = self::compareScalar($a->getAttribute($attribute), $b->getAttribute($attribute)); - if ($cmp !== 0) { - return $direction === Database::ORDER_DESC ? -$cmp : $cmp; - } - } - return 0; - }); - - return $documents; - } - - /** - * Apply limit and offset. - * - * @param array $documents - * @return array - */ - public static function paginate(array $documents, ?int $limit, ?int $offset): array - { - return \array_slice(\array_values($documents), $offset ?? 0, $limit); - } - - /** - * Compare two scalars in a way that handles null consistently (null sorts before any value). - */ - private static function compareScalar(mixed $a, mixed $b): int - { - if ($a === null && $b === null) { - return 0; - } - if ($a === null) { - return -1; - } - if ($b === null) { - return 1; - } - return $a <=> $b; - } - - /** - * Check if an attribute contains any of the given values. Handles both array attributes - * (checks membership) and string attributes (checks substring). - * - * @param array $values - */ - private static function containsValue(mixed $actual, array $values): bool - { - if (\is_array($actual)) { - foreach ($values as $value) { - if (\in_array($value, $actual, false)) { - return true; - } - } - return false; - } - - if (\is_string($actual)) { - foreach ($values as $value) { - if (\str_contains($actual, (string) $value)) { - return true; - } - } - return false; - } - - return false; - } -} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php b/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php deleted file mode 100644 index c2df415abd..0000000000 --- a/src/Appwrite/Utopia/Database/Validator/Queries/BaseInMemory.php +++ /dev/null @@ -1,41 +0,0 @@ - $allowedAttributes Map of attribute key to Database::VAR_* type - */ - public function __construct(array $allowedAttributes) - { - $attributes = []; - foreach ($allowedAttributes as $key => $type) { - $attributes[] = new Document([ - 'key' => $key, - 'type' => $type, - 'array' => false, - ]); - } - - parent::__construct([ - new Limit(), - new Offset(), - new Filter($attributes, Database::VAR_STRING, APP_DATABASE_QUERY_MAX_VALUES), - new Order($attributes), - ]); - } -} diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php b/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php deleted file mode 100644 index ea2b7c4cad..0000000000 --- a/src/Appwrite/Utopia/Database/Validator/Queries/ProjectTemplates.php +++ /dev/null @@ -1,24 +0,0 @@ - Database::VAR_STRING, - 'locale' => Database::VAR_STRING, - 'subject' => Database::VAR_STRING, - 'message' => Database::VAR_STRING, - 'senderName' => Database::VAR_STRING, - 'senderEmail' => Database::VAR_STRING, - 'replyTo' => Database::VAR_STRING, - 'custom' => Database::VAR_BOOLEAN, - ]; - - public function __construct() - { - parent::__construct(self::ALLOWED_ATTRIBUTES); - } -} diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 27601eddbe..45e09779b9 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -3,7 +3,6 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; -use Utopia\Database\Query; trait TemplatesBase { @@ -72,208 +71,6 @@ trait TemplatesBase $this->assertSame(401, $template['headers']['status-code']); } - // ========================================================================= - // List email templates tests - // ========================================================================= - - public function testListEmailTemplates(): void - { - $list = $this->listEmailTemplates(null, true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertIsArray($list['body']['templates']); - $this->assertGreaterThan(0, $list['body']['total']); - $this->assertGreaterThan(0, \count($list['body']['templates'])); - - foreach ($list['body']['templates'] as $template) { - $this->assertArrayHasKey('type', $template); - $this->assertArrayHasKey('locale', $template); - $this->assertArrayHasKey('custom', $template); - $this->assertArrayHasKey('subject', $template); - $this->assertArrayHasKey('message', $template); - } - } - - public function testListEmailTemplatesWithLimit(): void - { - $list = $this->listEmailTemplates([ - Query::limit(5)->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertCount(5, $list['body']['templates']); - $this->assertGreaterThanOrEqual(5, $list['body']['total']); - } - - public function testListEmailTemplatesWithOffset(): void - { - $first = $this->listEmailTemplates([ - Query::limit(2)->toString(), - ], true); - $this->assertSame(200, $first['headers']['status-code']); - - $second = $this->listEmailTemplates([ - Query::limit(2)->toString(), - Query::offset(2)->toString(), - ], true); - $this->assertSame(200, $second['headers']['status-code']); - - $firstIds = \array_map( - fn ($t) => $t['type'] . '-' . $t['locale'], - $first['body']['templates'] - ); - $secondIds = \array_map( - fn ($t) => $t['type'] . '-' . $t['locale'], - $second['body']['templates'] - ); - - $this->assertEmpty(\array_intersect($firstIds, $secondIds)); - } - - public function testListEmailTemplatesWithoutTotal(): void - { - $list = $this->listEmailTemplates(null, false); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertSame(0, $list['body']['total']); - $this->assertGreaterThan(0, \count($list['body']['templates'])); - } - - public function testListEmailTemplatesFilterByType(): void - { - $list = $this->listEmailTemplates([ - Query::equal('type', ['verification'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertGreaterThan(0, $list['body']['total']); - - foreach ($list['body']['templates'] as $template) { - $this->assertSame('verification', $template['type']); - } - } - - public function testListEmailTemplatesFilterByLocale(): void - { - $list = $this->listEmailTemplates([ - Query::equal('locale', ['en'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertGreaterThan(0, $list['body']['total']); - - foreach ($list['body']['templates'] as $template) { - $this->assertSame('en', $template['locale']); - } - } - - public function testListEmailTemplatesFilterByCustom(): void - { - $update = $this->updateEmailTemplate('recovery', 'en', 'Recovery Subject', 'Recovery Body'); - $this->assertSame(200, $update['headers']['status-code']); - - $list = $this->listEmailTemplates([ - Query::equal('custom', [true])->toString(), - Query::limit(100)->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertGreaterThanOrEqual(1, $list['body']['total']); - - $found = false; - foreach ($list['body']['templates'] as $template) { - $this->assertTrue($template['custom']); - if ($template['type'] === 'recovery' && $template['locale'] === 'en') { - $found = true; - } - } - $this->assertTrue($found, 'Customized template should appear in custom=true filter'); - - // Cleanup - $this->deleteEmailTemplate('recovery', 'en'); - } - - public function testListEmailTemplatesCombinedFilters(): void - { - $list = $this->listEmailTemplates([ - Query::equal('type', ['verification'])->toString(), - Query::equal('locale', ['en'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertSame(1, $list['body']['total']); - $this->assertCount(1, $list['body']['templates']); - $this->assertSame('verification', $list['body']['templates'][0]['type']); - $this->assertSame('en', $list['body']['templates'][0]['locale']); - } - - public function testListEmailTemplatesDefaultMatchesGet(): void - { - $get = $this->getEmailTemplate('verification', 'en'); - $this->assertSame(200, $get['headers']['status-code']); - $this->assertFalse($get['body']['custom']); - - $list = $this->listEmailTemplates([ - Query::equal('type', ['verification'])->toString(), - Query::equal('locale', ['en'])->toString(), - ], true); - - $this->assertSame(200, $list['headers']['status-code']); - $this->assertCount(1, $list['body']['templates']); - - $listed = $list['body']['templates'][0]; - - $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']); - } - - public function testListEmailTemplatesOrderByType(): void - { - $asc = $this->listEmailTemplates([ - Query::orderAsc('type')->toString(), - Query::limit(100)->toString(), - ], true); - $this->assertSame(200, $asc['headers']['status-code']); - - $ascTypes = \array_map(fn ($t) => $t['type'], $asc['body']['templates']); - $sorted = $ascTypes; - \sort($sorted); - $this->assertSame($sorted, $ascTypes); - - $desc = $this->listEmailTemplates([ - Query::orderDesc('type')->toString(), - Query::limit(100)->toString(), - ], true); - $this->assertSame(200, $desc['headers']['status-code']); - - $descTypes = \array_map(fn ($t) => $t['type'], $desc['body']['templates']); - $sorted = $descTypes; - \rsort($sorted); - $this->assertSame($sorted, $descTypes); - } - - public function testListEmailTemplatesInvalidQuery(): void - { - $list = $this->listEmailTemplates([ - Query::equal('notAnAttribute', ['foo'])->toString(), - ], true); - - $this->assertSame(400, $list['headers']['status-code']); - } - - public function testListEmailTemplatesWithoutAuthentication(): void - { - $list = $this->listEmailTemplates(null, true, false); - - $this->assertSame(401, $list['headers']['status-code']); - } - // ========================================================================= // Update email template tests // ========================================================================= @@ -507,31 +304,6 @@ trait TemplatesBase return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $type, $headers, $params); } - /** - * @param array|null $queries - */ - protected function listEmailTemplates(?array $queries, ?bool $total, 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 ($queries !== null) { - $params['queries'] = $queries; - } - if ($total !== null) { - $params['total'] = $total; - } - - return $this->client->call(Client::METHOD_GET, '/project/templates/email', $headers, $params); - } - protected function updateEmailTemplate( string $type, ?string $locale, From 2a95cfd5a3a6d973970e03ae03216eb579b313a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:35:57 +0200 Subject: [PATCH 26/81] Final template API rework --- .github/workflows/ci.yml | 3 +- CHANGES.md | 8 ++ README-CN.md | 6 +- README.md | 6 +- app/controllers/general.php | 8 ++ app/init/constants.php | 4 +- src/Appwrite/Migration/Migration.php | 1 + .../Http/Project/Templates/Email/Delete.php | 18 +-- .../Http/Project/Templates/Email/Get.php | 28 ++--- .../Http/Project/Templates/Email/Update.php | 18 +-- src/Appwrite/Utopia/Request/Filters/V23.php | 31 +++++ src/Appwrite/Utopia/Response/Filters/V23.php | 28 +++++ .../Utopia/Response/Model/Template.php | 32 ----- .../Utopia/Response/Model/TemplateEmail.php | 22 +++- tests/e2e/Services/Project/TemplatesBase.php | 113 ++++++++++++++++-- 15 files changed, 243 insertions(+), 83 deletions(-) create mode 100644 src/Appwrite/Utopia/Request/Filters/V23.php create mode 100644 src/Appwrite/Utopia/Response/Filters/V23.php delete mode 100644 src/Appwrite/Utopia/Response/Model/Template.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8256ddc7a..48c8ea901b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -701,9 +701,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 diff --git a/CHANGES.md b/CHANGES.md index 548c0d72b0..d717de4668 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,11 @@ +# Version 1.9.2 + +### Notable changes + +### Fixes + +### Miscellaneous + # Version 1.9.0 ## What's Changed diff --git a/README-CN.md b/README-CN.md index 2c7402f1ef..7f758bc247 100644 --- a/README-CN.md +++ b/README-CN.md @@ -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.2 ``` ### 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.2 ``` #### 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.2 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 31076ffa31..b0926bbeeb 100644 --- a/README.md +++ b/README.md @@ -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.2 ``` ### 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.2 ``` #### 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.2 ``` 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. diff --git a/app/controllers/general.php b/app/controllers/general.php index b4f4a5c1d1..596fbd0926 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -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()); } diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..54a94652fb 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -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'; diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index a01031de9b..ef0dd9f8b5 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -94,6 +94,7 @@ abstract class Migration '1.8.1' => 'V23', '1.9.0' => 'V24', '1.9.1' => 'V24', + '1.9.2' => 'V24', ]; /** diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php index 9133971c40..cf02704d47 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -33,13 +33,13 @@ class Delete extends Action $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) ->setHttpPath('/v1/project/templates/email') ->httpAlias('/v1/projects/:projectId/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->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/{response.type}') + ->label('audits.resource', 'project.template/{response.templateId}') ->label('sdk', new Method( namespace: 'project', group: 'templates', @@ -56,8 +56,8 @@ class Delete extends Action ], contentType: ContentType::NONE )) - ->param('type', '', 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.', optional: true, injections: ['localeCodes']) + ->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') @@ -68,7 +68,7 @@ class Delete extends Action } public function action( - string $type, + string $templateId, string $locale, Response $response, QueueEvent $queueForEvents, @@ -77,16 +77,16 @@ class Delete extends Action Document $project, Locale $localeObject, ) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; + $template = $templates['email.' . $templateId . '-' . $locale] ?? null; if (is_null($template)) { throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); } - unset($templates['email.' . $type . '-' . $locale]); + unset($templates['email.' . $templateId . '-' . $locale]); $updates = new Document([ 'templates' => $templates, @@ -94,7 +94,7 @@ class Delete extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $queueForEvents->setParam('templateType', $type); + $queueForEvents->setParam('templateType', $templateId); $response->noContent(); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 9725215613..115b10f7dd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -27,8 +27,8 @@ class Get extends Action public function __construct() { $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) - ->setHttpPath('/v1/project/templates/email/:type') - ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->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') @@ -47,8 +47,8 @@ class Get extends Action ) ] )) - ->param('type', '', 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.', optional: true, injections: ['localeCodes']) + ->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') @@ -56,16 +56,16 @@ class Get extends Action } public function action( - string $type, + string $templateId, string $locale, Response $response, Document $project, Locale $localeObject, ) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; + $template = $templates['email.' . $templateId . '-' . $locale] ?? null; $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); @@ -94,7 +94,7 @@ class Get extends Action ]; // fallback to the base template. - $config = $templateConfigs[$type] ?? [ + $config = $templateConfigs[$templateId] ?? [ 'file' => 'email-inner-base.tpl', 'placeholders' => ['buttonText', 'body', 'footer'] ]; @@ -107,21 +107,21 @@ class Get extends Action // 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->setParam("{{{$param}}}", $localeObj->getText("emails.{$templateId}.{$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")); + ->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.' . $type . '.subject'), + 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), 'senderEmail' => '', 'senderName' => '', 'custom' => false, @@ -130,7 +130,7 @@ class Get extends Action $template['custom'] = true; } - $template['type'] = $type; + $template['templateId'] = $templateId; $template['locale'] = $locale; $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index ff739f9fe8..f17be381f6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -33,13 +33,13 @@ class Update extends Action $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) ->setHttpPath('/v1/project/templates/email') ->httpAlias('/v1/projects/:projectId/templates/email') - ->httpAlias('/v1/projects/:projectId/templates/email/:type/:locale') + ->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.type}') + ->label('audits.resource', 'project.template/{response.templateId}') ->label('sdk', new Method( namespace: 'project', group: 'templates', @@ -55,8 +55,8 @@ class Update extends Action ) ] )) - ->param('type', '', 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.', optional: true, injections: ['localeCodes']) + ->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) @@ -72,7 +72,7 @@ class Update extends Action } public function action( - string $type, + string $templateId, string $locale, string $subject, string $message, @@ -86,7 +86,7 @@ class Update extends Action Document $project, Locale $localeObject, ) { - $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); $template = [ 'senderName' => $senderName, @@ -97,7 +97,7 @@ class Update extends Action ]; $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = $template; + $templates['email.' . $templateId . '-' . $locale] = $template; $updates = new Document([ 'templates' => $templates, @@ -105,10 +105,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $queueForEvents->setParam('templateType', $type); + $queueForEvents->setParam('templateType', $templateId); $response->dynamic(new Document([ - 'type' => $type, + 'templateId' => $templateId, 'locale' => $locale, 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php new file mode 100644 index 0000000000..adb5d69aea --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -0,0 +1,31 @@ +parseEmailTemplate($content); + break; + } + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php new file mode 100644 index 0000000000..54fcf8459f --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -0,0 +1,28 @@ + $this->parseEmailTemplate($content), + default => $content, + }; + } + + private function parseEmailTemplate(array $content): array + { + if (isset($content['templateId'])) { + $content['type'] = $content['templateId']; + unset($content['templateId']); + } + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php deleted file mode 100644 index 3ce9cacdb3..0000000000 --- a/src/Appwrite/Utopia/Response/Model/Template.php +++ /dev/null @@ -1,32 +0,0 @@ -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.', - ]) - ; - } -} diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index 95cd57a584..9b77617b8c 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -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', diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 45e09779b9..b61f85aeef 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -15,7 +15,7 @@ trait TemplatesBase $template = $this->getEmailTemplate('verification', 'en'); $this->assertSame(200, $template['headers']['status-code']); - $this->assertSame('verification', $template['body']['type']); + $this->assertSame('verification', $template['body']['templateId']); $this->assertSame('en', $template['body']['locale']); $this->assertFalse($template['body']['custom']); $this->assertNotEmpty($template['body']['subject']); @@ -27,7 +27,7 @@ trait TemplatesBase $template = $this->getEmailTemplate('verification'); $this->assertSame(200, $template['headers']['status-code']); - $this->assertSame('verification', $template['body']['type']); + $this->assertSame('verification', $template['body']['templateId']); $this->assertSame('en', $template['body']['locale']); $this->assertFalse($template['body']['custom']); } @@ -40,7 +40,7 @@ trait TemplatesBase $get = $this->getEmailTemplate('magicSession', 'en'); $this->assertSame(200, $get['headers']['status-code']); - $this->assertSame('magicSession', $get['body']['type']); + $this->assertSame('magicSession', $get['body']['templateId']); $this->assertSame('en', $get['body']['locale']); $this->assertTrue($get['body']['custom']); $this->assertSame('Magic Subject', $get['body']['subject']); @@ -85,7 +85,7 @@ trait TemplatesBase ); $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('verification', $update['body']['type']); + $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']); @@ -135,7 +135,7 @@ trait TemplatesBase ); $this->assertSame(200, $update['headers']['status-code']); - $this->assertSame('sessionAlert', $update['body']['type']); + $this->assertSame('sessionAlert', $update['body']['templateId']); $this->assertSame('en', $update['body']['locale']); // Cleanup @@ -281,6 +281,105 @@ trait TemplatesBase $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 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 // ========================================================================= @@ -315,7 +414,7 @@ trait TemplatesBase bool $authenticated = true, ): mixed { $params = [ - 'type' => $type, + 'templateId' => $type, ]; if ($locale !== null) { @@ -352,7 +451,7 @@ trait TemplatesBase protected function deleteEmailTemplate(string $type, ?string $locale = null, bool $authenticated = true): mixed { $params = [ - 'type' => $type, + 'templateId' => $type, ]; if ($locale !== null) { From afab349a7792b1d970a5fcdbb8cc57a2f9101be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:43:57 +0200 Subject: [PATCH 27/81] self review fixes --- CHANGES.md | 8 --- README-CN.md | 6 +-- README.md | 6 +-- tests/e2e/Services/Project/TemplatesBase.php | 54 ++++++++++++++++++++ 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d717de4668..548c0d72b0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,11 +1,3 @@ -# Version 1.9.2 - -### Notable changes - -### Fixes - -### Miscellaneous - # Version 1.9.0 ## What's Changed diff --git a/README-CN.md b/README-CN.md index 7f758bc247..212b5bb08d 100644 --- a/README-CN.md +++ b/README-CN.md @@ -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.2 + 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.2 + 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.2 + appwrite/appwrite:1.9.0 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index b0926bbeeb..88d527f060 100644 --- a/README.md +++ b/README.md @@ -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.2 + 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.2 + 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.2 + 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. diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index b61f85aeef..4f78992079 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -357,6 +357,60 @@ trait TemplatesBase $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([ From 447375dcbfec003feac0bea27ae7ba4e66cf01a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 10:53:11 +0200 Subject: [PATCH 28/81] Fix tests --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 59ff5e353c..4400c337ac 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -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', From 69d53cb2d4553ad074f2feb7c04bfb38a351cc18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 11:03:36 +0200 Subject: [PATCH 29/81] Remove unused email template list response model Co-Authored-By: Claude Opus 4.7 (1M context) --- app/init/models.php | 1 - src/Appwrite/Utopia/Response.php | 1 - 2 files changed, 2 deletions(-) diff --git a/app/init/models.php b/app/init/models.php index 924df52bdd..f654c10121 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -210,7 +210,6 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE)); 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('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)); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 4aecc62fd8..d747373b59 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -266,7 +266,6 @@ 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'; From 6b66923f1861d2f93e3f2802f07f653d44f593d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 19 Apr 2026 19:36:24 +0200 Subject: [PATCH 30/81] Fix delete response placeholder audit label --- app/controllers/api/account.php | 4 ++-- app/controllers/api/users.php | 4 ++-- .../Account/Http/Account/MFA/Authenticators/Delete.php | 4 ++-- .../Modules/Project/Http/Project/Platforms/Delete.php | 2 +- .../Modules/Project/Http/Project/Templates/Email/Delete.php | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index ffe2b54c5b..b26f6bc5c6 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -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', @@ -4618,7 +4618,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', diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index a8875fc442..c922b2a1f6 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -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( diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php index 754255be15..5765c5bf6e 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Authenticators/Delete.php @@ -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', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php index 4b58766751..24669b02b2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php @@ -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', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php index cf02704d47..176e8d7d63 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -39,7 +39,7 @@ class Delete extends Action ->label('scope', 'templates.write') ->label('event', 'templates.[templateType].delete') ->label('audits.event', 'project.template.delete') - ->label('audits.resource', 'project.template/{response.templateId}') + ->label('audits.resource', 'project.template/{request.templateId}') ->label('sdk', new Method( namespace: 'project', group: 'templates', From c4d9c3dc4f984b1a2df06239bd4a17116a35caf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:28:21 +0200 Subject: [PATCH 31/81] Improve endpoint quality --- .../Http/Project/SMTP/Credentials/Update.php | 144 ---------------- .../Http/Project/SMTP/Status/Update.php | 74 --------- .../Project/Http/Project/SMTP/Update.php | 157 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 - .../Utopia/Response/Model/Project.php | 14 +- 5 files changed, 169 insertions(+), 222 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php deleted file mode 100644 index 9117554622..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Credentials/Update.php +++ /dev/null @@ -1,144 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/smtp/credentials') - ->httpAlias('/v1/projects/:projectId/smtp') - ->desc('Update project SMTP details') - ->groups(['api', 'project']) - ->label('scope', 'project.write') - ->label('event', 'smtp.*.update') - ->label('audits.event', 'project.smtp.update') - ->label('audits.resource', 'project.smtp/{response.$id}') - ->label('sdk', new Method( - namespace: 'project', - group: 'smtp', - name: 'updateSMTPCredentials', - description: <<param('senderName', '', new Text(256), 'Name of the email sender') - ->param('senderEmail', '', new Email(), 'Email of the sender') - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new Hostname(), 'SMTP server host name') - ->param('port', 587, new Integer(), 'SMTP server port') - ->param('username', '', new Text(256), 'SMTP server username', true) - ->param('password', '', new Text(256), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->param('enabled', null, new Nullable(new Boolean()), 'Enable custom SMTP service', optional: true, deprecated: true) // Backwards compatibility - ->inject('response') - ->inject('dbForPlatform') - ->inject('project') - ->inject('authorization') - ->callback($this->action(...)); - } - - - public function action( - string $senderName, - string $senderEmail, - string $replyTo, - string $host, - int $port, - string $username, - string $password, - string $secure, - ?bool $enabled, // Backwards compatibility - Response $response, - Database $dbForPlatform, - Document $project, - Authorization $authorization - ): void { - // Backwards compatibility - if (!\is_null($enabled) && $enabled === false) { - $smtp = $project->getAttribute('smtp', []); - - $smtp['enabled'] = $enabled; - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); - - $response->dynamic($project, Response::MODEL_PROJECT); - - return; - } - - // Validate SMTP settings - $mail = new PHPMailer(true); - $mail->isSMTP(); - $mail->SMTPAuth = (!empty($username) && !empty($password)); - $mail->Username = $username; - $mail->Password = $password; - $mail->Host = $host; - $mail->Port = $port; - $mail->SMTPSecure = $secure; - $mail->SMTPAutoTLS = false; - $mail->Timeout = 5; - - try { - $valid = $mail->SmtpConnect(); - - if (!$valid) { - throw new \Exception('Connection is not valid.'); - } - } catch (Throwable $error) { - throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); - } - - $smtp = [ - 'enabled' => true, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'replyTo' => $replyTo, - 'host' => $host, - 'port' => $port, - 'username' => $username, - 'password' => $password, - 'secure' => $secure, - ]; - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); - - $response->dynamic($project, Response::MODEL_PROJECT); - } -} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php deleted file mode 100644 index 0669f6344f..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Status/Update.php +++ /dev/null @@ -1,74 +0,0 @@ -setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/smtp/status') - ->desc('Update project SMTP status') - ->groups(['api', 'project']) - ->label('scope', 'project.write') - ->label('event', 'smtp.*.update') - ->label('audits.event', 'project.smtp.update') - ->label('audits.resource', 'project.smtp/{response.$id}') - ->label('sdk', new Method( - namespace: 'project', - group: 'smtp', - name: 'updateSMTPStatus', - description: <<param('enabled', null, new Boolean(), 'SMTP status.') - ->inject('response') - ->inject('dbForPlatform') - ->inject('project') - ->inject('authorization') - ->callback($this->action(...)); - } - - public function action( - bool $enabled, - Response $response, - Database $dbForPlatform, - Document $project, - Authorization $authorization - ): void { - $smtp = $project->getAttribute('smtp', []); - - $smtp['enabled'] = $enabled; - - $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp))); - - $response->dynamic($project, Response::MODEL_PROJECT); - } -} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php new file mode 100644 index 0000000000..3c25750959 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -0,0 +1,157 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/smtp') + ->httpAlias('/v1/projects/:projectId/smtp') + ->desc('Update project SMTP configuration') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'smtp.*.update') + ->label('audits.event', 'project.smtp.update') + ->label('audits.resource', 'project.smtp/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'smtp', + name: 'updateSMTP', + description: <<param('host', '', new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true) + ->param('port', 587, new Nullable(new Integer()), 'SMTP server port', optional: true) + ->param('username', '', new Nullable(new Text(256)), 'SMTP server username. Leave empty for no authorization.', optional: true) + ->param('password', '', new Nullable(new Text(256)), 'SMTP server password. Leave empty for no authorization. This property is stored securely and cannot be read in future (write-only).', optional: true) + ->param('senderEmail', '', new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true) + ->param('senderName', '', new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true) + ->param('replyToEmail', '', new Nullable(new Email()), 'Email used when user replies to the email.', optional: true) + ->param('replyToName', '', new Nullable(new Text(256)), 'Name used when user replies to the email.', optional: true) + ->param('secure', '', new Nullable(new WhiteList(['tls', 'ssl'], true)), 'Configures if communication with SMTP server is encrypted. Allowed values are: tls, ssl. Leave empty for no encryption.', optional: true) + ->param('enabled', null, new Nullable(new Boolean()), 'Enable or disable custom SMTP. Custom SMTP is useful for branding purposes, but also allows use of custom email templates.', optional: true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + + public function action( + ?string $host, + ?int $port, + ?string $username, + ?string $password, + ?string $senderEmail, + ?string $senderName, + ?string $replyToEmail, + ?string $replyToName, + ?string $secure, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + // Fetch current configuration + $smtp = $project->getAttribute('smtp', []); + + // Apply changes + $keys = ['host', 'port', 'username', 'password', 'senderEmail', 'senderName', 'replyToEmail', 'replyToName', 'secure', 'enabled']; + foreach ($keys as $key) { + if (!\is_null(${$key})) { + $smtp[$key] = ${$key}; + } + } + + // Ensure required fields are set + $requiredKeys = ['host', 'port', 'senderEmail']; + foreach ($requiredKeys as $key) { + if (empty($smtp[$key])) { + throw new \Exception('"' . $key . '" is required. Please provide a value.'); + } + } + + // Validate SMTP credentials + if($smtp['enabled'] === true) { + $mail = new PHPMailer(true); + $mail->isSMTP(); + + $mail->Host = $smtp['host'] ?? ''; + $mail->Port = $smtp['port'] ?? ''; + $mail->SMTPSecure = $smtp['secure'] ?? ''; + $mail->setFrom($smtp['senderEmail'], $smtp['senderName'] ?? ''); + + if(!empty($smtp['username'] ?? '')) { + $mail->SMTPAuth = true; + $mail->Username = $smtp['username']; + $mail->Password = $smtp['password'] ?? ''; + } + + if(!empty($smtp['replyToEmail'] ?? '')) { + $mail->addReplyTo($smtp['replyToEmail'], $smtp['replyToName'] ?? ''); + } + + $mail->SMTPAutoTLS = false; + $mail->Timeout = 5; + + try { + $valid = $mail->SmtpConnect(); + + if (!$valid) { + throw new \Exception('Connection is not valid.'); + } + } catch (Throwable $error) { + throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); + } + } + + // Save configuration + $updates = new Document([ + 'smtp' => $smtp, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 4fea33220b..ac048c6022 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -25,7 +25,6 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatfo use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Credentials\Update as UpdateSMTPCredentials; -use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Status\Update as UpdateSMTPStatus; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; @@ -50,7 +49,6 @@ class Http extends Service // SMTP $this->addAction(UpdateSMTPCredentials::getName(), new UpdateSMTPCredentials()); - $this->addAction(UpdateSMTPStatus::getName(), new UpdateSMTPStatus()); $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); // Variables diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 4cb038fc37..e1909c4785 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -247,7 +247,13 @@ class Project extends Model 'default' => '', 'example' => 'john@appwrite.io', ]) - ->addRule('smtpReplyTo', [ + ->addRule('smtpReplyToName', [ + 'type' => self::TYPE_STRING, + 'description' => 'SMTP reply to name', + 'default' => '', + 'example' => 'Support Team', + ]) + ->addRule('smtpReplyToEmail', [ 'type' => self::TYPE_STRING, 'description' => 'SMTP reply to email', 'default' => '', @@ -271,12 +277,15 @@ class Project extends Model 'default' => '', 'example' => 'emailuser', ]) + /* + We intentionally do not expose SMTP password - it's write-only property. ->addRule('smtpPassword', [ 'type' => self::TYPE_STRING, 'description' => 'SMTP server password', 'default' => '', 'example' => 'securepassword', ]) + */ ->addRule('smtpSecure', [ 'type' => self::TYPE_STRING, 'description' => 'SMTP server secure protocol', @@ -409,7 +418,8 @@ class Project extends Model $document->setAttribute('smtpEnabled', $smtp['enabled'] ?? false); $document->setAttribute('smtpSenderEmail', $smtp['senderEmail'] ?? ''); $document->setAttribute('smtpSenderName', $smtp['senderName'] ?? ''); - $document->setAttribute('smtpReplyTo', $smtp['replyTo'] ?? ''); + $document->setAttribute('smtpReplyToEmail', $smtp['replyToEmail'] ?? ''); + $document->setAttribute('smtpReplyToName', $smtp['replyToName'] ?? ''); $document->setAttribute('smtpHost', $smtp['host'] ?? ''); $document->setAttribute('smtpPort', $smtp['port'] ?? ''); $document->setAttribute('smtpUsername', $smtp['username'] ?? ''); From 06a2d48debacc7fb4a6a928eeb48ee13ac6e5084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:29:13 +0200 Subject: [PATCH 32/81] Linter fix --- .../Project/Http/Project/SMTP/Update.php | 26 +++++++++---------- .../Modules/Project/Services/Http.php | 4 +-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 3c25750959..8e636fb2e2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -94,7 +94,7 @@ class Update extends Action ): void { // Fetch current configuration $smtp = $project->getAttribute('smtp', []); - + // Apply changes $keys = ['host', 'port', 'username', 'password', 'senderEmail', 'senderName', 'replyToEmail', 'replyToName', 'secure', 'enabled']; foreach ($keys as $key) { @@ -102,7 +102,7 @@ class Update extends Action $smtp[$key] = ${$key}; } } - + // Ensure required fields are set $requiredKeys = ['host', 'port', 'senderEmail']; foreach ($requiredKeys as $key) { @@ -110,33 +110,33 @@ class Update extends Action throw new \Exception('"' . $key . '" is required. Please provide a value.'); } } - + // Validate SMTP credentials - if($smtp['enabled'] === true) { + if ($smtp['enabled'] === true) { $mail = new PHPMailer(true); $mail->isSMTP(); - + $mail->Host = $smtp['host'] ?? ''; $mail->Port = $smtp['port'] ?? ''; $mail->SMTPSecure = $smtp['secure'] ?? ''; $mail->setFrom($smtp['senderEmail'], $smtp['senderName'] ?? ''); - - if(!empty($smtp['username'] ?? '')) { + + if (!empty($smtp['username'] ?? '')) { $mail->SMTPAuth = true; $mail->Username = $smtp['username']; $mail->Password = $smtp['password'] ?? ''; } - - if(!empty($smtp['replyToEmail'] ?? '')) { + + if (!empty($smtp['replyToEmail'] ?? '')) { $mail->addReplyTo($smtp['replyToEmail'], $smtp['replyToName'] ?? ''); } - + $mail->SMTPAutoTLS = false; $mail->Timeout = 5; - + try { $valid = $mail->SmtpConnect(); - + if (!$valid) { throw new \Exception('Connection is not valid.'); } @@ -149,7 +149,7 @@ class Update extends Action $updates = new Document([ 'smtp' => $smtp, ]); - + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); $response->dynamic($project, Response::MODEL_PROJECT); diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index ac048c6022..f768fb31be 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -24,7 +24,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as U use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; -use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Credentials\Update as UpdateSMTPCredentials; +use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; @@ -48,7 +48,7 @@ class Http extends Service $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); // SMTP - $this->addAction(UpdateSMTPCredentials::getName(), new UpdateSMTPCredentials()); + $this->addAction(UpdateSMTP::getName(), new UpdateSMTP()); $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); // Variables From bc592903dbe80932ded01d1841b2afd5cbc84395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:47:06 +0200 Subject: [PATCH 33/81] Support reply to name --- app/controllers/api/account.php | 81 +++++++++++++------ app/controllers/api/projects.php | 18 +++-- src/Appwrite/Bus/Listeners/Mails.php | 3 +- src/Appwrite/Event/Mail.php | 36 +++++++-- .../Http/Account/MFA/Challenges/Create.php | 20 +++-- .../Http/Project/SMTP/Tests/Create.php | 12 ++- .../Modules/Teams/Http/Memberships/Create.php | 20 +++-- src/Appwrite/Platform/Workers/Mails.php | 4 +- .../Utopia/Response/Model/TemplateEmail.php | 8 +- tests/e2e/Services/Project/SMTPBase.php | 33 +++++--- 10 files changed, 166 insertions(+), 69 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 0035778523..3f2a5c369b 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2295,8 +2295,8 @@ Http::post('/v1/account/tokens/magic-url') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -2305,8 +2305,11 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -2323,8 +2326,11 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -2332,7 +2338,8 @@ Http::post('/v1/account/tokens/magic-url') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -2611,7 +2618,8 @@ Http::post('/v1/account/tokens/email') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -2620,8 +2628,11 @@ Http::post('/v1/account/tokens/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -2638,8 +2649,11 @@ Http::post('/v1/account/tokens/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -2647,7 +2661,8 @@ Http::post('/v1/account/tokens/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -3743,7 +3758,8 @@ Http::post('/v1/account/recovery') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -3752,8 +3768,11 @@ Http::post('/v1/account/recovery') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -3770,8 +3789,11 @@ Http::post('/v1/account/recovery') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -3779,7 +3801,8 @@ Http::post('/v1/account/recovery') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } @@ -4060,7 +4083,8 @@ Http::post('/v1/account/verifications/email') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -4069,8 +4093,11 @@ Http::post('/v1/account/verifications/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -4087,8 +4114,11 @@ Http::post('/v1/account/verifications/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -4096,7 +4126,8 @@ Http::post('/v1/account/verifications/email') } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index e964933686..fae95b78a3 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -772,7 +772,9 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') 'message' => $message, 'subject' => $localeObj->getText('emails.' . $type . '.subject'), 'senderEmail' => '', - 'senderName' => '' + 'senderName' => '', + 'replyToEmail' => '', + 'replyToName' => '' ]; } @@ -873,10 +875,11 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->param('message', '', new Text(0), 'Template message') ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) + ->param('replyToEmail', '', new Email(), 'Reply to email', true) + ->param('replyToName', '', new Text(255, 0), 'Reply to name', 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) { + ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyToEmail, string $replyToName, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); @@ -889,7 +892,8 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, - 'replyTo' => $replyTo, + 'replyToEmail' => $replyToEmail, + 'replyToName' => $replyToName, 'message' => $message ]; @@ -901,7 +905,8 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, - 'replyTo' => $replyTo, + 'replyToEmail' => $replyToEmail, + 'replyToName' => $replyToName, 'message' => $message ]), Response::MODEL_EMAIL_TEMPLATE); }); @@ -1026,7 +1031,8 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], + 'replyToEmail' => $template['replyToEmail'] ?? '', + 'replyToName' => $template['replyToName'] ?? '', 'message' => $template['message'] ]), Response::MODEL_EMAIL_TEMPLATE); }); diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 2ffcbc9aa4..54754f317c 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -131,7 +131,8 @@ class Mails extends Listener ->setSmtpUsername($smtp['username'] ?? '') ->setSmtpPassword($smtp['password'] ?? '') ->setSmtpSecure($smtp['secure'] ?? '') - ->setSmtpReplyTo($customTemplate['replyTo'] ?? $smtp['replyTo'] ?? '') + ->setSmtpReplyToEmail($customTemplate['replyToEmail'] ?? $smtp['replyToEmail'] ?? '') + ->setSmtpReplyToName($customTemplate['replyToName'] ?? $smtp['replyToName'] ?? '') ->setSmtpSenderEmail($customTemplate['senderEmail'] ?? $smtp['senderEmail'] ?? System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM)) ->setSmtpSenderName($customTemplate['senderName'] ?? $smtp['senderName'] ?? System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')); } diff --git a/src/Appwrite/Event/Mail.php b/src/Appwrite/Event/Mail.php index d8f25489c6..0685586c60 100644 --- a/src/Appwrite/Event/Mail.php +++ b/src/Appwrite/Event/Mail.php @@ -251,14 +251,26 @@ class Mail extends Event } /** - * Set SMTP reply to + * Set SMTP reply-to email * - * @param string $replyTo + * @param string $email * @return self */ - public function setSmtpReplyTo(string $replyTo): self + public function setSmtpReplyToEmail(string $email): self { - $this->smtp['replyTo'] = $replyTo; + $this->smtp['replyToEmail'] = $email; + return $this; + } + + /** + * Set SMTP reply-to name + * + * @param string $name + * @return self + */ + public function setSmtpReplyToName(string $name): self + { + $this->smtp['replyToName'] = $name; return $this; } @@ -333,13 +345,23 @@ class Mail extends Event } /** - * Get SMTP reply to + * Get SMTP reply-to email * * @return string */ - public function getSmtpReplyTo(): string + public function getSmtpReplyToEmail(): string { - return $this->smtp['replyTo'] ?? ''; + return $this->smtp['replyToEmail'] ?? ''; + } + + /** + * Get SMTP reply-to name + * + * @return string + */ + public function getSmtpReplyToName(): string + { + return $this->smtp['replyToName'] ?? ''; } /** 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 20a6afed2e..1a0006de64 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 @@ -253,7 +253,8 @@ class Create extends Action $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ""; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -262,8 +263,11 @@ class Create extends Action if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (!empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (!empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -280,8 +284,11 @@ class Create extends Action if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (!empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (!empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -289,7 +296,8 @@ class Create extends Action } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php index ec6bc87717..d60cb08ffd 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -104,7 +104,8 @@ class Create extends Action $senderName = $paramSenderName ?: ($smtp['senderName'] ?? ''); $senderEmail = $paramSenderEmail ?: ($smtp['senderEmail'] ?? ''); - $replyTo = $paramReplyTo ?: ($smtp['replyTo'] ?? ''); + $replyToEmail = $paramReplyTo ?: ($smtp['replyToEmail'] ?? ''); + $replyToName = $smtp['replyToName'] ?? ''; $host = $paramHost ?: ($smtp['host'] ?? ''); $port = $paramPort ?? ($smtp['port'] ?? ''); $username = $paramUsername ?: ($smtp['username'] ?? ''); @@ -127,13 +128,15 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP port must be configured on the project to send a test email.'); } - $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; + // Fallback to sender details when reply-to is not explicitly configured + $replyToEmailDisplay = !empty($replyToEmail) ? $replyToEmail : $senderEmail; + $replyToNameDisplay = !empty($replyToName) ? $replyToName : $senderName; $subject = 'Custom SMTP email sample'; $template = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-smtp-test.tpl'); $template ->setParam('{{from}}', "{$senderName} ({$senderEmail})") - ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") + ->setParam('{{replyTo}}', "{$replyToNameDisplay} ({$replyToEmailDisplay})") ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) @@ -149,7 +152,8 @@ class Create extends Action ->setSmtpUsername($username) ->setSmtpPassword($password) ->setSmtpSecure($secure) - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName) ->setRecipient($email) diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 5edc69f445..4d13f125d8 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -341,7 +341,8 @@ class Create extends Action $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyTo = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (! empty($smtp['senderEmail'])) { @@ -350,8 +351,11 @@ class Create extends Action if (! empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (! empty($smtp['replyTo'])) { - $replyTo = $smtp['replyTo']; + if (! empty($smtp['replyToEmail'])) { + $replyToEmail = $smtp['replyToEmail']; + } + if (! empty($smtp['replyToName'])) { + $replyToName = $smtp['replyToName']; } $queueForMails @@ -368,8 +372,11 @@ class Create extends Action if (! empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (! empty($customTemplate['replyTo'])) { - $replyTo = $customTemplate['replyTo']; + if (! empty($customTemplate['replyToEmail'])) { + $replyToEmail = $customTemplate['replyToEmail']; + } + if (! empty($customTemplate['replyToName'])) { + $replyToName = $customTemplate['replyToName']; } $body = $customTemplate['message'] ?? ''; @@ -377,7 +384,8 @@ class Create extends Action } $queueForMails - ->setSmtpReplyTo($replyTo) + ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); } diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 32de1e50d6..3ddbcf482e 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -173,8 +173,8 @@ class Mails extends Action $replyTo = $customMailOptions['replyToEmail'] ?? $replyTo; $replyToName = $customMailOptions['replyToName'] ?? $replyToName; } elseif (!empty($smtp)) { - $replyTo = !empty($smtp['replyTo']) ? $smtp['replyTo'] : ($smtp['senderEmail'] ?? $replyTo); - $replyToName = $smtp['senderName'] ?? $replyToName; + $replyTo = !empty($smtp['replyToEmail']) ? $smtp['replyToEmail'] : ($smtp['senderEmail'] ?? $replyTo); + $replyToName = !empty($smtp['replyToName']) ? $smtp['replyToName'] : ($smtp['senderName'] ?? $replyToName); } $attachments = null; diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index ecdf89e774..626a9fa368 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -22,12 +22,18 @@ class TemplateEmail extends Template 'default' => '', 'example' => 'mail@appwrite.io', ]) - ->addRule('replyTo', [ + ->addRule('replyToEmail', [ 'type' => self::TYPE_STRING, 'description' => 'Reply to email address', 'default' => '', 'example' => 'emails@appwrite.io', ]) + ->addRule('replyToName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Reply to name', + 'default' => '', + 'example' => 'Support Team', + ]) ->addRule('subject', [ 'type' => self::TYPE_STRING, 'description' => 'Email subject', diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 51e2e5e8a9..887ed864ba 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -67,7 +67,8 @@ trait SMTPBase $this->assertArrayHasKey('smtpEnabled', $response['body']); $this->assertArrayHasKey('smtpSenderName', $response['body']); $this->assertArrayHasKey('smtpSenderEmail', $response['body']); - $this->assertArrayHasKey('smtpReplyTo', $response['body']); + $this->assertArrayHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToName', $response['body']); $this->assertArrayHasKey('smtpHost', $response['body']); $this->assertArrayHasKey('smtpPort', $response['body']); $this->assertArrayHasKey('smtpUsername', $response['body']); @@ -115,14 +116,16 @@ trait SMTPBase senderEmail: 'sender@example.com', host: 'maildev', port: 1025, - replyTo: 'reply@example.com', + replyToEmail: 'reply@example.com', + replyToName: 'Full Reply', ); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame(true, $response['body']['smtpEnabled']); $this->assertSame('Full Sender', $response['body']['smtpSenderName']); $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); - $this->assertSame('reply@example.com', $response['body']['smtpReplyTo']); + $this->assertSame('reply@example.com', $response['body']['smtpReplyToEmail']); + $this->assertSame('Full Reply', $response['body']['smtpReplyToName']); $this->assertSame('maildev', $response['body']['smtpHost']); $this->assertSame(1025, $response['body']['smtpPort']); @@ -188,7 +191,8 @@ trait SMTPBase $this->assertArrayHasKey('smtpEnabled', $response['body']); $this->assertArrayHasKey('smtpSenderName', $response['body']); $this->assertArrayHasKey('smtpSenderEmail', $response['body']); - $this->assertArrayHasKey('smtpReplyTo', $response['body']); + $this->assertArrayHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayHasKey('smtpReplyToName', $response['body']); $this->assertArrayHasKey('smtpHost', $response['body']); $this->assertArrayHasKey('smtpPort', $response['body']); $this->assertArrayHasKey('smtpUsername', $response['body']); @@ -279,7 +283,7 @@ trait SMTPBase senderEmail: 'sender@example.com', host: 'maildev', port: 1025, - replyTo: 'not-an-email', + replyToEmail: 'not-an-email', ); $this->assertSame(400, $response['headers']['status-code']); @@ -664,15 +668,17 @@ trait SMTPBase $senderName = 'SMTP Test Sender'; $senderEmail = 'smtptest@appwrite.io'; $replyToEmail = 'smtpreply@appwrite.io'; + $replyToName = 'SMTP Reply Team'; $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; - // Configure SMTP with replyTo and auth credentials + // Configure SMTP with reply-to and auth credentials $response = $this->updateSMTPCredentials( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', port: 1025, - replyTo: $replyToEmail, + replyToEmail: $replyToEmail, + replyToName: $replyToName, username: 'user', password: 'password', ); @@ -693,7 +699,7 @@ trait SMTPBase $this->assertSame($senderEmail, $email['from'][0]['address']); $this->assertSame($senderName, $email['from'][0]['name']); $this->assertSame($replyToEmail, $email['replyTo'][0]['address']); - $this->assertSame($senderName, $email['replyTo'][0]['name']); + $this->assertSame($replyToName, $email['replyTo'][0]['name']); $this->assertSame('Custom SMTP email sample', $email['subject']); $this->assertStringContainsStringIgnoringCase('working correctly', $email['text']); $this->assertStringContainsStringIgnoringCase('working correctly', $email['html']); @@ -769,7 +775,8 @@ trait SMTPBase string $senderEmail = '', string $host = '', int $port = 587, - ?string $replyTo = null, + ?string $replyToEmail = null, + ?string $replyToName = null, ?string $username = null, ?string $password = null, ?string $secure = null, @@ -792,8 +799,12 @@ trait SMTPBase 'port' => $port, ]; - if (!\is_null($replyTo)) { - $params['replyTo'] = $replyTo; + if (!\is_null($replyToEmail)) { + $params['replyToEmail'] = $replyToEmail; + } + + if (!\is_null($replyToName)) { + $params['replyToName'] = $replyToName; } if (!\is_null($username)) { From 56385ce167cdb03a1979668d1d141b02558f54ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:48:45 +0200 Subject: [PATCH 34/81] Backwards compatibility --- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 3 +++ src/Appwrite/Utopia/Response/Model/Project.php | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 8e636fb2e2..97295aa7b5 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -102,6 +102,9 @@ class Update extends Action $smtp[$key] = ${$key}; } } + + // Backwards compatibility + $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; // Ensure required fields are set $requiredKeys = ['host', 'port', 'senderEmail']; diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index e1909c4785..aef4927ad7 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -418,7 +418,7 @@ class Project extends Model $document->setAttribute('smtpEnabled', $smtp['enabled'] ?? false); $document->setAttribute('smtpSenderEmail', $smtp['senderEmail'] ?? ''); $document->setAttribute('smtpSenderName', $smtp['senderName'] ?? ''); - $document->setAttribute('smtpReplyToEmail', $smtp['replyToEmail'] ?? ''); + $document->setAttribute('smtpReplyToEmail', $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility $document->setAttribute('smtpReplyToName', $smtp['replyToName'] ?? ''); $document->setAttribute('smtpHost', $smtp['host'] ?? ''); $document->setAttribute('smtpPort', $smtp['port'] ?? ''); From f040a4dc319f90c49b689ea9ca96b9b8624b4be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 11:58:55 +0200 Subject: [PATCH 35/81] More backwards compatibility --- app/controllers/api/account.php | 78 +++++++++++-------- app/controllers/api/projects.php | 8 +- src/Appwrite/Bus/Listeners/Mails.php | 2 +- .../Http/Account/MFA/Challenges/Create.php | 12 ++- .../Http/Project/SMTP/Tests/Create.php | 2 +- .../Modules/Teams/Http/Memberships/Create.php | 12 ++- src/Appwrite/Platform/Workers/Mails.php | 4 +- 7 files changed, 75 insertions(+), 43 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 3f2a5c369b..fdb6137b9c 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2305,8 +2305,10 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; } if (!empty($smtp['replyToName'])) { $replyToName = $smtp['replyToName']; @@ -2326,8 +2328,10 @@ Http::post('/v1/account/tokens/magic-url') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; } if (!empty($customTemplate['replyToName'])) { $replyToName = $customTemplate['replyToName']; @@ -2618,8 +2622,8 @@ Http::post('/v1/account/tokens/email') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyToEmail = ''; - $replyToName = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -2628,9 +2632,11 @@ Http::post('/v1/account/tokens/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; - } + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } if (!empty($smtp['replyToName'])) { $replyToName = $smtp['replyToName']; } @@ -2649,9 +2655,11 @@ Http::post('/v1/account/tokens/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; - } + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } if (!empty($customTemplate['replyToName'])) { $replyToName = $customTemplate['replyToName']; } @@ -2661,7 +2669,7 @@ Http::post('/v1/account/tokens/email') } $queueForMails - ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToEmail($replyToEmail) ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); @@ -3758,8 +3766,8 @@ Http::post('/v1/account/recovery') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyToEmail = ''; - $replyToName = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -3768,9 +3776,11 @@ Http::post('/v1/account/recovery') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; - } + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } if (!empty($smtp['replyToName'])) { $replyToName = $smtp['replyToName']; } @@ -3789,9 +3799,11 @@ Http::post('/v1/account/recovery') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; - } + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } if (!empty($customTemplate['replyToName'])) { $replyToName = $customTemplate['replyToName']; } @@ -3801,7 +3813,7 @@ Http::post('/v1/account/recovery') } $queueForMails - ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToEmail($replyToEmail) ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); @@ -4083,8 +4095,8 @@ Http::post('/v1/account/verifications/email') $senderEmail = System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM); $senderName = System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'); - $replyToEmail = ''; - $replyToName = ''; + $replyToEmail = ''; + $replyToName = ''; if ($smtpEnabled) { if (!empty($smtp['senderEmail'])) { @@ -4093,9 +4105,11 @@ Http::post('/v1/account/verifications/email') if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; - } + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; + } if (!empty($smtp['replyToName'])) { $replyToName = $smtp['replyToName']; } @@ -4114,9 +4128,11 @@ Http::post('/v1/account/verifications/email') if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; - } + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; + } if (!empty($customTemplate['replyToName'])) { $replyToName = $customTemplate['replyToName']; } @@ -4126,7 +4142,7 @@ Http::post('/v1/account/verifications/email') } $queueForMails - ->setSmtpReplyToEmail($replyToEmail) + ->setSmtpReplyToEmail($replyToEmail) ->setSmtpReplyToName($replyToName) ->setSmtpSenderEmail($senderEmail) ->setSmtpSenderName($senderName); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index fae95b78a3..9f5cedac9f 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -716,6 +716,12 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $type . '-' . $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')); @@ -1031,7 +1037,7 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], - 'replyToEmail' => $template['replyToEmail'] ?? '', + 'replyToEmail' => $template['replyToEmail'] ?? $template['replyTo'] ?? '', // Includes backwards compatibility 'replyToName' => $template['replyToName'] ?? '', 'message' => $template['message'] ]), Response::MODEL_EMAIL_TEMPLATE); diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 54754f317c..68dbe1d89b 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -131,7 +131,7 @@ class Mails extends Listener ->setSmtpUsername($smtp['username'] ?? '') ->setSmtpPassword($smtp['password'] ?? '') ->setSmtpSecure($smtp['secure'] ?? '') - ->setSmtpReplyToEmail($customTemplate['replyToEmail'] ?? $smtp['replyToEmail'] ?? '') + ->setSmtpReplyToEmail($customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? '') // Includes backwards compatibility ->setSmtpReplyToName($customTemplate['replyToName'] ?? $smtp['replyToName'] ?? '') ->setSmtpSenderEmail($customTemplate['senderEmail'] ?? $smtp['senderEmail'] ?? System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM)) ->setSmtpSenderName($customTemplate['senderName'] ?? $smtp['senderName'] ?? System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server')); 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 1a0006de64..b53c19daa6 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 @@ -263,8 +263,10 @@ class Create extends Action if (!empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (!empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (!empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; } if (!empty($smtp['replyToName'])) { $replyToName = $smtp['replyToName']; @@ -284,8 +286,10 @@ class Create extends Action if (!empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (!empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (!empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; } if (!empty($customTemplate['replyToName'])) { $replyToName = $customTemplate['replyToName']; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php index d60cb08ffd..b61147aef6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -104,7 +104,7 @@ class Create extends Action $senderName = $paramSenderName ?: ($smtp['senderName'] ?? ''); $senderEmail = $paramSenderEmail ?: ($smtp['senderEmail'] ?? ''); - $replyToEmail = $paramReplyTo ?: ($smtp['replyToEmail'] ?? ''); + $replyToEmail = $paramReplyTo ?: ($smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility $replyToName = $smtp['replyToName'] ?? ''; $host = $paramHost ?: ($smtp['host'] ?? ''); $port = $paramPort ?? ($smtp['port'] ?? ''); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 4d13f125d8..b2bec33221 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -351,8 +351,10 @@ class Create extends Action if (! empty($smtp['senderName'])) { $senderName = $smtp['senderName']; } - if (! empty($smtp['replyToEmail'])) { - $replyToEmail = $smtp['replyToEmail']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + if (! empty($smtpReplyToEmail)) { + $replyToEmail = $smtpReplyToEmail; } if (! empty($smtp['replyToName'])) { $replyToName = $smtp['replyToName']; @@ -372,8 +374,10 @@ class Create extends Action if (! empty($customTemplate['senderName'])) { $senderName = $customTemplate['senderName']; } - if (! empty($customTemplate['replyToEmail'])) { - $replyToEmail = $customTemplate['replyToEmail']; + // Includes backwards compatibility: fall back to legacy `replyTo` key + $customReplyToEmail = $customTemplate['replyToEmail'] ?? $customTemplate['replyTo'] ?? ''; + if (! empty($customReplyToEmail)) { + $replyToEmail = $customReplyToEmail; } if (! empty($customTemplate['replyToName'])) { $replyToName = $customTemplate['replyToName']; diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 3ddbcf482e..04c588b2c8 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -173,7 +173,9 @@ class Mails extends Action $replyTo = $customMailOptions['replyToEmail'] ?? $replyTo; $replyToName = $customMailOptions['replyToName'] ?? $replyToName; } elseif (!empty($smtp)) { - $replyTo = !empty($smtp['replyToEmail']) ? $smtp['replyToEmail'] : ($smtp['senderEmail'] ?? $replyTo); + // Includes backwards compatibility: fall back to legacy `replyTo` key + $smtpReplyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; + $replyTo = !empty($smtpReplyToEmail) ? $smtpReplyToEmail : ($smtp['senderEmail'] ?? $replyTo); $replyToName = !empty($smtp['replyToName']) ? $smtp['replyToName'] : ($smtp['senderName'] ?? $replyToName); } From 78ef52cc9e45a37e84ca61e4e34b76d0adc99bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 13:11:11 +0200 Subject: [PATCH 36/81] Manual QA fixes --- docker-compose.yml | 2 +- .../Modules/Project/Http/Project/SMTP/Tests/Create.php | 3 --- .../Modules/Project/Http/Project/SMTP/Update.php | 10 ++++++++-- .../Platform/Modules/Project/Services/Http.php | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index aa2bfdd16a..14f00205ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -254,7 +254,7 @@ services: appwrite-console: <<: *x-logging container_name: appwrite-console - image: appwrite/console:7.8.26 + image: appwrite/console:7.8.45 restart: unless-stopped networks: - appwrite diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php index b61147aef6..a41292466c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -40,9 +40,6 @@ class Create extends Action ->desc('Create project SMTP test') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'smtp.*.update') - ->label('audits.event', 'project.smtp.update') - ->label('audits.resource', 'project.smtp/{response.$id}') ->label('sdk', new Method( namespace: 'project', group: 'smtp', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 97295aa7b5..58360058da 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -102,7 +102,7 @@ class Update extends Action $smtp[$key] = ${$key}; } } - + // Backwards compatibility $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; @@ -110,7 +110,7 @@ class Update extends Action $requiredKeys = ['host', 'port', 'senderEmail']; foreach ($requiredKeys as $key) { if (empty($smtp[$key])) { - throw new \Exception('"' . $key . '" is required. Please provide a value.'); + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, '"' . $key . '" is required. Please provide a value.'); } } @@ -143,6 +143,12 @@ class Update extends Action if (!$valid) { throw new \Exception('Connection is not valid.'); } + + // Auto-enable if configuration is valid + // Dont do this if specifically request to mark disabled + if(\is_null($enabled)) { + $smtp['enabled'] = true; + } } catch (Throwable $error) { throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); } diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index f768fb31be..e9e30f7590 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -24,8 +24,8 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as U use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; -use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; 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\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; From 2097b0a0b0d7a5f968e72baec6769c4be617d14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:04:53 +0200 Subject: [PATCH 37/81] Better support for post-smtp changes --- .../Http/Project/Templates/Email/Delete.php | 20 +++++++++++++------ .../Http/Project/Templates/Email/Get.php | 8 ++++++++ .../Http/Project/Templates/Email/Update.php | 12 +++++++---- .../Utopia/Response/Model/TemplateEmail.php | 8 +++++++- tests/e2e/Services/Project/TemplatesBase.php | 12 +++++++---- 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php index 176e8d7d63..7928486192 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php @@ -5,7 +5,6 @@ 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\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; @@ -50,11 +49,10 @@ class Delete extends Action auth: [AuthType::ADMIN, AuthType::KEY], responses: [ new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, + code: Response::STATUS_CODE_OK, + model: Response::MODEL_EMAIL_TEMPLATE, ) - ], - contentType: ContentType::NONE + ] )) ->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']) @@ -96,6 +94,16 @@ class Delete extends Action $queueForEvents->setParam('templateType', $templateId); - $response->noContent(); + $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); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 115b10f7dd..6e2b2ef56d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -67,6 +67,12 @@ class Get extends Action $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')); @@ -124,6 +130,8 @@ class Get extends Action 'subject' => $localeObj->getText('emails.' . $templateId . '.subject'), 'senderEmail' => '', 'senderName' => '', + 'replyToEmail' => '', + 'replyToName' => '', 'custom' => false, ]; } else { diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index f17be381f6..93cfa7a3fb 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -61,7 +61,8 @@ class Update extends Action ->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('replyTo', '', new Email(), 'Reply to email.', 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') @@ -78,7 +79,8 @@ class Update extends Action string $message, string $senderName, string $senderEmail, - string $replyTo, + string $replyToEmail, + string $replyToName, Response $response, QueueEvent $queueForEvents, Database $dbForPlatform, @@ -92,7 +94,8 @@ class Update extends Action 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, - 'replyTo' => $replyTo, + 'replyToEmail' => $replyToEmail, + 'replyToName' => $replyToName, 'message' => $message ]; @@ -113,7 +116,8 @@ class Update extends Action 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], + 'replyToEmail' => $template['replyToEmail'], + 'replyToName' => $template['replyToName'], 'message' => $template['message'], 'custom' => true, ]), Response::MODEL_EMAIL_TEMPLATE); diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index 9b77617b8c..48cd0ba556 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -40,12 +40,18 @@ class TemplateEmail extends Model 'default' => '', 'example' => 'mail@appwrite.io', ]) - ->addRule('replyTo', [ + ->addRule('replyToEmail', [ 'type' => self::TYPE_STRING, 'description' => 'Reply to email address', 'default' => '', 'example' => 'emails@appwrite.io', ]) + ->addRule('replyToName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Reply to name', + 'default' => '', + 'example' => 'My User', + ]) ->addRule('subject', [ 'type' => self::TYPE_STRING, 'description' => 'Email subject', diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 4f78992079..422f890785 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -119,7 +119,7 @@ trait TemplatesBase $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']['replyTo']); + $this->assertSame('reply@appwrite.io', $update['body']['replyToEmail']); // Cleanup $this->deleteEmailTemplate('invitation', 'en'); @@ -464,7 +464,8 @@ trait TemplatesBase ?string $message, ?string $senderName = null, ?string $senderEmail = null, - ?string $replyTo = null, + ?string $replyToEmail = null, + ?string $replyToName = null, bool $authenticated = true, ): mixed { $params = [ @@ -486,8 +487,11 @@ trait TemplatesBase if ($senderEmail !== null) { $params['senderEmail'] = $senderEmail; } - if ($replyTo !== null) { - $params['replyTo'] = $replyTo; + if ($replyToEmail !== null) { + $params['replyToEmail'] = $replyToEmail; + } + if ($replyToName !== null) { + $params['replyToName'] = $replyToName; } $headers = [ From afb8f7031633a431e7bf89194e7ab212c21b60a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:42:52 +0200 Subject: [PATCH 38/81] Fix tests --- tests/e2e/Services/Project/SMTPBase.php | 251 ++++++++++++------------ 1 file changed, 130 insertions(+), 121 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 887ed864ba..2cfe645290 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -11,21 +11,33 @@ trait SMTPBase public function testUpdateSMTPStatusEnable(): void { - $response = $this->updateSMTPStatus(true); + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); $this->assertSame(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertSame(true, $response['body']['smtpEnabled']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPStatusDisable(): void { - $this->updateSMTPStatus(true); + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); - $response = $this->updateSMTPStatus(false); + $response = $this->updateSMTP(enabled: false); $this->assertSame(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); @@ -34,32 +46,50 @@ trait SMTPBase public function testUpdateSMTPStatusEnableIdempotent(): void { - $first = $this->updateSMTPStatus(true); + $first = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); $this->assertSame(200, $first['headers']['status-code']); $this->assertSame(true, $first['body']['smtpEnabled']); - $second = $this->updateSMTPStatus(true); + $second = $this->updateSMTP(enabled: true); $this->assertSame(200, $second['headers']['status-code']); $this->assertSame(true, $second['body']['smtpEnabled']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPStatusDisableIdempotent(): void { - $first = $this->updateSMTPStatus(false); + $first = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); $this->assertSame(200, $first['headers']['status-code']); $this->assertSame(false, $first['body']['smtpEnabled']); - $second = $this->updateSMTPStatus(false); + $second = $this->updateSMTP(enabled: false); $this->assertSame(200, $second['headers']['status-code']); $this->assertSame(false, $second['body']['smtpEnabled']); } public function testUpdateSMTPStatusResponseModel(): void { - $response = $this->updateSMTPStatus(true); + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); $this->assertSame(200, $response['headers']['status-code']); $this->assertArrayHasKey('$id', $response['body']); @@ -76,12 +106,12 @@ trait SMTPBase $this->assertArrayHasKey('smtpSecure', $response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPStatusWithoutAuthentication(): void { - $response = $this->updateSMTPStatus(true, false); + $response = $this->updateSMTP(enabled: true, authenticated: false); $this->assertSame(401, $response['headers']['status-code']); } @@ -90,7 +120,7 @@ trait SMTPBase public function testUpdateSMTPCredentials(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -106,12 +136,12 @@ trait SMTPBase $this->assertSame(1025, $response['body']['smtpPort']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPWithOptionalReplyTo(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Full Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -130,19 +160,19 @@ trait SMTPBase $this->assertSame(1025, $response['body']['smtpPort']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPOverwritesPreviousSettings(): void { - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'First Sender', senderEmail: 'first@example.com', host: 'maildev', port: 1025, ); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Second Sender', senderEmail: 'second@example.com', host: 'maildev', @@ -154,15 +184,21 @@ trait SMTPBase $this->assertSame('second@example.com', $response['body']['smtpSenderEmail']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPEnablesSMTP(): void { // Ensure SMTP is disabled - $this->updateSMTPStatus(false); + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -173,12 +209,12 @@ trait SMTPBase $this->assertSame(true, $response['body']['smtpEnabled']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPResponseModel(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -200,12 +236,12 @@ trait SMTPBase $this->assertArrayHasKey('smtpSecure', $response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPWithoutAuthentication(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -218,7 +254,7 @@ trait SMTPBase public function testUpdateSMTPInvalidSenderEmail(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'not-an-email', host: 'maildev', @@ -230,7 +266,7 @@ trait SMTPBase public function testUpdateSMTPEmptySenderName(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: '', senderEmail: 'sender@example.com', host: 'maildev', @@ -242,7 +278,7 @@ trait SMTPBase public function testUpdateSMTPEmptySenderEmail(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: '', host: 'maildev', @@ -254,7 +290,7 @@ trait SMTPBase public function testUpdateSMTPEmptyHost(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: '', @@ -266,7 +302,7 @@ trait SMTPBase public function testUpdateSMTPInvalidHost(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'not a valid host!@#', @@ -278,7 +314,7 @@ trait SMTPBase public function testUpdateSMTPInvalidReplyToEmail(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -291,7 +327,7 @@ trait SMTPBase public function testUpdateSMTPInvalidSecure(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -304,7 +340,7 @@ trait SMTPBase public function testUpdateSMTPSenderNameMinLength(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'A', senderEmail: 'sender@example.com', host: 'maildev', @@ -315,13 +351,13 @@ trait SMTPBase $this->assertSame('A', $response['body']['smtpSenderName']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPSenderNameMaxLength(): void { $name = str_repeat('a', 256); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: $name, senderEmail: 'sender@example.com', host: 'maildev', @@ -332,12 +368,12 @@ trait SMTPBase $this->assertSame($name, $response['body']['smtpSenderName']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPSenderNameTooLong(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: str_repeat('a', 257), senderEmail: 'sender@example.com', host: 'maildev', @@ -349,7 +385,7 @@ trait SMTPBase public function testUpdateSMTPUsernameMinLength(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -361,13 +397,13 @@ trait SMTPBase $this->assertSame('u', $response['body']['smtpUsername']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPUsernameMaxLength(): void { $username = str_repeat('a', 256); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -379,12 +415,12 @@ trait SMTPBase $this->assertSame($username, $response['body']['smtpUsername']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPUsernameTooLong(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -397,7 +433,7 @@ trait SMTPBase public function testUpdateSMTPUsernameEmpty(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -410,7 +446,7 @@ trait SMTPBase public function testUpdateSMTPPasswordMinLength(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -422,13 +458,13 @@ trait SMTPBase $this->assertSame('p', $response['body']['smtpPassword']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPPasswordMaxLength(): void { $password = str_repeat('a', 256); - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -440,12 +476,12 @@ trait SMTPBase $this->assertSame($password, $response['body']['smtpPassword']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPPasswordTooLong(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -458,7 +494,7 @@ trait SMTPBase public function testUpdateSMTPPasswordEmpty(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -471,7 +507,7 @@ trait SMTPBase public function testUpdateSMTPWithoutSecure(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -482,12 +518,12 @@ trait SMTPBase $this->assertSame('', $response['body']['smtpSecure']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testUpdateSMTPInvalidConnectionRefused(): void { - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'localhost', @@ -501,10 +537,16 @@ trait SMTPBase public function testUpdateSMTPBackwardsCompatibilityDisable(): void { // First enable SMTP - $this->updateSMTPStatus(true); + $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); // Use the deprecated enabled=false parameter to disable - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'maildev', @@ -521,7 +563,7 @@ trait SMTPBase public function testCreateSMTPTest(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -534,13 +576,13 @@ trait SMTPBase $this->assertEmpty($response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestMultipleRecipients(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -557,13 +599,19 @@ trait SMTPBase $this->assertEmpty($response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestWhenSMTPDisabled(): void { // Ensure SMTP is disabled - $this->updateSMTPStatus(false); + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); $response = $this->createSMTPTest(['recipient@example.com']); @@ -580,7 +628,7 @@ trait SMTPBase public function testCreateSMTPTestEmptyEmails(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -593,13 +641,13 @@ trait SMTPBase $this->assertEmpty($response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestInvalidEmail(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -611,13 +659,13 @@ trait SMTPBase $this->assertSame(400, $response['headers']['status-code']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestExceedsMaxEmails(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -634,13 +682,13 @@ trait SMTPBase $this->assertSame(400, $response['headers']['status-code']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testCreateSMTPTestMaxEmails(): void { // First configure SMTP - $this->updateSMTPCredentials( + $this->updateSMTP( senderName: 'Test Sender', senderEmail: 'sender@example.com', host: 'maildev', @@ -658,7 +706,7 @@ trait SMTPBase $this->assertEmpty($response['body']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } // Integration tests @@ -672,7 +720,7 @@ trait SMTPBase $recipientEmail = 'smtpdelivery-' . \uniqid() . '@appwrite.io'; // Configure SMTP with reply-to and auth credentials - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', @@ -705,7 +753,7 @@ trait SMTPBase $this->assertStringContainsStringIgnoringCase('working correctly', $email['html']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } public function testMagicURLLoginUsesCustomSMTP(): void @@ -715,7 +763,7 @@ trait SMTPBase $recipientEmail = 'magicurl-' . \uniqid() . '@appwrite.io'; // Configure custom SMTP with auth credentials - $response = $this->updateSMTPCredentials( + $response = $this->updateSMTP( senderName: $senderName, senderEmail: $senderEmail, host: 'maildev', @@ -749,32 +797,16 @@ trait SMTPBase $this->assertSame($this->getProject()['name'] . ' Login', $email['subject']); // Cleanup - $this->updateSMTPStatus(false); + $this->updateSMTP(enabled: false); } // Helpers - protected function updateSMTPStatus(bool $enabled, bool $authenticated = true): mixed - { - $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/smtp/status', $headers, [ - 'enabled' => $enabled, - ]); - } - - protected function updateSMTPCredentials( - string $senderName = '', - string $senderEmail = '', - string $host = '', - int $port = 587, + protected function updateSMTP( + ?string $senderName = null, + ?string $senderEmail = null, + ?string $host = null, + ?int $port = null, ?string $replyToEmail = null, ?string $replyToName = null, ?string $username = null, @@ -792,38 +824,15 @@ trait SMTPBase $headers = array_merge($headers, $this->getHeaders()); } - $params = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'host' => $host, - 'port' => $port, - ]; + $params = []; - if (!\is_null($replyToEmail)) { - $params['replyToEmail'] = $replyToEmail; + foreach (['senderName', 'senderEmail', 'host', 'port', 'replyToEmail', 'replyToName', 'username', 'password', 'secure', 'enabled'] as $key) { + if (!\is_null(${$key})) { + $params[$key] = ${$key}; + } } - if (!\is_null($replyToName)) { - $params['replyToName'] = $replyToName; - } - - if (!\is_null($username)) { - $params['username'] = $username; - } - - if (!\is_null($password)) { - $params['password'] = $password; - } - - if (!\is_null($secure)) { - $params['secure'] = $secure; - } - - if (!\is_null($enabled)) { - $params['enabled'] = $enabled; - } - - return $this->client->call(Client::METHOD_PATCH, '/project/smtp/credentials', $headers, $params); + return $this->client->call(Client::METHOD_PATCH, '/project/smtp', $headers, $params); } /** From 1c1ec4315075d1d6e696c5e8a14e0b4c5adeba0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:47:01 +0200 Subject: [PATCH 39/81] Removeal post-merge --- app/controllers/api/projects.php | 214 ------------------------------- 1 file changed, 214 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..6fc52d2158 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -619,220 +619,6 @@ Http::post('/v1/projects/:projectId/jwts') ])]), Response::MODEL_JWT); }); -// CUSTOM SMTP and Templates -Http::patch('/v1/projects/:projectId/smtp') - ->desc('Update SMTP') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSmtp', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateSMTP', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSMTP', - description: '/docs/references/projects/update-smtp.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(), 'Enable custom SMTP service') - ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name', true) - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - // Ensure required params for when enabling SMTP - if ($enabled) { - if (empty($senderName)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender name is required when enabling SMTP.'); - } elseif (empty($senderEmail)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Sender email is required when enabling SMTP.'); - } elseif (empty($host)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Host is required when enabling SMTP.'); - } elseif (empty($port)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Port is required when enabling SMTP.'); - } - } - - // validate SMTP settings - if ($enabled) { - $mail = new PHPMailer(true); - $mail->isSMTP(); - $mail->SMTPAuth = (!empty($username) && !empty($password)); - $mail->Username = $username; - $mail->Password = $password; - $mail->Host = $host; - $mail->Port = $port; - $mail->SMTPSecure = $secure; - $mail->SMTPAutoTLS = false; - $mail->Timeout = 5; - - try { - $valid = $mail->SmtpConnect(); - - if (!$valid) { - throw new Exception('Connection is not valid.'); - } - } catch (Throwable $error) { - throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); - } - } - - // Save SMTP settings - if ($enabled) { - $smtp = [ - 'enabled' => $enabled, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'replyTo' => $replyTo, - 'host' => $host, - 'port' => $port, - 'username' => $username, - 'password' => $password, - 'secure' => $secure, - ]; - } else { - $smtp = [ - 'enabled' => false - ]; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('smtp', $smtp)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::post('/v1/projects/:projectId/smtp/tests') - ->desc('Create SMTP test') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSmtpTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.createSMTPTest', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'createSMTPTest', - description: '/docs/references/projects/create-smtp-test.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('emails', [], new ArrayList(new Email(), 10), 'Array of emails to send test email to. Maximum of 10 emails are allowed.') - ->param('senderName', System::getEnv('_APP_SYSTEM_EMAIL_NAME', APP_NAME . ' Server'), new Text(255, 0), 'Name of the email sender') - ->param('senderEmail', System::getEnv('_APP_SYSTEM_EMAIL_ADDRESS', APP_EMAIL_TEAM), new Email(), 'Email of the sender') - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->param('host', '', new HostName(), 'SMTP server host name') - ->param('port', 587, new Integer(), 'SMTP server port', true) - ->param('username', '', new Text(0, 0), 'SMTP server username', true) - ->param('password', '', new Text(0, 0), 'SMTP server password', true) - ->param('secure', '', new WhiteList(['tls', 'ssl'], true), 'Does SMTP server use secure connection', true) - ->inject('response') - ->inject('dbForPlatform') - ->inject('queueForMails') - ->inject('plan') - ->action(function (string $projectId, array $emails, string $senderName, string $senderEmail, string $replyTo, string $host, int $port, string $username, string $password, string $secure, Response $response, Database $dbForPlatform, Mail $queueForMails, array $plan) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $replyToEmail = !empty($replyTo) ? $replyTo : $senderEmail; - - $subject = 'Custom SMTP email sample'; - $template = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-smtp-test.tpl'); - $template - ->setParam('{{from}}', "{$senderName} ({$senderEmail})") - ->setParam('{{replyTo}}', "{$senderName} ({$replyToEmail})") - ->setParam('{{logoUrl}}', $plan['logoUrl'] ?? APP_EMAIL_LOGO_URL) - ->setParam('{{accentColor}}', $plan['accentColor'] ?? APP_EMAIL_ACCENT_COLOR) - ->setParam('{{twitterUrl}}', $plan['twitterUrl'] ?? APP_SOCIAL_TWITTER) - ->setParam('{{discordUrl}}', $plan['discordUrl'] ?? APP_SOCIAL_DISCORD) - ->setParam('{{githubUrl}}', $plan['githubUrl'] ?? APP_SOCIAL_GITHUB_APPWRITE) - ->setParam('{{termsUrl}}', $plan['termsUrl'] ?? APP_EMAIL_TERMS_URL) - ->setParam('{{privacyUrl}}', $plan['privacyUrl'] ?? APP_EMAIL_PRIVACY_URL); - - foreach ($emails as $email) { - $queueForMails - ->setSmtpHost($host) - ->setSmtpPort($port) - ->setSmtpUsername($username) - ->setSmtpPassword($password) - ->setSmtpSecure($secure) - ->setSmtpReplyTo($replyTo) - ->setSmtpSenderEmail($senderEmail) - ->setSmtpSenderName($senderName) - ->setRecipient($email) - ->setName('') - ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') - ->setBody($template->render()) - ->setVariables([]) - ->setSubject($subject) - ->trigger(); - } - - $response->noContent(); - }); - Http::get('/v1/projects/:projectId/templates/email') ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Get custom email template') From 8b41aed9196e94ddb06bd757cae1d1c6c611c23b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:49:43 +0200 Subject: [PATCH 40/81] Post-merge removal --- app/controllers/api/projects.php | 218 ------------------------------- 1 file changed, 218 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 6fc52d2158..5aeee1a8d7 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -619,224 +619,6 @@ Http::post('/v1/projects/:projectId/jwts') ])]), Response::MODEL_JWT); }); -Http::get('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Get custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'getEmailTemplate', - description: '/docs/references/projects/get-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->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', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('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()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - $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[$type] ?? [ - 'file' => 'email-inner-base.tpl', - 'placeholders' => ['buttonText', 'body', 'footer'] - ]; - - $templateString = file_get_contents(__DIR__ . '/../../config/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' => '' - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::patch('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Update custom email templates') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'updateEmailTemplate', - description: '/docs/references/projects/update-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ] - )) - ->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', '', 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) - ->param('senderEmail', '', new Email(), 'Email of the sender', true) - ->param('replyTo', '', new Email(), 'Reply to email', true) - ->inject('response') - ->inject('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()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['email.' . $type . '-' . $locale] = [ - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $senderName, - 'senderEmail' => $senderEmail, - 'subject' => $subject, - 'replyTo' => $replyTo, - 'message' => $message - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - -Http::delete('/v1/projects/:projectId/templates/email') - ->alias('/v1/projects/:projectId/templates/email/:type/:locale') - ->desc('Delete custom email template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteEmailTemplate', - description: '/docs/references/projects/delete-email-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_EMAIL_TEMPLATE, - ) - ], - contentType: ContentType::JSON - )) - ->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', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) - ->inject('response') - ->inject('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()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - - unset($templates['email.' . $type . '-' . $locale]); - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'senderName' => $template['senderName'], - 'senderEmail' => $template['senderEmail'], - 'subject' => $template['subject'], - 'replyTo' => $template['replyTo'], - 'message' => $template['message'] - ]), Response::MODEL_EMAIL_TEMPLATE); - }); - Http::patch('/v1/projects/:projectId/auth/session-invalidation') ->desc('Update invalidate session option of the project') ->groups(['api', 'projects']) From 52e3319a867eba490ef0325cd3af1e31113f8dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 14:50:12 +0200 Subject: [PATCH 41/81] Linter fix --- app/controllers/api/projects.php | 8 -------- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 5aeee1a8d7..562ff15c39 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -3,29 +3,21 @@ use Ahc\Jwt\JWT; use Appwrite\Auth\Validator\MockNumber; use Appwrite\Event\Delete; -use Appwrite\Event\Mail; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; -use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; -use Appwrite\Template\Template; use Appwrite\Utopia\Database\Validator\Queries\Keys; use Appwrite\Utopia\Response; -use PHPMailer\PHPMailer\PHPMailer; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Validator\UID; -use Utopia\Emails\Validator\Email; use Utopia\Http\Http; -use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; -use Utopia\Validator\Hostname; -use Utopia\Validator\Integer; use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 58360058da..edfb45a6e6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -143,10 +143,10 @@ class Update extends Action if (!$valid) { throw new \Exception('Connection is not valid.'); } - + // Auto-enable if configuration is valid // Dont do this if specifically request to mark disabled - if(\is_null($enabled)) { + if (\is_null($enabled)) { $smtp['enabled'] = true; } } catch (Throwable $error) { From 51f50b161c4a1f050fc1a652aa7a340a26e4318c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 15:28:05 +0200 Subject: [PATCH 42/81] Improved backwards compatibility --- .../Modules/Project/Services/Http.php | 1 + src/Appwrite/Utopia/Request/Filters/V23.php | 18 +++++++++++++++- src/Appwrite/Utopia/Response/Filters/V23.php | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 2e7ceafe14..b2ee23aca6 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -53,6 +53,7 @@ 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()); diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php index adb5d69aea..131844e6d5 100644 --- a/src/Appwrite/Utopia/Request/Filters/V23.php +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -17,14 +17,30 @@ class V23 extends Filter return $content; } + protected function parseReplyTo(array $content): array + { + if (isset($content['replyTo'])) { + $content['replyToEmail'] = $content['replyTo']; + unset($content['replyTo']); + } + + 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; + case 'project.updateEmailTemplate': + $content = $this->parseEmailTemplate($content); + $content = $this->parseReplyTo($content); + break; + case 'project.updateSMTP': + $content = $this->parseReplyTo($content); + break; } return $content; } diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php index 54fcf8459f..8303e98eed 100644 --- a/src/Appwrite/Utopia/Response/Filters/V23.php +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -12,6 +12,7 @@ class V23 extends Filter { return match ($model) { Response::MODEL_EMAIL_TEMPLATE => $this->parseEmailTemplate($content), + Response::MODEL_PROJECT => $this->parseProject($content), default => $content, }; } @@ -23,6 +24,26 @@ class V23 extends Filter unset($content['templateId']); } + if (isset($content['replyToEmail'])) { + $content['replyTo'] = $content['replyToEmail']; + unset($content['replyToEmail']); + } + + unset($content['replyToName']); + unset($content['custom']); + + return $content; + } + + private function parseProject(array $content): array + { + if (isset($content['smtpReplyToEmail'])) { + $content['smtpReplyTo'] = $content['smtpReplyToEmail']; + unset($content['smtpReplyToEmail']); + } + + unset($content['smtpReplyToName']); + return $content; } } From 848f09956eced39a4e1802ea228959c66429c55a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 15:36:49 +0200 Subject: [PATCH 43/81] Improve backwards compatibility test coverage --- .../Http/Project/SMTP/Tests/Create.php | 34 +++++-- tests/e2e/Services/Project/SMTPBase.php | 98 +++++++++++++++++++ tests/e2e/Services/Project/TemplatesBase.php | 96 ++++++++++++++++++ 3 files changed, 218 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php index a41292466c..a07d667d56 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -90,7 +90,9 @@ class Create extends Action Mail $queueForMails, array $plan ): void { - // Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config + // Backwards compatibility: use inline params if provided, otherwise fall back to project SMTP config. + // When inline params are provided they are treated as self-contained — project config is ignored + // so legacy (1.9.1) callers do not get project state (e.g. replyToName) leaked into their request. $hasInlineParams = !empty($paramHost); $smtp = $project->getAttribute('smtp', []); @@ -99,15 +101,27 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to send a test email.'); } - $senderName = $paramSenderName ?: ($smtp['senderName'] ?? ''); - $senderEmail = $paramSenderEmail ?: ($smtp['senderEmail'] ?? ''); - $replyToEmail = $paramReplyTo ?: ($smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''); // Includes backwards compatibility - $replyToName = $smtp['replyToName'] ?? ''; - $host = $paramHost ?: ($smtp['host'] ?? ''); - $port = $paramPort ?? ($smtp['port'] ?? ''); - $username = $paramUsername ?: ($smtp['username'] ?? ''); - $password = $paramPassword ?: ($smtp['password'] ?? ''); - $secure = $paramSecure ?: ($smtp['secure'] ?? ''); + if ($hasInlineParams) { + $senderName = $paramSenderName; + $senderEmail = $paramSenderEmail; + $replyToEmail = $paramReplyTo; + $replyToName = ''; // 1.9.1 inline params did not include replyToName + $host = $paramHost; + $port = $paramPort ?? 0; + $username = $paramUsername; + $password = $paramPassword; + $secure = $paramSecure; + } else { + $senderName = $smtp['senderName'] ?? ''; + $senderEmail = $smtp['senderEmail'] ?? ''; + $replyToEmail = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; // Includes backwards compatibility + $replyToName = $smtp['replyToName'] ?? ''; + $host = $smtp['host'] ?? ''; + $port = $smtp['port'] ?? 0; + $username = $smtp['username'] ?? ''; + $password = $smtp['password'] ?? ''; + $secure = $smtp['secure'] ?? ''; + } if (empty($senderName)) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender name must be configured on the project to send a test email.'); diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 2cfe645290..c6f7ea4b67 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -534,6 +534,104 @@ trait SMTPBase $this->assertSame('project_smtp_config_invalid', $response['body']['type']); } + public function testUpdateSMTPLegacyReplyToAndResponseFormat(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Legacy client sends `replyTo` (not `replyToEmail`). Request filter maps it. + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + $headers, + [ + 'enabled' => true, + 'senderName' => 'Legacy Sender', + 'senderEmail' => 'legacy-sender@example.com', + 'host' => 'maildev', + 'port' => 1025, + 'replyTo' => 'legacy-reply@example.com', + ], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + $this->assertSame('Legacy Sender', $response['body']['smtpSenderName']); + $this->assertSame('legacy-sender@example.com', $response['body']['smtpSenderEmail']); + + // Response filter must expose smtpReplyTo and strip smtpReplyToEmail / smtpReplyToName. + $this->assertArrayHasKey('smtpReplyTo', $response['body']); + $this->assertArrayNotHasKey('smtpReplyToEmail', $response['body']); + $this->assertArrayNotHasKey('smtpReplyToName', $response['body']); + $this->assertSame('legacy-reply@example.com', $response['body']['smtpReplyTo']); + + // Sanity-check: a modern (non-legacy) read sees the new field names. + $modern = $this->updateSMTP(enabled: true); + $this->assertArrayHasKey('smtpReplyToEmail', $modern['body']); + $this->assertSame('legacy-reply@example.com', $modern['body']['smtpReplyToEmail']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testCreateSMTPTestLegacyInlineParams(): void + { + // Seed the project with a distinct SMTP config so we can prove the + // inline (1.9.1-style) params take precedence over project config. + $this->updateSMTP( + senderName: 'Project Sender', + senderEmail: 'project-sender@example.com', + host: 'maildev', + port: 1025, + replyToEmail: 'project-reply@example.com', + replyToName: 'Project Reply', + enabled: false, + ); + + $recipient = 'legacy-smtp-' . \uniqid() . '@appwrite.io'; + + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $response = $this->client->call( + Client::METHOD_POST, + '/project/smtp/tests', + $headers, + [ + 'emails' => [$recipient], + 'senderName' => 'Inline Legacy Sender', + 'senderEmail' => 'inline-legacy@appwrite.io', + 'replyTo' => 'inline-legacy-reply@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ], + ); + + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Verify the email was sent using the inline params (not project SMTP). + $email = $this->getLastEmailByAddress($recipient, function ($email) { + $this->assertSame('Custom SMTP email sample', $email['subject']); + }); + + $this->assertSame('inline-legacy@appwrite.io', $email['from'][0]['address']); + $this->assertSame('Inline Legacy Sender', $email['from'][0]['name']); + $this->assertSame('inline-legacy-reply@appwrite.io', $email['replyTo'][0]['address']); + $this->assertSame('Inline Legacy Sender', $email['replyTo'][0]['name']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + public function testUpdateSMTPBackwardsCompatibilityDisable(): void { // First enable SMTP diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 422f890785..ade2bb3f2c 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -434,6 +434,102 @@ trait TemplatesBase $this->assertSame(400, $update['headers']['status-code']); } + public function testUpdateEmailTemplateLegacyReplyTo(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + // Legacy clients send replyTo (not replyToEmail) — request filter maps it. + $update = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + $headers, + [ + 'type' => 'invitation', + 'locale' => 'en', + 'subject' => 'Legacy reply-to subject', + 'message' => 'Legacy reply-to body', + 'senderName' => 'Legacy Sender', + 'senderEmail' => 'legacy-sender@appwrite.io', + 'replyTo' => 'legacy-reply@appwrite.io', + ], + ); + + $this->assertSame(200, $update['headers']['status-code']); + // Response filter should rename replyToEmail -> replyTo, strip replyToName / custom. + $this->assertArrayHasKey('replyTo', $update['body']); + $this->assertArrayNotHasKey('replyToEmail', $update['body']); + $this->assertArrayNotHasKey('replyToName', $update['body']); + $this->assertArrayNotHasKey('custom', $update['body']); + $this->assertSame('legacy-reply@appwrite.io', $update['body']['replyTo']); + $this->assertSame('Legacy Sender', $update['body']['senderName']); + $this->assertSame('legacy-sender@appwrite.io', $update['body']['senderEmail']); + + // Verify value is persisted and readable via the legacy GET shape. + $get = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/invitation', + $headers, + ['locale' => 'en'], + ); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertArrayHasKey('replyTo', $get['body']); + $this->assertArrayNotHasKey('replyToEmail', $get['body']); + $this->assertArrayNotHasKey('replyToName', $get['body']); + $this->assertArrayNotHasKey('custom', $get['body']); + $this->assertSame('legacy-reply@appwrite.io', $get['body']['replyTo']); + + // Cleanup + $this->deleteEmailTemplate('invitation', 'en'); + } + + public function testGetEmailTemplateLegacyReplyTo(): void + { + // Seed a custom template using the current API (includes replyToEmail + replyToName). + $update = $this->updateEmailTemplate( + 'otpSession', + 'en', + 'Legacy OTP', + 'Legacy OTP body', + 'Legacy Sender', + 'legacy-sender@appwrite.io', + 'legacy-reply@appwrite.io', + 'Legacy Reply Team', + ); + $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()); + + $get = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/otpSession', + $headers, + ['locale' => 'en'], + ); + + $this->assertSame(200, $get['headers']['status-code']); + // Legacy fields present + $this->assertArrayHasKey('type', $get['body']); + $this->assertArrayHasKey('replyTo', $get['body']); + $this->assertSame('otpSession', $get['body']['type']); + $this->assertSame('legacy-reply@appwrite.io', $get['body']['replyTo']); + // New fields stripped + $this->assertArrayNotHasKey('templateId', $get['body']); + $this->assertArrayNotHasKey('replyToEmail', $get['body']); + $this->assertArrayNotHasKey('replyToName', $get['body']); + $this->assertArrayNotHasKey('custom', $get['body']); + + // Cleanup + $this->deleteEmailTemplate('otpSession', 'en'); + } + // ========================================================================= // Helpers // ========================================================================= From dfec2b3cb7787ee58207199f862697fcc0bfc46e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 16:11:22 +0200 Subject: [PATCH 44/81] Improve test coverage --- .../Http/Project/SMTP/Tests/Create.php | 4 - .../Project/Http/Project/SMTP/Update.php | 6 +- tests/e2e/Services/Project/SMTPBase.php | 150 ++++++++++++++++++ 3 files changed, 154 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php index a07d667d56..7095c2d2d0 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Tests/Create.php @@ -123,10 +123,6 @@ class Create extends Action $secure = $smtp['secure'] ?? ''; } - if (empty($senderName)) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender name must be configured on the project to send a test email.'); - } - if (empty($senderEmail)) { throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP sender email must be configured on the project to send a test email.'); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index edfb45a6e6..63e9cf287e 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -115,7 +115,7 @@ class Update extends Action } // Validate SMTP credentials - if ($smtp['enabled'] === true) { + if (\is_null($smtp['enabled'] ?? null) || $smtp['enabled'] === true) { $mail = new PHPMailer(true); $mail->isSMTP(); @@ -150,7 +150,9 @@ class Update extends Action $smtp['enabled'] = true; } } catch (Throwable $error) { - throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); + if (($smtp['enabled'] ?? null) === true) { + throw new Exception(Exception::PROJECT_SMTP_CONFIG_INVALID, $error->getMessage()); + } } } diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index c6f7ea4b67..c8cb06ac4e 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -656,6 +656,156 @@ trait SMTPBase $this->assertSame(false, $response['body']['smtpEnabled']); } + public function testUpdateSMTPRequiredFieldsOptionalAfterConfigured(): void + { + // Seed with a known configuration so required fields (host, port, senderEmail) are stored. + $this->updateSMTP( + senderName: 'Initial Sender', + senderEmail: 'initial@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + // Partial update: only update senderName, omitting host/port/senderEmail. + // Required fields should not be re-required because they are already stored. + $response = $this->updateSMTP(senderName: 'Updated Sender'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Updated Sender', $response['body']['smtpSenderName']); + $this->assertSame('initial@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPAllParamsOptionalAfterConfigured(): void + { + // Seed a configuration so all fields are stored. + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: true, + ); + + // Issue a PATCH with no params at all. Once previously configured, this must succeed. + $response = $this->updateSMTP(); + + $this->assertSame(200, $response['headers']['status-code']); + // Previously-set values are preserved + $this->assertSame('Test Sender', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + + public function testUpdateSMTPEnabledTrueWithInvalidCredentials(): void + { + // Explicitly enabling SMTP with unreachable host/port must throw. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: true, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_smtp_config_invalid', $response['body']['type']); + } + + public function testUpdateSMTPEnabledFalseWithInvalidCredentials(): void + { + // enabled=false means SMTP is not in use, so invalid credentials must be accepted. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + $this->assertSame('localhost', $response['body']['smtpHost']); + $this->assertSame(12345, $response['body']['smtpPort']); + + // Cleanup (restore valid disabled config) + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + } + + public function testUpdateSMTPEnabledNullWithInvalidCredentialsDoesNotThrow(): void + { + // Ensure SMTP is currently disabled so we aren't enforcing validation on an enabled config. + $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + // With enabled omitted (null) and invalid credentials, the request must not throw. + // SMTP remains disabled because the credentials could not be validated. + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + + // Cleanup (restore valid disabled config) + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + } + + public function testUpdateSMTPEnabledNullWithValidCredentialsAutoEnables(): void + { + // Start from a disabled state. + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + enabled: false, + ); + + // With enabled omitted (null) and valid credentials, SMTP must be auto-enabled. + $response = $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['smtpEnabled']); + + // Cleanup + $this->updateSMTP(enabled: false); + } + // Create SMTP test tests public function testCreateSMTPTest(): void From 8ea69e03212ecbdc53f8c23accfcf01f4e103815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 16:57:23 +0200 Subject: [PATCH 45/81] Fix password visibility test --- src/Appwrite/Utopia/Response/Model/Project.php | 9 +++------ tests/e2e/Services/Project/SMTPBase.php | 14 ++++++++++++-- .../Projects/ProjectsConsoleClientTest.php | 6 ++++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index aef4927ad7..11e23b02d8 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -277,15 +277,12 @@ class Project extends Model 'default' => '', 'example' => 'emailuser', ]) - /* - We intentionally do not expose SMTP password - it's write-only property. ->addRule('smtpPassword', [ 'type' => self::TYPE_STRING, - 'description' => 'SMTP server password', + 'description' => 'SMTP server password. This property is write-only and always returned empty.', 'default' => '', - 'example' => 'securepassword', + 'example' => '', ]) - */ ->addRule('smtpSecure', [ 'type' => self::TYPE_STRING, 'description' => 'SMTP server secure protocol', @@ -423,7 +420,7 @@ class Project extends Model $document->setAttribute('smtpHost', $smtp['host'] ?? ''); $document->setAttribute('smtpPort', $smtp['port'] ?? ''); $document->setAttribute('smtpUsername', $smtp['username'] ?? ''); - $document->setAttribute('smtpPassword', $smtp['password'] ?? ''); + $document->setAttribute('smtpPassword', ''); // Write-only: never expose the stored value $document->setAttribute('smtpSecure', $smtp['secure'] ?? ''); } diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index c8cb06ac4e..fa58891b08 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -88,6 +88,8 @@ trait SMTPBase senderEmail: 'sender@example.com', host: 'maildev', port: 1025, + username: 'user', + password: 'password', enabled: true, ); @@ -103,6 +105,8 @@ trait SMTPBase $this->assertArrayHasKey('smtpPort', $response['body']); $this->assertArrayHasKey('smtpUsername', $response['body']); $this->assertArrayHasKey('smtpPassword', $response['body']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertSame('', $response['body']['smtpPassword']); $this->assertArrayHasKey('smtpSecure', $response['body']); // Cleanup @@ -219,6 +223,8 @@ trait SMTPBase senderEmail: 'sender@example.com', host: 'maildev', port: 1025, + username: 'user', + password: 'password', ); $this->assertSame(200, $response['headers']['status-code']); @@ -233,6 +239,8 @@ trait SMTPBase $this->assertArrayHasKey('smtpPort', $response['body']); $this->assertArrayHasKey('smtpUsername', $response['body']); $this->assertArrayHasKey('smtpPassword', $response['body']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertSame('', $response['body']['smtpPassword']); $this->assertArrayHasKey('smtpSecure', $response['body']); // Cleanup @@ -455,7 +463,8 @@ trait SMTPBase ); $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame('p', $response['body']['smtpPassword']); + // smtpPassword is write-only: the accepted password must not be echoed back + $this->assertSame('', $response['body']['smtpPassword']); // Cleanup $this->updateSMTP(enabled: false); @@ -473,7 +482,8 @@ trait SMTPBase ); $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame($password, $response['body']['smtpPassword']); + // smtpPassword is write-only: the accepted password must not be echoed back + $this->assertSame('', $response['body']['smtpPassword']); // Cleanup $this->updateSMTP(enabled: false); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 4400c337ac..9506c1a963 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -971,7 +971,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals($smtpHost, $response['body']['smtpHost']); $this->assertEquals($smtpPort, $response['body']['smtpPort']); $this->assertEquals($smtpUsername, $response['body']['smtpUsername']); - $this->assertEquals($smtpPassword, $response['body']['smtpPassword']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertEquals('', $response['body']['smtpPassword']); $this->assertEquals('', $response['body']['smtpSecure']); // Check the project @@ -987,7 +988,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals($smtpHost, $response['body']['smtpHost']); $this->assertEquals($smtpPort, $response['body']['smtpPort']); $this->assertEquals($smtpUsername, $response['body']['smtpUsername']); - $this->assertEquals($smtpPassword, $response['body']['smtpPassword']); + // smtpPassword is write-only: the stored password must never leak in responses + $this->assertEquals('', $response['body']['smtpPassword']); $this->assertEquals('', $response['body']['smtpSecure']); /** From 8c03db70e9484e5eeaeb688a70e721bce06f3a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:04:20 +0200 Subject: [PATCH 46/81] Finalize templates design --- app/config/errors.php | 5 + src/Appwrite/Extend/Exception.php | 1 + .../Http/Project/Platforms/Web/Create.php | 2 +- .../Http/Project/Templates/Email/Delete.php | 109 ---- .../Http/Project/Templates/Email/Get.php | 144 ++-- .../Http/Project/Templates/Email/Update.php | 67 +- .../Modules/Project/Services/Http.php | 2 - .../Utopia/Response/Model/TemplateEmail.php | 6 - tests/e2e/Services/Project/TemplatesBase.php | 617 ------------------ 9 files changed, 119 insertions(+), 834 deletions(-) delete mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php diff --git a/app/config/errors.php b/app/config/errors.php index 4190c6e277..1d9d4593b9 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1144,6 +1144,11 @@ return [ 'description' => 'Provided SMTP config is invalid. Please check the configured values and try again.', 'code' => 400, ], + Exception::PROJECT_TEMPLATE_CONFIG_INVALID => [ + 'name' => Exception::PROJECT_TEMPLATE_CONFIG_INVALID, + 'description' => 'Provided template config is invalid. Please check the configured values and try again.', + 'code' => 400, + ], Exception::PROJECT_TEMPLATE_DEFAULT_DELETION => [ 'name' => Exception::PROJECT_TEMPLATE_DEFAULT_DELETION, 'description' => 'You can\'t delete default template. If you are trying to reset your template changes, you can ignore this error as it\'s already been reset.', diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 58a21b5517..b2eb06b752 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -303,6 +303,7 @@ class Exception extends \Exception public const string ACCOUNT_KEY_EXPIRED = 'account_key_expired'; public const string PROJECT_SMTP_CONFIG_INVALID = 'project_smtp_config_invalid'; + public const string PROJECT_TEMPLATE_CONFIG_INVALID = 'project_template_config_invalid'; public const string PROJECT_TEMPLATE_DEFAULT_DELETION = 'project_template_default_deletion'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php index 2fca0ace6c..6c07727150 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -139,7 +139,7 @@ class Create extends Action if (empty($key) && empty($type)) { // Modern request, validate hostname if (empty($hostname)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Param "hostname" is not optional.'); + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "hostname" is not optional.'); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php deleted file mode 100644 index 7928486192..0000000000 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Delete.php +++ /dev/null @@ -1,109 +0,0 @@ -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: <<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); - } -} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 6e2b2ef56d..1843c556d4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -51,7 +51,6 @@ class Get extends Action ->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(...)); } @@ -60,87 +59,84 @@ class Get extends Action string $locale, Response $response, Document $project, - Locale $localeObject, ) { $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + // Get custom template if available $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 = $templates['email.' . $templateId . '-' . $locale] ?? []; + // Enforced params $template['templateId'] = $templateId; $template['locale'] = $locale; + // Prepare default tempaltes + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + + $defaultSubject = $localeObj->getText('emails.' . $templateId . '.subject'); + $defaultMessage = $this->getDefaultMessage($templateId, $localeObj); + + // Apply defaults if needed + if (\is_null($template['message'])) { + $template['message'] = $defaultMessage; + } + + if (\is_null($template['subject'])) { + $template['subject'] = $defaultSubject; + } + + // Backwards compatibility + if (!\is_null($template['replyTo'])) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } + $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); } + + protected function getDefaultMessage(string $templateId, Locale $localeObj): string + { + $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']); + $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 + ->setParam('{{hello}}', $localeObj->getText("emails.{$templateId}.hello")) + ->setParam('{{thanks}}', $localeObj->getText("emails.{$templateId}.thanks")) + ->setParam('{{signature}}', $localeObj->getText("emails.{$templateId}.signature")); + + $message = $message->render(useContent: true); + + return $message; + } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index 93cfa7a3fb..4a1d4a5a21 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -3,6 +3,7 @@ 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; @@ -12,10 +13,10 @@ 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\Nullable; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -57,51 +58,68 @@ class Update extends Action )) ->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) + ->param('subject', null, new Nullable(new Text(255)), 'Subject of the email template. Can be up to 255 characters.') + ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') + ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', true) + ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', true) + ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', true) + ->param('replyToName', null, new Nullable(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, + ?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 - ]; + // Prevent template update if custom SMTP is not configured + $smtp = $project->getAttribute('smtp', []); + if (($smtp['enabled'] ?? false) !== true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'SMTP must be enabled on the project to configure custom email templates.'); + } + // Fetch current configuration $templates = $project->getAttribute('templates', []); - $templates['email.' . $templateId . '-' . $locale] = $template; + $template = $templates['email.' . $templateId . '-' . $locale] ?? []; + // Apply changes + $keys = ['senderName', 'senderEmail', 'replyToEmail', 'replyToName', 'message', 'subject']; + foreach ($keys as $key) { + if (!\is_null(${$key})) { + $template[$key] = ${$key}; + } + } + + // Backwards compatibility + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + + // Ensure required fields are set + $requiredKeys = ['subject', 'message']; + foreach ($requiredKeys as $key) { + if (empty($template[$key])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.'); + } + } + + // Save configuration + $templates['email.' . $templateId . '-' . $locale] = $template; $updates = new Document([ 'templates' => $templates, ]); @@ -119,7 +137,6 @@ class Update extends Action 'replyToEmail' => $template['replyToEmail'], 'replyToName' => $template['replyToName'], 'message' => $template['message'], - 'custom' => true, ]), Response::MODEL_EMAIL_TEMPLATE); } } diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index b2ee23aca6..5774b377c9 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -26,7 +26,6 @@ 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; @@ -56,7 +55,6 @@ class Http extends Service // Templates $this->addAction(GetTemplate::getName(), new GetTemplate()); - $this->addAction(DeleteTemplate::getName(), new DeleteTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); // Variables diff --git a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php index 08ccd33524..833de90065 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateEmail.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateEmail.php @@ -58,12 +58,6 @@ class TemplateEmail extends Model '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, - ]) ; } diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index ade2bb3f2c..b53db7b6eb 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -6,621 +6,4 @@ 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']); - } - - public function testUpdateEmailTemplateLegacyReplyTo(): void - { - $headers = \array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()); - - // Legacy clients send replyTo (not replyToEmail) — request filter maps it. - $update = $this->client->call( - Client::METHOD_PATCH, - '/project/templates/email', - $headers, - [ - 'type' => 'invitation', - 'locale' => 'en', - 'subject' => 'Legacy reply-to subject', - 'message' => 'Legacy reply-to body', - 'senderName' => 'Legacy Sender', - 'senderEmail' => 'legacy-sender@appwrite.io', - 'replyTo' => 'legacy-reply@appwrite.io', - ], - ); - - $this->assertSame(200, $update['headers']['status-code']); - // Response filter should rename replyToEmail -> replyTo, strip replyToName / custom. - $this->assertArrayHasKey('replyTo', $update['body']); - $this->assertArrayNotHasKey('replyToEmail', $update['body']); - $this->assertArrayNotHasKey('replyToName', $update['body']); - $this->assertArrayNotHasKey('custom', $update['body']); - $this->assertSame('legacy-reply@appwrite.io', $update['body']['replyTo']); - $this->assertSame('Legacy Sender', $update['body']['senderName']); - $this->assertSame('legacy-sender@appwrite.io', $update['body']['senderEmail']); - - // Verify value is persisted and readable via the legacy GET shape. - $get = $this->client->call( - Client::METHOD_GET, - '/project/templates/email/invitation', - $headers, - ['locale' => 'en'], - ); - $this->assertSame(200, $get['headers']['status-code']); - $this->assertArrayHasKey('replyTo', $get['body']); - $this->assertArrayNotHasKey('replyToEmail', $get['body']); - $this->assertArrayNotHasKey('replyToName', $get['body']); - $this->assertArrayNotHasKey('custom', $get['body']); - $this->assertSame('legacy-reply@appwrite.io', $get['body']['replyTo']); - - // Cleanup - $this->deleteEmailTemplate('invitation', 'en'); - } - - public function testGetEmailTemplateLegacyReplyTo(): void - { - // Seed a custom template using the current API (includes replyToEmail + replyToName). - $update = $this->updateEmailTemplate( - 'otpSession', - 'en', - 'Legacy OTP', - 'Legacy OTP body', - 'Legacy Sender', - 'legacy-sender@appwrite.io', - 'legacy-reply@appwrite.io', - 'Legacy Reply Team', - ); - $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()); - - $get = $this->client->call( - Client::METHOD_GET, - '/project/templates/email/otpSession', - $headers, - ['locale' => 'en'], - ); - - $this->assertSame(200, $get['headers']['status-code']); - // Legacy fields present - $this->assertArrayHasKey('type', $get['body']); - $this->assertArrayHasKey('replyTo', $get['body']); - $this->assertSame('otpSession', $get['body']['type']); - $this->assertSame('legacy-reply@appwrite.io', $get['body']['replyTo']); - // New fields stripped - $this->assertArrayNotHasKey('templateId', $get['body']); - $this->assertArrayNotHasKey('replyToEmail', $get['body']); - $this->assertArrayNotHasKey('replyToName', $get['body']); - $this->assertArrayNotHasKey('custom', $get['body']); - - // Cleanup - $this->deleteEmailTemplate('otpSession', 'en'); - } - - // ========================================================================= - // 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); - } } From 8bf2f54a511c75c2e19fb189462848f850c1a6c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:04:46 +0200 Subject: [PATCH 47/81] Leftovers --- app/config/errors.php | 5 ----- src/Appwrite/Extend/Exception.php | 1 - 2 files changed, 6 deletions(-) diff --git a/app/config/errors.php b/app/config/errors.php index 1d9d4593b9..4190c6e277 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1144,11 +1144,6 @@ return [ 'description' => 'Provided SMTP config is invalid. Please check the configured values and try again.', 'code' => 400, ], - Exception::PROJECT_TEMPLATE_CONFIG_INVALID => [ - 'name' => Exception::PROJECT_TEMPLATE_CONFIG_INVALID, - 'description' => 'Provided template config is invalid. Please check the configured values and try again.', - 'code' => 400, - ], Exception::PROJECT_TEMPLATE_DEFAULT_DELETION => [ 'name' => Exception::PROJECT_TEMPLATE_DEFAULT_DELETION, 'description' => 'You can\'t delete default template. If you are trying to reset your template changes, you can ignore this error as it\'s already been reset.', diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index b2eb06b752..58a21b5517 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -303,7 +303,6 @@ class Exception extends \Exception public const string ACCOUNT_KEY_EXPIRED = 'account_key_expired'; public const string PROJECT_SMTP_CONFIG_INVALID = 'project_smtp_config_invalid'; - public const string PROJECT_TEMPLATE_CONFIG_INVALID = 'project_template_config_invalid'; public const string PROJECT_TEMPLATE_DEFAULT_DELETION = 'project_template_default_deletion'; From e27719108b6cb6f1da58d82445ddd869a7ed624a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:15:03 +0200 Subject: [PATCH 48/81] Add tests --- tests/e2e/Services/Project/TemplatesBase.php | 731 +++++++++++++++++++ 1 file changed, 731 insertions(+) diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index b53db7b6eb..d6dbd0c6b6 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -6,4 +6,735 @@ use Tests\E2E\Client; trait TemplatesBase { + // Get email template tests + + public function testGetEmailTemplateDefault(): void + { + $response = $this->getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateDefaultLocale(): void + { + // When locale is omitted, the fallback locale (en) is applied server-side. + $response = $this->getEmailTemplate('recovery'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('recovery', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateAllSupportedTypes(): void + { + $types = [ + 'verification', + 'magicSession', + 'recovery', + 'invitation', + 'mfaChallenge', + 'sessionAlert', + 'otpSession', + ]; + + foreach ($types as $type) { + $response = $this->getEmailTemplate($type, 'en'); + + $this->assertSame(200, $response['headers']['status-code'], "type={$type}"); + $this->assertSame($type, $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject'], "type={$type} must have default subject"); + $this->assertNotEmpty($response['body']['message'], "type={$type} must have default message"); + } + } + + public function testGetEmailTemplateNonDefaultLocale(): void + { + // Even a non-en locale that has no custom template must return defaults. + $response = $this->getEmailTemplate('verification', 'fr'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('fr', $response['body']['locale']); + $this->assertNotEmpty($response['body']['subject']); + $this->assertNotEmpty($response['body']['message']); + } + + public function testGetEmailTemplateResponseModel(): void + { + $response = $this->getEmailTemplate('verification', 'en'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('templateId', $response['body']); + $this->assertArrayHasKey('locale', $response['body']); + $this->assertArrayHasKey('subject', $response['body']); + $this->assertArrayHasKey('message', $response['body']); + $this->assertArrayHasKey('senderName', $response['body']); + $this->assertArrayHasKey('senderEmail', $response['body']); + $this->assertArrayHasKey('replyToEmail', $response['body']); + $this->assertArrayHasKey('replyToName', $response['body']); + } + + public function testGetEmailTemplateInvalidType(): void + { + $response = $this->getEmailTemplate('notATemplate', 'en'); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testGetEmailTemplateInvalidLocale(): void + { + $response = $this->getEmailTemplate('verification', 'not-a-locale'); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testGetEmailTemplateWithoutAuthentication(): void + { + $response = $this->getEmailTemplate('verification', 'en', false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testGetEmailTemplateReturnsCustomValues(): void + { + $this->ensureSMTPEnabled(); + + $subject = 'Custom invitation subject ' . \uniqid(); + $message = 'Custom invitation body ' . \uniqid(); + + $update = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: $subject, + message: $message, + senderName: 'Invitation Sender', + senderEmail: 'invitation@appwrite.io', + replyToEmail: 'reply-invitation@appwrite.io', + replyToName: 'Invitation Reply', + ); + $this->assertSame(200, $update['headers']['status-code']); + + $get = $this->getEmailTemplate('invitation', 'en'); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('invitation', $get['body']['templateId']); + $this->assertSame('en', $get['body']['locale']); + $this->assertSame($subject, $get['body']['subject']); + $this->assertSame($message, $get['body']['message']); + $this->assertSame('Invitation Sender', $get['body']['senderName']); + $this->assertSame('invitation@appwrite.io', $get['body']['senderEmail']); + $this->assertSame('reply-invitation@appwrite.io', $get['body']['replyToEmail']); + $this->assertSame('Invitation Reply', $get['body']['replyToName']); + } + + public function testGetEmailTemplateCustomizationIsLocaleScoped(): void + { + $this->ensureSMTPEnabled(); + + $enSubject = 'EN only subject ' . \uniqid(); + $update = $this->updateEmailTemplate( + templateId: 'mfaChallenge', + locale: 'en', + subject: $enSubject, + message: 'EN only message', + ); + $this->assertSame(200, $update['headers']['status-code']); + + // Another locale must still return its defaults — not the en customization. + $other = $this->getEmailTemplate('mfaChallenge', 'de'); + $this->assertSame(200, $other['headers']['status-code']); + $this->assertSame('de', $other['body']['locale']); + $this->assertNotSame($enSubject, $other['body']['subject']); + } + + // Update email template tests + + public function testUpdateEmailTemplateRequiredFields(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Please verify your email', + message: 'Click here to verify: {{url}}', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('verification', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + $this->assertSame('Please verify your email', $response['body']['subject']); + $this->assertSame('Click here to verify: {{url}}', $response['body']['message']); + } + + public function testUpdateEmailTemplateAllFields(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'en', + subject: 'Password reset', + message: 'Reset your password', + senderName: 'Security Team', + senderEmail: 'security@appwrite.io', + replyToEmail: 'noreply@appwrite.io', + replyToName: 'No Reply', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Password reset', $response['body']['subject']); + $this->assertSame('Reset your password', $response['body']['message']); + $this->assertSame('Security Team', $response['body']['senderName']); + $this->assertSame('security@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('noreply@appwrite.io', $response['body']['replyToEmail']); + $this->assertSame('No Reply', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateDefaultLocale(): void + { + $this->ensureSMTPEnabled(); + + // Omit locale entirely; server falls back to `en`. + $response = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: null, + subject: 'Session alert', + message: 'Someone signed in', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('sessionAlert', $response['body']['templateId']); + $this->assertSame('en', $response['body']['locale']); + } + + public function testUpdateEmailTemplateOverwritesPrevious(): void + { + $this->ensureSMTPEnabled(); + + $first = $this->updateEmailTemplate( + templateId: 'otpSession', + locale: 'en', + subject: 'First subject', + message: 'First body', + ); + $this->assertSame(200, $first['headers']['status-code']); + + $second = $this->updateEmailTemplate( + templateId: 'otpSession', + locale: 'en', + subject: 'Second subject', + message: 'Second body', + ); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame('Second subject', $second['body']['subject']); + $this->assertSame('Second body', $second['body']['message']); + + $get = $this->getEmailTemplate('otpSession', 'en'); + $this->assertSame('Second subject', $get['body']['subject']); + $this->assertSame('Second body', $get['body']['message']); + } + + public function testUpdateEmailTemplatePartialAfterSeed(): void + { + $this->ensureSMTPEnabled(); + + // Seed a fully configured template. + $seed = $this->updateEmailTemplate( + templateId: 'magicSession', + locale: 'en', + subject: 'Magic subject', + message: 'Magic body', + senderName: 'Magic Sender', + senderEmail: 'magic@appwrite.io', + replyToEmail: 'magic-reply@appwrite.io', + replyToName: 'Magic Reply', + ); + $this->assertSame(200, $seed['headers']['status-code']); + + // Once seeded, sending just one field is fine: previous subject/message persist. + $response = $this->updateEmailTemplate( + templateId: 'magicSession', + locale: 'en', + senderName: 'Updated Sender', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('Updated Sender', $response['body']['senderName']); + $this->assertSame('Magic subject', $response['body']['subject']); + $this->assertSame('Magic body', $response['body']['message']); + $this->assertSame('magic@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('magic-reply@appwrite.io', $response['body']['replyToEmail']); + $this->assertSame('Magic Reply', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateDifferentLocales(): void + { + $this->ensureSMTPEnabled(); + + $enUpdate = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: 'English subject', + message: 'English body', + ); + $this->assertSame(200, $enUpdate['headers']['status-code']); + $this->assertSame('en', $enUpdate['body']['locale']); + $this->assertSame('English subject', $enUpdate['body']['subject']); + + $frUpdate = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'fr', + subject: 'Sujet francais', + message: 'Corps francais', + ); + $this->assertSame(200, $frUpdate['headers']['status-code']); + $this->assertSame('fr', $frUpdate['body']['locale']); + $this->assertSame('Sujet francais', $frUpdate['body']['subject']); + + // Locales remain independent. + $enGet = $this->getEmailTemplate('invitation', 'en'); + $this->assertSame('English subject', $enGet['body']['subject']); + + $frGet = $this->getEmailTemplate('invitation', 'fr'); + $this->assertSame('Sujet francais', $frGet['body']['subject']); + } + + public function testUpdateEmailTemplateResponseModel(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Model check subject', + message: 'Model check body', + senderName: 'Sender', + senderEmail: 'sender@appwrite.io', + replyToEmail: 'reply@appwrite.io', + replyToName: 'Reply', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('templateId', $response['body']); + $this->assertArrayHasKey('locale', $response['body']); + $this->assertArrayHasKey('subject', $response['body']); + $this->assertArrayHasKey('message', $response['body']); + $this->assertArrayHasKey('senderName', $response['body']); + $this->assertArrayHasKey('senderEmail', $response['body']); + $this->assertArrayHasKey('replyToEmail', $response['body']); + $this->assertArrayHasKey('replyToName', $response['body']); + } + + public function testUpdateEmailTemplateSubjectMaxLength(): void + { + $this->ensureSMTPEnabled(); + + $subject = \str_repeat('a', 255); + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: $subject, + message: 'Body', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($subject, $response['body']['subject']); + } + + public function testUpdateEmailTemplateSubjectTooLong(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: \str_repeat('a', 256), + message: 'Body', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateSenderNameEmptyAllowed(): void + { + $this->ensureSMTPEnabled(); + + // senderName validator explicitly allows empty strings (Text(255, 0)). + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderName: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['senderName']); + } + + public function testUpdateEmailTemplateReplyToNameEmptyAllowed(): void + { + $this->ensureSMTPEnabled(); + + // replyToName validator explicitly allows empty strings (Text(255, 0)). + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + replyToName: '', + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['replyToName']); + } + + public function testUpdateEmailTemplateSenderNameTooLong(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderName: \str_repeat('a', 256), + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidType(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'notATemplate', + locale: 'en', + subject: 'Subject', + message: 'Message', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidLocale(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'not-a-locale', + subject: 'Subject', + message: 'Message', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateMissingSubjectOnFirstWrite(): void + { + $this->ensureSMTPEnabled(); + + // 'recovery'/'de' was never customized, so there is no persisted subject + // to fall back on — the endpoint must reject the request. + $response = $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'de', + subject: null, + message: 'Body only', + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateEmailTemplateMissingMessageOnFirstWrite(): void + { + $this->ensureSMTPEnabled(); + + // 'invitation'/'es' was never customized, so there is no persisted message + // to fall back on — the endpoint must reject the request. + $response = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'es', + subject: 'Subject only', + message: null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateEmailTemplateEmptySubject(): void + { + $this->ensureSMTPEnabled(); + + // Text(255) validator requires min length 1 — empty subject is rejected. + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: '', + message: 'Body', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateEmptyMessage(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: '', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidSenderEmail(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + senderEmail: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateInvalidReplyToEmail(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + replyToEmail: 'not-an-email', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateWithoutAuthentication(): void + { + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Subject', + message: 'Message', + authenticated: false, + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void + { + // Custom templates only make sense alongside a custom SMTP configuration. + $this->patchSMTP(['enabled' => false]); + + try { + $response = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Should be blocked', + message: 'Should be blocked', + ); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + $this->assertStringContainsStringIgnoringCase('SMTP', $response['body']['message']); + } finally { + $this->ensureSMTPEnabled(); + } + } + + // Backwards compatibility (x-appwrite-response-format: 1.9.1) + + public function testGetEmailTemplateLegacyResponseFormat(): void + { + $response = $this->client->call( + Client::METHOD_GET, + '/project/templates/email/verification', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + ); + + $this->assertSame(200, $response['headers']['status-code']); + // The 1.9.1 response filter renames templateId -> type and strips replyToName. + $this->assertArrayHasKey('type', $response['body']); + $this->assertArrayNotHasKey('templateId', $response['body']); + $this->assertArrayNotHasKey('replyToName', $response['body']); + $this->assertSame('verification', $response['body']['type']); + $this->assertSame('en', $response['body']['locale']); + } + + public function testUpdateEmailTemplateLegacyRequestAndResponse(): void + { + $this->ensureSMTPEnabled(); + + // Legacy clients send `type` + `replyTo`; request filter maps both. + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + [ + 'type' => 'magicSession', + 'locale' => 'en', + 'subject' => 'Legacy subject', + 'message' => 'Legacy body', + 'senderName' => 'Legacy Sender', + 'senderEmail' => 'legacy-sender@appwrite.io', + 'replyTo' => 'legacy-reply@appwrite.io', + ], + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('type', $response['body']); + $this->assertArrayNotHasKey('templateId', $response['body']); + $this->assertArrayHasKey('replyTo', $response['body']); + $this->assertArrayNotHasKey('replyToEmail', $response['body']); + $this->assertArrayNotHasKey('replyToName', $response['body']); + $this->assertSame('magicSession', $response['body']['type']); + $this->assertSame('Legacy subject', $response['body']['subject']); + $this->assertSame('Legacy body', $response['body']['message']); + $this->assertSame('Legacy Sender', $response['body']['senderName']); + $this->assertSame('legacy-sender@appwrite.io', $response['body']['senderEmail']); + $this->assertSame('legacy-reply@appwrite.io', $response['body']['replyTo']); + + // Modern clients see the new field names for the exact same record. + $modern = $this->getEmailTemplate('magicSession', 'en'); + $this->assertSame('magicSession', $modern['body']['templateId']); + $this->assertSame('legacy-reply@appwrite.io', $modern['body']['replyToEmail']); + } + + public function testUpdateEmailTemplateLegacyInvalidType(): void + { + $this->ensureSMTPEnabled(); + + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/templates/email', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()), + [ + 'type' => 'notATemplate', + 'locale' => 'en', + 'subject' => 'Subject', + 'message' => 'Message', + ], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // Helpers + + protected function getEmailTemplate(string $templateId, ?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/' . $templateId, $headers, $params); + } + + protected function updateEmailTemplate( + string $templateId, + ?string $locale = null, + ?string $subject = null, + ?string $message = null, + ?string $senderName = null, + ?string $senderEmail = null, + ?string $replyToEmail = null, + ?string $replyToName = 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 = ['templateId' => $templateId]; + + foreach (['locale', 'subject', 'message', 'senderName', 'senderEmail', 'replyToEmail', 'replyToName'] as $key) { + if (!\is_null(${$key})) { + $params[$key] = ${$key}; + } + } + + return $this->client->call(Client::METHOD_PATCH, '/project/templates/email', $headers, $params); + } + + protected function ensureSMTPEnabled(): void + { + $this->patchSMTP([ + 'enabled' => true, + 'senderName' => 'Mailer', + 'senderEmail' => 'mailer@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ]); + } + + /** + * @param array $params + */ + protected function patchSMTP(array $params): void + { + $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + $params, + ); + } + } From 9e94f15f024a47c71021b93fa0317d9069062fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:23:34 +0200 Subject: [PATCH 49/81] Finalize tests --- app/controllers/api/projects.php | 27 ++++ tests/e2e/Services/Project/TemplatesBase.php | 153 ++++++++++++++++--- 2 files changed, 161 insertions(+), 19 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 562ff15c39..78583d4748 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -647,3 +647,30 @@ Http::patch('/v1/projects/:projectId/auth/session-invalidation') $response->dynamic($project, Response::MODEL_PROJECT); }); + +// Backwards compatibility +Http::delete('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') + ->desc('Delete custom email template') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->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', '', 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) { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + + $project = $dbForPlatform->getDocument('projects', $projectId); + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $templates = $project->getAttribute('templates', []); + unset($templates['email.' . $type . '-' . $locale]); + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); + + $response->noContent(); + }); diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index d6dbd0c6b6..efdc3e4579 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; +use Utopia\Database\Helpers\ID; trait TemplatesBase { @@ -549,7 +550,15 @@ trait TemplatesBase public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void { // Custom templates only make sense alongside a custom SMTP configuration. - $this->patchSMTP(['enabled' => false]); + $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + ['enabled' => false], + ); try { $response = $this->updateEmailTemplate( @@ -656,6 +665,121 @@ trait TemplatesBase $this->assertSame(400, $response['headers']['status-code']); } + // Session alert integration + + public function testSessionAlertUsesCustomTemplatePerLocale(): void + { + $this->ensureSMTPEnabled(); + + // session-alerts lives under /projects (console scope), so it's driven with the + // root console session rather than the current test's project-scoped headers. + $alertsResponse = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $this->getProject()['$id'] . '/auth/session-alerts', + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ], + ['alerts' => true], + ); + $this->assertSame(200, $alertsResponse['headers']['status-code'], 'failed to enable session alerts'); + + $runId = \uniqid(); + $enSubject = "EN alert subject {$runId}"; + $enMessage = "EN alert body marker {$runId}"; + $skSubject = "SK alert subject {$runId}"; + $skMessage = "SK alert body marker {$runId}"; + + // Configure custom EN template via the default-locale path (omit `locale`). + $enUpdate = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: null, + subject: $enSubject, + message: $enMessage, + ); + $this->assertSame(200, $enUpdate['headers']['status-code']); + $this->assertSame('en', $enUpdate['body']['locale']); + + // Configure custom SK template explicitly. + $skUpdate = $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: 'sk', + subject: $skSubject, + message: $skMessage, + ); + $this->assertSame(200, $skUpdate['headers']['status-code']); + + // Matrix of request-time locales and the custom template each one must resolve to. + // `de` has no custom template stored, so it must fall back to the `en` custom template. + $cases = [ + ['requestLocale' => 'en', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ['requestLocale' => null, 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ['requestLocale' => 'sk', 'expectedSubject' => $skSubject, 'expectedMessageMarker' => $skMessage], + ['requestLocale' => 'de', 'expectedSubject' => $enSubject, 'expectedMessageMarker' => $enMessage], + ]; + + foreach ($cases as $case) { + $localeLabel = $case['requestLocale'] ?? 'none'; + $email = "session-alert-{$runId}-{$localeLabel}@appwrite.io"; + $password = 'password123'; + + // Fresh user per case so the session count starts at zero. + $create = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-dev-key' => $this->getProject()['devKey'] ?? '', + ], [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Session Alert ' . $localeLabel, + ]); + $this->assertSame(201, $create['headers']['status-code'], "create user ({$localeLabel})"); + + // First session must NOT trigger an alert (count === 1 returns early). + $first = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $first['headers']['status-code'], "first session ({$localeLabel})"); + + // Second session — this one triggers the alert, with the test's request locale. + $headers = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + if ($case['requestLocale'] !== null) { + $headers['x-appwrite-locale'] = $case['requestLocale']; + } + $second = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $headers, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $second['headers']['status-code'], "second session ({$localeLabel})"); + + // The custom subject is uniquely tagged per run, so matching it proves both + // that an alert was sent and that the correct locale template was resolved. + $received = $this->getLastEmailByAddress($email, function ($mail) use ($case) { + $this->assertSame($case['expectedSubject'], $mail['subject']); + }); + + $this->assertSame($case['expectedSubject'], $received['subject'], "subject ({$localeLabel})"); + $this->assertStringContainsString( + $case['expectedMessageMarker'], + $received['text'] . $received['html'], + "message marker ({$localeLabel})", + ); + } + } + // Helpers protected function getEmailTemplate(string $templateId, ?string $locale = null, bool $authenticated = true): mixed @@ -709,22 +833,6 @@ trait TemplatesBase } protected function ensureSMTPEnabled(): void - { - $this->patchSMTP([ - 'enabled' => true, - 'senderName' => 'Mailer', - 'senderEmail' => 'mailer@appwrite.io', - 'host' => 'maildev', - 'port' => 1025, - 'username' => 'user', - 'password' => 'password', - ]); - } - - /** - * @param array $params - */ - protected function patchSMTP(array $params): void { $this->client->call( Client::METHOD_PATCH, @@ -733,8 +841,15 @@ trait TemplatesBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), - $params, + [ + 'enabled' => true, + 'senderName' => 'Mailer', + 'senderEmail' => 'mailer@appwrite.io', + 'host' => 'maildev', + 'port' => 1025, + 'username' => 'user', + 'password' => 'password', + ], ); } - } From a4ad1b6df3d48256978eb6b46fb5d106b1c1718e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:35:35 +0200 Subject: [PATCH 50/81] Code quality improvs --- .../Project/Http/Project/Templates/Email/Update.php | 12 ++++++------ src/Appwrite/SDK/Specification/Format.php | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index 4a1d4a5a21..c4a96d3c3c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -38,7 +38,7 @@ class Update extends Action ->desc('Update project email template') ->groups(['api', 'project']) ->label('scope', 'templates.write') - ->label('event', 'templates.[templateType].update') + ->label('event', 'templates.[templateId].update') ->label('audits.event', 'project.template.update') ->label('audits.resource', 'project.template/{response.templateId}') ->label('sdk', new Method( @@ -126,17 +126,17 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $queueForEvents->setParam('templateType', $templateId); + $queueForEvents->setParam('templateId', $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'], + 'senderName' => $template['senderName'] ?? '', + 'senderEmail' => $template['senderEmail'] ?? '', + 'replyToEmail' => $template['replyToEmail'] ?? '', + 'replyToName' => $template['replyToName'] ?? '', ]), Response::MODEL_EMAIL_TEMPLATE); } } diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index e68e9438ca..1dad8764fa 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -763,7 +763,6 @@ abstract class Format switch ($method) { case 'getEmailTemplate': case 'updateEmailTemplate': - case 'deleteEmailTemplate': switch ($param) { case 'type': return 'EmailTemplateType'; From 8c31e9f206390a46b05eadb35c42093f16fab512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:36:47 +0200 Subject: [PATCH 51/81] Server error fixes --- .../Platform/Modules/Project/Http/Project/Labels/Update.php | 2 +- .../Platform/Modules/Project/Http/Project/SMTP/Update.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php index 304d9dc8a6..5eee625b0a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php @@ -32,7 +32,7 @@ class Update extends Action ->desc('Update project labels') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'labels.*.update') + ->label('event', 'project.labels.update') ->label('audits.event', 'project.labels.update') ->label('audits.resource', 'project.labels/{response.$id}') ->label('sdk', new Method( diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 63e9cf287e..820c0be287 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -40,7 +40,7 @@ class Update extends Action ->desc('Update project SMTP configuration') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'smtp.*.update') + ->label('event', 'project.smtp.update') ->label('audits.event', 'project.smtp.update') ->label('audits.resource', 'project.smtp/{response.$id}') ->label('sdk', new Method( From c3e411fcaa4db2f314606f79a3a33689ac02128d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 22:47:47 +0200 Subject: [PATCH 52/81] Fix tests --- .../Project/Http/Project/Labels/Update.php | 2 +- .../Modules/Project/Http/Project/SMTP/Update.php | 14 ++++++++------ .../Project/Http/Project/Templates/Email/Get.php | 6 +++--- .../Http/Project/Templates/Email/Update.php | 16 +++++++++------- tests/e2e/Services/Project/TemplatesBase.php | 5 ++++- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php index 5eee625b0a..8a3506eb13 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Labels/Update.php @@ -32,7 +32,7 @@ class Update extends Action ->desc('Update project labels') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'project.labels.update') + // ->label('event', 'project.labels.update') ->label('audits.event', 'project.labels.update') ->label('audits.resource', 'project.labels/{response.$id}') ->label('sdk', new Method( diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 820c0be287..736865f299 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -40,7 +40,7 @@ class Update extends Action ->desc('Update project SMTP configuration') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'project.smtp.update') + // ->label('event', 'project.smtp.update') ->label('audits.event', 'project.smtp.update') ->label('audits.resource', 'project.smtp/{response.$id}') ->label('sdk', new Method( @@ -106,11 +106,13 @@ class Update extends Action // Backwards compatibility $smtp['replyToEmail'] = $smtp['replyToEmail'] ?? $smtp['replyTo'] ?? ''; - // Ensure required fields are set - $requiredKeys = ['host', 'port', 'senderEmail']; - foreach ($requiredKeys as $key) { - if (empty($smtp[$key])) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, '"' . $key . '" is required. Please provide a value.'); + if (($smtp['enabled'] ?? false) === true) { + // Ensure required fields are set + $requiredKeys = ['host', 'port', 'senderEmail']; + foreach ($requiredKeys as $key) { + if (empty($smtp[$key])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Param "' . $key . '" is not optional.'); + } } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php index 1843c556d4..02ba431775 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Get.php @@ -78,16 +78,16 @@ class Get extends Action $defaultMessage = $this->getDefaultMessage($templateId, $localeObj); // Apply defaults if needed - if (\is_null($template['message'])) { + if (\is_null($template['message'] ?? null)) { $template['message'] = $defaultMessage; } - if (\is_null($template['subject'])) { + if (\is_null($template['subject'] ?? null)) { $template['subject'] = $defaultSubject; } // Backwards compatibility - if (!\is_null($template['replyTo'])) { + if (!\is_null($template['replyTo'] ?? null)) { $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php index c4a96d3c3c..ef93abf683 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/Update.php @@ -58,12 +58,12 @@ class Update extends Action )) ->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', null, new Nullable(new Text(255)), 'Subject of the email template. Can be up to 255 characters.') - ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.') - ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', true) - ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', true) - ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', true) - ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', true) + ->param('subject', null, new Nullable(new Text(255)), 'Subject of the email template. Can be up to 255 characters.', optional: true) + ->param('message', null, new Nullable(new Text(10485760)), 'Plain or HTML body of the email template message. Can be up to 10MB of content.', optional: true) + ->param('senderName', null, new Nullable(new Text(255, 0)), 'Name of the email sender.', optional: true) + ->param('senderEmail', null, new Nullable(new Email()), 'Email of the sender.', optional: true) + ->param('replyToEmail', null, new Nullable(new Email()), 'Reply to email.', optional: true) + ->param('replyToName', null, new Nullable(new Text(255, 0)), 'Reply to name.', optional: true) ->inject('response') ->inject('queueForEvents') ->inject('dbForPlatform') @@ -108,7 +108,9 @@ class Update extends Action } // Backwards compatibility - $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + if (!\is_null($template['replyTo'] ?? null)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } // Ensure required fields are set $requiredKeys = ['subject', 'message']; diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index efdc3e4579..72a14210a5 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -550,7 +550,7 @@ trait TemplatesBase public function testUpdateEmailTemplateBlockedWhenSMTPDisabled(): void { // Custom templates only make sense alongside a custom SMTP configuration. - $this->client->call( + $response = $this->client->call( Client::METHOD_PATCH, '/project/smtp', \array_merge([ @@ -560,6 +560,9 @@ trait TemplatesBase ['enabled' => false], ); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + try { $response = $this->updateEmailTemplate( templateId: 'verification', From 72cd7671c43381b6e82eb7163b4a493b556ece70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 23:01:00 +0200 Subject: [PATCH 53/81] Fix projects tests --- .../Projects/ProjectsConsoleClientTest.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 9506c1a963..471d737f76 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1131,6 +1131,40 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('', $response['body']['senderEmail']); $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('en-us', $response['body']['locale']); + + /** Update Email template, fail due to SMTP disabled */ + $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}}', + 'senderName' => 'Appwrite Custom', + 'senderEmail' => 'custom@appwrite.io', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** Configure custom SMTP pointing to maildev, so changing template is allowed */ + $smtpHost = 'maildev'; + $smtpPort = 1025; + $smtpUsername = 'user'; + $smtpPassword = 'password'; + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/smtp', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $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']); /** Update Email template */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ From 4808cad08196b479b871f2cec45ec434d275290b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 23:01:53 +0200 Subject: [PATCH 54/81] Fix most of Project tests --- .../Project/Http/Project/SMTP/Update.php | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php index 736865f299..97e723f52c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/SMTP/Update.php @@ -58,15 +58,15 @@ class Update extends Action ) ], )) - ->param('host', '', new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true) - ->param('port', 587, new Nullable(new Integer()), 'SMTP server port', optional: true) - ->param('username', '', new Nullable(new Text(256)), 'SMTP server username. Leave empty for no authorization.', optional: true) - ->param('password', '', new Nullable(new Text(256)), 'SMTP server password. Leave empty for no authorization. This property is stored securely and cannot be read in future (write-only).', optional: true) - ->param('senderEmail', '', new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true) - ->param('senderName', '', new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true) - ->param('replyToEmail', '', new Nullable(new Email()), 'Email used when user replies to the email.', optional: true) - ->param('replyToName', '', new Nullable(new Text(256)), 'Name used when user replies to the email.', optional: true) - ->param('secure', '', new Nullable(new WhiteList(['tls', 'ssl'], true)), 'Configures if communication with SMTP server is encrypted. Allowed values are: tls, ssl. Leave empty for no encryption.', optional: true) + ->param('host', null, new Nullable(new Hostname()), 'SMTP server hostname (domain)', optional: true) + ->param('port', null, new Nullable(new Integer()), 'SMTP server port', optional: true) + ->param('username', null, new Nullable(new Text(256)), 'SMTP server username. Leave empty for no authorization.', optional: true) + ->param('password', null, new Nullable(new Text(256)), 'SMTP server password. Leave empty for no authorization. This property is stored securely and cannot be read in future (write-only).', optional: true) + ->param('senderEmail', null, new Nullable(new Email()), 'Email address shown in inbox as the sender of the email.', optional: true) + ->param('senderName', null, new Nullable(new Text(256)), 'Name shown in inbox as the sender of the email.', optional: true) + ->param('replyToEmail', null, new Nullable(new Email()), 'Email used when user replies to the email.', optional: true) + ->param('replyToName', null, new Nullable(new Text(256)), 'Name used when user replies to the email.', optional: true) + ->param('secure', null, new Nullable(new WhiteList(['tls', 'ssl'], true)), 'Configures if communication with SMTP server is encrypted. Allowed values are: tls, ssl. Leave empty for no encryption.', optional: true) ->param('enabled', null, new Nullable(new Boolean()), 'Enable or disable custom SMTP. Custom SMTP is useful for branding purposes, but also allows use of custom email templates.', optional: true) ->inject('response') ->inject('dbForPlatform') @@ -117,7 +117,10 @@ class Update extends Action } // Validate SMTP credentials - if (\is_null($smtp['enabled'] ?? null) || $smtp['enabled'] === true) { + // Validate when the caller is explicitly enabling or hasn't expressed a preference + // (so a credentials-only PATCH can auto-enable). Skip only when the caller is + // explicitly keeping/turning SMTP off. + if (\is_null($enabled) || $enabled === true) { $mail = new PHPMailer(true); $mail->isSMTP(); From b1d37bc4bea36263f8f48ba1eaf639932d661377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 20 Apr 2026 23:03:16 +0200 Subject: [PATCH 55/81] Fix remaining test failures --- tests/e2e/Services/Project/SMTPBase.php | 2 +- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index fa58891b08..c3a9058a32 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -313,7 +313,7 @@ trait SMTPBase $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', - host: 'not a valid host!@#', + host: 'https://myhost.com/v1', port: 1025, ); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 471d737f76..d51c4c1128 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1131,7 +1131,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('', $response['body']['senderEmail']); $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('en-us', $response['body']['locale']); - + /** Update Email template, fail due to SMTP disabled */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', @@ -1145,7 +1145,7 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(400, $response['headers']['status-code']); - + /** Configure custom SMTP pointing to maildev, so changing template is allowed */ $smtpHost = 'maildev'; $smtpPort = 1025; From cce04b3bd1848cd454391c9e78ed5265a5f2dd31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 10:28:11 +0200 Subject: [PATCH 56/81] Improve test coverage --- tests/e2e/Services/Project/SMTPBase.php | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index c3a9058a32..4bdf073e19 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -531,19 +531,38 @@ trait SMTPBase $this->updateSMTP(enabled: false); } - public function testUpdateSMTPInvalidConnectionRefused(): void + public function testUpdateSMTPInvalidConnectionEnabled(): void { $response = $this->updateSMTP( senderName: 'Test', senderEmail: 'sender@example.com', host: 'localhost', port: 12345, + enabled: true, ); $this->assertSame(400, $response['headers']['status-code']); $this->assertSame('project_smtp_config_invalid', $response['body']['type']); } + public function testUpdateSMTPInvalidConnectionDisabled(): void + { + $response = $this->updateSMTP( + senderName: 'Test', + senderEmail: 'sender@example.com', + host: 'localhost', + port: 12345, + enabled: false, + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['smtpEnabled']); + $this->assertSame('Test', $response['body']['smtpSenderName']); + $this->assertSame('sender@example.com', $response['body']['smtpSenderEmail']); + $this->assertSame('localhost', $response['body']['smtpHost']); + $this->assertSame(12345, $response['body']['smtpPort']); + } + public function testUpdateSMTPLegacyReplyToAndResponseFormat(): void { $headers = \array_merge([ From 4317ee5617d477ef9c2a108102f8aac685fe4de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 13:11:42 +0200 Subject: [PATCH 57/81] Move some of auth settings to project policies --- app/controllers/api/projects.php | 265 ------------------ app/init/constants.php | 2 - .../Policies/PasswordDictionary/Update.php | 78 ++++++ .../Policies/PasswordHistory/Update.php | 84 ++++++ .../Policies/PasswordPersonalData/Update.php | 78 ++++++ .../Policies/SessionDuration/Update.php | 78 ++++++ .../Policies/SessionInvalidation/Update.php | 78 ++++++ .../Project/Policies/SessionLimit/Update.php | 84 ++++++ .../Project/Policies/UserLimit/Update.php | 84 ++++++ .../Modules/Projects/Http/Projects/Create.php | 2 +- 10 files changed, 565 insertions(+), 268 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 439692e1dd..87552c6508 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -218,82 +218,6 @@ Http::patch('/v1/projects/:projectId/auth/memberships-privacy') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/auth/limit') - ->desc('Update project users limit') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthLimit', - description: '/docs/references/projects/update-auth-limit.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', false, new Range(0, APP_LIMIT_USERS), 'Set the max number of users allowed in this project. Use 0 for unlimited.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['limit'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/duration') - ->desc('Update project authentication duration') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthDuration', - description: '/docs/references/projects/update-auth-duration.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('duration', 31536000, new Range(0, 31536000), 'Project session length in seconds. Max length: 31536000 seconds.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $duration, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['duration'] = $duration; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/auth/:method') ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.') ->groups(['api', 'projects']) @@ -335,158 +259,6 @@ Http::patch('/v1/projects/:projectId/auth/:method') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/auth/password-history') - ->desc('Update authentication password history. Use this endpoint to set the number of password history to save and 0 to disable password history.') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthPasswordHistory', - description: '/docs/references/projects/update-auth-password-history.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', 0, new Range(0, APP_LIMIT_USER_PASSWORD_HISTORY), 'Set the max number of passwords to store in user history. User can\'t choose a new password that is already stored in the password history list. Max number of passwords allowed in history is' . APP_LIMIT_USER_PASSWORD_HISTORY . '. Default value is 0') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['passwordHistory'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/password-dictionary') - ->desc('Update authentication password dictionary status. Use this endpoint to enable or disable the dicitonary check for user password') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthPasswordDictionary', - description: '/docs/references/projects/update-auth-password-dictionary.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(false), 'Set whether or not to enable checking user\'s password against most commonly used passwords. Default is false.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['passwordDictionary'] = $enabled; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/personal-data') - ->desc('Update personal data check') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updatePersonalDataCheck', - description: '/docs/references/projects/update-personal-data-check.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(false), 'Set whether or not to check a password for similarity with personal data. Default is false.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['personalDataCheck'] = $enabled; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/max-sessions') - ->desc('Update project user sessions limit') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthSessionsLimit', - description: '/docs/references/projects/update-auth-sessions-limit.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('limit', false, new Range(1, APP_LIMIT_USER_SESSIONS_MAX), 'Set the max number of users allowed in this project. Value allowed is between 1-' . APP_LIMIT_USER_SESSIONS_MAX . '. Default is ' . APP_LIMIT_USER_SESSIONS_DEFAULT) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, int $limit, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['maxSessions'] = $limit; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/auth/mock-numbers') ->desc('Update the mock numbers for the project') ->groups(['api', 'projects']) @@ -1050,40 +822,3 @@ Http::delete('/v1/projects/:projectId/templates/email') 'message' => $template['message'] ]), Response::MODEL_EMAIL_TEMPLATE); }); - -Http::patch('/v1/projects/:projectId/auth/session-invalidation') - ->desc('Update invalidate session option of the project') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateSessionInvalidation', - description: '/docs/references/projects/update-session-invalidation.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('enabled', false, new Boolean(), 'Update authentication session invalidation status. Use this endpoint to enable or disable session invalidation on password change') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['invalidateSessions'] = $enabled; - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..443aaaa680 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -24,8 +24,6 @@ const APP_MODE_ADMIN = 'admin'; const APP_PAGING_LIMIT = 12; const APP_LIMIT_COUNT = 5000; const APP_LIMIT_USERS = 10_000; -const APP_LIMIT_USER_PASSWORD_HISTORY = 20; -const APP_LIMIT_USER_SESSIONS_MAX = 100; const APP_LIMIT_USER_SESSIONS_DEFAULT = 10; const APP_LIMIT_ANTIVIRUS = 20_000_000; //20MB const APP_LIMIT_ENCRYPTION = 20_000_000; //20MB diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php new file mode 100644 index 0000000000..6218165daf --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-dictionary') + ->httpAlias('/v1/projects/:projectId/auth/password-dictionary') + ->desc('Update password dictionary policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.password-dictionary.update') + ->label('audits.event', 'policies.password-dictionary.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordDictionaryPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle password dictionary policy. Set to true if you want password change to block passwords in the dictionary, or false to allow them. When changing this policy, existing passwords remain valid.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['passwordDictionary'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php new file mode 100644 index 0000000000..437cc29b33 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -0,0 +1,84 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-history') + ->httpAlias('/v1/projects/:projectId/auth/password-history') + ->desc('Update password history policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.password-history.update') + ->label('audits.event', 'policies.password-history.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordHistoryPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT), 'Set the password history length per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['passwordHistory'] = 0; + } else { + $auths['passwordHistory'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php new file mode 100644 index 0000000000..cc661366d7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/password-personal-data') + ->httpAlias('/v1/projects/:projectId/auth/personal-data') + ->desc('Update password personal data policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.password-personal-data.update') + ->label('audits.event', 'policies.password-personal-data.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updatePasswordPersonalDataPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle password personal data policy. Set to true if you want to block passwords including user\'s personal data, or false to allow it. When changing this policy, existing passwords remain valid.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['personalDataCheck'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php new file mode 100644 index 0000000000..ad2540172c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-duration') + ->httpAlias('/v1/projects/:projectId/auth/duration') + ->desc('Update session duration policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.session-duration.update') + ->label('audits.event', 'policies.session-duration.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionDurationPolicy', + description: <<param('duration', null, new Range(60, 31536000), 'Maximum session length in seconds. Minium allowed value is 60 seconds, and maximum is 1 year, which is 31536000 seconds.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + int $duration, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['duration'] = $duration; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php new file mode 100644 index 0000000000..0963d7eb56 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-invalidation') + ->httpAlias('/v1/projects/:projectId/auth/session-invalidation') + ->desc('Update session invalidation policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.session-invalidation.update') + ->label('audits.event', 'policies.session-invalidation.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionInvalidationPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle session invalidation policy. Set to true if you want password change to invalidate all sessions of an user, or false to keep sessions active.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['invalidateSessions'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php new file mode 100644 index 0000000000..3860b7ccc4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php @@ -0,0 +1,84 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-limit') + ->httpAlias('/v1/projects/:projectId/auth/max-sessions') + ->desc('Update session limit policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.session-limit.update') + ->label('audits.event', 'policies.session-limit.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionLimitPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT), 'Set the maximum number of sessions allowed per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['maxSessions'] = 0; + } else { + $auths['maxSessions'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php new file mode 100644 index 0000000000..6b21a22138 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php @@ -0,0 +1,84 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/user-limit') + ->httpAlias('/v1/projects/:projectId/auth/limit') + ->desc('Update user limit policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.user-limit.update') + ->label('audits.event', 'policies.user-limit.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateUserLimitPolicy', + description: <<param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT), 'Set the maximum number of users allowed in the project. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + int $total, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + + if (\is_null($total)) { + $auths['limit'] = 0; + } else { + $auths['limit'] = $total; + } + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index c509a565cd..e190b5a719 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -107,7 +107,7 @@ class Create extends Action $auth = Config::getParam('auth', []); $auths = [ 'limit' => 0, - 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, + 'maxSessions' => 0, 'passwordHistory' => 0, 'passwordDictionary' => false, 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, From 2f272f0480611e9818db0ef96d8d815ed89008db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 13:27:08 +0200 Subject: [PATCH 58/81] Cleanup unused markdowns descriptions --- docs/references/account/create-2fa-challenge.md | 1 - docs/references/account/delete-session-current.md | 1 - docs/references/documentsdb/get-collection-logs.md | 1 - docs/references/documentsdb/get-document-logs.md | 1 - docs/references/documentsdb/list-attributes.md | 1 - docs/references/functions/create-build.md | 1 - docs/references/functions/create-deployment.md | 5 ----- docs/references/functions/create-execution.md | 1 - docs/references/functions/create-function.md | 1 - docs/references/functions/create-variable.md | 1 - docs/references/functions/delete-deployment.md | 1 - docs/references/functions/delete-execution.md | 1 - docs/references/functions/delete-function.md | 1 - docs/references/functions/delete-variable.md | 1 - docs/references/functions/get-deployment-download.md | 1 - docs/references/functions/get-deployment.md | 1 - docs/references/functions/get-execution.md | 1 - docs/references/functions/get-function-usage.md | 1 - docs/references/functions/get-function.md | 1 - docs/references/functions/get-functions-usage.md | 1 - docs/references/functions/get-template.md | 1 - docs/references/functions/get-variable.md | 1 - docs/references/functions/list-deployments.md | 1 - docs/references/functions/list-executions.md | 1 - docs/references/functions/list-functions.md | 1 - docs/references/functions/list-runtimes.md | 1 - docs/references/functions/list-specifications.md | 1 - docs/references/functions/list-templates.md | 1 - docs/references/functions/list-variables.md | 1 - docs/references/functions/update-deployment-build.md | 1 - docs/references/functions/update-function-deployment.md | 1 - docs/references/functions/update-function.md | 1 - docs/references/functions/update-variable.md | 1 - docs/references/health/get-queue-stats-usage-dump.md | 1 - docs/references/health/get-queue-tasks.md | 1 - docs/references/health/get-queue.md | 1 - docs/references/messaging/delete.md | 1 - docs/references/project/create-variable.md | 1 - docs/references/project/delete-variable.md | 1 - docs/references/project/get-variable.md | 1 - docs/references/project/list-variables.md | 1 - docs/references/project/update-variable.md | 1 - docs/references/projects/create-key.md | 1 - docs/references/projects/create-platform.md | 1 - docs/references/projects/create-webhook.md | 1 - docs/references/projects/delete-key.md | 1 - docs/references/projects/delete-platform.md | 1 - docs/references/projects/delete-webhook.md | 1 - docs/references/projects/get-key.md | 1 - docs/references/projects/get-platform.md | 1 - docs/references/projects/get-webhook.md | 1 - docs/references/projects/list-keys.md | 1 - docs/references/projects/list-platforms.md | 1 - docs/references/projects/list-webhooks.md | 1 - docs/references/projects/update-api-status-all.md | 1 - docs/references/projects/update-api-status.md | 1 - docs/references/projects/update-auth-duration.md | 1 - docs/references/projects/update-auth-limit.md | 1 - docs/references/projects/update-auth-password-dictionary.md | 1 - docs/references/projects/update-auth-password-history.md | 1 - docs/references/projects/update-auth-sessions-limit.md | 1 - docs/references/projects/update-key.md | 1 - docs/references/projects/update-memberships-privacy.md | 1 - docs/references/projects/update-personal-data-check.md | 1 - docs/references/projects/update-platform.md | 1 - docs/references/projects/update-service-status-all.md | 1 - docs/references/projects/update-service-status.md | 1 - docs/references/projects/update-session-invalidation.md | 1 - docs/references/projects/update-webhook-signature.md | 1 - docs/references/projects/update-webhook.md | 1 - docs/references/tablesdb/get-database.md | 1 - docs/references/vectorsdb/decrement-document-attribute.md | 1 - docs/references/vectorsdb/get-collection-logs.md | 1 - docs/references/vectorsdb/get-document-logs.md | 1 - docs/references/vectorsdb/increment-document-attribute.md | 1 - docs/references/vectorsdb/list-attributes.md | 1 - 76 files changed, 80 deletions(-) delete mode 100644 docs/references/account/create-2fa-challenge.md delete mode 100644 docs/references/account/delete-session-current.md delete mode 100644 docs/references/documentsdb/get-collection-logs.md delete mode 100644 docs/references/documentsdb/get-document-logs.md delete mode 100644 docs/references/documentsdb/list-attributes.md delete mode 100644 docs/references/functions/create-build.md delete mode 100644 docs/references/functions/create-deployment.md delete mode 100644 docs/references/functions/create-execution.md delete mode 100644 docs/references/functions/create-function.md delete mode 100644 docs/references/functions/create-variable.md delete mode 100644 docs/references/functions/delete-deployment.md delete mode 100644 docs/references/functions/delete-execution.md delete mode 100644 docs/references/functions/delete-function.md delete mode 100644 docs/references/functions/delete-variable.md delete mode 100644 docs/references/functions/get-deployment-download.md delete mode 100644 docs/references/functions/get-deployment.md delete mode 100644 docs/references/functions/get-execution.md delete mode 100644 docs/references/functions/get-function-usage.md delete mode 100644 docs/references/functions/get-function.md delete mode 100644 docs/references/functions/get-functions-usage.md delete mode 100644 docs/references/functions/get-template.md delete mode 100644 docs/references/functions/get-variable.md delete mode 100644 docs/references/functions/list-deployments.md delete mode 100644 docs/references/functions/list-executions.md delete mode 100644 docs/references/functions/list-functions.md delete mode 100644 docs/references/functions/list-runtimes.md delete mode 100644 docs/references/functions/list-specifications.md delete mode 100644 docs/references/functions/list-templates.md delete mode 100644 docs/references/functions/list-variables.md delete mode 100644 docs/references/functions/update-deployment-build.md delete mode 100644 docs/references/functions/update-function-deployment.md delete mode 100644 docs/references/functions/update-function.md delete mode 100644 docs/references/functions/update-variable.md delete mode 100644 docs/references/health/get-queue-stats-usage-dump.md delete mode 100644 docs/references/health/get-queue-tasks.md delete mode 100644 docs/references/health/get-queue.md delete mode 100644 docs/references/messaging/delete.md delete mode 100644 docs/references/project/create-variable.md delete mode 100644 docs/references/project/delete-variable.md delete mode 100644 docs/references/project/get-variable.md delete mode 100644 docs/references/project/list-variables.md delete mode 100644 docs/references/project/update-variable.md delete mode 100644 docs/references/projects/create-key.md delete mode 100644 docs/references/projects/create-platform.md delete mode 100644 docs/references/projects/create-webhook.md delete mode 100644 docs/references/projects/delete-key.md delete mode 100644 docs/references/projects/delete-platform.md delete mode 100644 docs/references/projects/delete-webhook.md delete mode 100644 docs/references/projects/get-key.md delete mode 100644 docs/references/projects/get-platform.md delete mode 100644 docs/references/projects/get-webhook.md delete mode 100644 docs/references/projects/list-keys.md delete mode 100644 docs/references/projects/list-platforms.md delete mode 100644 docs/references/projects/list-webhooks.md delete mode 100644 docs/references/projects/update-api-status-all.md delete mode 100644 docs/references/projects/update-api-status.md delete mode 100644 docs/references/projects/update-auth-duration.md delete mode 100644 docs/references/projects/update-auth-limit.md delete mode 100644 docs/references/projects/update-auth-password-dictionary.md delete mode 100644 docs/references/projects/update-auth-password-history.md delete mode 100644 docs/references/projects/update-auth-sessions-limit.md delete mode 100644 docs/references/projects/update-key.md delete mode 100644 docs/references/projects/update-memberships-privacy.md delete mode 100644 docs/references/projects/update-personal-data-check.md delete mode 100644 docs/references/projects/update-platform.md delete mode 100644 docs/references/projects/update-service-status-all.md delete mode 100644 docs/references/projects/update-service-status.md delete mode 100644 docs/references/projects/update-session-invalidation.md delete mode 100644 docs/references/projects/update-webhook-signature.md delete mode 100644 docs/references/projects/update-webhook.md delete mode 100644 docs/references/tablesdb/get-database.md delete mode 100644 docs/references/vectorsdb/decrement-document-attribute.md delete mode 100644 docs/references/vectorsdb/get-collection-logs.md delete mode 100644 docs/references/vectorsdb/get-document-logs.md delete mode 100644 docs/references/vectorsdb/increment-document-attribute.md delete mode 100644 docs/references/vectorsdb/list-attributes.md diff --git a/docs/references/account/create-2fa-challenge.md b/docs/references/account/create-2fa-challenge.md deleted file mode 100644 index ee6ef2f2ac..0000000000 --- a/docs/references/account/create-2fa-challenge.md +++ /dev/null @@ -1 +0,0 @@ -Initialize an MFA challenge of the specified factor. The factor must be available on the account. \ No newline at end of file diff --git a/docs/references/account/delete-session-current.md b/docs/references/account/delete-session-current.md deleted file mode 100644 index d38520f479..0000000000 --- a/docs/references/account/delete-session-current.md +++ /dev/null @@ -1 +0,0 @@ -Use this endpoint to log out the currently logged in user from their account. When successful this endpoint will delete the user session and remove the session secret cookie from the user client. \ No newline at end of file diff --git a/docs/references/documentsdb/get-collection-logs.md b/docs/references/documentsdb/get-collection-logs.md deleted file mode 100644 index 8578cef03c..0000000000 --- a/docs/references/documentsdb/get-collection-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the collection activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get-document-logs.md b/docs/references/documentsdb/get-document-logs.md deleted file mode 100644 index 9b96df5ad4..0000000000 --- a/docs/references/documentsdb/get-document-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the document activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/list-attributes.md b/docs/references/documentsdb/list-attributes.md deleted file mode 100644 index 72ad6d727f..0000000000 --- a/docs/references/documentsdb/list-attributes.md +++ /dev/null @@ -1 +0,0 @@ -List attributes in the collection. \ No newline at end of file diff --git a/docs/references/functions/create-build.md b/docs/references/functions/create-build.md deleted file mode 100644 index 160a04c291..0000000000 --- a/docs/references/functions/create-build.md +++ /dev/null @@ -1 +0,0 @@ -Create a new build for an existing function deployment. This endpoint allows you to rebuild a deployment with the updated function configuration, including its entrypoint and build commands if they have been modified. The build process will be queued and executed asynchronously. The original deployment's code will be preserved and used for the new build. \ No newline at end of file diff --git a/docs/references/functions/create-deployment.md b/docs/references/functions/create-deployment.md deleted file mode 100644 index 3bbdbfc848..0000000000 --- a/docs/references/functions/create-deployment.md +++ /dev/null @@ -1,5 +0,0 @@ -Create a new function code deployment. Use this endpoint to upload a new version of your code function. To execute your newly uploaded code, you'll need to update the function's deployment to use your new deployment UID. - -This endpoint accepts a tar.gz file compressed with your code. Make sure to include any dependencies your code has within the compressed file. You can learn more about code packaging in the [Appwrite Cloud Functions tutorial](https://appwrite.io/docs/functions). - -Use the "command" param to set the entrypoint used to execute your code. \ No newline at end of file diff --git a/docs/references/functions/create-execution.md b/docs/references/functions/create-execution.md deleted file mode 100644 index 6089c4ff01..0000000000 --- a/docs/references/functions/create-execution.md +++ /dev/null @@ -1 +0,0 @@ -Trigger a function execution. The returned object will return you the current execution status. You can ping the `Get Execution` endpoint to get updates on the current execution status. Once this endpoint is called, your function execution process will start asynchronously. \ No newline at end of file diff --git a/docs/references/functions/create-function.md b/docs/references/functions/create-function.md deleted file mode 100644 index 1ac9143f45..0000000000 --- a/docs/references/functions/create-function.md +++ /dev/null @@ -1 +0,0 @@ -Create a new function. You can pass a list of [permissions](https://appwrite.io/docs/permissions) to allow different project users or team with access to execute the function using the client API. \ No newline at end of file diff --git a/docs/references/functions/create-variable.md b/docs/references/functions/create-variable.md deleted file mode 100644 index 40fabd75a8..0000000000 --- a/docs/references/functions/create-variable.md +++ /dev/null @@ -1 +0,0 @@ -Create a new function environment variable. These variables can be accessed in the function at runtime as environment variables. \ No newline at end of file diff --git a/docs/references/functions/delete-deployment.md b/docs/references/functions/delete-deployment.md deleted file mode 100644 index 19c74965bd..0000000000 --- a/docs/references/functions/delete-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Delete a code deployment by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/delete-execution.md b/docs/references/functions/delete-execution.md deleted file mode 100644 index d7cad98ac1..0000000000 --- a/docs/references/functions/delete-execution.md +++ /dev/null @@ -1 +0,0 @@ -Delete a function execution by its unique ID. diff --git a/docs/references/functions/delete-function.md b/docs/references/functions/delete-function.md deleted file mode 100644 index 92835e3c82..0000000000 --- a/docs/references/functions/delete-function.md +++ /dev/null @@ -1 +0,0 @@ -Delete a function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/delete-variable.md b/docs/references/functions/delete-variable.md deleted file mode 100644 index 9b1326d96f..0000000000 --- a/docs/references/functions/delete-variable.md +++ /dev/null @@ -1 +0,0 @@ -Delete a variable by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-deployment-download.md b/docs/references/functions/get-deployment-download.md deleted file mode 100644 index e662ae2733..0000000000 --- a/docs/references/functions/get-deployment-download.md +++ /dev/null @@ -1 +0,0 @@ -Get a Deployment's contents by its unique ID. This endpoint supports range requests for partial or streaming file download. \ No newline at end of file diff --git a/docs/references/functions/get-deployment.md b/docs/references/functions/get-deployment.md deleted file mode 100644 index 6d73976eb1..0000000000 --- a/docs/references/functions/get-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Get a code deployment by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-execution.md b/docs/references/functions/get-execution.md deleted file mode 100644 index fc38260bdb..0000000000 --- a/docs/references/functions/get-execution.md +++ /dev/null @@ -1 +0,0 @@ -Get a function execution log by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-function-usage.md b/docs/references/functions/get-function-usage.md deleted file mode 100644 index 4498abb05b..0000000000 --- a/docs/references/functions/get-function-usage.md +++ /dev/null @@ -1 +0,0 @@ -Get usage metrics and statistics for a for a specific function. View statistics including total deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days. \ No newline at end of file diff --git a/docs/references/functions/get-function.md b/docs/references/functions/get-function.md deleted file mode 100644 index 557ec316ba..0000000000 --- a/docs/references/functions/get-function.md +++ /dev/null @@ -1 +0,0 @@ -Get a function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/get-functions-usage.md b/docs/references/functions/get-functions-usage.md deleted file mode 100644 index 14427d335d..0000000000 --- a/docs/references/functions/get-functions-usage.md +++ /dev/null @@ -1 +0,0 @@ -Get usage metrics and statistics for a for all functions. View statistics including total functions, deployments, builds, executions, storage usage, and compute time. The response includes both current totals and historical data for each metric. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, defaults to 30 days. \ No newline at end of file diff --git a/docs/references/functions/get-template.md b/docs/references/functions/get-template.md deleted file mode 100644 index ccdcce7352..0000000000 --- a/docs/references/functions/get-template.md +++ /dev/null @@ -1 +0,0 @@ -Get a function template using ID. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method. \ No newline at end of file diff --git a/docs/references/functions/get-variable.md b/docs/references/functions/get-variable.md deleted file mode 100644 index f0fa853655..0000000000 --- a/docs/references/functions/get-variable.md +++ /dev/null @@ -1 +0,0 @@ -Get a variable by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/list-deployments.md b/docs/references/functions/list-deployments.md deleted file mode 100644 index 80bbba1bf6..0000000000 --- a/docs/references/functions/list-deployments.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the function's code deployments. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-executions.md b/docs/references/functions/list-executions.md deleted file mode 100644 index 168c795b20..0000000000 --- a/docs/references/functions/list-executions.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the current user function execution logs. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-functions.md b/docs/references/functions/list-functions.md deleted file mode 100644 index 9ad432fdc0..0000000000 --- a/docs/references/functions/list-functions.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all the project's functions. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/functions/list-runtimes.md b/docs/references/functions/list-runtimes.md deleted file mode 100644 index d4d3d23b18..0000000000 --- a/docs/references/functions/list-runtimes.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all runtimes that are currently active on your instance. \ No newline at end of file diff --git a/docs/references/functions/list-specifications.md b/docs/references/functions/list-specifications.md deleted file mode 100644 index d65a215827..0000000000 --- a/docs/references/functions/list-specifications.md +++ /dev/null @@ -1 +0,0 @@ -List allowed function specifications for this instance. diff --git a/docs/references/functions/list-templates.md b/docs/references/functions/list-templates.md deleted file mode 100644 index ed43b9cbf4..0000000000 --- a/docs/references/functions/list-templates.md +++ /dev/null @@ -1 +0,0 @@ -List available function templates. You can use template details in [createFunction](/docs/references/cloud/server-nodejs/functions#create) method. \ No newline at end of file diff --git a/docs/references/functions/list-variables.md b/docs/references/functions/list-variables.md deleted file mode 100644 index 68bd5e17e1..0000000000 --- a/docs/references/functions/list-variables.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all variables of a specific function. \ No newline at end of file diff --git a/docs/references/functions/update-deployment-build.md b/docs/references/functions/update-deployment-build.md deleted file mode 100644 index d047990adf..0000000000 --- a/docs/references/functions/update-deployment-build.md +++ /dev/null @@ -1 +0,0 @@ -Cancel an ongoing function deployment build. If the build is already in progress, it will be stopped and marked as canceled. If the build hasn't started yet, it will be marked as canceled without executing. You cannot cancel builds that have already completed (status 'ready') or failed. The response includes the final build status and details. \ No newline at end of file diff --git a/docs/references/functions/update-function-deployment.md b/docs/references/functions/update-function-deployment.md deleted file mode 100644 index 7a85188842..0000000000 --- a/docs/references/functions/update-function-deployment.md +++ /dev/null @@ -1 +0,0 @@ -Update the function code deployment ID using the unique function ID. Use this endpoint to switch the code deployment that should be executed by the execution endpoint. \ No newline at end of file diff --git a/docs/references/functions/update-function.md b/docs/references/functions/update-function.md deleted file mode 100644 index 5a9a84ad94..0000000000 --- a/docs/references/functions/update-function.md +++ /dev/null @@ -1 +0,0 @@ -Update function by its unique ID. \ No newline at end of file diff --git a/docs/references/functions/update-variable.md b/docs/references/functions/update-variable.md deleted file mode 100644 index af2c38aea2..0000000000 --- a/docs/references/functions/update-variable.md +++ /dev/null @@ -1 +0,0 @@ -Update variable by its unique ID. \ No newline at end of file diff --git a/docs/references/health/get-queue-stats-usage-dump.md b/docs/references/health/get-queue-stats-usage-dump.md deleted file mode 100644 index 3c95da1b8a..0000000000 --- a/docs/references/health/get-queue-stats-usage-dump.md +++ /dev/null @@ -1 +0,0 @@ -Get the number of projects containing metrics that are waiting to be processed in the Appwrite internal queue server. \ No newline at end of file diff --git a/docs/references/health/get-queue-tasks.md b/docs/references/health/get-queue-tasks.md deleted file mode 100644 index ea6fa22087..0000000000 --- a/docs/references/health/get-queue-tasks.md +++ /dev/null @@ -1 +0,0 @@ -Get the number of tasks that are waiting to be processed in the Appwrite internal queue server. \ No newline at end of file diff --git a/docs/references/health/get-queue.md b/docs/references/health/get-queue.md deleted file mode 100644 index e4558f941f..0000000000 --- a/docs/references/health/get-queue.md +++ /dev/null @@ -1 +0,0 @@ -Check the Appwrite queue messaging servers are up and connection is successful. \ No newline at end of file diff --git a/docs/references/messaging/delete.md b/docs/references/messaging/delete.md deleted file mode 100644 index b07d020900..0000000000 --- a/docs/references/messaging/delete.md +++ /dev/null @@ -1 +0,0 @@ -Delete a message by its unique ID. \ No newline at end of file diff --git a/docs/references/project/create-variable.md b/docs/references/project/create-variable.md deleted file mode 100644 index 2bbee5bf99..0000000000 --- a/docs/references/project/create-variable.md +++ /dev/null @@ -1 +0,0 @@ -Create a new project variable. This variable will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/project/delete-variable.md b/docs/references/project/delete-variable.md deleted file mode 100644 index 9be15f83ca..0000000000 --- a/docs/references/project/delete-variable.md +++ /dev/null @@ -1 +0,0 @@ -Delete a project variable by its unique ID. \ No newline at end of file diff --git a/docs/references/project/get-variable.md b/docs/references/project/get-variable.md deleted file mode 100644 index 8636768434..0000000000 --- a/docs/references/project/get-variable.md +++ /dev/null @@ -1 +0,0 @@ -Get a project variable by its unique ID. \ No newline at end of file diff --git a/docs/references/project/list-variables.md b/docs/references/project/list-variables.md deleted file mode 100644 index fbe191178a..0000000000 --- a/docs/references/project/list-variables.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all project variables. These variables will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/project/update-variable.md b/docs/references/project/update-variable.md deleted file mode 100644 index 603622b2c7..0000000000 --- a/docs/references/project/update-variable.md +++ /dev/null @@ -1 +0,0 @@ -Update project variable by its unique ID. This variable will be accessible in all Appwrite Functions at runtime. \ No newline at end of file diff --git a/docs/references/projects/create-key.md b/docs/references/projects/create-key.md deleted file mode 100644 index d6633d936d..0000000000 --- a/docs/references/projects/create-key.md +++ /dev/null @@ -1 +0,0 @@ -Create a new API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project. \ No newline at end of file diff --git a/docs/references/projects/create-platform.md b/docs/references/projects/create-platform.md deleted file mode 100644 index b5d8be0ff9..0000000000 --- a/docs/references/projects/create-platform.md +++ /dev/null @@ -1 +0,0 @@ -Create a new platform for your project. Use this endpoint to register a new platform where your users will run your application which will interact with the Appwrite API. \ No newline at end of file diff --git a/docs/references/projects/create-webhook.md b/docs/references/projects/create-webhook.md deleted file mode 100644 index cd0e93332b..0000000000 --- a/docs/references/projects/create-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Create a new webhook. Use this endpoint to configure a URL that will receive events from Appwrite when specific events occur. \ No newline at end of file diff --git a/docs/references/projects/delete-key.md b/docs/references/projects/delete-key.md deleted file mode 100644 index 9f3774b419..0000000000 --- a/docs/references/projects/delete-key.md +++ /dev/null @@ -1 +0,0 @@ -Delete a key by its unique ID. Once deleted, the key can no longer be used to authenticate API calls. \ No newline at end of file diff --git a/docs/references/projects/delete-platform.md b/docs/references/projects/delete-platform.md deleted file mode 100644 index 7d538cac26..0000000000 --- a/docs/references/projects/delete-platform.md +++ /dev/null @@ -1 +0,0 @@ -Delete a platform by its unique ID. This endpoint removes the platform and all its configurations from the project. \ No newline at end of file diff --git a/docs/references/projects/delete-webhook.md b/docs/references/projects/delete-webhook.md deleted file mode 100644 index 74fee2bcec..0000000000 --- a/docs/references/projects/delete-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Delete a webhook by its unique ID. Once deleted, the webhook will no longer receive project events. \ No newline at end of file diff --git a/docs/references/projects/get-key.md b/docs/references/projects/get-key.md deleted file mode 100644 index bd6351f420..0000000000 --- a/docs/references/projects/get-key.md +++ /dev/null @@ -1 +0,0 @@ -Get a key by its unique ID. This endpoint returns details about a specific API key in your project including it's scopes. \ No newline at end of file diff --git a/docs/references/projects/get-platform.md b/docs/references/projects/get-platform.md deleted file mode 100644 index 87129b829d..0000000000 --- a/docs/references/projects/get-platform.md +++ /dev/null @@ -1 +0,0 @@ -Get a platform by its unique ID. This endpoint returns the platform's details, including its name, type, and key configurations. \ No newline at end of file diff --git a/docs/references/projects/get-webhook.md b/docs/references/projects/get-webhook.md deleted file mode 100644 index 559c73c748..0000000000 --- a/docs/references/projects/get-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Get a webhook by its unique ID. This endpoint returns details about a specific webhook configured for a project. \ No newline at end of file diff --git a/docs/references/projects/list-keys.md b/docs/references/projects/list-keys.md deleted file mode 100644 index a7b701b0d7..0000000000 --- a/docs/references/projects/list-keys.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all API keys from the current project. \ No newline at end of file diff --git a/docs/references/projects/list-platforms.md b/docs/references/projects/list-platforms.md deleted file mode 100644 index ed9ade0852..0000000000 --- a/docs/references/projects/list-platforms.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all platforms in the project. This endpoint returns an array of all platforms and their configurations. \ No newline at end of file diff --git a/docs/references/projects/list-webhooks.md b/docs/references/projects/list-webhooks.md deleted file mode 100644 index bbbf4c7376..0000000000 --- a/docs/references/projects/list-webhooks.md +++ /dev/null @@ -1 +0,0 @@ -Get a list of all webhooks belonging to the project. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/projects/update-api-status-all.md b/docs/references/projects/update-api-status-all.md deleted file mode 100644 index 654070759f..0000000000 --- a/docs/references/projects/update-api-status-all.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of all API types. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime all at once. \ No newline at end of file diff --git a/docs/references/projects/update-api-status.md b/docs/references/projects/update-api-status.md deleted file mode 100644 index af10a0d4f4..0000000000 --- a/docs/references/projects/update-api-status.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of a specific API type. Use this endpoint to enable or disable API types such as REST, GraphQL and Realtime. \ No newline at end of file diff --git a/docs/references/projects/update-auth-duration.md b/docs/references/projects/update-auth-duration.md deleted file mode 100644 index bdc75fa6f0..0000000000 --- a/docs/references/projects/update-auth-duration.md +++ /dev/null @@ -1 +0,0 @@ -Update how long sessions created within a project should stay active for. \ No newline at end of file diff --git a/docs/references/projects/update-auth-limit.md b/docs/references/projects/update-auth-limit.md deleted file mode 100644 index c8faa3fe37..0000000000 --- a/docs/references/projects/update-auth-limit.md +++ /dev/null @@ -1 +0,0 @@ -Update the maximum number of users allowed in this project. Set to 0 for unlimited users. \ No newline at end of file diff --git a/docs/references/projects/update-auth-password-dictionary.md b/docs/references/projects/update-auth-password-dictionary.md deleted file mode 100644 index 1d47d30bb5..0000000000 --- a/docs/references/projects/update-auth-password-dictionary.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable checking user passwords against common passwords dictionary. This helps ensure users don't use common and insecure passwords. \ No newline at end of file diff --git a/docs/references/projects/update-auth-password-history.md b/docs/references/projects/update-auth-password-history.md deleted file mode 100644 index 3a892915d5..0000000000 --- a/docs/references/projects/update-auth-password-history.md +++ /dev/null @@ -1 +0,0 @@ -Update the authentication password history requirement. Use this endpoint to require new passwords to be different than the last X amount of previously used ones. \ No newline at end of file diff --git a/docs/references/projects/update-auth-sessions-limit.md b/docs/references/projects/update-auth-sessions-limit.md deleted file mode 100644 index 7d5fdffae7..0000000000 --- a/docs/references/projects/update-auth-sessions-limit.md +++ /dev/null @@ -1 +0,0 @@ -Update the maximum number of sessions allowed per user within the project, if the limit is hit the oldest session will be deleted to make room for new sessions. \ No newline at end of file diff --git a/docs/references/projects/update-key.md b/docs/references/projects/update-key.md deleted file mode 100644 index 4934a51497..0000000000 --- a/docs/references/projects/update-key.md +++ /dev/null @@ -1 +0,0 @@ -Update a key by its unique ID. Use this endpoint to update the name, scopes, or expiration time of an API key. \ No newline at end of file diff --git a/docs/references/projects/update-memberships-privacy.md b/docs/references/projects/update-memberships-privacy.md deleted file mode 100644 index a1affc1166..0000000000 --- a/docs/references/projects/update-memberships-privacy.md +++ /dev/null @@ -1 +0,0 @@ -Update project membership privacy settings. Use this endpoint to control what user information is visible to other team members, such as user name, email, and MFA status. \ No newline at end of file diff --git a/docs/references/projects/update-personal-data-check.md b/docs/references/projects/update-personal-data-check.md deleted file mode 100644 index 42847fdbfc..0000000000 --- a/docs/references/projects/update-personal-data-check.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable checking user passwords against their personal data. This helps prevent users from using personal information in their passwords. \ No newline at end of file diff --git a/docs/references/projects/update-platform.md b/docs/references/projects/update-platform.md deleted file mode 100644 index d04b07bafd..0000000000 --- a/docs/references/projects/update-platform.md +++ /dev/null @@ -1 +0,0 @@ -Update a platform by its unique ID. Use this endpoint to update the platform's name, key, platform store ID, or hostname. \ No newline at end of file diff --git a/docs/references/projects/update-service-status-all.md b/docs/references/projects/update-service-status-all.md deleted file mode 100644 index f05e7d8c5c..0000000000 --- a/docs/references/projects/update-service-status-all.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of all services. Use this endpoint to enable or disable all optional services at once. \ No newline at end of file diff --git a/docs/references/projects/update-service-status.md b/docs/references/projects/update-service-status.md deleted file mode 100644 index 9d3b0743a8..0000000000 --- a/docs/references/projects/update-service-status.md +++ /dev/null @@ -1 +0,0 @@ -Update the status of a specific service. Use this endpoint to enable or disable a service in your project. \ No newline at end of file diff --git a/docs/references/projects/update-session-invalidation.md b/docs/references/projects/update-session-invalidation.md deleted file mode 100644 index cbaf378624..0000000000 --- a/docs/references/projects/update-session-invalidation.md +++ /dev/null @@ -1 +0,0 @@ -Invalidate all existing sessions. An optional auth security setting for projects, and enabled by default for console project. \ No newline at end of file diff --git a/docs/references/projects/update-webhook-signature.md b/docs/references/projects/update-webhook-signature.md deleted file mode 100644 index 8525a05777..0000000000 --- a/docs/references/projects/update-webhook-signature.md +++ /dev/null @@ -1 +0,0 @@ -Update the webhook signature key. This endpoint can be used to regenerate the signature key used to sign and validate payload deliveries for a specific webhook. \ No newline at end of file diff --git a/docs/references/projects/update-webhook.md b/docs/references/projects/update-webhook.md deleted file mode 100644 index 745e4aebe1..0000000000 --- a/docs/references/projects/update-webhook.md +++ /dev/null @@ -1 +0,0 @@ -Update a webhook by its unique ID. Use this endpoint to update the URL, events, or status of an existing webhook. \ No newline at end of file diff --git a/docs/references/tablesdb/get-database.md b/docs/references/tablesdb/get-database.md deleted file mode 100644 index 24183f6f6b..0000000000 --- a/docs/references/tablesdb/get-database.md +++ /dev/null @@ -1 +0,0 @@ -Get a database by its unique ID. This endpoint response returns a JSON object with the database metadata. \ No newline at end of file diff --git a/docs/references/vectorsdb/decrement-document-attribute.md b/docs/references/vectorsdb/decrement-document-attribute.md deleted file mode 100644 index b7b32d6148..0000000000 --- a/docs/references/vectorsdb/decrement-document-attribute.md +++ /dev/null @@ -1 +0,0 @@ -Decrement a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-collection-logs.md b/docs/references/vectorsdb/get-collection-logs.md deleted file mode 100644 index 8578cef03c..0000000000 --- a/docs/references/vectorsdb/get-collection-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the collection activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-document-logs.md b/docs/references/vectorsdb/get-document-logs.md deleted file mode 100644 index 9b96df5ad4..0000000000 --- a/docs/references/vectorsdb/get-document-logs.md +++ /dev/null @@ -1 +0,0 @@ -Get the document activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/increment-document-attribute.md b/docs/references/vectorsdb/increment-document-attribute.md deleted file mode 100644 index 7a19b3fbc7..0000000000 --- a/docs/references/vectorsdb/increment-document-attribute.md +++ /dev/null @@ -1 +0,0 @@ -Increment a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/vectorsdb/list-attributes.md b/docs/references/vectorsdb/list-attributes.md deleted file mode 100644 index 72ad6d727f..0000000000 --- a/docs/references/vectorsdb/list-attributes.md +++ /dev/null @@ -1 +0,0 @@ -List attributes in the collection. \ No newline at end of file From d0f853d4cd909a54b2b759853d9986e0dfba7443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 13:38:27 +0200 Subject: [PATCH 59/81] Add more project policies --- app/controllers/api/projects.php | 80 ---------------- .../projects/update-session-alerts.md | 1 - .../Policies/MembershipPrivacy/Update.php | 91 +++++++++++++++++++ .../Policies/PasswordPersonalData/Update.php | 1 + .../Project/Policies/SessionAlert/Update.php | 78 ++++++++++++++++ .../Modules/Projects/Http/Projects/Create.php | 2 + .../Modules/Teams/Http/Memberships/Get.php | 16 +++- .../Modules/Teams/Http/Memberships/XList.php | 16 +++- .../Utopia/Response/Model/Membership.php | 6 ++ .../Utopia/Response/Model/Project.php | 14 +++ 10 files changed, 218 insertions(+), 87 deletions(-) delete mode 100644 docs/references/projects/update-session-alerts.md create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 87552c6508..ea5e5754d6 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -138,86 +138,6 @@ Http::patch('/v1/projects/:projectId/oauth2') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/auth/session-alerts') - ->desc('Update project sessions emails') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateSessionAlerts', - description: '/docs/references/projects/update-session-alerts.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('alerts', false, new Boolean(true), 'Set to true to enable session emails.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $alerts, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths['sessionAlerts'] = $alerts; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/auth/memberships-privacy') - ->desc('Update project memberships privacy attributes') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateMembershipsPrivacy', - description: '/docs/references/projects/update-memberships-privacy.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('userName', true, new Boolean(true), 'Set to true to show userName to members of a team.') - ->param('userEmail', true, new Boolean(true), 'Set to true to show email to members of a team.') - ->param('mfa', true, new Boolean(true), 'Set to true to show mfa to members of a team.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $userName, bool $userEmail, bool $mfa, Response $response, Database $dbForPlatform) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - - $auths['membershipsUserName'] = $userName; - $auths['membershipsUserEmail'] = $userEmail; - $auths['membershipsMfa'] = $mfa; - - $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/auth/:method') ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.') ->groups(['api', 'projects']) diff --git a/docs/references/projects/update-session-alerts.md b/docs/references/projects/update-session-alerts.md deleted file mode 100644 index 36859e0c1e..0000000000 --- a/docs/references/projects/update-session-alerts.md +++ /dev/null @@ -1 +0,0 @@ -Enable or disable session email alerts. When enabled, users will receive email notifications when new sessions are created. \ No newline at end of file diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php new file mode 100644 index 0000000000..2b0b0432cc --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/membership-privacy') + ->httpAlias('/v1/projects/:projectId/auth/memberships-privacy') + ->desc('Update membership privacy policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.membership-privacy.update') + ->label('audits.event', 'policies.membership-privacy.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateMembershipPrivacyPolicy', + description: <<param('userId', null, new Boolean(), 'Set to true if you want make user ID visible to all team members, or false to hide it.') + ->param('userEmail', null, new Boolean(), 'Set to true if you want make user email visible to all team members, or false to hide it.') + ->param('userPhone', null, new Boolean(), 'Set to true if you want make user phone number visible to all team members, or false to hide it.') + ->param('userName', null, new Boolean(), 'Set to true if you want make user name visible to all team members, or false to hide it.') + ->param('userMFA', null, new Boolean(), 'Set to true if you want make user MFA status visible to all team members, or false to hide it.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $userId, + bool $userEmail, + bool $userPhone, + bool $userName, + bool $userMFA, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + + $auths['membershipsUserName'] = $userName; + $auths['membershipsUserEmail'] = $userEmail; + $auths['membershipsMfa'] = $userMFA; + $auths['membershipsUserId'] = $userId; + $auths['membershipsUserPhone'] = $userPhone; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php index cc661366d7..4ba90045c8 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php @@ -49,6 +49,7 @@ class Update extends Action ) ], )) + // TODO: Split into more toggles, simiplar to membership privacy policy ->param('enabled', null, new Boolean(), 'Toggle password personal data policy. Set to true if you want to block passwords including user\'s personal data, or false to allow it. When changing this policy, existing passwords remain valid.') ->inject('response') ->inject('dbForPlatform') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php new file mode 100644 index 0000000000..fe9a0dac0b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/policies/session-alert') + ->httpAlias('/v1/projects/:projectId/auth/session-alerts') + ->desc('Update session alert policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.write') + ->label('event', 'policies.session-alert.update') + ->label('audits.event', 'policies.session-alert.update') + ->label('audits.resource', 'project/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'updateSessionAlertPolicy', + description: <<param('enabled', null, new Boolean(), 'Toggle session alert policy. Set to true if you want users to receive email notifications when a sessions are created for their users, or false to not send email alerts.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ): void { + $auths = $project->getAttribute('auths', []); + $auths['sessionAlerts'] = $enabled; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php index e190b5a719..363c99dc1f 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Create.php @@ -120,6 +120,8 @@ class Create extends Action 'membershipsUserName' => false, 'membershipsUserEmail' => false, 'membershipsMfa' => false, + 'membershipsUserId' => false, + 'membershipsUserPhone' => false, 'invalidateSessions' => true ]; diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index f3fd9a4bb9..9cd784e5d0 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -71,9 +71,11 @@ class Get extends Action } $membershipsPrivacy = [ - 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, - 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, - 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false, + 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false, + 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false, ]; $roles = $authorization->getRoles(); @@ -113,6 +115,14 @@ class Get extends Action $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } + if ($membershipsPrivacy['userId']) { + $membership->setAttribute('userId', $memberUser->getId()); + } + + if ($membershipsPrivacy['userPhone']) { + $membership->setAttribute('userPhone', $memberUser->getAttribute('phone')); + } + $membership->setAttribute('teamName', $team->getAttribute('name')); $response->dynamic($membership, Response::MODEL_MEMBERSHIP); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index 364f92e1c5..ca18ed4920 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -124,9 +124,11 @@ class XList extends Action $memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId'))); $membershipsPrivacy = [ - 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, - 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, - 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false, + 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false, + 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false, ]; $roles = $authorization->getRoles(); @@ -167,6 +169,14 @@ class XList extends Action $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } + if ($membershipsPrivacy['userId']) { + $membership->setAttribute('userId', $memberUser->getId()); + } + + if ($membershipsPrivacy['userPhone']) { + $membership->setAttribute('userPhone', $memberUser->getAttribute('phone')); + } + $membership->setAttribute('teamName', $team->getAttribute('name')); return $membership; diff --git a/src/Appwrite/Utopia/Response/Model/Membership.php b/src/Appwrite/Utopia/Response/Model/Membership.php index 46153842bc..9be7102145 100644 --- a/src/Appwrite/Utopia/Response/Model/Membership.php +++ b/src/Appwrite/Utopia/Response/Model/Membership.php @@ -46,6 +46,12 @@ class Membership extends Model 'default' => '', 'example' => 'john@appwrite.io', ]) + ->addRule('userPhone', [ + 'type' => self::TYPE_STRING, + 'description' => 'User phone number. Hide this attribute by toggling membership privacy in the Console.', + 'default' => '', + 'example' => '+1 555 555 5555', + ]) ->addRule('teamId', [ 'type' => self::TYPE_STRING, 'description' => 'Team ID.', diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 4cb038fc37..ea219d6047 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -181,6 +181,18 @@ class Project extends Model 'default' => false, 'example' => true, ]) + ->addRule('authMembershipsUserId', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to show user IDs in the teams membership response.', + 'default' => false, + 'example' => true, + ]) + ->addRule('authMembershipsUserPhone', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether or not to show user phone numbers in the teams membership response.', + 'default' => false, + 'example' => true, + ]) ->addRule('authInvalidateSessions', [ 'type' => self::TYPE_BOOLEAN, 'description' => 'Whether or not all existing sessions should be invalidated on password change', @@ -475,6 +487,8 @@ class Project extends Model $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true); $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? true); $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true); + $document->setAttribute('authMembershipsUserId', $authValues['membershipsUserId'] ?? true); + $document->setAttribute('authMembershipsUserPhone', $authValues['membershipsUserPhone'] ?? true); $document->setAttribute('authInvalidateSessions', $authValues['invalidateSessions'] ?? false); foreach ($auth as $method) { From 5f9dc0fcd8604c1195a77937c9b96f7f43e2244b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 13:58:36 +0200 Subject: [PATCH 60/81] Req & res filters, review fixes --- app/config/roles.php | 1 + app/config/scopes/project.php | 4 ++ app/controllers/general.php | 8 ++++ app/init/constants.php | 4 +- src/Appwrite/Migration/Migration.php | 1 + .../Policies/MembershipPrivacy/Update.php | 40 ++++++++++++------- .../Policies/PasswordHistory/Update.php | 4 +- .../Project/Policies/SessionLimit/Update.php | 4 +- .../Project/Policies/UserLimit/Update.php | 4 +- .../Modules/Project/Services/Http.php | 20 ++++++++++ src/Appwrite/Platform/Workers/Migrations.php | 1 + src/Appwrite/Utopia/Request/Filters/V23.php | 28 +++++++++++++ src/Appwrite/Utopia/Response/Filters/V23.php | 36 +++++++++++++++++ .../Utopia/Response/Model/Project.php | 10 ++--- tests/e2e/Scopes/ProjectCustom.php | 1 + 15 files changed, 138 insertions(+), 28 deletions(-) create mode 100644 src/Appwrite/Utopia/Request/Filters/V23.php create mode 100644 src/Appwrite/Utopia/Response/Filters/V23.php diff --git a/app/config/roles.php b/app/config/roles.php index 116e8ac932..2eaa81ce49 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,7 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'policies.write', 'projects.write', 'keys.read', 'keys.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 6c7f75c08e..74c14ea933 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -204,4 +204,8 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s platforms", ], + "policies.write" => [ + "description" => + "Access to update project\'s policies", + ], ]; diff --git a/app/controllers/general.php b/app/controllers/general.php index b4f4a5c1d1..596fbd0926 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -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()); } diff --git a/app/init/constants.php b/app/init/constants.php index 443aaaa680..8503032266 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -44,8 +44,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'; diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index a01031de9b..ef0dd9f8b5 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -94,6 +94,7 @@ abstract class Migration '1.8.1' => 'V23', '1.9.0' => 'V24', '1.9.1' => 'V24', + '1.9.2' => 'V24', ]; /** diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php index 2b0b0432cc..fcaf44aff4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -49,11 +49,11 @@ class Update extends Action ) ], )) - ->param('userId', null, new Boolean(), 'Set to true if you want make user ID visible to all team members, or false to hide it.') - ->param('userEmail', null, new Boolean(), 'Set to true if you want make user email visible to all team members, or false to hide it.') - ->param('userPhone', null, new Boolean(), 'Set to true if you want make user phone number visible to all team members, or false to hide it.') - ->param('userName', null, new Boolean(), 'Set to true if you want make user name visible to all team members, or false to hide it.') - ->param('userMFA', null, new Boolean(), 'Set to true if you want make user MFA status visible to all team members, or false to hide it.') + ->param('userId', null, new Boolean(), 'Set to true if you want make user ID visible to all team members, or false to hide it.', optional: true) + ->param('userEmail', null, new Boolean(), 'Set to true if you want make user email visible to all team members, or false to hide it.', optional: true) + ->param('userPhone', null, new Boolean(), 'Set to true if you want make user phone number visible to all team members, or false to hide it.', optional: true) + ->param('userName', null, new Boolean(), 'Set to true if you want make user name visible to all team members, or false to hide it.', optional: true) + ->param('userMFA', null, new Boolean(), 'Set to true if you want make user MFA status visible to all team members, or false to hide it.', optional: true) ->inject('response') ->inject('dbForPlatform') ->inject('project') @@ -62,11 +62,11 @@ class Update extends Action } public function action( - bool $userId, - bool $userEmail, - bool $userPhone, - bool $userName, - bool $userMFA, + ?bool $userId, + ?bool $userEmail, + ?bool $userPhone, + ?bool $userName, + ?bool $userMFA, Response $response, Database $dbForPlatform, Document $project, @@ -74,11 +74,21 @@ class Update extends Action ): void { $auths = $project->getAttribute('auths', []); - $auths['membershipsUserName'] = $userName; - $auths['membershipsUserEmail'] = $userEmail; - $auths['membershipsMfa'] = $userMFA; - $auths['membershipsUserId'] = $userId; - $auths['membershipsUserPhone'] = $userPhone; + if ($userId !== null) { + $auths['membershipsUserId'] = $userId; + } + if ($userEmail !== null) { + $auths['membershipsUserEmail'] = $userEmail; + } + if ($userPhone !== null) { + $auths['membershipsUserPhone'] = $userPhone; + } + if ($userName !== null) { + $auths['membershipsUserName'] = $userName; + } + if ($userMFA !== null) { + $auths['membershipsMfa'] = $userMFA; + } $updates = new Document([ 'auths' => $auths, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php index 437cc29b33..012f3e11f0 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -50,7 +50,7 @@ class Update extends Action ) ], )) - ->param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT), 'Set the password history length per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')) + ->param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the password history length per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') ->inject('response') ->inject('dbForPlatform') ->inject('project') @@ -59,7 +59,7 @@ class Update extends Action } public function action( - int $total, + ?int $total, Response $response, Database $dbForPlatform, Document $project, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php index 3860b7ccc4..407e7e43a6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php @@ -50,7 +50,7 @@ class Update extends Action ) ], )) - ->param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT), 'Set the maximum number of sessions allowed per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')) + ->param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of sessions allowed per user. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') ->inject('response') ->inject('dbForPlatform') ->inject('project') @@ -59,7 +59,7 @@ class Update extends Action } public function action( - int $total, + ?int $total, Response $response, Database $dbForPlatform, Document $project, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php index 6b21a22138..6b614fdedc 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php @@ -50,7 +50,7 @@ class Update extends Action ) ], )) - ->param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT), 'Set the maximum number of users allowed in the project. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.')) + ->param('total', null, new Nullable(new Range(1, APP_LIMIT_COUNT)), 'Set the maximum number of users allowed in the project. Value can be between 1 and ' . APP_LIMIT_COUNT . ', or null to disable the limit.') ->inject('response') ->inject('dbForPlatform') ->inject('project') @@ -59,7 +59,7 @@ class Update extends Action } public function action( - int $total, + ?int $total, Response $response, Database $dbForPlatform, Document $project, diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index bcab75a8c5..e70f495bb5 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -22,6 +22,15 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\MembershipPrivacy\Update as UpdateMembershipPrivacyPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordDictionary\Update as UpdatePasswordDictionaryPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordHistory\Update as UpdatePasswordHistoryPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordPersonalData\Update as UpdatePasswordPersonalDataPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionAlert\Update as UpdateSessionAlertPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration\Update as UpdateSessionDurationPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionInvalidation\Update as UpdateSessionInvalidationPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit\Update as UpdateSessionLimitPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\UserLimit\Update as UpdateUserLimitPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol; use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; @@ -73,5 +82,16 @@ class Http extends Service $this->addAction(CreateLinuxPlatform::getName(), new CreateLinuxPlatform()); $this->addAction(GetPlatform::getName(), new GetPlatform()); $this->addAction(ListPlatforms::getName(), new ListPlatforms()); + + // Policies + $this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy()); + $this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy()); + $this->addAction(UpdatePasswordHistoryPolicy::getName(), new UpdatePasswordHistoryPolicy()); + $this->addAction(UpdatePasswordPersonalDataPolicy::getName(), new UpdatePasswordPersonalDataPolicy()); + $this->addAction(UpdateSessionAlertPolicy::getName(), new UpdateSessionAlertPolicy()); + $this->addAction(UpdateSessionDurationPolicy::getName(), new UpdateSessionDurationPolicy()); + $this->addAction(UpdateSessionInvalidationPolicy::getName(), new UpdateSessionInvalidationPolicy()); + $this->addAction(UpdateSessionLimitPolicy::getName(), new UpdateSessionLimitPolicy()); + $this->addAction(UpdateUserLimitPolicy::getName(), new UpdateUserLimitPolicy()); } } diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 339084727d..e19d705553 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -392,6 +392,7 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'policies.write', ] ]); diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php new file mode 100644 index 0000000000..8e92f9c13a --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -0,0 +1,28 @@ +parseUpdateMembershipPrivacyPolicy($content); + break; + } + + return $content; + } + + protected function parseUpdateMembershipPrivacyPolicy(array $content): array + { + $content['userId'] = false; + $content['userPhone'] = false; + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php new file mode 100644 index 0000000000..ccceb13f44 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -0,0 +1,36 @@ + $this->parseMembership($content), + Response::MODEL_MEMBERSHIP_LIST => $this->handleList($content, 'memberships', fn ($item) => $this->parseMembership($item)), + Response::MODEL_PROJECT => $this->parseProject($content), + Response::MODEL_PROJECT_LIST => $this->handleList($content, 'projects', fn ($item) => $this->parseProject($item)), + default => $content, + }; + } + + private function parseMembership(array $content): array + { + unset($content['userPhone']); + + return $content; + } + + private function parseProject(array $content): array + { + unset($content['authMembershipsUserId']); + unset($content['authMembershipsUserPhone']); + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index ea219d6047..75d92ac013 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -484,11 +484,11 @@ class Project extends Model $document->setAttribute('authFreeEmails', $authValues['freeEmails'] ?? false); $document->setAttribute('authMockNumbers', $authValues['mockNumbers'] ?? []); $document->setAttribute('authSessionAlerts', $authValues['sessionAlerts'] ?? false); - $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true); - $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? true); - $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? true); - $document->setAttribute('authMembershipsUserId', $authValues['membershipsUserId'] ?? true); - $document->setAttribute('authMembershipsUserPhone', $authValues['membershipsUserPhone'] ?? true); + $document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? false); + $document->setAttribute('authMembershipsUserEmail', $authValues['membershipsUserEmail'] ?? false); + $document->setAttribute('authMembershipsMfa', $authValues['membershipsMfa'] ?? false); + $document->setAttribute('authMembershipsUserId', $authValues['membershipsUserId'] ?? false); + $document->setAttribute('authMembershipsUserPhone', $authValues['membershipsUserPhone'] ?? false); $document->setAttribute('authInvalidateSessions', $authValues['invalidateSessions'] ?? false); foreach ($auth as $method) { diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index a62a1e8ba3..d0b9ef4b4f 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,7 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'policies.write', ], ]); From 6adabae62090acd41155f6330486ac4daee423d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 14:05:46 +0200 Subject: [PATCH 61/81] Add policy tests --- tests/e2e/Services/Project/PoliciesBase.php | 825 ++++++++++++++++++ .../Project/PoliciesConsoleClientTest.php | 14 + .../Project/PoliciesCustomServerTest.php | 14 + 3 files changed, 853 insertions(+) create mode 100644 tests/e2e/Services/Project/PoliciesBase.php create mode 100644 tests/e2e/Services/Project/PoliciesConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/PoliciesCustomServerTest.php diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php new file mode 100644 index 0000000000..666ca55fd6 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -0,0 +1,825 @@ +updatePasswordDictionaryPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authPasswordDictionary']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authPasswordDictionary']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + } + + public function testUpdatePasswordDictionaryPolicyDisable(): void + { + $this->updatePasswordDictionaryPolicy(true); + + $response = $this->updatePasswordDictionaryPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authPasswordDictionary']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authPasswordDictionary']); + } + + public function testUpdatePasswordDictionaryPolicyIdempotent(): void + { + $first = $this->updatePasswordDictionaryPolicy(true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['authPasswordDictionary']); + + $second = $this->updatePasswordDictionaryPolicy(true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['authPasswordDictionary']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + } + + public function testUpdatePasswordDictionaryPolicyWithoutAuth(): void + { + $response = $this->updatePasswordDictionaryPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdatePasswordDictionaryPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordDictionaryPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // ========================================================================= + // Password History Policy + // ========================================================================= + + public function testUpdatePasswordHistoryPolicyEnable(): void + { + $response = $this->updatePasswordHistoryPolicy(5); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(5, $response['body']['authPasswordHistory']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(5, $project['body']['authPasswordHistory']); + + // Cleanup (disable by setting total to null which maps to 0) + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyMin(): void + { + $response = $this->updatePasswordHistoryPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authPasswordHistory']); + + // Cleanup + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyMax(): void + { + $response = $this->updatePasswordHistoryPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authPasswordHistory']); + + // Cleanup + $this->updatePasswordHistoryPolicy(null); + } + + public function testUpdatePasswordHistoryPolicyDisable(): void + { + $this->updatePasswordHistoryPolicy(5); + + $response = $this->updatePasswordHistoryPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authPasswordHistory']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(0, $project['body']['authPasswordHistory']); + } + + public function testUpdatePasswordHistoryPolicyBelowMin(): void + { + $response = $this->updatePasswordHistoryPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyAboveMax(): void + { + $response = $this->updatePasswordHistoryPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordHistoryPolicyWithoutAuth(): void + { + $response = $this->updatePasswordHistoryPolicy(5, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Password Personal Data Policy + // ========================================================================= + + public function testUpdatePasswordPersonalDataPolicyEnable(): void + { + $response = $this->updatePasswordPersonalDataPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authPersonalDataCheck']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authPersonalDataCheck']); + + // Cleanup + $this->updatePasswordPersonalDataPolicy(false); + } + + public function testUpdatePasswordPersonalDataPolicyDisable(): void + { + $this->updatePasswordPersonalDataPolicy(true); + + $response = $this->updatePasswordPersonalDataPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authPersonalDataCheck']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authPersonalDataCheck']); + } + + public function testUpdatePasswordPersonalDataPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordPersonalDataPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdatePasswordPersonalDataPolicyWithoutAuth(): void + { + $response = $this->updatePasswordPersonalDataPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Alert Policy + // ========================================================================= + + public function testUpdateSessionAlertPolicyEnable(): void + { + $response = $this->updateSessionAlertPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authSessionAlerts']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authSessionAlerts']); + + // Cleanup + $this->updateSessionAlertPolicy(false); + } + + public function testUpdateSessionAlertPolicyDisable(): void + { + $this->updateSessionAlertPolicy(true); + + $response = $this->updateSessionAlertPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authSessionAlerts']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authSessionAlerts']); + } + + public function testUpdateSessionAlertPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionAlertPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionAlertPolicyWithoutAuth(): void + { + $response = $this->updateSessionAlertPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Duration Policy + // ========================================================================= + + public function testUpdateSessionDurationPolicy(): void + { + $response = $this->updateSessionDurationPolicy(3600); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(3600, $response['body']['authDuration']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(3600, $project['body']['authDuration']); + + // Cleanup (reset to default 1 year) + $this->updateSessionDurationPolicy(31536000); + } + + public function testUpdateSessionDurationPolicyMin(): void + { + $response = $this->updateSessionDurationPolicy(60); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(60, $response['body']['authDuration']); + + // Cleanup + $this->updateSessionDurationPolicy(31536000); + } + + public function testUpdateSessionDurationPolicyMax(): void + { + $response = $this->updateSessionDurationPolicy(31536000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(31536000, $response['body']['authDuration']); + } + + public function testUpdateSessionDurationPolicyBelowMin(): void + { + $response = $this->updateSessionDurationPolicy(59); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyAboveMax(): void + { + $response = $this->updateSessionDurationPolicy(31536001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders(), [ + 'duration' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionDurationPolicyWithoutAuth(): void + { + $response = $this->updateSessionDurationPolicy(3600, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Invalidation Policy + // ========================================================================= + + public function testUpdateSessionInvalidationPolicyEnable(): void + { + $response = $this->updateSessionInvalidationPolicy(true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authInvalidateSessions']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authInvalidateSessions']); + + // Cleanup + $this->updateSessionInvalidationPolicy(false); + } + + public function testUpdateSessionInvalidationPolicyDisable(): void + { + $this->updateSessionInvalidationPolicy(true); + + $response = $this->updateSessionInvalidationPolicy(false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authInvalidateSessions']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authInvalidateSessions']); + } + + public function testUpdateSessionInvalidationPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders(), [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionInvalidationPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionInvalidationPolicyWithoutAuth(): void + { + $response = $this->updateSessionInvalidationPolicy(true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Session Limit Policy + // ========================================================================= + + public function testUpdateSessionLimitPolicy(): void + { + $response = $this->updateSessionLimitPolicy(5); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(5, $response['body']['authSessionsLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(5, $project['body']['authSessionsLimit']); + + // Cleanup (reset to default) + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyMin(): void + { + $response = $this->updateSessionLimitPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyMax(): void + { + $response = $this->updateSessionLimitPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyDisable(): void + { + $response = $this->updateSessionLimitPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authSessionsLimit']); + + // Cleanup + $this->updateSessionLimitPolicy(10); + } + + public function testUpdateSessionLimitPolicyBelowMin(): void + { + $response = $this->updateSessionLimitPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyAboveMax(): void + { + $response = $this->updateSessionLimitPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateSessionLimitPolicyWithoutAuth(): void + { + $response = $this->updateSessionLimitPolicy(5, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // User Limit Policy + // ========================================================================= + + public function testUpdateUserLimitPolicy(): void + { + $response = $this->updateUserLimitPolicy(100); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(100, $response['body']['authLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(100, $project['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyMin(): void + { + $response = $this->updateUserLimitPolicy(1); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(1, $response['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyMax(): void + { + $response = $this->updateUserLimitPolicy(5000); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(5000, $response['body']['authLimit']); + + // Cleanup + $this->updateUserLimitPolicy(null); + } + + public function testUpdateUserLimitPolicyDisable(): void + { + $this->updateUserLimitPolicy(100); + + $response = $this->updateUserLimitPolicy(null); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authLimit']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(0, $project['body']['authLimit']); + } + + public function testUpdateUserLimitPolicyBelowMin(): void + { + $response = $this->updateUserLimitPolicy(0); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyAboveMax(): void + { + $response = $this->updateUserLimitPolicy(5001); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders(), [ + 'total' => 'not-a-number', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyMissingParam(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders(), []); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateUserLimitPolicyWithoutAuth(): void + { + $response = $this->updateUserLimitPolicy(100, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Membership Privacy Policy + // ========================================================================= + + public function testUpdateMembershipPrivacyPolicyAllEnabled(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['authMembershipsUserId']); + $this->assertSame(true, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserPhone']); + $this->assertSame(true, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(true, $project['body']['authMembershipsUserId']); + $this->assertSame(true, $project['body']['authMembershipsUserEmail']); + $this->assertSame(true, $project['body']['authMembershipsUserPhone']); + $this->assertSame(true, $project['body']['authMembershipsUserName']); + $this->assertSame(true, $project['body']['authMembershipsMfa']); + } + + public function testUpdateMembershipPrivacyPolicyAllDisabled(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(false, $response['body']['authMembershipsMfa']); + + $project = $this->getProjectDocument(); + $this->assertSame(200, $project['headers']['status-code']); + $this->assertSame(false, $project['body']['authMembershipsUserId']); + $this->assertSame(false, $project['body']['authMembershipsUserEmail']); + $this->assertSame(false, $project['body']['authMembershipsUserPhone']); + $this->assertSame(false, $project['body']['authMembershipsUserName']); + $this->assertSame(false, $project['body']['authMembershipsMfa']); + + // Cleanup (restore defaults) + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyMixed(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => false, + 'userPhone' => true, + 'userName' => false, + 'userMFA' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyMissingParam(): void + { + // Missing userMFA + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateMembershipPrivacyPolicyInvalidType(): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ + 'userId' => 'not-a-boolean', + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateMembershipPrivacyPolicyWithoutAuth(): void + { + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ], false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + protected function buildHeaders(bool $authenticated = true): array + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $headers; + } + + protected function getProjectDocument(): array + { + return $this->client->call(Client::METHOD_GET, '/projects/' . $this->getProject()['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]); + } + + protected function updatePasswordDictionaryPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updatePasswordHistoryPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + protected function updatePasswordPersonalDataPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionAlertPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionDurationPolicy(int $duration, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $this->buildHeaders($authenticated), [ + 'duration' => $duration, + ]); + } + + protected function updateSessionInvalidationPolicy(bool $enabled, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $this->buildHeaders($authenticated), [ + 'enabled' => $enabled, + ]); + } + + protected function updateSessionLimitPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + protected function updateUserLimitPolicy(?int $total, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $this->buildHeaders($authenticated), [ + 'total' => $total, + ]); + } + + /** + * @param array $params + */ + protected function updateMembershipPrivacyPolicy(array $params, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders($authenticated), $params); + } +} diff --git a/tests/e2e/Services/Project/PoliciesConsoleClientTest.php b/tests/e2e/Services/Project/PoliciesConsoleClientTest.php new file mode 100644 index 0000000000..2db8e57a35 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Tue, 21 Apr 2026 14:39:02 +0200 Subject: [PATCH 62/81] Integration tests --- .../Policies/PasswordHistory/Update.php | 2 + ...liciesMembershipPrivacyIntegrationTest.php | 176 ++++++++++++++++++ ...iciesPasswordDictionaryIntegrationTest.php | 68 +++++++ ...PoliciesPasswordHistoryIntegrationTest.php | 152 +++++++++++++++ .../PoliciesSessionLimitIntegrationTest.php | 121 ++++++++++++ .../PoliciesUserLimitIntegrationTest.php | 87 +++++++++ 6 files changed, 606 insertions(+) create mode 100644 tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php index 012f3e11f0..a1aea6b0a4 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -41,6 +41,8 @@ class Update extends Action name: 'updatePasswordHistoryPolicy', description: <<getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // Step 1: Configure privacy to false + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $serverHeaders, [ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertFalse($response['body']['authMembershipsUserId']); + $this->assertFalse($response['body']['authMembershipsUserEmail']); + $this->assertFalse($response['body']['authMembershipsUserPhone']); + $this->assertFalse($response['body']['authMembershipsUserName']); + $this->assertFalse($response['body']['authMembershipsMfa']); + + // Step 2: Setup two users + $user1Email = 'user1_' . uniqid() . '@localhost.test'; + $user1Name = 'Alice Anderson'; + $user1Phone = '+12025550101'; + $password = 'password1234'; + + $user1 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $user1Email, + 'password' => $password, + 'name' => $user1Name, + ]); + $this->assertSame(201, $user1['headers']['status-code']); + $user1Id = $user1['body']['$id']; + + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $user1Id . '/phone', $serverHeaders, [ + 'number' => $user1Phone, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $user2Email = 'user2_' . uniqid() . '@localhost.test'; + $user2Name = 'Bob Baker'; + $user2Phone = '+12025550102'; + + $user2 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $user2Email, + 'password' => $password, + 'name' => $user2Name, + ]); + $this->assertSame(201, $user2['headers']['status-code']); + $user2Id = $user2['body']['$id']; + + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $user2Id . '/phone', $serverHeaders, [ + 'number' => $user2Phone, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Create team and add both users as members + $team = $this->client->call(Client::METHOD_POST, '/teams', $serverHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'Privacy Team', + 'roles' => ['member'], + ]); + $this->assertSame(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + $membership1 = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $serverHeaders, [ + 'userId' => $user1Id, + 'roles' => ['member'], + ]); + $this->assertSame(201, $membership1['headers']['status-code']); + $this->assertTrue($membership1['body']['confirm']); + + $membership2 = $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $serverHeaders, [ + 'userId' => $user2Id, + 'roles' => ['member'], + ]); + $this->assertSame(201, $membership2['headers']['status-code']); + $this->assertTrue($membership2['body']['confirm']); + + // Step 4: Sign in as user1 and list memberships with privacy disabled + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $user1Email, + 'password' => $password, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $user1Session = $session['cookies']['a_session_' . $projectId]; + + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $user1Session, + ]; + + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + $this->assertCount(2, $response['body']['memberships']); + + foreach ($response['body']['memberships'] as $membership) { + $this->assertSame('', $membership['userName']); + $this->assertSame('', $membership['userEmail']); + $this->assertSame('', $membership['userPhone']); + $this->assertSame('', $membership['userId']); + $this->assertFalse($membership['mfa']); + } + + // Step 5: Update privacy to true + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $serverHeaders, [ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authMembershipsUserId']); + $this->assertTrue($response['body']['authMembershipsUserEmail']); + $this->assertTrue($response['body']['authMembershipsUserPhone']); + $this->assertTrue($response['body']['authMembershipsUserName']); + $this->assertTrue($response['body']['authMembershipsMfa']); + + // Step 6: List memberships with privacy enabled - user details exposed + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(2, $response['body']['total']); + $this->assertCount(2, $response['body']['memberships']); + + $membershipsByUser = []; + foreach ($response['body']['memberships'] as $membership) { + $membershipsByUser[$membership['userId']] = $membership; + } + + $this->assertArrayHasKey($user1Id, $membershipsByUser); + $this->assertSame($user1Id, $membershipsByUser[$user1Id]['userId']); + $this->assertSame($user1Name, $membershipsByUser[$user1Id]['userName']); + $this->assertSame($user1Email, $membershipsByUser[$user1Id]['userEmail']); + $this->assertSame($user1Phone, $membershipsByUser[$user1Id]['userPhone']); + $this->assertFalse($membershipsByUser[$user1Id]['mfa']); + + $this->assertArrayHasKey($user2Id, $membershipsByUser); + $this->assertSame($user2Id, $membershipsByUser[$user2Id]['userId']); + $this->assertSame($user2Name, $membershipsByUser[$user2Id]['userName']); + $this->assertSame($user2Email, $membershipsByUser[$user2Id]['userEmail']); + $this->assertSame($user2Phone, $membershipsByUser[$user2Id]['userPhone']); + $this->assertFalse($membershipsByUser[$user2Id]['mfa']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php new file mode 100644 index 0000000000..2d0e15a70f --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordDictionaryIntegrationTest.php @@ -0,0 +1,68 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // "password" is the top entry in the common-passwords dictionary and is 8 chars (min length). + $commonPassword = 'football'; + + // Step 1: Disable password dictionary policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => false, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertFalse($response['body']['authPasswordDictionary']); + + // Step 2: Create user with common password - should succeed + $user1 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => 'dict_off_' . uniqid() . '@localhost.test', + 'password' => $commonPassword, + 'name' => 'Dictionary Off User', + ]); + $this->assertSame(201, $user1['headers']['status-code']); + $this->assertNotEmpty($user1['body']['$id']); + + // Step 3: Enable password dictionary policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => true, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authPasswordDictionary']); + + // Step 4: Creating another user with the common password must fail + $user2 = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => 'dict_on_' . uniqid() . '@localhost.test', + 'password' => $commonPassword, + 'name' => 'Dictionary On User', + ]); + $this->assertSame(400, $user2['headers']['status-code']); + + // Cleanup: disable policy + $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $serverHeaders, [ + 'enabled' => false, + ]); + } +} diff --git a/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php new file mode 100644 index 0000000000..c2dfd7be5e --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordHistoryIntegrationTest.php @@ -0,0 +1,152 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // Step 1: Enable password history policy with limit 3 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $serverHeaders, [ + 'total' => 3, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(3, $response['body']['authPasswordHistory']); + + $firstPassword = 'firstpassword'; + $secondPassword = 'secondpassword'; + $thirdPassword = 'thirdpassword'; + $fourthPassword = 'fourthpassword'; + + // Step 2: Sign up user with firstpassword (policy on, so signup populates history) + $email = 'history_' . uniqid() . '@localhost.test'; + $userId = ID::unique(); + + $account = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => $userId, + 'email' => $email, + 'password' => $firstPassword, + 'name' => 'History User', + ]); + $this->assertSame(201, $account['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $firstPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $sessionCookie = $session['cookies']['a_session_' . $projectId]; + + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + + // Change password: first -> second + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $secondPassword, + 'oldPassword' => $firstPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Change password: second -> third + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $thirdPassword, + 'oldPassword' => $secondPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Attempt to reuse each of the 3 previous passwords - all should fail + foreach ([$firstPassword, $secondPassword, $thirdPassword] as $reused) { + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $reused, + 'oldPassword' => $thirdPassword, + ]); + $this->assertSame(400, $response['headers']['status-code'], 'Reusing password "' . $reused . '" should be blocked by history policy'); + $this->assertSame('password_recently_used', $response['body']['type']); + } + + // Step 4: Setting fourthpassword succeeds + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $fourthPassword, + 'oldPassword' => $thirdPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + // Verify the new password works by signing in again + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $fourthPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + + // Step 5: Disable password history policy + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', $serverHeaders, [ + 'total' => null, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authPasswordHistory']); + + // Step 6: With policy off, reusing any previous password should succeed, as should setting a brand new one. + // oldPassword must match current password, so walk through each previous password sequentially. + $fifthPassword = 'fifthpassword'; + $chain = [ + [$fourthPassword, $firstPassword], + [$firstPassword, $secondPassword], + [$secondPassword, $thirdPassword], + [$thirdPassword, $fourthPassword], + [$fourthPassword, $fifthPassword], + ]; + + foreach ($chain as [$current, $next]) { + $response = $this->client->call(Client::METHOD_PATCH, '/account/password', $clientHeaders, [ + 'password' => $next, + 'oldPassword' => $current, + ]); + $this->assertSame(200, $response['headers']['status-code'], 'Changing password from "' . $current . '" to "' . $next . '" should succeed with history policy disabled'); + } + + // Verify the final password works by signing in + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $fifthPassword, + ]); + $this->assertSame(201, $session['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php new file mode 100644 index 0000000000..66e45b06a9 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php @@ -0,0 +1,121 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $email = 'session_' . uniqid() . '@localhost.test'; + $password = 'password1234'; + + // Create user (via API key so signup rules don't interfere) + $response = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Session User', + ]); + $this->assertSame(201, $response['headers']['status-code']); + + $login = function () use ($publicHeaders, $email, $password): string { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + return $response['cookies']['a_session_' . $this->getProject()['$id']]; + }; + + $accountHeaders = function (string $sessionCookie) use ($projectId): array { + return [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + }; + + $getAccount = function (string $sessionCookie) use ($accountHeaders): array { + return $this->client->call(Client::METHOD_GET, '/account', $accountHeaders($sessionCookie)); + }; + + $setSessionLimit = function (?int $total) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', $serverHeaders, [ + 'total' => $total, + ]); + $this->assertSame(200, $response['headers']['status-code']); + }; + + // Step 1: Session limit = 1 + $setSessionLimit(1); + + $session1 = $login(); + $this->assertEventually(function () use ($getAccount, $session1) { + $response = $getAccount($session1); + $this->assertSame(200, $response['headers']['status-code']); + }, 15_000, 500); + + // New session pushes old one out + $session2 = $login(); + $this->assertEventually(function () use ($getAccount, $session1, $session2) { + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + }, 15_000, 500); + + // Step 2: Session limit = 2 + $setSessionLimit(2); + + $session3 = $login(); + $this->assertEventually(function () use ($getAccount, $session2, $session3) { + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + }, 15_000, 500); + + // Step 3: 4th session evicts session2 (oldest), session3 and session4 remain + $session4 = $login(); + $this->assertEventually(function () use ($getAccount, $session2, $session3, $session4) { + $this->assertSame(200, $getAccount($session4)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + }, 15_000, 500); + + // Step 4: Disable session limit, create 5 new sessions, all should remain usable + $setSessionLimit(null); + + $newSessions = []; + for ($i = 0; $i < 5; $i++) { + $newSessions[] = $login(); + } + + $this->assertEventually(function () use ($getAccount, $newSessions) { + foreach ($newSessions as $index => $sessionCookie) { + $this->assertSame(200, $getAccount($sessionCookie)['headers']['status-code'], 'Session #' . ($index + 1) . ' should remain valid when limit is disabled'); + } + }, 15_000, 500); + } +} diff --git a/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php new file mode 100644 index 0000000000..5ddcd8aaa1 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesUserLimitIntegrationTest.php @@ -0,0 +1,87 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $signupHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $signup = function () use ($signupHeaders): array { + return $this->client->call(Client::METHOD_POST, '/account', $signupHeaders, [ + 'userId' => ID::unique(), + 'email' => 'limit_' . uniqid() . '@localhost.test', + 'password' => 'password1234', + 'name' => 'Limit User', + ]); + }; + + // Step 1: Set user limit to 3 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => 3, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(3, $response['body']['authLimit']); + + // Create 3 users - all should succeed + for ($i = 1; $i <= 3; $i++) { + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code'], 'User ' . $i . ' should be created under limit of 3'); + } + + // User 4 should be blocked + $response = $signup(); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('user_count_exceeded', $response['body']['type']); + + // Step 2: Raise user limit to 4 + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => 4, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(4, $response['body']['authLimit']); + + // User 4 now succeeds + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code']); + + // User 5 should be blocked + $response = $signup(); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('user_count_exceeded', $response['body']['type']); + + // Step 3: Remove user limit (null -> stored as 0 -> unlimited) + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', $serverHeaders, [ + 'total' => null, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['authLimit']); + + // User 5 now succeeds + $response = $signup(); + $this->assertSame(201, $response['headers']['status-code']); + } +} From eba145ee2d09175cfaa9075991208b05dc3a450b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 15:13:27 +0200 Subject: [PATCH 63/81] More integration tests --- .../Policies/SessionDuration/Update.php | 2 +- tests/e2e/Services/Project/PoliciesBase.php | 4 +- ...iesPasswordPersonalDataIntegrationTest.php | 104 +++++++++++++++ .../PoliciesSessionAlertIntegrationTest.php | 121 ++++++++++++++++++ ...PoliciesSessionDurationIntegrationTest.php | 102 +++++++++++++++ ...ciesSessionInvalidationIntegrationTest.php | 119 +++++++++++++++++ 6 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php create mode 100644 tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php index ad2540172c..c58951213d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php @@ -49,7 +49,7 @@ class Update extends Action ) ], )) - ->param('duration', null, new Range(60, 31536000), 'Maximum session length in seconds. Minium allowed value is 60 seconds, and maximum is 1 year, which is 31536000 seconds.') + ->param('duration', null, new Range(5, 31536000), 'Maximum session length in seconds. Minium allowed value is 5 second, and maximum is 1 year, which is 31536000 seconds.') ->inject('response') ->inject('dbForPlatform') ->inject('project') diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php index 666ca55fd6..db3576a523 100644 --- a/tests/e2e/Services/Project/PoliciesBase.php +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -306,7 +306,7 @@ trait PoliciesBase public function testUpdateSessionDurationPolicyMin(): void { - $response = $this->updateSessionDurationPolicy(60); + $response = $this->updateSessionDurationPolicy(1); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame(60, $response['body']['authDuration']); @@ -325,7 +325,7 @@ trait PoliciesBase public function testUpdateSessionDurationPolicyBelowMin(): void { - $response = $this->updateSessionDurationPolicy(59); + $response = $this->updateSessionDurationPolicy(0); $this->assertSame(400, $response['headers']['status-code']); } diff --git a/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php new file mode 100644 index 0000000000..3284fed16f --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesPasswordPersonalDataIntegrationTest.php @@ -0,0 +1,104 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $setPersonalData = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authPersonalDataCheck']); + }; + + $buildCases = function (): array { + $suffix = \uniqid(); + $userId = 'personaluser' . $suffix; + $emailLocal = 'personalmail' . $suffix; + $email = $emailLocal . '@localhost.test'; + $name = 'Personalname' . $suffix; + $phone = '+12025550' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT); + + return [ + 'userId' => [ + 'userId' => $userId, + 'email' => 'safe_' . $suffix . '@localhost.test', + 'phone' => '+12025559' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => 'Safe Name', + 'password' => $userId . 'extra', + ], + 'email' => [ + 'userId' => 'safeid' . $suffix, + 'email' => $email, + 'phone' => '+12025558' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => 'Safe Name', + 'password' => 'prefix_' . $emailLocal . '_suffix', + ], + 'name' => [ + 'userId' => 'safeid2' . $suffix, + 'email' => 'safename_' . $suffix . '@localhost.test', + 'phone' => '+12025557' . \str_pad((string) \rand(100, 999), 3, '0', STR_PAD_LEFT), + 'name' => $name, + 'password' => 'prefix' . $name . 'xyz', + ], + 'phone' => [ + 'userId' => 'safeid3' . $suffix, + 'email' => 'safephone_' . $suffix . '@localhost.test', + 'phone' => $phone, + 'name' => 'Safe Name', + 'password' => 'prefix' . \str_replace('+', '', $phone) . 'xyz', + ], + ]; + }; + + $createUser = function (array $params) use ($serverHeaders): array { + return $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => $params['userId'], + 'email' => $params['email'], + 'phone' => $params['phone'], + 'password' => $params['password'], + 'name' => $params['name'], + ]); + }; + + // Step 1: Enable password personal data policy + $setPersonalData(true); + + // Step 2: Each of the four personal-data fields in the password must block user creation + foreach ($buildCases() as $field => $params) { + $response = $createUser($params); + $this->assertSame(400, $response['headers']['status-code'], 'Password containing ' . $field . ' should be rejected'); + $this->assertSame('password_personal_data', $response['body']['type']); + } + + // Step 3: Disable password personal data policy + $setPersonalData(false); + + // Step 4: The same categories of passwords should now be accepted (fresh data to avoid uniqueness conflicts) + foreach ($buildCases() as $field => $params) { + $response = $createUser($params); + $this->assertSame(201, $response['headers']['status-code'], 'Password containing ' . $field . ' should be accepted with policy disabled'); + $this->assertSame($params['userId'], $response['body']['$id']); + } + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php new file mode 100644 index 0000000000..1500a1dcfa --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionAlertIntegrationTest.php @@ -0,0 +1,121 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + $password = 'password1234'; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setSessionAlert = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authSessionAlerts']); + }; + + $createUser = function (string $email) use ($serverHeaders, $password): void { + $response = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Alert User', + ]); + $this->assertSame(201, $response['headers']['status-code']); + }; + + $createSession = function (string $email) use ($publicHeaders, $password): void { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + }; + + $countEmailsTo = function (string $address): int { + $emails = \json_decode(\file_get_contents('http://maildev:1080/email'), true) ?? []; + $count = 0; + foreach ($emails as $email) { + foreach ($email['to'] ?? [] as $recipient) { + if (($recipient['address'] ?? '') === $address) { + $count++; + } + } + } + return $count; + }; + + $assertEmailCountStays = function (string $address, int $expected, int $seconds) use ($countEmailsTo): void { + $deadline = \microtime(true) + $seconds; + while (\microtime(true) < $deadline) { + $this->assertSame($expected, $countEmailsTo($address), 'Unexpected email count for ' . $address); + \usleep(500_000); + } + }; + + // Step 1: Disable session alerts + $setSessionAlert(false); + + // Step 2: Create user1 and two sessions + $user1Email = 'alert1_' . uniqid() . '@localhost.test'; + $createUser($user1Email); + $createSession($user1Email); + $createSession($user1Email); + + // Step 3: No alert should arrive in the next 10 seconds + $assertEmailCountStays($user1Email, 0, 10); + + // Step 4: Enable session alerts + $setSessionAlert(true); + + // Step 5: Create user2 and one session + $user2Email = 'alert2_' . uniqid() . '@localhost.test'; + $createUser($user2Email); + $createSession($user2Email); + + // Step 6: First session never alerts, so nothing arrives in 10 seconds + $assertEmailCountStays($user2Email, 0, 10); + + // Step 7: Create the second session for user2 + $createSession($user2Email); + + // Step 8: Session alert email should eventually arrive + $this->assertEventually(function () use ($countEmailsTo, $user2Email) { + $this->assertSame(1, $countEmailsTo($user2Email)); + }, 15_000, 500); + + // Step 9: Disable session alerts + $setSessionAlert(false); + + // Step 10: Create the third session for user2 + $createSession($user2Email); + + // Step 11: No additional alert email should arrive in 10 seconds + $assertEmailCountStays($user2Email, 1, 10); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php new file mode 100644 index 0000000000..b58514a348 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php @@ -0,0 +1,102 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setDuration = function (int $seconds) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', $serverHeaders, [ + 'duration' => $seconds, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($seconds, $response['body']['authDuration']); + }; + + // Step 1: Set session duration to 5 seconds + $setDuration(5); + + // Step 2: Create user and a session + $email = 'duration_' . uniqid() . '@localhost.test'; + $password = 'password1234'; + + $user = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Duration User', + ]); + $this->assertSame(201, $user['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $session['headers']['status-code']); + $sessionCookie = $session['cookies']['a_session_' . $projectId]; + + $accountHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(200, $response['headers']['status-code']); + + // Step 3: Poll until the 5s TTL elapses - session should expire + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + }, 15_000, 500); + + // Step 4: Raise duration to 10s - same session should be usable again + $setDuration(10); + + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(200, $response['headers']['status-code']); + }, 15_000, 500); + + // Step 5: Poll until the 10s TTL elapses - session should expire again + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); + }, 20_000, 500); + + // Step 6: Set duration to 1 year + $setDuration(31536000); + + // Step 7: Same session should be usable again + $this->assertEventually(function () use ($accountHeaders) { + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(200, $response['headers']['status-code']); + }, 15_000, 500); + } +} diff --git a/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php new file mode 100644 index 0000000000..c9de2be9a5 --- /dev/null +++ b/tests/e2e/Services/Project/PoliciesSessionInvalidationIntegrationTest.php @@ -0,0 +1,119 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setInvalidation = function (bool $enabled) use ($serverHeaders): void { + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', $serverHeaders, [ + 'enabled' => $enabled, + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($enabled, $response['body']['authInvalidateSessions']); + }; + + $accountHeaders = function (string $sessionCookie) use ($projectId): array { + return [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionCookie, + ]; + }; + + $getAccount = function (string $sessionCookie) use ($accountHeaders): array { + return $this->client->call(Client::METHOD_GET, '/account', $accountHeaders($sessionCookie)); + }; + + // Step 1: Disable session invalidation + $setInvalidation(false); + + // Step 2: Create user and two sessions + $email = 'invalidation_' . uniqid() . '@localhost.test'; + $firstPassword = 'firstpassword'; + + $user = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $firstPassword, + 'name' => 'Invalidation User', + ]); + $this->assertSame(201, $user['headers']['status-code']); + $userId = $user['body']['$id']; + + $login = function (string $password) use ($publicHeaders, $email, $projectId): string { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + $this->assertSame(201, $response['headers']['status-code']); + return $response['cookies']['a_session_' . $projectId]; + }; + + $session1 = $login($firstPassword); + $session2 = $login($firstPassword); + + $this->assertSame(200, $getAccount($session1)['headers']['status-code']); + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + + // Step 3: Change password while invalidation is disabled - both sessions survive + $secondPassword = 'secondpassword'; + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $serverHeaders, [ + 'password' => $secondPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $this->assertEventually(function () use ($getAccount, $session1, $session2) { + $this->assertSame(200, $getAccount($session1)['headers']['status-code']); + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + }, 15_000, 500); + + // Step 4: Enable session invalidation + $setInvalidation(true); + + // Step 5: Change password - both sessions should be invalidated + $thirdPassword = 'thirdpassword'; + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $userId . '/password', $serverHeaders, [ + 'password' => $thirdPassword, + ]); + $this->assertSame(200, $response['headers']['status-code']); + + $this->assertEventually(function () use ($getAccount, $session1, $session2) { + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + }, 15_000, 500); + + // Step 6: Disable session invalidation again + $setInvalidation(false); + + // Step 7: Previously-invalidated sessions stay dead + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); + } +} From 0be94a7aff270b3c9246a8a3d83a659fb7e74797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 15:57:30 +0200 Subject: [PATCH 64/81] Fix tests --- .../Policies/MembershipPrivacy/Update.php | 1 - .../Modules/Teams/Http/Memberships/Get.php | 2 + .../Modules/Teams/Http/Memberships/XList.php | 2 + tests/e2e/Services/Project/PoliciesBase.php | 105 ++++++++++++++++-- ...liciesMembershipPrivacyIntegrationTest.php | 2 +- 5 files changed, 99 insertions(+), 13 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php index fcaf44aff4..c56e65308e 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -95,7 +95,6 @@ class Update extends Action ]); $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); - $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index 9cd784e5d0..d146684a20 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -117,6 +117,8 @@ class Get extends Action if ($membershipsPrivacy['userId']) { $membership->setAttribute('userId', $memberUser->getId()); + } else { + $membership->removeAttribute('userId'); } if ($membershipsPrivacy['userPhone']) { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index ca18ed4920..70b78e02c6 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -171,6 +171,8 @@ class XList extends Action if ($membershipsPrivacy['userId']) { $membership->setAttribute('userId', $memberUser->getId()); + } else { + $membership->removeAttribute('userId'); } if ($membershipsPrivacy['userPhone']) { diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php index db3576a523..84f5938d3e 100644 --- a/tests/e2e/Services/Project/PoliciesBase.php +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -306,10 +306,10 @@ trait PoliciesBase public function testUpdateSessionDurationPolicyMin(): void { - $response = $this->updateSessionDurationPolicy(1); + $response = $this->updateSessionDurationPolicy(5); $this->assertSame(200, $response['headers']['status-code']); - $this->assertSame(60, $response['body']['authDuration']); + $this->assertSame(5, $response['body']['authDuration']); // Cleanup $this->updateSessionDurationPolicy(31536000); @@ -325,7 +325,7 @@ trait PoliciesBase public function testUpdateSessionDurationPolicyBelowMin(): void { - $response = $this->updateSessionDurationPolicy(0); + $response = $this->updateSessionDurationPolicy(4); $this->assertSame(400, $response['headers']['status-code']); } @@ -693,27 +693,110 @@ trait PoliciesBase ]); } - public function testUpdateMembershipPrivacyPolicyMissingParam(): void + public function testUpdateMembershipPrivacyPolicyIndividualFields(): void { - // Missing userMFA - $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ + // Start from a known baseline where every field is enabled + $this->updateMembershipPrivacyPolicy([ 'userId' => true, 'userEmail' => true, 'userPhone' => true, 'userName' => true, + 'userMFA' => true, ]); - $this->assertSame(400, $response['headers']['status-code']); + $fields = [ + 'userId' => 'authMembershipsUserId', + 'userEmail' => 'authMembershipsUserEmail', + 'userPhone' => 'authMembershipsUserPhone', + 'userName' => 'authMembershipsUserName', + 'userMFA' => 'authMembershipsMfa', + ]; + + // Each field can be toggled individually without clobbering the others + foreach ($fields as $param => $attribute) { + $response = $this->updateMembershipPrivacyPolicy([$param => false]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body'][$attribute]); + + foreach ($fields as $otherParam => $otherAttribute) { + if ($otherParam === $param) { + continue; + } + $this->assertSame(true, $response['body'][$otherAttribute], $otherAttribute . ' should be untouched while only ' . $param . ' was updated'); + } + + // Restore the field before the next iteration + $restore = $this->updateMembershipPrivacyPolicy([$param => true]); + $this->assertSame(200, $restore['headers']['status-code']); + $this->assertSame(true, $restore['body'][$attribute]); + } + } + + public function testUpdateMembershipPrivacyPolicyMultipleFields(): void + { + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + + $response = $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userPhone' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(true, $response['body']['authMembershipsUserEmail']); + $this->assertSame(true, $response['body']['authMembershipsUserName']); + $this->assertSame(true, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + } + + public function testUpdateMembershipPrivacyPolicyEmptyBody(): void + { + // PATCH with no fields should be a no-op, leaving state unchanged + $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + + $response = $this->updateMembershipPrivacyPolicy([]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authMembershipsUserId']); + $this->assertSame(false, $response['body']['authMembershipsUserEmail']); + $this->assertSame(false, $response['body']['authMembershipsUserPhone']); + $this->assertSame(false, $response['body']['authMembershipsUserName']); + $this->assertSame(false, $response['body']['authMembershipsMfa']); + + // Cleanup + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); } public function testUpdateMembershipPrivacyPolicyInvalidType(): void { $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', $this->buildHeaders(), [ 'userId' => 'not-a-boolean', - 'userEmail' => true, - 'userPhone' => true, - 'userName' => true, - 'userMFA' => true, ]); $this->assertSame(400, $response['headers']['status-code']); diff --git a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php index 378cd1800d..2acdb9715d 100644 --- a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php @@ -119,7 +119,7 @@ class PoliciesMembershipPrivacyIntegrationTest extends Scope 'x-appwrite-project' => $projectId, 'cookie' => 'a_session_' . $projectId . '=' . $user1Session, ]; - + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame(2, $response['body']['total']); From 648ffcdcfa9f7f7e1439e3f131d0be2a1e754c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 16:07:52 +0200 Subject: [PATCH 65/81] Fix event labels --- .../Project/Policies/MembershipPrivacy/Update.php | 12 ++++++++++-- .../Project/Policies/PasswordDictionary/Update.php | 11 +++++++++-- .../Http/Project/Policies/PasswordHistory/Update.php | 11 +++++++++-- .../Project/Policies/PasswordPersonalData/Update.php | 11 +++++++++-- .../Http/Project/Policies/SessionAlert/Update.php | 11 +++++++++-- .../Http/Project/Policies/SessionDuration/Update.php | 11 +++++++++-- .../Project/Policies/SessionInvalidation/Update.php | 11 +++++++++-- .../Http/Project/Policies/SessionLimit/Update.php | 11 +++++++++-- .../Http/Project/Policies/UserLimit/Update.php | 11 +++++++++-- 9 files changed, 82 insertions(+), 18 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php index c56e65308e..c947ff225a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/MembershipPrivacy/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\MembershipPrivacy; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update membership privacy policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.membership-privacy.update') - ->label('audits.event', 'policies.membership-privacy.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -58,6 +59,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -71,6 +73,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); @@ -95,6 +98,11 @@ class Update extends Action ]); $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'membership-privacy'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php index 6218165daf..e2c678abb6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordDictionary/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordDictionary; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update password dictionary policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.password-dictionary.update') - ->label('audits.event', 'policies.password-dictionary.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -54,6 +55,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -63,6 +65,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['passwordDictionary'] = $enabled; @@ -73,6 +76,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-dictionary'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php index a1aea6b0a4..a8ae81caff 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordHistory/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordHistory; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -32,8 +33,8 @@ class Update extends Action ->desc('Update password history policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.password-history.update') - ->label('audits.event', 'policies.password-history.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -57,6 +58,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -66,6 +68,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); @@ -81,6 +84,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-history'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php index 4ba90045c8..9db7cf0549 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/PasswordPersonalData/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordPersonalData; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update password personal data policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.password-personal-data.update') - ->label('audits.event', 'policies.password-personal-data.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -55,6 +56,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -64,6 +66,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['personalDataCheck'] = $enabled; @@ -74,6 +77,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'password-personal-data'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php index fe9a0dac0b..22b7a44b04 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionAlert/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionAlert; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update session alert policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.session-alert.update') - ->label('audits.event', 'policies.session-alert.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -54,6 +55,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -63,6 +65,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['sessionAlerts'] = $enabled; @@ -73,6 +76,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-alert'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php index c58951213d..ba72c93a6f 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionDuration/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update session duration policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.session-duration.update') - ->label('audits.event', 'policies.session-duration.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -54,6 +55,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -63,6 +65,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['duration'] = $duration; @@ -73,6 +76,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-duration'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php index 0963d7eb56..8f8a959959 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionInvalidation/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionInvalidation; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -31,8 +32,8 @@ class Update extends Action ->desc('Update session invalidation policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.session-invalidation.update') - ->label('audits.event', 'policies.session-invalidation.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -54,6 +55,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -63,6 +65,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); $auths['invalidateSessions'] = $enabled; @@ -73,6 +76,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-invalidation'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php index 407e7e43a6..382ed6f0d9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/SessionLimit/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -32,8 +33,8 @@ class Update extends Action ->desc('Update session limit policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.session-limit.update') - ->label('audits.event', 'policies.session-limit.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -55,6 +56,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -64,6 +66,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); @@ -79,6 +82,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'session-limit'); + $response->dynamic($project, Response::MODEL_PROJECT); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php index 6b614fdedc..9129b81250 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/UserLimit/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Policies\UserLimit; +use Appwrite\Event\Event; use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -32,8 +33,8 @@ class Update extends Action ->desc('Update user limit policy') ->groups(['api', 'project']) ->label('scope', 'policies.write') - ->label('event', 'policies.user-limit.update') - ->label('audits.event', 'policies.user-limit.update') + ->label('event', 'projects.[projectId].policies.[policy].update') + ->label('audits.event', 'projects.[projectId].policies.[policy].update') ->label('audits.resource', 'project/{response.$id}') ->label('sdk', new Method( namespace: 'project', @@ -55,6 +56,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('project') ->inject('authorization') + ->inject('queueForEvents') ->callback($this->action(...)); } @@ -64,6 +66,7 @@ class Update extends Action Database $dbForPlatform, Document $project, Authorization $authorization, + Event $queueForEvents, ): void { $auths = $project->getAttribute('auths', []); @@ -79,6 +82,10 @@ class Update extends Action $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + $queueForEvents + ->setParam('projectId', $project->getId()) + ->setParam('policy', 'user-limit'); + $response->dynamic($project, Response::MODEL_PROJECT); } } From 6c89a05a608d13fcc87626abc03682e8593b8f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 16:38:53 +0200 Subject: [PATCH 66/81] Fix 0 session to mean unlimited --- app/controllers/shared/api.php | 5 +++++ .../Project/PoliciesMembershipPrivacyIntegrationTest.php | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index bba00bede1..6519e1f28f 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -758,6 +758,11 @@ Http::shutdown() ->inject('dbForProject') ->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) { $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT; + + if ($sessionLimit === 0) { + return; + } + $session = $response->getPayload(); $userId = $session['userId'] ?? ''; if (empty($userId)) { diff --git a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php index 2acdb9715d..378cd1800d 100644 --- a/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesMembershipPrivacyIntegrationTest.php @@ -119,7 +119,7 @@ class PoliciesMembershipPrivacyIntegrationTest extends Scope 'x-appwrite-project' => $projectId, 'cookie' => 'a_session_' . $projectId . '=' . $user1Session, ]; - + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamId . '/memberships', $clientHeaders); $this->assertSame(200, $response['headers']['status-code']); $this->assertSame(2, $response['body']['total']); From 06eb550e9893d740a38a4eb0c752a03e0a818ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 16:56:00 +0200 Subject: [PATCH 67/81] Finalize tests --- .github/workflows/ci.yml | 2 -- app/controllers/api/account.php | 2 +- ...PoliciesSessionDurationIntegrationTest.php | 24 ++++++------------- 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b02d021f1a..ac30bd64ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -692,9 +692,7 @@ jobs: - name: Installing latest version run: | - rm docker-compose.yml 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 diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index ffe2b54c5b..761ea33806 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -3274,7 +3274,7 @@ Http::patch('/v1/account/password') } $history[] = $newPassword; - $history = array_slice($history, (count($history) - $historyLimit), $historyLimit); + $history = array_slice($history, -$historyLimit); } if ($project->getAttribute('auths', [])['personalDataCheck'] ?? false) { diff --git a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php index b58514a348..71562f52a5 100644 --- a/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesSessionDurationIntegrationTest.php @@ -76,27 +76,17 @@ class PoliciesSessionDurationIntegrationTest extends Scope $this->assertSame(401, $response['headers']['status-code']); }, 15_000, 500); - // Step 4: Raise duration to 10s - same session should be usable again + // Step 4: Raise duration to 10s - same session should still not be usable $setDuration(10); - $this->assertEventually(function () use ($accountHeaders) { - $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); - $this->assertSame(200, $response['headers']['status-code']); - }, 15_000, 500); + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); - // Step 5: Poll until the 10s TTL elapses - session should expire again - $this->assertEventually(function () use ($accountHeaders) { - $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); - $this->assertSame(401, $response['headers']['status-code']); - }, 20_000, 500); - - // Step 6: Set duration to 1 year + // Step 5: Set duration to 1 year $setDuration(31536000); - // Step 7: Same session should be usable again - $this->assertEventually(function () use ($accountHeaders) { - $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); - $this->assertSame(200, $response['headers']['status-code']); - }, 15_000, 500); + // Step 6: Same session should still not be usable + $response = $this->client->call(Client::METHOD_GET, '/account', $accountHeaders); + $this->assertSame(401, $response['headers']['status-code']); } } From c8a1746119341c72aba116fc0c557169f85253f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 17:34:44 +0200 Subject: [PATCH 68/81] Fix existing tests --- src/Appwrite/Utopia/Request/Filters/V23.php | 33 +++++++++++++++++ .../Account/AccountCustomClientTest.php | 1 + .../PoliciesSessionLimitIntegrationTest.php | 37 ++++++++++--------- tests/e2e/Services/Projects/ProjectsBase.php | 1 + .../Projects/ProjectsConsoleClientTest.php | 19 ++++++++++ tests/e2e/Services/Teams/TeamsBaseClient.php | 2 +- 6 files changed, 74 insertions(+), 19 deletions(-) diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php index 8e92f9c13a..acd238a0fe 100644 --- a/src/Appwrite/Utopia/Request/Filters/V23.php +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -13,6 +13,14 @@ class V23 extends Filter case 'project.updateMembershipPrivacyPolicy': $content = $this->parseUpdateMembershipPrivacyPolicy($content); break; + case 'project.updateSessionAlertPolicy': + $content = $this->parseUpdateSessionAlertPolicy($content); + break; + case 'project.updateUserLimitPolicy': + case 'project.updatePasswordHistoryPolicy': + case 'project.updateSessionLimitPolicy': + $content = $this->parseLimitToTotal($content); + break; } return $content; @@ -23,6 +31,31 @@ class V23 extends Filter $content['userId'] = false; $content['userPhone'] = false; + if (isset($content['mfa'])) { + $content['userMFA'] = $content['mfa']; + unset($content['mfa']); + } + + return $content; + } + + protected function parseUpdateSessionAlertPolicy(array $content): array + { + if (isset($content['alerts'])) { + $content['enabled'] = $content['alerts']; + unset($content['alerts']); + } + + return $content; + } + + protected function parseLimitToTotal(array $content): array + { + if (isset($content['limit'])) { + $content['total'] = $content['limit']; + unset($content['limit']); + } + return $content; } } diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 49f0c4c245..1ad42750e7 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2050,6 +2050,7 @@ class AccountCustomClientTest extends Scope 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', + 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'alerts' => true, diff --git a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php index 66e45b06a9..df8a22a151 100644 --- a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php @@ -82,27 +82,30 @@ class PoliciesSessionLimitIntegrationTest extends Scope // New session pushes old one out $session2 = $login(); - $this->assertEventually(function () use ($getAccount, $session1, $session2) { - $this->assertSame(200, $getAccount($session2)['headers']['status-code']); - $this->assertSame(401, $getAccount($session1)['headers']['status-code']); - }, 15_000, 500); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(401, $getAccount($session1)['headers']['status-code']); // Step 2: Session limit = 2 $setSessionLimit(2); $session3 = $login(); - $this->assertEventually(function () use ($getAccount, $session2, $session3) { - $this->assertSame(200, $getAccount($session2)['headers']['status-code']); - $this->assertSame(200, $getAccount($session3)['headers']['status-code']); - }, 15_000, 500); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); // Step 3: 4th session evicts session2 (oldest), session3 and session4 remain $session4 = $login(); - $this->assertEventually(function () use ($getAccount, $session2, $session3, $session4) { - $this->assertSame(200, $getAccount($session4)['headers']['status-code']); - $this->assertSame(200, $getAccount($session3)['headers']['status-code']); - $this->assertSame(401, $getAccount($session2)['headers']['status-code']); - }, 15_000, 500); + + \sleep(3); // Giving ::shutdown() hooks some time + + $this->assertSame(200, $getAccount($session4)['headers']['status-code']); + $this->assertSame(200, $getAccount($session3)['headers']['status-code']); + $this->assertSame(401, $getAccount($session2)['headers']['status-code']); // Step 4: Disable session limit, create 5 new sessions, all should remain usable $setSessionLimit(null); @@ -112,10 +115,8 @@ class PoliciesSessionLimitIntegrationTest extends Scope $newSessions[] = $login(); } - $this->assertEventually(function () use ($getAccount, $newSessions) { - foreach ($newSessions as $index => $sessionCookie) { - $this->assertSame(200, $getAccount($sessionCookie)['headers']['status-code'], 'Session #' . ($index + 1) . ' should remain valid when limit is disabled'); - } - }, 15_000, 500); + foreach ($newSessions as $index => $sessionCookie) { + $this->assertSame(200, $getAccount($sessionCookie)['headers']['status-code'], 'Session #' . ($index + 1) . ' should remain valid when limit is disabled'); + } } } diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index 122104e3d9..ef83e65d95 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -331,6 +331,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 59ff5e353c..df49ef5993 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1255,6 +1255,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/auth/session-alerts', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'alerts' => true, ]); @@ -1397,6 +1398,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => 10, // Set session duration to 10 seconds ]); @@ -1464,6 +1466,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => 600, // seconds ]); @@ -1484,6 +1487,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/duration', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, ]); @@ -1540,6 +1544,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); @@ -1556,6 +1561,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -1874,6 +1880,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -1952,6 +1959,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -1989,6 +1997,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2001,6 +2010,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -2072,6 +2082,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 10, ]); @@ -2090,6 +2101,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 25, ]); @@ -2103,6 +2115,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 1, ]); @@ -2176,6 +2189,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2436,6 +2450,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -2493,6 +2508,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'limit' => 0, ]); @@ -2506,6 +2522,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-dictionary', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); @@ -2525,6 +2542,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => true, ]); @@ -2637,6 +2655,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/personal-data', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'enabled' => false, ]); diff --git a/tests/e2e/Services/Teams/TeamsBaseClient.php b/tests/e2e/Services/Teams/TeamsBaseClient.php index 80d73b3bc0..397bc42c3b 100644 --- a/tests/e2e/Services/Teams/TeamsBaseClient.php +++ b/tests/e2e/Services/Teams/TeamsBaseClient.php @@ -254,7 +254,7 @@ trait TeamsBaseClient $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertFalse($response['body']['mfa']); - $this->assertNotEmpty($response['body']['userId']); + $this->assertArrayHasKey($response['body']['userId']); $this->assertArrayHasKey('userName', $response['body']); $this->assertArrayHasKey('userEmail', $response['body']); $this->assertNotEmpty($response['body']['teamId']); From 70cb5ca68a665a3133c8a284c1fc7e8f5ce8f2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 17:34:59 +0200 Subject: [PATCH 69/81] linter fix --- .../Project/PoliciesSessionLimitIntegrationTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php index df8a22a151..295418a974 100644 --- a/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php +++ b/tests/e2e/Services/Project/PoliciesSessionLimitIntegrationTest.php @@ -82,9 +82,9 @@ class PoliciesSessionLimitIntegrationTest extends Scope // New session pushes old one out $session2 = $login(); - + \sleep(3); // Giving ::shutdown() hooks some time - + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); $this->assertSame(401, $getAccount($session1)['headers']['status-code']); @@ -92,17 +92,17 @@ class PoliciesSessionLimitIntegrationTest extends Scope $setSessionLimit(2); $session3 = $login(); - + \sleep(3); // Giving ::shutdown() hooks some time - + $this->assertSame(200, $getAccount($session2)['headers']['status-code']); $this->assertSame(200, $getAccount($session3)['headers']['status-code']); // Step 3: 4th session evicts session2 (oldest), session3 and session4 remain $session4 = $login(); - + \sleep(3); // Giving ::shutdown() hooks some time - + $this->assertSame(200, $getAccount($session4)['headers']['status-code']); $this->assertSame(200, $getAccount($session3)['headers']['status-code']); $this->assertSame(401, $getAccount($session2)['headers']['status-code']); From 193891a7c94d22ceb7e91c5a70a3fd20d3831473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 17:38:32 +0200 Subject: [PATCH 70/81] Fix analyzer --- tests/e2e/Services/Teams/TeamsBaseClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Teams/TeamsBaseClient.php b/tests/e2e/Services/Teams/TeamsBaseClient.php index 397bc42c3b..5b04108f71 100644 --- a/tests/e2e/Services/Teams/TeamsBaseClient.php +++ b/tests/e2e/Services/Teams/TeamsBaseClient.php @@ -254,7 +254,7 @@ trait TeamsBaseClient $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertFalse($response['body']['mfa']); - $this->assertArrayHasKey($response['body']['userId']); + $this->assertArrayHasKey('userId', $response['body']); $this->assertArrayHasKey('userName', $response['body']); $this->assertArrayHasKey('userEmail', $response['body']); $this->assertNotEmpty($response['body']['teamId']); From d9d5a8133753bc34d116684ae410dfeb8b40cda6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 17:51:31 +0200 Subject: [PATCH 71/81] Fix 0->null backwrds compatibility --- src/Appwrite/Utopia/Request/Filters/V23.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php index acd238a0fe..a8a52a7ea0 100644 --- a/src/Appwrite/Utopia/Request/Filters/V23.php +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -52,7 +52,7 @@ class V23 extends Filter protected function parseLimitToTotal(array $content): array { if (isset($content['limit'])) { - $content['total'] = $content['limit']; + $content['total'] = $content['limit'] === 0 ? null : $content['limit']; unset($content['limit']); } From 39af9c544d21bc4e54ab0b67ba98e9e691c50513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 21 Apr 2026 18:22:44 +0200 Subject: [PATCH 72/81] Remove unnessessary backwards compatibility --- .../Projects/ProjectsConsoleClientTest.php | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index df49ef5993..ff18eab938 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1991,19 +1991,6 @@ class ProjectsConsoleClientTest extends Scope 'region' => System::getEnv('_APP_REGION', 'default') ]); - /** - * Test for failure - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()), [ - 'limit' => 0, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - /** * Test for SUCCESS */ @@ -2095,20 +2082,6 @@ class ProjectsConsoleClientTest extends Scope $data = $this->setupProjectWithAuthLimit(); $id = $data['projectId']; - /** - * Test for Failure - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/password-history', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.1', - ], $this->getHeaders()), [ - 'limit' => 25, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - - /** * Test for Success */ From e530bf41f7033fe341689d425334c00191864de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 09:59:00 +0200 Subject: [PATCH 73/81] Post-merge fix --- app/controllers/api/projects.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 84f109b975..bd5d0504cf 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -301,3 +301,30 @@ Http::post('/v1/projects/:projectId/jwts') 'scopes' => $scopes ])]), Response::MODEL_JWT); }); + +// Backwards compatibility +Http::delete('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') + ->desc('Delete custom email template') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->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', '', 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) { + $locale = $locale ?: System::getEnv('_APP_LOCALE', 'en'); + + $project = $dbForPlatform->getDocument('projects', $projectId); + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $templates = $project->getAttribute('templates', []); + unset($templates['email.' . $type . '-' . $locale]); + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); + + $response->noContent(); + }); From bfa1960d8aa94c63d702d5e36221fd4251c843eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 10:00:10 +0200 Subject: [PATCH 74/81] Remove unneeded const --- app/init/constants.php | 1 - src/Appwrite/Migration/Version/V17.php | 2 +- src/Appwrite/Utopia/Response/Model/Project.php | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index ce1b34f574..8eacf2fe12 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -25,7 +25,6 @@ const APP_MODE_ADMIN = 'admin'; const APP_PAGING_LIMIT = 12; const APP_LIMIT_COUNT = 5000; const APP_LIMIT_USERS = 10_000; -const APP_LIMIT_USER_SESSIONS_DEFAULT = 10; const APP_LIMIT_ANTIVIRUS = 20_000_000; //20MB const APP_LIMIT_ENCRYPTION = 20_000_000; //20MB const APP_LIMIT_COMPRESSION = 20_000_000; //20MB diff --git a/src/Appwrite/Migration/Version/V17.php b/src/Appwrite/Migration/Version/V17.php index 3297206ccd..862ab7f26c 100644 --- a/src/Appwrite/Migration/Version/V17.php +++ b/src/Appwrite/Migration/Version/V17.php @@ -262,7 +262,7 @@ class V17 extends Migration * Set default maxSessions */ $document->setAttribute('auths', array_merge($document->getAttribute('auths', []), [ - 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT + 'maxSessions' => 10 ])); break; case 'users': diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index a599d08a04..97b58d8a51 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -482,7 +482,7 @@ class Project extends Model $document->setAttribute('authLimit', $authValues['limit'] ?? 0); $document->setAttribute('authDuration', $authValues['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG); - $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT); + $document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? 0); $document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0); $document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false); $document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false); From 72bb6378c23f009a045f5d6a83a75063cc777172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 10:00:19 +0200 Subject: [PATCH 75/81] Leftover --- app/controllers/shared/api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 74a05ce4e4..8b8c7ee066 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -753,7 +753,7 @@ Http::shutdown() ->inject('project') ->inject('dbForProject') ->action(function (Http $utopia, Request $request, Response $response, Document $project, Database $dbForProject) { - $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT; + $sessionLimit = $project->getAttribute('auths', [])['maxSessions'] ?? 0; if ($sessionLimit === 0) { return; From affd5876abf11fcc88208f05e5a2cf84d97c5308 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 14:49:35 +0530 Subject: [PATCH 76/81] Add spec enum service overlap validation --- src/Appwrite/Platform/Tasks/Specs.php | 141 ++++++++++++++++++++++++ tests/unit/Platform/Tasks/SpecsTest.php | 122 ++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 tests/unit/Platform/Tasks/SpecsTest.php diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 2c03ad3108..e9fa15e6c7 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -297,6 +297,146 @@ class Specs extends Action ]; } + protected function verifyParsedSpec(array $spec): void + { + $services = []; + foreach ($spec['tags'] ?? [] as $tag) { + if (!\is_array($tag)) { + continue; + } + + $service = $tag['name'] ?? null; + if (!\is_string($service) || $service === '') { + continue; + } + + $services[$this->normalizeSdkName($service)] = $service; + } + + if (empty($services)) { + return; + } + + $enums = []; + $this->collectSpecEnumNames($spec, $enums); + + if (empty($enums)) { + return; + } + + $overlaps = []; + foreach ($services as $normalized => $service) { + if (!isset($enums[$normalized])) { + continue; + } + + foreach ($enums[$normalized] as $enum) { + $overlaps[] = "{$enum} (service '{$service}', enum '{$enum}')"; + } + } + + if (!empty($overlaps)) { + throw new \RuntimeException( + 'Spec service names must not overlap enum names. Overlaps: ' + . \implode(', ', \array_unique($overlaps)) + ); + } + } + + private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null): void + { + if (isset($node['enum']) && \is_array($node['enum'])) { + $enumName = $this->getExplicitSpecEnumName($node) + ?? $this->getFallbackSpecEnumName($node, $fallbackName); + + if (!\is_null($enumName)) { + $this->addSpecEnumName($enums, $enumName); + } + } + + if ( + isset($node['items']) + && \is_array($node['items']) + && isset($node['items']['enum']) + && \is_array($node['items']['enum']) + ) { + $enumName = $this->getExplicitSpecEnumName($node['items']) + ?? $this->getExplicitSpecEnumName($node) + ?? $this->getFallbackSpecEnumName($node, $fallbackName); + + if (!\is_null($enumName)) { + $this->addSpecEnumName($enums, $enumName); + } + } + + $explicitEnumName = $this->getExplicitSpecEnumName($node); + if (!\is_null($explicitEnumName)) { + $this->addSpecEnumName($enums, $explicitEnumName); + } + + foreach ($node as $key => $value) { + if (!\is_array($value)) { + continue; + } + + $this->collectSpecEnumNames( + $value, + $enums, + $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName) + ); + } + } + + private function addSpecEnumName(array &$enums, string $name): void + { + $enums[$this->normalizeSdkName($name)][] = $this->formatSdkName($name); + } + + private function getExplicitSpecEnumName(array $node): ?string + { + $enumName = $node['x-enum-name'] ?? null; + + return \is_string($enumName) && $enumName !== '' ? $enumName : null; + } + + private function getFallbackSpecEnumName(array $node, ?string $fallbackName): ?string + { + $name = $node['name'] ?? $fallbackName; + + return \is_string($name) && $name !== '' ? $name : null; + } + + private function getChildSpecEnumFallbackName( + array $parent, + int|string $key, + array $child, + ?string $fallbackName + ): ?string { + if (isset($child['name']) && \is_string($child['name']) && $child['name'] !== '') { + return $child['name']; + } + + if ($key === 'schema' || $key === 'items') { + return $this->getFallbackSpecEnumName($parent, $fallbackName); + } + + if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'parameters', 'paths', 'properties', 'responses'], true)) { + return $key; + } + + return $fallbackName; + } + + private function formatSdkName(string $name): string + { + return \str_replace(' ', '', \ucwords(\str_replace(['-', '_', '/'], ' ', $name))); + } + + private function normalizeSdkName(string $name): string + { + return \strtolower((string) \preg_replace('/[^a-z0-9]/i', '', $name)); + } + public function getSDKPlatformsForRouteSecurity(array $routeSecurity): array { $sdkPlatforms = []; @@ -483,6 +623,7 @@ class Specs extends Action try { $parsedSpecs = $specs->parse(); + $this->verifyParsedSpec($parsedSpecs); } catch (\RuntimeException $e) { throw new \RuntimeException("Spec generation failed for {$platform} ({$format}): " . $e->getMessage(), 0, $e); } diff --git a/tests/unit/Platform/Tasks/SpecsTest.php b/tests/unit/Platform/Tasks/SpecsTest.php new file mode 100644 index 0000000000..a10c66bf81 --- /dev/null +++ b/tests/unit/Platform/Tasks/SpecsTest.php @@ -0,0 +1,122 @@ +verifyParsedSpec($spec); + } +} + +class SpecsTest extends TestCase +{ + private TestSpecs $specs; + + protected function setUp(): void + { + $this->specs = new TestSpecs(); + } + + public function testVerifyParsedSpecFailsOnServiceEnumNameOverlap(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); + + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'locale', + 'description' => 'Locale APIs', + ], + ], + 'paths' => [ + '/account/sessions/oauth2/{provider}' => [ + 'get' => [ + 'parameters' => [ + [ + 'name' => 'provider', + 'schema' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => 'Locale', + ], + ], + ], + ], + ], + ], + ]); + } + + public function testVerifyParsedSpecFailsOnDerivedServiceEnumNameOverlap(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); + + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'locale', + 'description' => 'Locale APIs', + ], + ], + 'paths' => [ + '/projects/{projectId}/templates/email/{type}/{locale}' => [ + 'patch' => [ + 'parameters' => [ + [ + 'name' => 'payload', + 'in' => 'body', + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'locale' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => null, + ], + ], + ], + ], + ], + ], + ], + ], + ]); + } + + public function testVerifyParsedSpecAllowsDistinctServiceAndEnumNames(): void + { + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'locale', + 'description' => 'Locale APIs', + ], + ], + 'paths' => [ + '/projects/{projectId}/templates/email/{type}/{locale}' => [ + 'patch' => [ + 'parameters' => [ + [ + 'name' => 'locale', + 'schema' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => 'EmailTemplateLocale', + ], + ], + ], + ], + ], + ], + ]); + + $this->addToAssertionCount(1); + } +} From 4f74394e8fb24eee10ebeedadfa6f58b661967aa Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:21:41 +0530 Subject: [PATCH 77/81] Fix spec enum name validation --- src/Appwrite/Platform/Tasks/Specs.php | 6 +- src/Appwrite/SDK/Specification/Format.php | 9 +++ tests/unit/Platform/Tasks/SpecsTest.php | 67 +++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index e9fa15e6c7..31fb69460f 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -370,12 +370,12 @@ class Specs extends Action } $explicitEnumName = $this->getExplicitSpecEnumName($node); - if (!\is_null($explicitEnumName)) { + if (!\is_null($explicitEnumName) && !isset($node['enum'])) { $this->addSpecEnumName($enums, $explicitEnumName); } foreach ($node as $key => $value) { - if (!\is_array($value)) { + if ($key === 'items' || !\is_array($value)) { continue; } @@ -420,7 +420,7 @@ class Specs extends Action return $this->getFallbackSpecEnumName($parent, $fallbackName); } - if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'parameters', 'paths', 'properties', 'responses'], true)) { + if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'delete', 'get', 'head', 'options', 'parameters', 'patch', 'paths', 'post', 'properties', 'put', 'responses'], true)) { return $key; } diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index e68e9438ca..0c28384db1 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -751,6 +751,15 @@ abstract class Format break; case 'project': switch ($method) { + case 'getEmailTemplate': + case 'updateEmailTemplate': + switch ($param) { + case 'templateId': + return 'EmailTemplateType'; + case 'locale': + return 'EmailTemplateLocale'; + } + break; case 'getUsage': switch ($param) { case 'period': diff --git a/tests/unit/Platform/Tasks/SpecsTest.php b/tests/unit/Platform/Tasks/SpecsTest.php index a10c66bf81..6cb11b310e 100644 --- a/tests/unit/Platform/Tasks/SpecsTest.php +++ b/tests/unit/Platform/Tasks/SpecsTest.php @@ -11,6 +11,18 @@ class TestSpecs extends Specs { $this->verifyParsedSpec($spec); } + + public function collectEnums(array $spec): array + { + $collect = \Closure::bind(function (array $spec): array { + $enums = []; + $this->collectSpecEnumNames($spec, $enums); + + return $enums; + }, $this, Specs::class); + + return $collect($spec); + } } class SpecsTest extends TestCase @@ -119,4 +131,59 @@ class SpecsTest extends TestCase $this->addToAssertionCount(1); } + + public function testCollectSpecEnumNamesDoesNotDoubleRegisterExplicitNames(): void + { + $enums = $this->specs->collectEnums([ + 'schema' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => 'Locale', + ], + ]); + + $this->assertSame(['Locale'], $enums['locale']); + } + + public function testCollectSpecEnumNamesDoesNotDoubleRegisterItemsEnums(): void + { + $enums = $this->specs->collectEnums([ + 'name' => 'Locale', + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => ['en'], + ], + ]); + + $this->assertSame(['Locale'], $enums['locale']); + } + + public function testVerifyParsedSpecIgnoresHttpMethodFallbackNames(): void + { + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'patch', + 'description' => 'Patch APIs', + ], + ], + 'paths' => [ + '/example' => [ + 'patch' => [ + 'parameters' => [ + [ + 'schema' => [ + 'type' => 'string', + 'enum' => ['enabled'], + ], + ], + ], + ], + ], + ], + ]); + + $this->addToAssertionCount(1); + } } From 2390d40731dd54ff517e34d51f01065aca739ddc Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:24:13 +0530 Subject: [PATCH 78/81] Remove specs task unit test --- tests/unit/Platform/Tasks/SpecsTest.php | 189 ------------------------ 1 file changed, 189 deletions(-) delete mode 100644 tests/unit/Platform/Tasks/SpecsTest.php diff --git a/tests/unit/Platform/Tasks/SpecsTest.php b/tests/unit/Platform/Tasks/SpecsTest.php deleted file mode 100644 index 6cb11b310e..0000000000 --- a/tests/unit/Platform/Tasks/SpecsTest.php +++ /dev/null @@ -1,189 +0,0 @@ -verifyParsedSpec($spec); - } - - public function collectEnums(array $spec): array - { - $collect = \Closure::bind(function (array $spec): array { - $enums = []; - $this->collectSpecEnumNames($spec, $enums); - - return $enums; - }, $this, Specs::class); - - return $collect($spec); - } -} - -class SpecsTest extends TestCase -{ - private TestSpecs $specs; - - protected function setUp(): void - { - $this->specs = new TestSpecs(); - } - - public function testVerifyParsedSpecFailsOnServiceEnumNameOverlap(): void - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); - - $this->specs->verify([ - 'tags' => [ - [ - 'name' => 'locale', - 'description' => 'Locale APIs', - ], - ], - 'paths' => [ - '/account/sessions/oauth2/{provider}' => [ - 'get' => [ - 'parameters' => [ - [ - 'name' => 'provider', - 'schema' => [ - 'type' => 'string', - 'enum' => ['en'], - 'x-enum-name' => 'Locale', - ], - ], - ], - ], - ], - ], - ]); - } - - public function testVerifyParsedSpecFailsOnDerivedServiceEnumNameOverlap(): void - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); - - $this->specs->verify([ - 'tags' => [ - [ - 'name' => 'locale', - 'description' => 'Locale APIs', - ], - ], - 'paths' => [ - '/projects/{projectId}/templates/email/{type}/{locale}' => [ - 'patch' => [ - 'parameters' => [ - [ - 'name' => 'payload', - 'in' => 'body', - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'locale' => [ - 'type' => 'string', - 'enum' => ['en'], - 'x-enum-name' => null, - ], - ], - ], - ], - ], - ], - ], - ], - ]); - } - - public function testVerifyParsedSpecAllowsDistinctServiceAndEnumNames(): void - { - $this->specs->verify([ - 'tags' => [ - [ - 'name' => 'locale', - 'description' => 'Locale APIs', - ], - ], - 'paths' => [ - '/projects/{projectId}/templates/email/{type}/{locale}' => [ - 'patch' => [ - 'parameters' => [ - [ - 'name' => 'locale', - 'schema' => [ - 'type' => 'string', - 'enum' => ['en'], - 'x-enum-name' => 'EmailTemplateLocale', - ], - ], - ], - ], - ], - ], - ]); - - $this->addToAssertionCount(1); - } - - public function testCollectSpecEnumNamesDoesNotDoubleRegisterExplicitNames(): void - { - $enums = $this->specs->collectEnums([ - 'schema' => [ - 'type' => 'string', - 'enum' => ['en'], - 'x-enum-name' => 'Locale', - ], - ]); - - $this->assertSame(['Locale'], $enums['locale']); - } - - public function testCollectSpecEnumNamesDoesNotDoubleRegisterItemsEnums(): void - { - $enums = $this->specs->collectEnums([ - 'name' => 'Locale', - 'type' => 'array', - 'items' => [ - 'type' => 'string', - 'enum' => ['en'], - ], - ]); - - $this->assertSame(['Locale'], $enums['locale']); - } - - public function testVerifyParsedSpecIgnoresHttpMethodFallbackNames(): void - { - $this->specs->verify([ - 'tags' => [ - [ - 'name' => 'patch', - 'description' => 'Patch APIs', - ], - ], - 'paths' => [ - '/example' => [ - 'patch' => [ - 'parameters' => [ - [ - 'schema' => [ - 'type' => 'string', - 'enum' => ['enabled'], - ], - ], - ], - ], - ], - ], - ]); - - $this->addToAssertionCount(1); - } -} From 038be90969e01a75aa8e83fc9e04fc0c786c47cf Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:33:18 +0530 Subject: [PATCH 79/81] Preserve nested items enum validation --- src/Appwrite/Platform/Tasks/Specs.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 31fb69460f..ce1d78799d 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -343,9 +343,9 @@ class Specs extends Action } } - private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null): void + private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null, bool $skipCurrentEnum = false): void { - if (isset($node['enum']) && \is_array($node['enum'])) { + if (!$skipCurrentEnum && isset($node['enum']) && \is_array($node['enum'])) { $enumName = $this->getExplicitSpecEnumName($node) ?? $this->getFallbackSpecEnumName($node, $fallbackName); @@ -354,6 +354,7 @@ class Specs extends Action } } + $itemsEnumHandled = false; if ( isset($node['items']) && \is_array($node['items']) @@ -367,6 +368,8 @@ class Specs extends Action if (!\is_null($enumName)) { $this->addSpecEnumName($enums, $enumName); } + + $itemsEnumHandled = true; } $explicitEnumName = $this->getExplicitSpecEnumName($node); @@ -375,14 +378,15 @@ class Specs extends Action } foreach ($node as $key => $value) { - if ($key === 'items' || !\is_array($value)) { + if (!\is_array($value)) { continue; } $this->collectSpecEnumNames( $value, $enums, - $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName) + $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName), + $key === 'items' && $itemsEnumHandled ); } } From 00512df4caebe7d735f718cb72023c8752bc301a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:45:16 +0530 Subject: [PATCH 80/81] Clean up spec enum validation reporting --- src/Appwrite/Platform/Tasks/Specs.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index ce1d78799d..82020b05b1 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -331,7 +331,7 @@ class Specs extends Action } foreach ($enums[$normalized] as $enum) { - $overlaps[] = "{$enum} (service '{$service}', enum '{$enum}')"; + $overlaps[] = "service '{$service}' with enum '{$enum}'"; } } @@ -373,7 +373,7 @@ class Specs extends Action } $explicitEnumName = $this->getExplicitSpecEnumName($node); - if (!\is_null($explicitEnumName) && !isset($node['enum'])) { + if (!\is_null($explicitEnumName) && !isset($node['enum']) && !$itemsEnumHandled) { $this->addSpecEnumName($enums, $explicitEnumName); } From d106e1d5bb06c027b435cde4909b546323dd6f5c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 15:57:32 +0530 Subject: [PATCH 81/81] Fix session alert test payload --- tests/e2e/Services/Project/TemplatesBase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 72a14210a5..d415c3267d 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -685,7 +685,7 @@ trait TemplatesBase 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ], - ['alerts' => true], + ['enabled' => true], ); $this->assertSame(200, $alertsResponse['headers']['status-code'], 'failed to enable session alerts');