From 9da57a457b2378b8eb6afcd98df242fbc3edb70a Mon Sep 17 00:00:00 2001 From: hmacr Date: Mon, 21 Jul 2025 22:31:06 +0530 Subject: [PATCH 01/22] fix: Use direct source for file-preview when empty --- app/controllers/api/storage.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index c6e242296b..ef9cec1e73 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -1072,6 +1072,10 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') $decompressionTime = \microtime(true) - $startTime - $downloadTime - $decryptionTime; + if (empty($source)) { + $source = $deviceForFiles->read($path); + } + try { $image = new Image($source); } catch (ImagickException $e) { @@ -1870,7 +1874,7 @@ App::get('/v1/storage/usage') $total = []; Authorization::skip(function () use ($dbForProject, $days, $metrics, &$stats, &$total) { foreach ($metrics as $metric) { - $result = $dbForProject->findOne('stats', [ + $result = $dbForProject->findOne('stats', [ Query::equal('metric', [$metric]), Query::equal('period', ['inf']) ]); @@ -1899,7 +1903,7 @@ App::get('/v1/storage/usage') }; foreach ($metrics as $metric) { - $usage[$metric]['total'] = $stats[$metric]['total']; + $usage[$metric]['total'] = $stats[$metric]['total']; $usage[$metric]['data'] = []; $leap = time() - ($days['limit'] * $days['factor']); while ($leap < time()) { @@ -1917,8 +1921,8 @@ App::get('/v1/storage/usage') 'filesTotal' => $usage[$metrics[1]]['total'], 'filesStorageTotal' => $usage[$metrics[2]]['total'], 'buckets' => $usage[$metrics[0]]['data'], - 'files' => $usage[$metrics[1]]['data'], - 'storage' => $usage[$metrics[2]]['data'], + 'files' => $usage[$metrics[1]]['data'], + 'storage' => $usage[$metrics[2]]['data'], ]), Response::MODEL_USAGE_STORAGE); }); @@ -1970,7 +1974,7 @@ App::get('/v1/storage/:bucketId/usage') ? $dbForLogs : $dbForProject; - $result = $db->findOne('stats', [ + $result = $db->findOne('stats', [ Query::equal('metric', [$metric]), Query::equal('period', ['inf']) ]); @@ -2000,7 +2004,7 @@ App::get('/v1/storage/:bucketId/usage') }; foreach ($metrics as $metric) { - $usage[$metric]['total'] = $stats[$metric]['total']; + $usage[$metric]['total'] = $stats[$metric]['total']; $usage[$metric]['data'] = []; $leap = time() - ($days['limit'] * $days['factor']); while ($leap < time()) { From bf3efea98d5119cea56585168386fa071f615cd1 Mon Sep 17 00:00:00 2001 From: hmacr Date: Tue, 22 Jul 2025 16:43:17 +0530 Subject: [PATCH 02/22] framework support + content for OTP & add-member email --- .../locale/templates/email-base-styled.tpl | 5 ++++ app/config/locale/templates/email-base.tpl | 5 ++++ app/config/locale/translations/en.json | 2 ++ app/controllers/api/account.php | 2 ++ app/controllers/api/teams.php | 2 ++ src/Appwrite/Event/Mail.php | 24 +++++++++++++++++ src/Appwrite/Platform/Workers/Mails.php | 27 +++++++++++++++++++ 7 files changed, 67 insertions(+) diff --git a/app/config/locale/templates/email-base-styled.tpl b/app/config/locale/templates/email-base-styled.tpl index f6d3e8cd63..b5aece0253 100644 --- a/app/config/locale/templates/email-base-styled.tpl +++ b/app/config/locale/templates/email-base-styled.tpl @@ -120,6 +120,11 @@ +
+ {{preview}} +
{{previewWhitespace}}
+
+
diff --git a/app/config/locale/templates/email-base.tpl b/app/config/locale/templates/email-base.tpl index 13056fd5ae..f6807ce7b2 100644 --- a/app/config/locale/templates/email-base.tpl +++ b/app/config/locale/templates/email-base.tpl @@ -121,6 +121,11 @@ +
+ {{preview}} +
{{previewWhitespace}}
+
+
diff --git a/app/config/locale/translations/en.json b/app/config/locale/translations/en.json index dbfa2e1be8..653e8c16e5 100644 --- a/app/config/locale/translations/en.json +++ b/app/config/locale/translations/en.json @@ -29,6 +29,7 @@ "emails.sessionAlert.thanks": "Thanks,", "emails.sessionAlert.signature": "{{project}} team", "emails.otpSession.subject": "OTP for {{project}} Login", + "emails.otpSession.preview": "Use OTP {{otp}} to sign in to {{project}}", "emails.otpSession.hello": "Hello {{user}},", "emails.otpSession.description": "Enter the following verification code when prompted to securely sign in to your {{b}}{{project}}{{/b}} account. This code will expire in 15 minutes.", "emails.otpSession.clientInfo": "This sign in was requested using {{b}}{{agentClient}}{{/b}} on {{b}}{{agentDevice}}{{/b}} {{b}}{{agentOs}}{{/b}}. If you didn't request the sign in, you can safely ignore this email.", @@ -49,6 +50,7 @@ "emails.recovery.buttonText": "Reset password", "emails.recovery.signature": "{{project}} team", "emails.invitation.subject": "Invitation to %s Team at %s", + "emails.invitation.preview": "{{owner}} invited you to join {{team}} at {{project}}", "emails.invitation.hello": "Hello {{user}},", "emails.invitation.body": "This mail was sent to you because {{b}}{{owner}}{{/b}} wanted to invite you to become a member of the {{b}}{{team}}{{/b}} team at {{b}}{{project}}{{/b}}.", "emails.invitation.footer": "If you are not interested, you can ignore this message.", diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 6de91f23cc..81bafa29f1 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2254,6 +2254,7 @@ App::post('/v1/account/tokens/email') $dbForProject->purgeCachedDocument('users', $user->getId()); $subject = $locale->getText("emails.otpSession.subject"); + $preview = $locale->getText("emails.otpSession.preview"); $customTemplate = $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); @@ -2339,6 +2340,7 @@ App::post('/v1/account/tokens/email') $queueForMails ->setSubject($subject) + ->setPreview($preview) ->setBody($body) ->setVariables($emailVariables) ->setRecipient($email) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 98ec49ca48..fe411d53ab 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -657,6 +657,7 @@ App::post('/v1/teams/:teamId/memberships') $projectName = $project->isEmpty() ? 'Console' : $project->getAttribute('name', '[APP-NAME]'); $body = $locale->getText("emails.invitation.body"); + $preview = $locale->getText("emails.invitation.preview"); $subject = \sprintf($locale->getText("emails.invitation.subject"), $team->getAttribute('name'), $projectName); $customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? []; @@ -729,6 +730,7 @@ App::post('/v1/teams/:teamId/memberships') $queueForMails ->setSubject($subject) ->setBody($body) + ->setPreview($preview) ->setRecipient($invitee->getAttribute('email')) ->setName($invitee->getAttribute('name', '')) ->setVariables($emailVariables) diff --git a/src/Appwrite/Event/Mail.php b/src/Appwrite/Event/Mail.php index 87312182ea..c9a0671461 100644 --- a/src/Appwrite/Event/Mail.php +++ b/src/Appwrite/Event/Mail.php @@ -10,6 +10,7 @@ class Mail extends Event protected string $name = ''; protected string $subject = ''; protected string $body = ''; + protected string $preview = ''; protected array $smtp = []; protected array $variables = []; protected string $bodyTemplate = ''; @@ -93,6 +94,28 @@ class Mail extends Event return $this->body; } + /** + * Sets preview for the mail event. + * + * @return string + */ + public function setPreview(string $preview): self + { + $this->preview = $preview; + + return $this; + } + + /** + * Returns preview for the mail event. + * + * @return string + */ + public function getPreview(string $preview): string + { + return $this->preview; + } + /** * Sets name for the mail event. * @@ -409,6 +432,7 @@ class Mail extends Event 'subject' => $this->subject, 'bodyTemplate' => $this->bodyTemplate, 'body' => $this->body, + 'preview' => $this->preview, 'smtp' => $this->smtp, 'variables' => $this->variables, 'attachment' => $this->attachment, diff --git a/src/Appwrite/Platform/Workers/Mails.php b/src/Appwrite/Platform/Workers/Mails.php index 4e8b5e085c..117b689863 100644 --- a/src/Appwrite/Platform/Workers/Mails.php +++ b/src/Appwrite/Platform/Workers/Mails.php @@ -14,6 +14,11 @@ use Utopia\System\System; class Mails extends Action { + protected int $previewMaxLen = 150; + + protected string $whitespaceCodes = ' ‌​‍‎‏'; + + public static function getName(): string { return 'mails'; @@ -74,6 +79,7 @@ class Mails extends Action $variables['host'] = $protocol . '://' . $hostname; $name = $payload['name']; $body = $payload['body']; + $preview = $payload['preview'] ?? ''; $variables['subject'] = $subject; $variables['year'] = date("Y"); @@ -92,6 +98,27 @@ class Mails extends Action foreach ($this->richTextParams as $key => $value) { $bodyTemplate->setParam('{{' . $key . '}}', $value, escapeHtml: false); } + + $previewWhitespace = ''; + + if (!empty($preview)) { + $previewTemplate = Template::fromString($preview); + foreach ($variables as $key => $value) { + $previewTemplate->setParam('{{' . $key . '}}', $value); + } + // render() will return the subject in

tags, so use strip_tags() to remove them + $preview = \strip_tags($previewTemplate->render()); + + $previewLen = strlen($preview); + if ($previewLen < $this->previewMaxLen) { + $previewWhitespace = str_repeat($this->whitespaceCodes, $this->previewMaxLen - $previewLen); + } + } + + + $bodyTemplate->setParam('{{preview}}', $preview); + $bodyTemplate->setParam('{{previewWhitespace}}', $previewWhitespace, false); + $body = $bodyTemplate->render(); $subjectTemplate = Template::fromString($subject); From 9c0e6f4203ea44744e11372281c55bd5e1274a3c Mon Sep 17 00:00:00 2001 From: hmacr Date: Tue, 22 Jul 2025 20:39:36 +0530 Subject: [PATCH 03/22] Skip deployment when commit is created by us --- app/controllers/api/vcs.php | 5 +++-- app/init/constants.php | 2 ++ src/Appwrite/Platform/Modules/Functions/Workers/Builds.php | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/vcs.php b/app/controllers/api/vcs.php index a8efd093b5..0dcc281a9c 100644 --- a/app/controllers/api/vcs.php +++ b/app/controllers/api/vcs.php @@ -1235,6 +1235,7 @@ App::post('/v1/vcs/github/events') $providerCommitHash = $parsedPayload["commitHash"] ?? ''; $providerRepositoryOwner = $parsedPayload["owner"] ?? ''; $providerCommitAuthor = $parsedPayload["headCommitAuthor"] ?? ''; + $providerCommitAuthorEmail = $parsedPayload["headCommitAuthorEmail"] ?? ''; $providerCommitAuthorUrl = $parsedPayload["authorUrl"] ?? ''; $providerCommitMessage = $parsedPayload["headCommitMessage"] ?? ''; $providerCommitUrl = $parsedPayload["headCommitUrl"] ?? ''; @@ -1247,8 +1248,8 @@ App::post('/v1/vcs/github/events') Query::limit(100), ])); - // create new deployment only on push and not when branch is created or deleted - if (!$providerBranchCreated && !$providerBranchDeleted) { + // create new deployment only on push (not committed by us) and not when branch is created or deleted + if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchCreated && !$providerBranchDeleted) { $createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $queueForBuilds, $getProjectDB, $request); } } elseif ($event == $github::EVENT_INSTALLATION) { diff --git a/app/init/constants.php b/app/init/constants.php index ebf79086a7..62d9f16853 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -80,6 +80,8 @@ const APP_COMPUTE_SPECIFICATION_DEFAULT = Specification::S_1VCPU_512MB; const APP_PLATFORM_SERVER = 'server'; const APP_PLATFORM_CLIENT = 'client'; const APP_PLATFORM_CONSOLE = 'console'; +const APP_VCS_GITHUB_USERNAME = 'Appwrite'; +const APP_VCS_GITHUB_EMAIL = 'team@appwrite.io'; // Database Reconnect const DATABASE_RECONNECT_SLEEP = 2; diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 890c8572d9..70e7c682f1 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -444,7 +444,7 @@ class Builds extends Action Console::execute('rsync -av --exclude \'.git\' ' . \escapeshellarg($tmpTemplateDirectory . '/' . $templateRootDirectory . '/') . ' ' . \escapeshellarg($tmpDirectory . '/' . $rootDirectory), '', $stdout, $stderr); // Commit and push - $exit = Console::execute('git config --global user.email "team@appwrite.io" && git config --global user.name "Appwrite" && cd ' . \escapeshellarg($tmpDirectory) . ' && git checkout -b ' . \escapeshellarg($branchName) . ' && git add . && git commit -m "Create ' . \escapeshellarg($resource->getAttribute('name', '')) . ' function" && git push origin ' . \escapeshellarg($branchName), '', $stdout, $stderr); + $exit = Console::execute('git config --global user.email '. \escapeshellarg(APP_VCS_GITHUB_EMAIL) .' && git config --global user.name '. \escapeshellarg(APP_VCS_GITHUB_USERNAME) .' && cd ' . \escapeshellarg($tmpDirectory) . ' && git checkout -b ' . \escapeshellarg($branchName) . ' && git add . && git commit -m "Create ' . \escapeshellarg($resource->getAttribute('name', '')) . ' function" && git push origin ' . \escapeshellarg($branchName), '', $stdout, $stderr); if ($exit !== 0) { throw new \Exception('Unable to push code repository: ' . $stderr); From 50eca9ebbc8b879a4ce3c58fa21747bc76b3df2d Mon Sep 17 00:00:00 2001 From: hmacr Date: Wed, 23 Jul 2025 09:53:09 +0530 Subject: [PATCH 04/22] update commit author name param --- app/controllers/api/vcs.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/vcs.php b/app/controllers/api/vcs.php index 0dcc281a9c..82300bff47 100644 --- a/app/controllers/api/vcs.php +++ b/app/controllers/api/vcs.php @@ -1234,7 +1234,7 @@ App::post('/v1/vcs/github/events') $providerRepositoryUrl = $parsedPayload["repositoryUrl"] ?? ''; $providerCommitHash = $parsedPayload["commitHash"] ?? ''; $providerRepositoryOwner = $parsedPayload["owner"] ?? ''; - $providerCommitAuthor = $parsedPayload["headCommitAuthor"] ?? ''; + $providerCommitAuthorName = $parsedPayload["headCommitAuthorName"] ?? ''; $providerCommitAuthorEmail = $parsedPayload["headCommitAuthorEmail"] ?? ''; $providerCommitAuthorUrl = $parsedPayload["authorUrl"] ?? ''; $providerCommitMessage = $parsedPayload["headCommitMessage"] ?? ''; @@ -1250,7 +1250,7 @@ App::post('/v1/vcs/github/events') // create new deployment only on push (not committed by us) and not when branch is created or deleted if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchCreated && !$providerBranchDeleted) { - $createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $queueForBuilds, $getProjectDB, $request); + $createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $queueForBuilds, $getProjectDB, $request); } } elseif ($event == $github::EVENT_INSTALLATION) { if ($parsedPayload["action"] == "deleted") { From c83dd7aa1396d75d0f695ac6ee250ec1061e9b4b Mon Sep 17 00:00:00 2001 From: hmacr Date: Wed, 23 Jul 2025 11:45:27 +0530 Subject: [PATCH 05/22] Add defaultBranch in getRepository response --- app/config/specs/open-api3-latest-client.json | 24 +++++++++++++++++++ .../specs/open-api3-latest-console.json | 18 ++++++++++++++ app/config/specs/swagger2-latest-client.json | 24 +++++++++++++++++++ app/config/specs/swagger2-latest-console.json | 18 ++++++++++++++ app/controllers/api/vcs.php | 1 + .../Response/Model/ProviderRepository.php | 6 +++++ 6 files changed, 91 insertions(+) diff --git a/app/config/specs/open-api3-latest-client.json b/app/config/specs/open-api3-latest-client.json index d09108e51d..f004aaa883 100644 --- a/app/config/specs/open-api3-latest-client.json +++ b/app/config/specs/open-api3-latest-client.json @@ -4492,6 +4492,30 @@ } ], "description": "Create a new Document. Before using this route, you should create a new collection resource using either a [server integration](https:\/\/appwrite.io\/docs\/server\/databases#databasesCreateCollection) API or directly from your database console." + }, + { + "name": "createDocuments", + "auth": { + "Admin": [], + "Key": [] + }, + "parameters": [ + "databaseId", + "collectionId", + "documents" + ], + "required": [ + "databaseId", + "collectionId", + "documents" + ], + "responses": [ + { + "code": 201, + "model": "#\/components\/schemas\/documentList" + } + ], + "description": "**WARNING: Experimental Feature** - This endpoint is experimental and not yet officially supported. It may be subject to breaking changes or removal in future versions.\n\nCreate new Documents. Before using this route, you should create a new collection resource using either a [server integration](https:\/\/appwrite.io\/docs\/server\/databases#databasesCreateCollection) API or directly from your database console." } ], "auth": { diff --git a/app/config/specs/open-api3-latest-console.json b/app/config/specs/open-api3-latest-console.json index b7450bc7e6..7a16af6a76 100644 --- a/app/config/specs/open-api3-latest-console.json +++ b/app/config/specs/open-api3-latest-console.json @@ -39702,6 +39702,11 @@ "description": "Is VCS (Version Control System) repository private?", "x-example": true }, + "defaultBranch": { + "type": "string", + "description": "VCS (Version Control System) repository's default branch name.", + "x-example": "main" + }, "pushedAt": { "type": "string", "description": "Last commit date in ISO 8601 format.", @@ -39714,6 +39719,7 @@ "organization", "provider", "private", + "defaultBranch", "pushedAt" ] }, @@ -39746,6 +39752,11 @@ "description": "Is VCS (Version Control System) repository private?", "x-example": true }, + "defaultBranch": { + "type": "string", + "description": "VCS (Version Control System) repository's default branch name.", + "x-example": "main" + }, "pushedAt": { "type": "string", "description": "Last commit date in ISO 8601 format.", @@ -39763,6 +39774,7 @@ "organization", "provider", "private", + "defaultBranch", "pushedAt", "framework" ] @@ -39796,6 +39808,11 @@ "description": "Is VCS (Version Control System) repository private?", "x-example": true }, + "defaultBranch": { + "type": "string", + "description": "VCS (Version Control System) repository's default branch name.", + "x-example": "main" + }, "pushedAt": { "type": "string", "description": "Last commit date in ISO 8601 format.", @@ -39813,6 +39830,7 @@ "organization", "provider", "private", + "defaultBranch", "pushedAt", "runtime" ] diff --git a/app/config/specs/swagger2-latest-client.json b/app/config/specs/swagger2-latest-client.json index c3353e157f..a75d9828b0 100644 --- a/app/config/specs/swagger2-latest-client.json +++ b/app/config/specs/swagger2-latest-client.json @@ -4629,6 +4629,30 @@ } ], "description": "Create a new Document. Before using this route, you should create a new collection resource using either a [server integration](https:\/\/appwrite.io\/docs\/server\/databases#databasesCreateCollection) API or directly from your database console." + }, + { + "name": "createDocuments", + "auth": { + "Admin": [], + "Key": [] + }, + "parameters": [ + "databaseId", + "collectionId", + "documents" + ], + "required": [ + "databaseId", + "collectionId", + "documents" + ], + "responses": [ + { + "code": 201, + "model": "#\/definitions\/documentList" + } + ], + "description": "**WARNING: Experimental Feature** - This endpoint is experimental and not yet officially supported. It may be subject to breaking changes or removal in future versions.\n\nCreate new Documents. Before using this route, you should create a new collection resource using either a [server integration](https:\/\/appwrite.io\/docs\/server\/databases#databasesCreateCollection) API or directly from your database console." } ], "auth": { diff --git a/app/config/specs/swagger2-latest-console.json b/app/config/specs/swagger2-latest-console.json index 4fa839aa39..271d1d5ee9 100644 --- a/app/config/specs/swagger2-latest-console.json +++ b/app/config/specs/swagger2-latest-console.json @@ -39878,6 +39878,11 @@ "description": "Is VCS (Version Control System) repository private?", "x-example": true }, + "defaultBranch": { + "type": "string", + "description": "VCS (Version Control System) repository's default branch name.", + "x-example": "main" + }, "pushedAt": { "type": "string", "description": "Last commit date in ISO 8601 format.", @@ -39890,6 +39895,7 @@ "organization", "provider", "private", + "defaultBranch", "pushedAt" ] }, @@ -39922,6 +39928,11 @@ "description": "Is VCS (Version Control System) repository private?", "x-example": true }, + "defaultBranch": { + "type": "string", + "description": "VCS (Version Control System) repository's default branch name.", + "x-example": "main" + }, "pushedAt": { "type": "string", "description": "Last commit date in ISO 8601 format.", @@ -39939,6 +39950,7 @@ "organization", "provider", "private", + "defaultBranch", "pushedAt", "framework" ] @@ -39972,6 +39984,11 @@ "description": "Is VCS (Version Control System) repository private?", "x-example": true }, + "defaultBranch": { + "type": "string", + "description": "VCS (Version Control System) repository's default branch name.", + "x-example": "main" + }, "pushedAt": { "type": "string", "description": "Last commit date in ISO 8601 format.", @@ -39989,6 +40006,7 @@ "organization", "provider", "private", + "defaultBranch", "pushedAt", "runtime" ] diff --git a/app/controllers/api/vcs.php b/app/controllers/api/vcs.php index a8efd093b5..6126a4f7b1 100644 --- a/app/controllers/api/vcs.php +++ b/app/controllers/api/vcs.php @@ -1137,6 +1137,7 @@ App::get('/v1/vcs/github/installations/:installationId/providerRepositories/:pro $repository['pushedAt'] = $repository['pushed_at'] ?? ''; $repository['organization'] = $installation->getAttribute('organization', ''); $repository['provider'] = $installation->getAttribute('provider', ''); + $repository['defaultBranch'] = $repository["default_branch"] ?? ''; $response->dynamic(new Document($repository), Response::MODEL_PROVIDER_REPOSITORY); }); diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepository.php b/src/Appwrite/Utopia/Response/Model/ProviderRepository.php index 518b5de9ee..eee058a05b 100644 --- a/src/Appwrite/Utopia/Response/Model/ProviderRepository.php +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepository.php @@ -41,6 +41,12 @@ class ProviderRepository extends Model 'default' => false, 'example' => true, ]) + ->addRule('defaultBranch', [ + 'type' => self::TYPE_STRING, + 'description' => "VCS (Version Control System) repository's default branch name.", + 'default' => '', + 'example' => 'main', + ]) ->addRule('pushedAt', [ 'type' => self::TYPE_DATETIME, 'description' => 'Last commit date in ISO 8601 format.', From c29ef4087b03af60fb3516a11d791a1455e76914 Mon Sep 17 00:00:00 2001 From: hmacr Date: Wed, 23 Jul 2025 16:28:09 +0530 Subject: [PATCH 06/22] compress screenshots before uploading --- .../Platform/Modules/Functions/Workers/Builds.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 890c8572d9..86b536899c 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -35,6 +35,7 @@ use Utopia\Fetch\Client as FetchClient; use Utopia\Logger\Log; use Utopia\Platform\Action; use Utopia\Queue\Message; +use Utopia\Storage\Compression\Algorithms\GZIP; use Utopia\Storage\Compression\Compression; use Utopia\Storage\Device; use Utopia\Storage\Device\Local; @@ -980,6 +981,9 @@ class Builds extends Action throw new \Exception($screenshotError); } + $mimeType = "image/png"; + $compressor = new GZIP(); + foreach ($screenshots as $data) { $key = $data['key']; $screenshot = $data['screenshot']; @@ -988,7 +992,7 @@ class Builds extends Action $fileName = $fileId . '.png'; $path = $deviceForFiles->getPath($fileName); $path = str_ireplace($deviceForFiles->getRoot(), $deviceForFiles->getRoot() . DIRECTORY_SEPARATOR . $bucket->getId(), $path); // Add bucket id to path after root - $success = $deviceForFiles->write($path, $screenshot, "image/png"); + $success = $deviceForFiles->write($path, $compressor->compress($screenshot), $mimeType); if (!$success) { throw new \Exception("Screenshot failed to save"); @@ -1005,7 +1009,7 @@ class Builds extends Action 'name' => $fileName, 'path' => $path, 'signature' => $deviceForFiles->getFileHash($path), - 'mimeType' => $deviceForFiles->getFileMimeType($path), + 'mimeType' => $mimeType, 'sizeOriginal' => \strlen($screenshot), 'sizeActual' => $deviceForFiles->getFileSize($path), 'algorithm' => Compression::GZIP, @@ -1017,7 +1021,7 @@ class Builds extends Action 'openSSLTag' => null, 'openSSLIV' => null, 'search' => implode(' ', [$fileId, $fileName]), - 'metadata' => ['content_type' => $deviceForFiles->getFileMimeType($path)], + 'metadata' => ['content_type' => $mimeType], ]); Authorization::skip(fn () => $dbForPlatform->createDocument('bucket_' . $bucket->getSequence(), $file)); From b194558226d5588340338f5dc671b4f440992fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 23 Jul 2025 15:49:07 +0200 Subject: [PATCH 07/22] Switch from gzip to none compression --- app/controllers/api/storage.php | 4 ---- src/Appwrite/Platform/Modules/Functions/Workers/Builds.php | 6 ++---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/app/controllers/api/storage.php b/app/controllers/api/storage.php index ef9cec1e73..480f6c63c6 100644 --- a/app/controllers/api/storage.php +++ b/app/controllers/api/storage.php @@ -1072,10 +1072,6 @@ App::get('/v1/storage/buckets/:bucketId/files/:fileId/preview') $decompressionTime = \microtime(true) - $startTime - $downloadTime - $decryptionTime; - if (empty($source)) { - $source = $deviceForFiles->read($path); - } - try { $image = new Image($source); } catch (ImagickException $e) { diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 86b536899c..82683d8d5f 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -35,7 +35,6 @@ use Utopia\Fetch\Client as FetchClient; use Utopia\Logger\Log; use Utopia\Platform\Action; use Utopia\Queue\Message; -use Utopia\Storage\Compression\Algorithms\GZIP; use Utopia\Storage\Compression\Compression; use Utopia\Storage\Device; use Utopia\Storage\Device\Local; @@ -982,7 +981,6 @@ class Builds extends Action } $mimeType = "image/png"; - $compressor = new GZIP(); foreach ($screenshots as $data) { $key = $data['key']; @@ -992,7 +990,7 @@ class Builds extends Action $fileName = $fileId . '.png'; $path = $deviceForFiles->getPath($fileName); $path = str_ireplace($deviceForFiles->getRoot(), $deviceForFiles->getRoot() . DIRECTORY_SEPARATOR . $bucket->getId(), $path); // Add bucket id to path after root - $success = $deviceForFiles->write($path, $compressor->compress($screenshot), $mimeType); + $success = $deviceForFiles->write($path, $screenshot, $mimeType); if (!$success) { throw new \Exception("Screenshot failed to save"); @@ -1012,7 +1010,7 @@ class Builds extends Action 'mimeType' => $mimeType, 'sizeOriginal' => \strlen($screenshot), 'sizeActual' => $deviceForFiles->getFileSize($path), - 'algorithm' => Compression::GZIP, + 'algorithm' => Compression::NONE, 'comment' => '', 'chunksTotal' => 1, 'chunksUploaded' => 1, From 8240c9ccdc20298bab992b5043b3f931078669a1 Mon Sep 17 00:00:00 2001 From: hmacr Date: Wed, 23 Jul 2025 20:39:02 +0530 Subject: [PATCH 08/22] add tests --- .../Services/Sites/SitesConsoleClientTest.php | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/e2e/Services/Sites/SitesConsoleClientTest.php b/tests/e2e/Services/Sites/SitesConsoleClientTest.php index 28ce2a35ec..227e36a50e 100644 --- a/tests/e2e/Services/Sites/SitesConsoleClientTest.php +++ b/tests/e2e/Services/Sites/SitesConsoleClientTest.php @@ -92,12 +92,51 @@ class SitesConsoleClientTest extends Scope $this->assertNotEquals($screenshotDarkHash, $screenshotHash); + $screenshotId = $deployment['body']['screenshotLight']; $file = $this->client->call(Client::METHOD_GET, "/storage/buckets/screenshots/files/$screenshotId/view?project=console"); $this->assertEquals(404, $file['headers']['status-code']); + $screenshotId = $deployment['body']['screenshotDark']; $file = $this->client->call(Client::METHOD_GET, "/storage/buckets/screenshots/files/$screenshotId/view?project=console"); $this->assertEquals(404, $file['headers']['status-code']); + // Verify previews + $screenshotId = $deployment['body']['screenshotLight']; + $file = $this->client->call(Client::METHOD_GET, "/storage/buckets/screenshots/files/$screenshotId/preview?project=console", array_merge($this->getHeaders(), [ + 'x-appwrite-mode' => 'default' // NOT ADMIN! + ])); + + $this->assertEquals(200, $file['headers']['status-code']); + $this->assertNotEmpty(200, $file['body']); + $this->assertGreaterThan(1, $file['headers']['content-length']); + $this->assertEquals('image/png', $file['headers']['content-type']); + + $screenshotHash = \md5($file['body']); + $this->assertNotEmpty($screenshotHash); + + $screenshotId = $deployment['body']['screenshotDark']; + $file = $this->client->call(Client::METHOD_GET, "/storage/buckets/screenshots/files/$screenshotId/preview?project=console", array_merge($this->getHeaders(), [ + 'x-appwrite-mode' => 'default' // NOT ADMIN! + ])); + + $this->assertEquals(200, $file['headers']['status-code']); + $this->assertNotEmpty(200, $file['body']); + $this->assertGreaterThan(1, $file['headers']['content-length']); + $this->assertEquals('image/png', $file['headers']['content-type']); + + $screenshotDarkHash = \md5($file['body']); + $this->assertNotEmpty($screenshotDarkHash); + + $this->assertNotEquals($screenshotDarkHash, $screenshotHash); + + $screenshotId = $deployment['body']['screenshotLight']; + $file = $this->client->call(Client::METHOD_GET, "/storage/buckets/screenshots/files/$screenshotId/preview?project=console"); + $this->assertEquals(404, $file['headers']['status-code']); + + $screenshotId = $deployment['body']['screenshotDark']; + $file = $this->client->call(Client::METHOD_GET, "/storage/buckets/screenshots/files/$screenshotId/preview?project=console"); + $this->assertEquals(404, $file['headers']['status-code']); + $this->cleanupSite($siteId); } } From f8d6706fde8ce2cbc2adcf5ee4ce47878aa5be60 Mon Sep 17 00:00:00 2001 From: hmacr Date: Wed, 23 Jul 2025 20:45:46 +0530 Subject: [PATCH 09/22] update composer --- composer.json | 2 +- composer.lock | 38 +++++++++++++++++++------------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/composer.json b/composer.json index 5e90143b13..31a31af9f2 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,7 @@ "utopia-php/swoole": "0.8.*", "utopia-php/system": "0.9.*", "utopia-php/telemetry": "0.1.*", - "utopia-php/vcs": "0.10.*", + "utopia-php/vcs": "0.11.*", "utopia-php/websocket": "0.3.*", "matomo/device-detector": "6.1.*", "dragonmantank/cron-expression": "3.3.*", diff --git a/composer.lock b/composer.lock index ea3b3459a6..8e54055326 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f53e1ccd394581428d9efcb53b46d479", + "content-hash": "edbe5912c45e1f467f398541a75a77de", "packages": [ { "name": "adhocore/jwt", @@ -3942,16 +3942,16 @@ }, { "name": "utopia-php/messaging", - "version": "0.18.1", + "version": "0.18.2", "source": { "type": "git", "url": "https://github.com/utopia-php/messaging.git", - "reference": "5d1245207a61d7ca065daddad7ac5f1d5640152f" + "reference": "0d364edacf4d4867964c7e17f653031dd39394bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/messaging/zipball/5d1245207a61d7ca065daddad7ac5f1d5640152f", - "reference": "5d1245207a61d7ca065daddad7ac5f1d5640152f", + "url": "https://api.github.com/repos/utopia-php/messaging/zipball/0d364edacf4d4867964c7e17f653031dd39394bf", + "reference": "0d364edacf4d4867964c7e17f653031dd39394bf", "shasum": "" }, "require": { @@ -3987,9 +3987,9 @@ ], "support": { "issues": "https://github.com/utopia-php/messaging/issues", - "source": "https://github.com/utopia-php/messaging/tree/0.18.1" + "source": "https://github.com/utopia-php/messaging/tree/0.18.2" }, - "time": "2025-06-26T18:26:07+00:00" + "time": "2025-07-21T18:27:03+00:00" }, { "name": "utopia-php/migration", @@ -4587,16 +4587,16 @@ }, { "name": "utopia-php/vcs", - "version": "0.10.5", + "version": "0.11.0", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "b358439dc387f6097019eb83ebb9fc258fe9da05" + "reference": "0e665eaa7d906168525bf6aac50b6bcc3e4fe528" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/b358439dc387f6097019eb83ebb9fc258fe9da05", - "reference": "b358439dc387f6097019eb83ebb9fc258fe9da05", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/0e665eaa7d906168525bf6aac50b6bcc3e4fe528", + "reference": "0e665eaa7d906168525bf6aac50b6bcc3e4fe528", "shasum": "" }, "require": { @@ -4630,9 +4630,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/0.10.5" + "source": "https://github.com/utopia-php/vcs/tree/0.11.0" }, - "time": "2025-06-10T15:01:16+00:00" + "time": "2025-07-23T13:54:58+00:00" }, { "name": "utopia-php/websocket", @@ -4810,16 +4810,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "0.41.17", + "version": "0.41.19", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "ef5e9d952032deecffee7f825a941c40ed4c84b8" + "reference": "ae6d44b373ae09f28d8b2dbb781ea3e2491b842d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/ef5e9d952032deecffee7f825a941c40ed4c84b8", - "reference": "ef5e9d952032deecffee7f825a941c40ed4c84b8", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/ae6d44b373ae09f28d8b2dbb781ea3e2491b842d", + "reference": "ae6d44b373ae09f28d8b2dbb781ea3e2491b842d", "shasum": "" }, "require": { @@ -4855,9 +4855,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/0.41.17" + "source": "https://github.com/appwrite/sdk-generator/tree/0.41.19" }, - "time": "2025-07-18T08:18:48+00:00" + "time": "2025-07-23T06:33:44+00:00" }, { "name": "doctrine/annotations", From c5f8bdfd36ba2b0223d89c62eb4659a9f56c4141 Mon Sep 17 00:00:00 2001 From: hmacr Date: Wed, 23 Jul 2025 22:04:25 +0530 Subject: [PATCH 10/22] Preview texts for emails --- app/config/locale/translations/en.json | 8 +++++++- app/controllers/api/account.php | 10 ++++++++++ src/Appwrite/Platform/Workers/Certificates.php | 2 ++ src/Appwrite/Platform/Workers/Webhooks.php | 2 ++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/config/locale/translations/en.json b/app/config/locale/translations/en.json index 653e8c16e5..614915828e 100644 --- a/app/config/locale/translations/en.json +++ b/app/config/locale/translations/en.json @@ -4,6 +4,7 @@ "settings.direction": "ltr", "emails.sender": "%s Team", "emails.verification.subject": "Account Verification", + "emails.verification.preview": "Verify your email to activate your {{project}} account.", "emails.verification.hello": "Hello {{user}},", "emails.verification.body": "Follow this link to verify your email address to your {{b}}{{project}}{{/b}} account.", "emails.verification.footer": "If you didn’t ask to verify this address, you can ignore this message.", @@ -11,6 +12,7 @@ "emails.verification.buttonText": "Confirm email address", "emails.verification.signature": "{{project}} team", "emails.magicSession.subject": "{{project}} Login", + "emails.magicSession.preview": "Sign in to {{project}} with your secure link. Expires in 1 hour.", "emails.magicSession.hello": "Hello {{user}},", "emails.magicSession.optionButton": "Click the button below to securely sign in to your {{b}}{{project}}{{/b}} account. This link will expire in 1 hour.", "emails.magicSession.buttonText": "Sign in to {{project}}", @@ -20,6 +22,7 @@ "emails.magicSession.thanks": "Thanks,", "emails.magicSession.signature": "{{project}} team", "emails.sessionAlert.subject": "Security alert: new session on your {{project}} account", + "emails.sessionAlert.preview": "New login detected on {{project}} at {{time}} UTC.", "emails.sessionAlert.hello": "Hello {{user}},", "emails.sessionAlert.body": "A new session has been created on your {{b}}{{project}}{{/b}} account, {{b}}on {{date}}, {{year}} at {{time}} UTC{{/b}}.\nHere are the details of the new session: ", "emails.sessionAlert.listDevice": "Device: {{b}}{{device}}{{/b}}", @@ -29,7 +32,7 @@ "emails.sessionAlert.thanks": "Thanks,", "emails.sessionAlert.signature": "{{project}} team", "emails.otpSession.subject": "OTP for {{project}} Login", - "emails.otpSession.preview": "Use OTP {{otp}} to sign in to {{project}}", + "emails.otpSession.preview": "Use OTP {{otp}} to sign in to {{project}}. Expires in 15 minutes.", "emails.otpSession.hello": "Hello {{user}},", "emails.otpSession.description": "Enter the following verification code when prompted to securely sign in to your {{b}}{{project}}{{/b}} account. This code will expire in 15 minutes.", "emails.otpSession.clientInfo": "This sign in was requested using {{b}}{{agentClient}}{{/b}} on {{b}}{{agentDevice}}{{/b}} {{b}}{{agentOs}}{{/b}}. If you didn't request the sign in, you can safely ignore this email.", @@ -37,12 +40,14 @@ "emails.otpSession.thanks": "Thanks,", "emails.otpSession.signature": "{{project}} team", "emails.mfaChallenge.subject": "Verification Code for {{project}}", + "emails.mfaChallenge.preview": "Your {{project}} verification code. Expires in 15 minutes.", "emails.mfaChallenge.hello": "Hello {{user}},", "emails.mfaChallenge.description": "Enter the following verification code to verify your email and activate two-step verification in {{b}}{{project}}{{/b}}. This code will expire in 15 minutes.", "emails.mfaChallenge.clientInfo": "This verification code was requested using {{b}}{{agentClient}}{{/b}} on {{b}}{{agentDevice}}{{/b}} {{b}}{{agentOs}}{{/b}}. If you didn't request the verification code, you can safely ignore this email.", "emails.mfaChallenge.thanks": "Thanks,", "emails.mfaChallenge.signature": "{{project}} team", "emails.recovery.subject": "Password Reset", + "emails.recovery.preview": "Reset your {{project}} password using the link.", "emails.recovery.hello": "Hello {{user}},", "emails.recovery.body": "Follow this link to reset your {{b}}{{project}}{{/b}} password.", "emails.recovery.footer": "If you didn't ask to reset your password, you can ignore this message.", @@ -58,6 +63,7 @@ "emails.invitation.buttonText": "Accept invite to {{team}}", "emails.invitation.signature": "{{project}} team", "emails.certificate.subject": "Certificate failure for %s", + "emails.certificate.preview": "Your domain %s certificate generation has failed.", "emails.certificate.hello": "Hello,", "emails.certificate.body": "Certificate for your domain '{{domain}}' could not be generated. This is attempt no. {{attempt}}, and the failure was caused by: {{error}}", "emails.certificate.footer": "Your previous certificate will be valid for 30 days since the first failure. We highly recommend investigating this case, otherwise your domain will end up without a valid SSL communication.", diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 81bafa29f1..410ce548f1 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -71,6 +71,7 @@ $oauthDefaultFailure = '/console/auth/oauth2/failure'; function sendSessionAlert(Locale $locale, Document $user, Document $project, Document $session, Mail $queueForMails) { $subject = $locale->getText("emails.sessionAlert.subject"); + $preview = $locale->getText("emails.sessionAlert.preview"); $customTemplate = $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->default] ?? []; $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-session-alert.tpl'); @@ -148,6 +149,7 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, Doc $queueForMails ->setSubject($subject) + ->setPreview($preview) ->setBody($body) ->setVariables($emailVariables) ->setRecipient($email) @@ -2025,6 +2027,7 @@ App::post('/v1/account/tokens/magic-url') $url = Template::unParseURL($url); $subject = $locale->getText("emails.magicSession.subject"); + $preview = $locale->getText("emails.magicSession.preview"); $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); @@ -2113,6 +2116,7 @@ App::post('/v1/account/tokens/magic-url') $queueForMails ->setSubject($subject) + ->setPreview($preview) ->setBody($body) ->setVariables($emailVariables) ->setRecipient($email) @@ -3267,6 +3271,7 @@ App::post('/v1/account/recovery') $projectName = $project->isEmpty() ? 'Console' : $project->getAttribute('name', '[APP-NAME]'); $body = $locale->getText("emails.recovery.body"); $subject = $locale->getText("emails.recovery.subject"); + $preview = $locale->getText("emails.recovery.preview"); $customTemplate = $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? []; $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl'); @@ -3341,6 +3346,7 @@ App::post('/v1/account/recovery') ->setBody($body) ->setVariables($emailVariables) ->setSubject($subject) + ->setPreview($preview) ->trigger(); $recovery->setAttribute('secret', $secret); @@ -3522,6 +3528,7 @@ App::post('/v1/account/verification') $projectName = $project->isEmpty() ? 'Console' : $project->getAttribute('name', '[APP-NAME]'); $body = $locale->getText("emails.verification.body"); + $preview = $locale->getText("emails.verification.preview"); $subject = $locale->getText("emails.verification.subject"); $customTemplate = $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? []; @@ -3594,6 +3601,7 @@ App::post('/v1/account/verification') $queueForMails ->setSubject($subject) + ->setPreview($preview) ->setBody($body) ->setVariables($emailVariables) ->setRecipient($user->getAttribute('email')) @@ -4439,6 +4447,7 @@ App::post('/v1/account/mfa/challenge') } $subject = $locale->getText("emails.mfaChallenge.subject"); + $preview = $locale->getText("emails.mfaChallenge.preview"); $customTemplate = $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); @@ -4515,6 +4524,7 @@ App::post('/v1/account/mfa/challenge') $queueForMails ->setSubject($subject) + ->setPreview($preview) ->setBody($body) ->setVariables($emailVariables) ->setRecipient($user->getAttribute('email')) diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index a9104e0017..a2ffba69c1 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -382,9 +382,11 @@ class Certificates extends Action ]; $subject = \sprintf($locale->getText("emails.certificate.subject"), $domain); + $preview = \sprintf($locale->getText("emails.certificate.preview"), $domain); $queueForMails ->setSubject($subject) + ->setPreview($preview) ->setBody($body) ->setName('Appwrite Administrator') ->setbodyTemplate(__DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl') diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index 3ffc3f0aad..394eae5fb8 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -241,6 +241,7 @@ class Webhooks extends Action // TODO: Use setbodyTemplate once #7307 is merged $subject = 'Webhook deliveries have been paused'; + $preview = 'Webhook deliveries to your endpoint have been paused.'; $body = Template::fromFile(__DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl'); $body @@ -250,6 +251,7 @@ class Webhooks extends Action $queueForMails ->setSubject($subject) + ->setPreview($preview) ->setBody($body->render()); foreach ($users as $user) { From 7ca03a58891f24cef59aaf23097369e9eabd1b8e Mon Sep 17 00:00:00 2001 From: hmacr Date: Wed, 23 Jul 2025 22:26:35 +0530 Subject: [PATCH 11/22] Suppress git-action exception in deployment worker --- .../Modules/Functions/Workers/Builds.php | 173 ++++++++++-------- 1 file changed, 93 insertions(+), 80 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 890c8572d9..adeb16c8cb 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -1441,96 +1441,109 @@ class Builds extends Action Database $dbForProject, Database $dbForPlatform ): void { - if ($resource->getAttribute('providerSilentMode', false) === true) { - return; - } - - $deployment = $dbForProject->getDocument('deployments', $deploymentId); - $commentId = $deployment->getAttribute('providerCommentId', ''); - - if (!empty($providerCommitHash)) { - $message = match ($status) { - 'ready' => 'Build succeeded.', - 'failed' => 'Build failed.', - 'processing' => 'Building...', - default => $status - }; - - $state = match ($status) { - 'ready' => 'success', - 'failed' => 'failure', - 'processing' => 'pending', - default => $status - }; - - $resourceName = $resource->getAttribute('name'); - $projectName = $project->getAttribute('name'); - - $name = "{$resourceName} ({$projectName})"; - - $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $hostname = System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', '')); - - $projectId = $project->getId(); - $region = $project->getAttribute('region', 'default'); - $resourceId = $resource->getId(); - $providerTargetUrl = match ($resource->getCollection()) { - 'functions' => "{$protocol}://{$hostname}/console/project-{$region}-{$projectId}/functions/function-{$resourceId}", - 'sites' => "{$protocol}://{$hostname}/console/project-{$region}-{$projectId}/sites/site-{$resourceId}", - default => throw new \Exception('Invalid resource type') - }; - - $github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, $state, $message, $providerTargetUrl, $name); - } - - if (!empty($commentId)) { - $retries = 0; - - while (true) { - $retries++; - - try { - $dbForPlatform->createDocument('vcsCommentLocks', new Document([ - '$id' => $commentId - ])); - break; - } catch (\Throwable $err) { - if ($retries >= 9) { - throw $err; - } - - \sleep(1); - } + try { + if ($resource->getAttribute('providerSilentMode', false) === true) { + return; } - // Wrap in try/finally to ensure lock file gets deleted - try { - $resourceType = match($resource->getCollection()) { - 'functions' => 'function', - 'sites' => 'site', - default => throw new \Exception('Invalid resource type') + $deployment = $dbForProject->getDocument('deployments', $deploymentId); + $commentId = $deployment->getAttribute('providerCommentId', ''); + + if (!empty($providerCommitHash)) { + $message = match ($status) { + 'ready' => 'Build succeeded.', + 'failed' => 'Build failed.', + 'processing' => 'Building...', + default => $status }; - $rule = Authorization::skip(fn () => $dbForPlatform->findOne('rules', [ - Query::equal("projectInternalId", [$project->getSequence()]), - Query::equal("type", ["deployment"]), - Query::equal("deploymentInternalId", [$deployment->getSequence()]), - ])); + $state = match ($status) { + 'ready' => 'success', + 'failed' => 'failure', + 'processing' => 'pending', + default => $status + }; + + $resourceName = $resource->getAttribute('name'); + $projectName = $project->getAttribute('name'); + + $name = "{$resourceName} ({$projectName})"; $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $previewUrl = match($resource->getCollection()) { - 'functions' => '', - 'sites' => !empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '', + $hostname = System::getEnv('_APP_CONSOLE_DOMAIN', System::getEnv('_APP_DOMAIN', '')); + + $projectId = $project->getId(); + $region = $project->getAttribute('region', 'default'); + $resourceId = $resource->getId(); + $providerTargetUrl = match ($resource->getCollection()) { + 'functions' => "{$protocol}://{$hostname}/console/project-{$region}-{$projectId}/functions/function-{$resourceId}", + 'sites' => "{$protocol}://{$hostname}/console/project-{$region}-{$projectId}/sites/site-{$resourceId}", default => throw new \Exception('Invalid resource type') }; - $comment = new Comment(); - $comment->parseComment($github->getComment($owner, $repositoryName, $commentId)); - $comment->addBuild($project, $resource, $resourceType, $status, $deployment->getId(), ['type' => 'logs'], $previewUrl); - $github->updateComment($owner, $repositoryName, $commentId, $comment->generateComment()); - } finally { - $dbForPlatform->deleteDocument('vcsCommentLocks', $commentId); + $github->updateCommitStatus($repositoryName, $providerCommitHash, $owner, $state, $message, $providerTargetUrl, $name); } + + if (!empty($commentId)) { + $retries = 0; + + while (true) { + $retries++; + + try { + $dbForPlatform->createDocument('vcsCommentLocks', new Document([ + '$id' => $commentId + ])); + break; + } catch (\Throwable $err) { + if ($retries >= 9) { + throw $err; + } + + \sleep(1); + } + } + + // Wrap in try/finally to ensure lock file gets deleted + try { + $resourceType = match($resource->getCollection()) { + 'functions' => 'function', + 'sites' => 'site', + default => throw new \Exception('Invalid resource type') + }; + + $rule = Authorization::skip(fn () => $dbForPlatform->findOne('rules', [ + Query::equal("projectInternalId", [$project->getSequence()]), + Query::equal("type", ["deployment"]), + Query::equal("deploymentInternalId", [$deployment->getSequence()]), + ])); + + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + $previewUrl = match($resource->getCollection()) { + 'functions' => '', + 'sites' => !empty($rule) ? ("{$protocol}://" . $rule->getAttribute('domain', '')) : '', + default => throw new \Exception('Invalid resource type') + }; + + $comment = new Comment(); + $comment->parseComment($github->getComment($owner, $repositoryName, $commentId)); + $comment->addBuild($project, $resource, $resourceType, $status, $deployment->getId(), ['type' => 'logs'], $previewUrl); + $github->updateComment($owner, $repositoryName, $commentId, $comment->generateComment()); + } finally { + $dbForPlatform->deleteDocument('vcsCommentLocks', $commentId); + } + } + } catch (\Throwable $th) { + Console::warning("Git action failed:"); + Console::warning($th->getMessage()); + Console::warning($th->getTraceAsString()); + + $logs = $deployment->getAttribute('buildLogs', ''); + $date = \date('H:i:s'); + $logs .= "[$date] [appwrite] Git action failed. Deployment will continue. \n"; + + $deployment->setAttribute('buildLogs', $logs); + $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); } } From 7ae237e43362eaf81fdc6d39cdd8b6291f7d3e83 Mon Sep 17 00:00:00 2001 From: hmacr Date: Thu, 24 Jul 2025 11:44:44 +0530 Subject: [PATCH 12/22] Better error message for invalid function scheduled time --- .../Platform/Modules/Functions/Http/Executions/Create.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index d502a78e07..ccc8a24c70 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -82,7 +82,7 @@ class Create extends Base ->param('path', '/', new Text(2048), 'HTTP path of execution. Path can include query params. Default value is /', true) ->param('method', 'POST', new Whitelist(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], true), 'HTTP method of execution. Default value is GET.', true) ->param('headers', [], new AnyOf([new Assoc(), new Text(65535)], AnyOf::TYPE_MIXED), 'HTTP headers of execution. Defaults to empty.', true) - ->param('scheduledAt', null, new DatetimeValidator(requireDateInFuture: true, precision: DateTimeValidator::PRECISION_MINUTES, offset: 60), 'Scheduled execution time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. DateTime value must be in future with precision in minutes.', true) + ->param('scheduledAt', null, new Text(100), 'Scheduled execution time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. DateTime value must be in future with precision in minutes.', true) ->inject('response') ->inject('request') ->inject('project') @@ -123,6 +123,11 @@ class Create extends Base throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Scheduled executions must run asynchronously. Set scheduledAt to a future date, or set async to true.'); } + $validator = new DatetimeValidator(requireDateInFuture: true, precision: DateTimeValidator::PRECISION_MINUTES, offset: 60); + if (!$validator->isValid($scheduledAt)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Scheduled execution date must be at least 60 seconds in the future'); + } + /** * @var array $headers */ From ed4e85f28a3f592e6f408a6ab6f6c08f838fb3b6 Mon Sep 17 00:00:00 2001 From: hmacr Date: Thu, 24 Jul 2025 11:56:05 +0530 Subject: [PATCH 13/22] validate only when scheduledAt is available --- .../Platform/Modules/Functions/Http/Executions/Create.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index ccc8a24c70..d04861b6ec 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -123,9 +123,11 @@ class Create extends Base throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Scheduled executions must run asynchronously. Set scheduledAt to a future date, or set async to true.'); } - $validator = new DatetimeValidator(requireDateInFuture: true, precision: DateTimeValidator::PRECISION_MINUTES, offset: 60); - if (!$validator->isValid($scheduledAt)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Scheduled execution date must be at least 60 seconds in the future'); + if (!is_null($scheduledAt)) { + $validator = new DatetimeValidator(requireDateInFuture: true, precision: DateTimeValidator::PRECISION_MINUTES, offset: 60); + if (!$validator->isValid($scheduledAt)) { + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Scheduled execution date must be at least 60 seconds in the future'); + } } /** From 9055d41aa0f84a8cd73acc766ed3e4909b3a6087 Mon Sep 17 00:00:00 2001 From: hmacr Date: Thu, 24 Jul 2025 17:56:24 +0530 Subject: [PATCH 14/22] send log to queue --- .../Platform/Modules/Functions/Workers/Builds.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index adeb16c8cb..9c656e735c 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -516,7 +516,7 @@ class Builds extends Action ->setPayload($deployment->getArrayCopy()) ->trigger(); - $this->runGitAction('processing', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform); + $this->runGitAction('processing', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime); } /** Request the executor to build the code... */ @@ -532,7 +532,7 @@ class Builds extends Action ->trigger(); if ($isVcsEnabled) { - $this->runGitAction('building', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform); + $this->runGitAction('building', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime); } $deploymentModel = new Deployment(); @@ -1067,7 +1067,7 @@ class Builds extends Action ->trigger(); if ($isVcsEnabled) { - $this->runGitAction('ready', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform); + $this->runGitAction('ready', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime); } Console::success("Build id: $deploymentId created"); @@ -1285,7 +1285,7 @@ class Builds extends Action ->trigger(); if ($isVcsEnabled) { - $this->runGitAction('failed', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform); + $this->runGitAction('failed', $github, $providerCommitHash, $owner, $repositoryName, $project, $resource, $deployment->getId(), $dbForProject, $dbForPlatform, $queueForRealtime); } } finally { $queueForRealtime @@ -1439,7 +1439,8 @@ class Builds extends Action Document $resource, string $deploymentId, Database $dbForProject, - Database $dbForPlatform + Database $dbForPlatform, + Realtime $queueForRealtime, ): void { try { if ($resource->getAttribute('providerSilentMode', false) === true) { @@ -1544,6 +1545,10 @@ class Builds extends Action $deployment->setAttribute('buildLogs', $logs); $deployment = $dbForProject->updateDocument('deployments', $deployment->getId(), $deployment); + + $queueForRealtime + ->setPayload($deployment->getArrayCopy()) + ->trigger(); } } From ee5a644771ba55f9ca8258655f30b6361f0965db Mon Sep 17 00:00:00 2001 From: hmacr Date: Thu, 24 Jul 2025 19:39:23 +0530 Subject: [PATCH 15/22] tests --- tests/e2e/Services/Account/AccountBase.php | 2 ++ tests/e2e/Services/Account/AccountCustomClientTest.php | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index 1a77cccb18..7c83edf3e3 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -170,6 +170,7 @@ trait AccountBase $userId = $response['body']['userId']; $lastEmail = $this->getLastEmail(); + $this->assertEquals('otpuser@appwrite.io', $lastEmail['to'][0]['address']); $this->assertEquals('OTP for ' . $this->getProject()['name'] . ' Login', $lastEmail['subject']); @@ -178,6 +179,7 @@ trait AccountBase $code = ($matches[0] ?? [])[0] ?? ''; $this->assertNotEmpty($code); + $this->assertStringContainsStringIgnoringCase('Use OTP ' . $code . ' to sign in to '. $this->getProject()['name'] . '. Expires in 15 minutes.', $lastEmail['text']); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', array_merge([ 'origin' => 'http://localhost', diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index bccc51cb8a..9c5d976476 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -779,6 +779,7 @@ class AccountCustomClientTest extends Scope $this->assertEquals($email, $lastEmail['to'][0]['address']); $this->assertEquals($name, $lastEmail['to'][0]['name']); $this->assertEquals('Account Verification', $lastEmail['subject']); + $this->assertStringContainsStringIgnoringCase('Verify your email to activate your ' . $this->getProject()['name'] . ' account.', $lastEmail['text']); $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html']); $verification = $tokens['secret']; @@ -1082,6 +1083,8 @@ class AccountCustomClientTest extends Scope $this->assertEquals($email, $lastEmail['to'][0]['address']); $this->assertEquals($name, $lastEmail['to'][0]['name']); $this->assertEquals('Password Reset', $lastEmail['subject']); + $this->assertStringContainsStringIgnoringCase('Reset your ' . $this->getProject()['name'] . ' password using the link.', $lastEmail['text']); + $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html']); @@ -1286,6 +1289,7 @@ class AccountCustomClientTest extends Scope $this->assertNotEmpty($response['body']['expire']); $this->assertEmpty($response['body']['secret']); $this->assertEmpty($response['body']['phrase']); + $this->assertStringContainsStringIgnoringCase('New login detected on '. $this->getProject()['name'], $lastEmail['text']); $userId = $response['body']['userId']; @@ -2545,6 +2549,7 @@ class AccountCustomClientTest extends Scope $lastEmail = $this->getLastEmail(); $this->assertEquals($email, $lastEmail['to'][0]['address']); $this->assertEquals($this->getProject()['name'] . ' Login', $lastEmail['subject']); + $this->assertStringContainsStringIgnoringCase('Sign in to '. $this->getProject()['name'] . ' with your secure link. Expires in 1 hour.', $lastEmail['text']); $this->assertStringNotContainsStringIgnoringCase('security phrase', $lastEmail['text']); $token = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 64); From 5ce141b75b3eee30b45d76b077747e6d672af70f Mon Sep 17 00:00:00 2001 From: hmacr Date: Thu, 24 Jul 2025 19:43:14 +0530 Subject: [PATCH 16/22] change error msg --- .../Platform/Modules/Functions/Http/Executions/Create.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index d04861b6ec..e41c58cd79 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -126,7 +126,7 @@ class Create extends Base if (!is_null($scheduledAt)) { $validator = new DatetimeValidator(requireDateInFuture: true, precision: DateTimeValidator::PRECISION_MINUTES, offset: 60); if (!$validator->isValid($scheduledAt)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Scheduled execution date must be at least 60 seconds in the future'); + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Execution schedule must be a valid date, and at least 1 minute from now.'); } } From a8d5fc99982ffdcd56a87284af49b54e6b66c7f5 Mon Sep 17 00:00:00 2001 From: hmacr Date: Thu, 24 Jul 2025 19:49:52 +0530 Subject: [PATCH 17/22] format --- app/controllers/api/vcs.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/vcs.php b/app/controllers/api/vcs.php index b9b8f0d3f0..9271e87448 100644 --- a/app/controllers/api/vcs.php +++ b/app/controllers/api/vcs.php @@ -1137,7 +1137,7 @@ App::get('/v1/vcs/github/installations/:installationId/providerRepositories/:pro $repository['pushedAt'] = $repository['pushed_at'] ?? ''; $repository['organization'] = $installation->getAttribute('organization', ''); $repository['provider'] = $installation->getAttribute('provider', ''); - $repository['defaultBranch'] = $repository["default_branch"] ?? ''; + $repository['defaultBranch'] = $repository['default_branch'] ?? ''; $response->dynamic(new Document($repository), Response::MODEL_PROVIDER_REPOSITORY); }); From d7f85f9f58ed04046f14f7af6e16099d29b4eb97 Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Thu, 24 Jul 2025 20:06:49 +0530 Subject: [PATCH 18/22] remove dot --- .../Platform/Modules/Functions/Http/Executions/Create.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index e41c58cd79..905acf15df 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -126,7 +126,7 @@ class Create extends Base if (!is_null($scheduledAt)) { $validator = new DatetimeValidator(requireDateInFuture: true, precision: DateTimeValidator::PRECISION_MINUTES, offset: 60); if (!$validator->isValid($scheduledAt)) { - throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Execution schedule must be a valid date, and at least 1 minute from now.'); + throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Execution schedule must be a valid date, and at least 1 minute from now'); } } From 8d1bc10cc6aae0d55088206063a156cfb96324b9 Mon Sep 17 00:00:00 2001 From: Steven Nguyen <1477010+stnguyen90@users.noreply.github.com> Date: Wed, 23 Jul 2025 12:31:28 -0700 Subject: [PATCH 19/22] docs: update CONTRIBUTING.md to clarify how to start --- CONTRIBUTING.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 277a509447..c6837673d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,31 @@ You can [find issues using this query](https://github.com/search?q=org%3Aappwrit ## How to Start? -If you are worried or don’t know where to start, check out the next section that explains what kind of help we could use and where you can get involved. You can send your questions to [@appwrite on Twitter](https://twitter.com/appwrite) or to anyone from the [Appwrite team on Discord](https://appwrite.io/discord). You can also submit an issue, and a maintainer can guide you! +Welcome! We're excited that you're interested in contributing to Appwrite. To make sure your time is valued and your contributions are successful, please follow these steps before writing any code: + +### 🔍 Step 1: Find an Issue + +Browse open issues and look for ones labeled [good first issue](https://github.com/search?q=org%3Aappwrite+is%3Aopen+type%3Aissue+label%3A%22good+first+issue%22&type=issues) or [help wanted](https://github.com/search?q=org%3Aappwrite+is%3Aopen+type%3Aissue+label%3A%22help+wanted%22&type=issues). + +If you're not sure which issue to pick, ask in our ⁠[maintainers channel](https://discord.com/channels/564160730845151244/636852860709240842) on Discord. + +### 📝 Step 2: Ask to Be Assigned + +Before working on an issue, comment on the GitHub issue asking to be assigned. This prevents multiple people working on the same task. + +Then, create a thread in the ⁠[maintainers channel](https://discord.com/channels/564160730845151244/636852860709240842) on Discord with a link to the issue. + +Our team is small and may not see your GitHub comment right away - posting in the ⁠[maintainers channel](https://discord.com/channels/564160730845151244/636852860709240842) ensures it gets seen. + +### 💬 Step 3: Don’t Submit Random PRs + +If you're not working on an assigned issue, create a GitHub issue first. + +PRs submitted without context or discussion may not align with our roadmap and may be closed without review. + +### ⚠️ Please Note + +We’re a very small team managing a large project. Many PRs are submitted, and while we appreciate every effort, we can only review contributions that follow the process above. This helps us keep things fair and organized. ## Code of Conduct From 1d86cc60101fbe5051e46ea5a0736a7a81369d29 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 16 Jul 2025 18:11:04 +0100 Subject: [PATCH 20/22] feat: stats-usage on redis --- app/cli.php | 12 ++++++-- app/controllers/api/health.php | 23 ++++----------- app/init/resources.php | 24 ++++++++++++---- app/worker.php | 28 +++++++++++++++---- src/Appwrite/Platform/Tasks/ScheduleBase.php | 21 ++++++-------- .../Platform/Tasks/ScheduleExecutions.php | 12 ++------ .../Platform/Tasks/ScheduleFunctions.php | 11 ++------ .../Platform/Tasks/ScheduleMessages.php | 3 +- 8 files changed, 68 insertions(+), 66 deletions(-) diff --git a/app/cli.php b/app/cli.php index 86ec241c93..504e4fb5e6 100644 --- a/app/cli.php +++ b/app/cli.php @@ -191,9 +191,15 @@ CLI::setResource('getLogsDB', function (Group $pools, Cache $cache) { CLI::setResource('publisher', function (Group $pools) { return new BrokerPool(publisher: $pools->get('publisher')); }, ['pools']); -CLI::setResource('publisherRedis', function () { - // Stub -}); +CLI::setResource('publisherDatabases', function (BrokerPool $publisher) { + return $publisher; +}, ['publisher']); +CLI::setResource('publisherMigrations', function (BrokerPool $publisher) { + return $publisher; +}, ['publisher']); +CLI::setResource('publisherStatsUsage', function (BrokerPool $publisher) { + return $publisher; +}, ['publisher']); CLI::setResource('queueForStatsUsage', function (Publisher $publisher) { return new StatsUsage($publisher); }, ['publisher']); diff --git a/app/controllers/api/health.php b/app/controllers/api/health.php index 43368b8d3f..39ebae9590 100644 --- a/app/controllers/api/health.php +++ b/app/controllers/api/health.php @@ -522,17 +522,11 @@ App::get('/v1/health/queue/databases') )) ->param('name', 'database_db_main', new Text(256), 'Queue name for which to check the queue size', true) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('publisher') - ->inject('publisherRedis') + ->inject('publisherDatabases') ->inject('response') - ->action(function (string $name, int|string $threshold, Publisher $publisher, ?Publisher $publisherRedis, Response $response) { + ->action(function (string $name, int|string $threshold, Publisher $publisherDatabases, Response $response) { $threshold = \intval($threshold); - - $isRedisFallback = \str_contains(System::getEnv('_APP_WORKER_REDIS_FALLBACK', ''), 'databases'); - - $size = $isRedisFallback - ? $publisherRedis->getQueueSize(new Queue($name)) - : $publisher->getQueueSize(new Queue($name)); + $size = $publisherDatabases->getQueueSize(new Queue($name)); if ($size >= $threshold) { throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); @@ -659,17 +653,12 @@ App::get('/v1/health/queue/migrations') contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('publisher') - ->inject('publisherRedis') + ->inject('publisherMigrations') ->inject('response') - ->action(function (int|string $threshold, Publisher $publisher, ?Publisher $publisherRedis, Response $response) { + ->action(function (int|string $threshold, Publisher $publisherMigrations, Response $response) { $threshold = \intval($threshold); - $isRedisFallback = \str_contains(System::getEnv('_APP_WORKER_REDIS_FALLBACK', ''), 'migrations'); - - $size = $isRedisFallback - ? $publisherRedis->getQueueSize(new Queue(Event::MIGRATIONS_QUEUE_NAME)) - : $publisher->getQueueSize(new Queue(Event::MIGRATIONS_QUEUE_NAME)); + $size = $publisherMigrations->getQueueSize(new Queue(Event::MIGRATIONS_QUEUE_NAME)); if ($size >= $threshold) { throw new Exception(Exception::HEALTH_QUEUE_SIZE_EXCEEDED, "Queue size threshold hit. Current size is {$size} and threshold is {$threshold}."); diff --git a/app/init/resources.php b/app/init/resources.php index bc798aaba8..162eab1973 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -80,15 +80,27 @@ App::setResource('localeCodes', function () { App::setResource('publisher', function (Group $pools) { return new BrokerPool(publisher: $pools->get('publisher')); }, ['pools']); -App::setResource('publisherRedis', function () { - // Stub -}); +App::setResource('publisherDatabases', function (BrokerPool $publisher) { + return $publisher; +}, ['publisher']); +App::setResource('publisherMigrations', function (BrokerPool $publisher) { + return $publisher; +}, ['publisher']); +App::setResource('publisherStatsUsage', function (BrokerPool $publisher) { + return $publisher; +}, ['publisher']); App::setResource('consumer', function (Group $pools) { return new BrokerPool(consumer: $pools->get('consumer')); }, ['pools']); -App::setResource('consumerRedis', function () { - // Stub -}); +App::setResource('consumerDatabases', function (BrokerPool $consumer) { + return $consumer; +}, ['consumer']); +App::setResource('consumerMigrations', function (BrokerPool $consumer) { + return $consumer; +}, ['publisher']); +App::setResource('consumerStatsUsage', function (BrokerPool $consumer) { + return $consumer; +}, ['publisher']); App::setResource('queueForMessaging', function (Publisher $publisher) { return new Messaging($publisher); }, ['publisher']); diff --git a/app/worker.php b/app/worker.php index 4f0f569a9e..90f3368fe7 100644 --- a/app/worker.php +++ b/app/worker.php @@ -247,17 +247,33 @@ Server::setResource('publisher', function (Group $pools) { return new BrokerPool(publisher: $pools->get('publisher')); }, ['pools']); -Server::setResource('publisherRedis', function () { - // Stub -}); +Server::setResource('publisherDatabases', function (BrokerPool $publisher) { + return $publisher; +}, ['publisher']); + +Server::setResource('publisherMigrations', function (BrokerPool $publisher) { + return $publisher; +}, ['publisher']); + +Server::setResource('publisherStatsUsage', function (BrokerPool $publisher) { + return $publisher; +}, ['publisher']); Server::setResource('consumer', function (Group $pools) { return new BrokerPool(consumer: $pools->get('consumer')); }, ['pools']); -Server::setResource('consumerRedis', function () { - // Stub -}); +Server::setResource('consumerDatabases', function (BrokerPool $consumer) { + return $consumer; +}, ['consumer']); + +Server::setResource('consumerMigrations', function (BrokerPool $consumer) { + return $consumer; +}, ['consumer']); + +Server::setResource('consumerStatsUsage', function (BrokerPool $consumer) { + return $consumer; +}, ['consumer']); Server::setResource('queueForStatsUsage', function (Publisher $publisher) { return new StatsUsage($publisher); diff --git a/src/Appwrite/Platform/Tasks/ScheduleBase.php b/src/Appwrite/Platform/Tasks/ScheduleBase.php index 7686815868..222051a67f 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleBase.php +++ b/src/Appwrite/Platform/Tasks/ScheduleBase.php @@ -11,7 +11,6 @@ use Utopia\Database\Exception; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Platform\Action; -use Utopia\Pools\Group; use Utopia\Queue\Broker\Pool as BrokerPool; use Utopia\System\System; use Utopia\Telemetry\Adapter as Telemetry; @@ -26,7 +25,7 @@ abstract class ScheduleBase extends Action protected array $schedules = []; protected BrokerPool $publisher; - protected ?BrokerPool $publisherRedis = null; + protected BrokerPool $publisherMigrations; private ?Histogram $collectSchedulesTelemetryDuration = null; private ?Gauge $collectSchedulesTelemetryCount = null; @@ -36,7 +35,7 @@ abstract class ScheduleBase extends Action abstract public static function getName(): string; abstract public static function getSupportedResource(): string; abstract public static function getCollectionId(): string; - abstract protected function enqueueResources(Group $pools, Database $dbForPlatform, callable $getProjectDB): void; + abstract protected function enqueueResources(Database $dbForPlatform, callable $getProjectDB): void; public function __construct() { @@ -44,7 +43,8 @@ abstract class ScheduleBase extends Action $this ->desc("Execute {$type}s scheduled in Appwrite") - ->inject('pools') + ->inject('publisher') + ->inject('publisherMigrations') ->inject('dbForPlatform') ->inject('getProjectDB') ->inject('telemetry') @@ -67,18 +67,13 @@ abstract class ScheduleBase extends Action * 2. Create timer that sync all changes from 'schedules' collection to local copy. Only reading changes thanks to 'resourceUpdatedAt' attribute * 3. Create timer that prepares coroutines for soon-to-execute schedules. When it's ready, coroutine sleeps until exact time before sending request to worker. */ - public function action(Group $pools, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): void + public function action(BrokerPool $publisher, BrokerPool $publisherMigrations, Database $dbForPlatform, callable $getProjectDB, Telemetry $telemetry): void { Console::title(\ucfirst(static::getSupportedResource()) . ' scheduler V1'); Console::success(APP_NAME . ' ' . \ucfirst(static::getSupportedResource()) . ' scheduler v1 has started'); - $this->publisher = new BrokerPool($pools->get('publisher')); - - try { - $this->publisherRedis = new BrokerPool($pools->get('publisherRedis')); - } catch (\Throwable) { - $this->publisherRedis = null; - } + $this->publisher = $publisher; + $this->publisherMigrations = $publisherMigrations; $this->scheduleTelemetryCount = $telemetry->createGauge('task.schedule.count'); $this->collectSchedulesTelemetryDuration = $telemetry->createHistogram('task.schedule.collect_schedules.duration', 's'); @@ -101,7 +96,7 @@ abstract class ScheduleBase extends Action while (true) { try { - go(fn () => $this->enqueueResources($pools, $dbForPlatform, $getProjectDB)); + go(fn () => $this->enqueueResources($dbForPlatform, $getProjectDB)); $this->scheduleTelemetryCount->record(count($this->schedules), ['resourceType' => static::getSupportedResource()]); sleep(static::ENQUEUE_TIMER); } catch (\Throwable $th) { diff --git a/src/Appwrite/Platform/Tasks/ScheduleExecutions.php b/src/Appwrite/Platform/Tasks/ScheduleExecutions.php index acb2dd3070..96a5a05f0e 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleExecutions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleExecutions.php @@ -5,8 +5,6 @@ namespace Appwrite\Platform\Tasks; use Appwrite\Event\Func; use Swoole\Coroutine as Co; use Utopia\Database\Database; -use Utopia\Pools\Group; -use Utopia\System\System; class ScheduleExecutions extends ScheduleBase { @@ -28,17 +26,11 @@ class ScheduleExecutions extends ScheduleBase return 'executions'; } - protected function enqueueResources(Group $pools, Database $dbForPlatform, callable $getProjectDB): void + protected function enqueueResources(Database $dbForPlatform, callable $getProjectDB): void { $intervalEnd = (new \DateTime())->modify('+' . self::ENQUEUE_TIMER . ' seconds'); - $isRedisFallback = \str_contains(System::getEnv('_APP_WORKER_REDIS_FALLBACK', ''), 'functions'); - - $queueForFunctions = new Func( - $isRedisFallback - ? $this->publisherRedis - : $this->publisher - ); + $queueForFunctions = new Func($this->publisher); foreach ($this->schedules as $schedule) { if (!$schedule['active']) { diff --git a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php index 7a3363d74d..43f1025c08 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleFunctions.php +++ b/src/Appwrite/Platform/Tasks/ScheduleFunctions.php @@ -8,7 +8,6 @@ use Utopia\CLI\Console; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Pools\Group; -use Utopia\System\System; class ScheduleFunctions extends ScheduleBase { @@ -32,7 +31,7 @@ class ScheduleFunctions extends ScheduleBase return 'functions'; } - protected function enqueueResources(Group $pools, Database $dbForPlatform, callable $getProjectDB): void + protected function enqueueResources(Database $dbForPlatform, callable $getProjectDB): void { $timerStart = \microtime(true); $time = DateTime::now(); @@ -91,13 +90,7 @@ class ScheduleFunctions extends ScheduleBase $this->updateProjectAccess($schedule['project'], $dbForPlatform); - $isRedisFallback = \str_contains(System::getEnv('_APP_WORKER_REDIS_FALLBACK', ''), 'functions'); - - $queueForFunctions = new Func( - $isRedisFallback - ? $this->publisherRedis - : $this->publisher - ); + $queueForFunctions = new Func($this->publisher); $queueForFunctions ->setType('schedule') diff --git a/src/Appwrite/Platform/Tasks/ScheduleMessages.php b/src/Appwrite/Platform/Tasks/ScheduleMessages.php index af15f9583f..c4e1376ff9 100644 --- a/src/Appwrite/Platform/Tasks/ScheduleMessages.php +++ b/src/Appwrite/Platform/Tasks/ScheduleMessages.php @@ -4,7 +4,6 @@ namespace Appwrite\Platform\Tasks; use Appwrite\Event\Messaging; use Utopia\Database\Database; -use Utopia\Pools\Group; class ScheduleMessages extends ScheduleBase { @@ -26,7 +25,7 @@ class ScheduleMessages extends ScheduleBase return 'messages'; } - protected function enqueueResources(Group $pools, Database $dbForPlatform, callable $getProjectDB): void + protected function enqueueResources(Database $dbForPlatform, callable $getProjectDB): void { foreach ($this->schedules as $schedule) { if (!$schedule['active']) { From e67e6303b2ae72f6d7ece148a05f946827a65929 Mon Sep 17 00:00:00 2001 From: Darshan Date: Fri, 25 Jul 2025 14:30:03 +0530 Subject: [PATCH 21/22] fix: templates on `1.7.x`. --- .../locale/templates/email-smtp-test.tpl | 2 +- .../locale/templates/email-webhook-failed.tpl | 2 +- app/controllers/api/account.php | 10 ++++ app/controllers/api/projects.php | 53 ++++++++++++++++--- src/Appwrite/Event/Mail.php | 4 +- .../Platform/Workers/Certificates.php | 2 +- src/Appwrite/Template/Template.php | 4 +- 7 files changed, 62 insertions(+), 15 deletions(-) diff --git a/app/config/locale/templates/email-smtp-test.tpl b/app/config/locale/templates/email-smtp-test.tpl index e40b7ba5c8..1b1eccdb7c 100644 --- a/app/config/locale/templates/email-smtp-test.tpl +++ b/app/config/locale/templates/email-smtp-test.tpl @@ -9,4 +9,4 @@

If you have trouble with the sender's image, ensure it is set in the Gravatar database.

Best regards,

-

Appwrtite team

\ No newline at end of file +

Appwrite team

\ No newline at end of file diff --git a/app/config/locale/templates/email-webhook-failed.tpl b/app/config/locale/templates/email-webhook-failed.tpl index 921af9ee29..a176de5754 100644 --- a/app/config/locale/templates/email-webhook-failed.tpl +++ b/app/config/locale/templates/email-webhook-failed.tpl @@ -14,7 +14,7 @@
- Webhook settings + Webhook settings
\ No newline at end of file diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 410ce548f1..6adbf61d6d 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -133,6 +133,16 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, Doc ->setSmtpSenderName($senderName); } + // session alerts should always have a client name! + $clientName = $session->getAttribute('clientName'); + if (empty($clientName)) { + // fallback to the user agent and then unknown! + $userAgent = $session->getAttribute('userAgent'); + $clientName = !empty($userAgent) ? $userAgent : 'UNKNOWN'; + + $session->setAttribute('clientName', $clientName); + } + $emailVariables = [ 'direction' => $locale->getText('settings.direction'), 'date' => (new \DateTime())->format('F j'), diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index e583503237..6f8e2d45b8 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -2175,7 +2175,7 @@ App::post('/v1/projects/:projectId/smtp/tests') ->setSmtpSenderName($senderName) ->setRecipient($email) ->setName('') - ->setbodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') + ->setBodyTemplate(__DIR__ . '/../../config/locale/templates/email-base-styled.tpl') ->setBody($template->render()) ->setVariables([]) ->setSubject($subject) @@ -2268,16 +2268,53 @@ App::get('/v1/projects/:projectId/templates/email/:type/:locale') $localeObj = new Locale($locale); if (is_null($template)) { - $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl'); + /** + * 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('{{footer}}', $localeObj->getText("emails.{$type}.footer")) - ->setParam('{{body}}', $localeObj->getText('emails.' . $type . '.body'), escapeHtml: false) ->setParam('{{thanks}}', $localeObj->getText("emails.{$type}.thanks")) - ->setParam('{{buttonText}}', $localeObj->getText("emails.{$type}.buttonText")) - ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")) - ->setParam('{{direction}}', $localeObj->getText('settings.direction')); - $message = $message->render(); + ->setParam('{{signature}}', $localeObj->getText("emails.{$type}.signature")); + + // `useContent: false` will strip new lines! + $message = $message->render(useContent: true); $template = [ 'message' => $message, diff --git a/src/Appwrite/Event/Mail.php b/src/Appwrite/Event/Mail.php index c9a0671461..aaaa148fde 100644 --- a/src/Appwrite/Event/Mail.php +++ b/src/Appwrite/Event/Mail.php @@ -145,7 +145,7 @@ class Mail extends Event * @param string $bodyTemplate * @return self */ - public function setbodyTemplate(string $bodyTemplate): self + public function setBodyTemplate(string $bodyTemplate): self { $this->bodyTemplate = $bodyTemplate; @@ -157,7 +157,7 @@ class Mail extends Event * * @return string */ - public function getbodyTemplate(): string + public function getBodyTemplate(): string { return $this->bodyTemplate; } diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index a2ffba69c1..207f95ff7d 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -389,7 +389,7 @@ class Certificates extends Action ->setPreview($preview) ->setBody($body) ->setName('Appwrite Administrator') - ->setbodyTemplate(__DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl') + ->setBodyTemplate(__DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl') ->setVariables($emailVariables) ->setRecipient(System::getEnv('_APP_EMAIL_CERTIFICATES', System::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS'))) ->trigger(); diff --git a/src/Appwrite/Template/Template.php b/src/Appwrite/Template/Template.php index c01d54389b..e0568c98e9 100644 --- a/src/Appwrite/Template/Template.php +++ b/src/Appwrite/Template/Template.php @@ -63,7 +63,7 @@ class Template extends View * * @throws Exception */ - public function render($minify = true): string + public function render($minify = true, $useContent = false): string { if ($this->rendered) { // Don't render any template return ''; @@ -72,7 +72,7 @@ class Template extends View if (\is_readable($this->path)) { $template = \file_get_contents($this->path); // Include template file } elseif (!empty($this->content)) { - $template = $this->print($this->content, self::FILTER_NL2P); + $template = !$useContent ? $this->print($this->content, self::FILTER_NL2P) : $this->content; } else { throw new Exception('"' . $this->path . '" template is not readable or not found'); } From 4940dcbf3a6158dcfc06e4e846685a608c6efdb7 Mon Sep 17 00:00:00 2001 From: hmacr Date: Fri, 25 Jul 2025 16:08:11 +0530 Subject: [PATCH 22/22] Change preview & body for MFA email --- app/config/locale/translations/en.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/config/locale/translations/en.json b/app/config/locale/translations/en.json index 614915828e..072a7f7552 100644 --- a/app/config/locale/translations/en.json +++ b/app/config/locale/translations/en.json @@ -40,9 +40,9 @@ "emails.otpSession.thanks": "Thanks,", "emails.otpSession.signature": "{{project}} team", "emails.mfaChallenge.subject": "Verification Code for {{project}}", - "emails.mfaChallenge.preview": "Your {{project}} verification code. Expires in 15 minutes.", + "emails.mfaChallenge.preview": "Use code {{otp}} for two-step verification in {{project}}. Expires in 15 minutes.", "emails.mfaChallenge.hello": "Hello {{user}},", - "emails.mfaChallenge.description": "Enter the following verification code to verify your email and activate two-step verification in {{b}}{{project}}{{/b}}. This code will expire in 15 minutes.", + "emails.mfaChallenge.description": "Enter the following code to confirm your two-step verification in {{b}}{{project}}{{/b}}. This code will expire in 15 minutes.", "emails.mfaChallenge.clientInfo": "This verification code was requested using {{b}}{{agentClient}}{{/b}} on {{b}}{{agentDevice}}{{/b}} {{b}}{{agentOs}}{{/b}}. If you didn't request the verification code, you can safely ignore this email.", "emails.mfaChallenge.thanks": "Thanks,", "emails.mfaChallenge.signature": "{{project}} team",