From 9813afb2d7e0ddf8f00002fa30c0318d697e8d97 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 21 May 2026 16:55:26 +0100 Subject: [PATCH 1/3] Add Email Templates migration --- src/Migration/Destinations/Appwrite.php | 57 ++++++++ src/Migration/Resource.php | 4 + .../Resources/Templates/EmailTemplate.php | 122 ++++++++++++++++++ src/Migration/Source.php | 17 +++ src/Migration/Sources/Appwrite.php | 83 ++++++++++++ src/Migration/Sources/CSV.php | 5 + src/Migration/Sources/Firebase.php | 5 + src/Migration/Sources/JSON.php | 5 + src/Migration/Sources/NHost.php | 5 + src/Migration/Transfer.php | 10 ++ tests/Migration/Unit/Adapters/MockSource.php | 11 ++ 11 files changed, 324 insertions(+) create mode 100644 src/Migration/Resources/Templates/EmailTemplate.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 18be32f1..8c164d05 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -76,6 +76,7 @@ use Utopia\Migration\Resources\Sites\Site; use Utopia\Migration\Resources\Storage\Bucket; use Utopia\Migration\Resources\Storage\File; +use Utopia\Migration\Resources\Templates\EmailTemplate; use Utopia\Migration\Transfer; class Appwrite extends Destination @@ -312,6 +313,9 @@ public static function getSupportedResources(): array // Domains Resource::TYPE_RULE, + + // Templates + Resource::TYPE_EMAIL_TEMPLATE, ]; } @@ -472,6 +476,7 @@ protected function import(array $resources, callable $callback): void Transfer::GROUP_BACKUPS => $this->importBackupResource($resource), Transfer::GROUP_SETTINGS => $this->importSettingsResource($resource), Transfer::GROUP_DOMAINS => $this->importDomainsResource($resource), + Transfer::GROUP_TEMPLATES => $this->importTemplatesResource($resource), default => throw new \Exception('Invalid resource group', Exception::CODE_VALIDATION), }; } catch (\Throwable $e) { @@ -3186,6 +3191,22 @@ public function importDomainsResource(Resource $resource): Resource return $resource; } + public function importTemplatesResource(Resource $resource): Resource + { + switch ($resource->getName()) { + case Resource::TYPE_EMAIL_TEMPLATE: + /** @var EmailTemplate $resource */ + $this->createEmailTemplate($resource); + break; + } + + if ($resource->getStatus() !== Resource::STATUS_SKIPPED) { + $resource->setStatus(Resource::STATUS_SUCCESS); + } + + return $resource; + } + protected function createProjectVariable(ProjectVariable $resource): bool { $existing = $this->dbForProject->findOne('variables', [ @@ -3324,6 +3345,42 @@ protected function createSMTP(SMTP $resource): bool return true; } + /** + * Direct DB write rather than the SDK's updateEmailTemplate — the SDK path + * rejects the call unless custom SMTP is enabled on the project. Templates + * may legitimately migrate before SMTP (different groups, different runs), + * so bypass that check by writing the project.templates map directly. + * Read-then-merge preserves any templates already configured on the + * destination (other locales, untouched template types). + */ + protected function createEmailTemplate(EmailTemplate $resource): bool + { + $project = $this->dbForPlatform->getDocument('projects', $this->projectId); + $templates = $project->getAttribute('templates', []); + + $key = 'email.' . $resource->getTemplateId() . '-' . $resource->getLocale(); + $existing = $templates[$key] ?? []; + + $existing['subject'] = $resource->getSubject(); + $existing['message'] = $resource->getMessage(); + $existing['senderName'] = $resource->getSenderName(); + $existing['senderEmail'] = $resource->getSenderEmail(); + $existing['replyToEmail'] = $resource->getReplyToEmail(); + $existing['replyToName'] = $resource->getReplyToName(); + + $templates[$key] = $existing; + + $this->dbForPlatform->getAuthorization()->skip(fn () => $this->dbForPlatform->updateDocument( + 'projects', + $this->projectId, + new UtopiaDocument(['templates' => $templates]), + )); + + $this->dbForPlatform->purgeCachedDocument('projects', $this->projectId); + + return true; + } + /** * Auto-generated rules (default `.appwrite.network` domains for functions/sites) * are recreated automatically on the destination when the parent Function/Site diff --git a/src/Migration/Resource.php b/src/Migration/Resource.php index 1a5e076b..38304ca3 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -87,6 +87,9 @@ abstract class Resource implements \JsonSerializable public const TYPE_SERVICES = 'services'; public const TYPE_SMTP = 'smtp'; + // Templates + public const TYPE_EMAIL_TEMPLATE = 'email-template'; + // Domains public const TYPE_RULE = 'rule'; @@ -139,6 +142,7 @@ abstract class Resource implements \JsonSerializable self::TYPE_SERVICES, self::TYPE_SMTP, self::TYPE_RULE, + self::TYPE_EMAIL_TEMPLATE, self::TYPE_PROVIDER, self::TYPE_TOPIC, self::TYPE_SUBSCRIBER, diff --git a/src/Migration/Resources/Templates/EmailTemplate.php b/src/Migration/Resources/Templates/EmailTemplate.php new file mode 100644 index 00000000..e5ec2e91 --- /dev/null +++ b/src/Migration/Resources/Templates/EmailTemplate.php @@ -0,0 +1,122 @@ +id = $id; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + } + + /** + * @param array $array + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + (string) $array['templateId'], + (string) $array['locale'], + (string) ($array['subject'] ?? ''), + (string) ($array['message'] ?? ''), + (string) ($array['senderName'] ?? ''), + (string) ($array['senderEmail'] ?? ''), + (string) ($array['replyToEmail'] ?? ''), + (string) ($array['replyToName'] ?? ''), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'templateId' => $this->templateId, + 'locale' => $this->locale, + 'subject' => $this->subject, + 'message' => $this->message, + 'senderName' => $this->senderName, + 'senderEmail' => $this->senderEmail, + 'replyToEmail' => $this->replyToEmail, + 'replyToName' => $this->replyToName, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_EMAIL_TEMPLATE; + } + + public function getGroup(): string + { + return Transfer::GROUP_TEMPLATES; + } + + public function getTemplateId(): string + { + return $this->templateId; + } + + public function getLocale(): string + { + return $this->locale; + } + + public function getSubject(): string + { + return $this->subject; + } + + public function getMessage(): string + { + return $this->message; + } + + public function getSenderName(): string + { + return $this->senderName; + } + + public function getSenderEmail(): string + { + return $this->senderEmail; + } + + public function getReplyToEmail(): string + { + return $this->replyToEmail; + } + + public function getReplyToName(): string + { + return $this->replyToName; + } +} diff --git a/src/Migration/Source.php b/src/Migration/Source.php index 840f323b..daa4e858 100644 --- a/src/Migration/Source.php +++ b/src/Migration/Source.php @@ -66,6 +66,11 @@ public function getDomainsBatchSize(): int return static::$defaultBatchSize; } + public function getTemplatesBatchSize(): int + { + return static::$defaultBatchSize; + } + /** * @param array $resources * @return void @@ -133,6 +138,7 @@ public function exportResources(array $resources): void Transfer::GROUP_BACKUPS => Transfer::GROUP_BACKUPS_RESOURCES, Transfer::GROUP_SETTINGS => Transfer::GROUP_SETTINGS_RESOURCES, Transfer::GROUP_DOMAINS => Transfer::GROUP_DOMAINS_RESOURCES, + Transfer::GROUP_TEMPLATES => Transfer::GROUP_TEMPLATES_RESOURCES, ]; foreach ($mapping as $group => $resources) { @@ -179,6 +185,9 @@ public function exportResources(array $resources): void case Transfer::GROUP_DOMAINS: $this->exportGroupDomains($this->getDomainsBatchSize(), $resources); break; + case Transfer::GROUP_TEMPLATES: + $this->exportGroupTemplates($this->getTemplatesBatchSize(), $resources); + break; } } } @@ -262,4 +271,12 @@ abstract protected function exportGroupSettings(int $batchSize, array $resources * @param array $resources Resources to export */ abstract protected function exportGroupDomains(int $batchSize, array $resources): void; + + /** + * Export Templates Group + * + * @param int $batchSize + * @param array $resources Resources to export + */ + abstract protected function exportGroupTemplates(int $batchSize, array $resources): void; } diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 8fb94819..cd7cd9ef 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -79,6 +79,7 @@ use Utopia\Migration\Resources\Sites\Deployment as SiteDeployment; use Utopia\Migration\Resources\Sites\EnvVar as SiteEnvVar; use Utopia\Migration\Resources\Sites\Site; +use Utopia\Migration\Resources\Templates\EmailTemplate; use Utopia\Migration\Resources\Storage\Bucket; use Utopia\Migration\Resources\Storage\File; use Utopia\Migration\Source; @@ -246,6 +247,9 @@ public static function getSupportedResources(): array // Domains Resource::TYPE_RULE, + + // Templates + Resource::TYPE_EMAIL_TEMPLATE, ]; } @@ -288,6 +292,7 @@ public function report(array $resources = [], array $resourceIds = []): array $this->reportBackups($resources, $report, $resourceIds); $this->reportSettings($resources, $report, $resourceIds); $this->reportDomains($resources, $report, $resourceIds); + $this->reportTemplates($resources, $report, $resourceIds); $report['version'] = $this->call( 'GET', @@ -1589,6 +1594,19 @@ private function reportDomains(array $resources, array &$report, array $resource } } + private function reportTemplates(array $resources, array &$report, array $resourceIds = []): void + { + if (\in_array(Resource::TYPE_EMAIL_TEMPLATE, $resources)) { + try { + // listEmailTemplates is paginated by limit/offset (not cursor); pass total: true + // so the report count is accurate even if the page is empty. + $report[Resource::TYPE_EMAIL_TEMPLATE] = $this->project->listEmailTemplates([Query::limit(1)], total: true)->total; + } catch (\Throwable) { + $report[Resource::TYPE_EMAIL_TEMPLATE] = 0; + } + } + } + private function reportSettings(array $resources, array &$report, array $resourceIds = []): void { if (\in_array(Resource::TYPE_PROJECT_VARIABLE, $resources)) { @@ -1885,6 +1903,71 @@ private function exportRules(int $batchSize): void } } + protected function exportGroupTemplates(int $batchSize, array $resources): void + { + if (\in_array(Resource::TYPE_EMAIL_TEMPLATE, $resources)) { + try { + $this->exportEmailTemplates($batchSize); + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_EMAIL_TEMPLATE, + Transfer::GROUP_TEMPLATES, + message: $e->getMessage(), + code: (int) $e->getCode() ?: Exception::CODE_INTERNAL, + previous: $e + )); + } + } + } + + /** + * @throws AppwriteException + */ + private function exportEmailTemplates(int $batchSize): void + { + // listEmailTemplates is offset-paginated (cursor not supported — the list is + // assembled from a project attribute map, not a collection). Walk the offset + // until a page comes back smaller than the requested limit. + $offset = 0; + + while (true) { + $response = $this->project->listEmailTemplates([ + Query::limit($batchSize), + Query::offset($offset), + ]); + + if (\count($response->templates) === 0) { + break; + } + + $templates = []; + + foreach ($response->templates as $template) { + $id = 'email.' . $template->templateId . '-' . $template->locale; + + $templates[] = new EmailTemplate( + $id, + $template->templateId, + $template->locale, + $template->subject, + $template->message, + $template->senderName, + $template->senderEmail, + $template->replyToEmail, + $template->replyToName, + ); + } + + $this->callback($templates); + + if (\count($response->templates) < $batchSize) { + break; + } + + $offset += $batchSize; + } + } + /** * @throws AppwriteException */ diff --git a/src/Migration/Sources/CSV.php b/src/Migration/Sources/CSV.php index 4d67d551..67cdd633 100644 --- a/src/Migration/Sources/CSV.php +++ b/src/Migration/Sources/CSV.php @@ -450,6 +450,11 @@ protected function exportGroupDomains(int $batchSize, array $resources): void throw new \Exception('Not Implemented'); } + protected function exportGroupTemplates(int $batchSize, array $resources): void + { + throw new \Exception('Not Implemented'); + } + /** * @param callable(resource $stream, string $delimiter): void $callback * @return void diff --git a/src/Migration/Sources/Firebase.php b/src/Migration/Sources/Firebase.php index b9211663..9e1b5d2e 100644 --- a/src/Migration/Sources/Firebase.php +++ b/src/Migration/Sources/Firebase.php @@ -837,4 +837,9 @@ protected function exportGroupDomains(int $batchSize, array $resources): void { throw new \Exception('Not implemented'); } + + protected function exportGroupTemplates(int $batchSize, array $resources): void + { + throw new \Exception('Not implemented'); + } } diff --git a/src/Migration/Sources/JSON.php b/src/Migration/Sources/JSON.php index 56464100..7f232274 100644 --- a/src/Migration/Sources/JSON.php +++ b/src/Migration/Sources/JSON.php @@ -224,6 +224,11 @@ protected function exportGroupDomains(int $batchSize, array $resources): void throw new \Exception('Not Implemented'); } + protected function exportGroupTemplates(int $batchSize, array $resources): void + { + throw new \Exception('Not Implemented'); + } + /** * @throws \Exception */ diff --git a/src/Migration/Sources/NHost.php b/src/Migration/Sources/NHost.php index a2fafc4d..859770e4 100644 --- a/src/Migration/Sources/NHost.php +++ b/src/Migration/Sources/NHost.php @@ -972,4 +972,9 @@ protected function exportGroupDomains(int $batchSize, array $resources): void { throw new \Exception('Not Implemented'); } + + protected function exportGroupTemplates(int $batchSize, array $resources): void + { + throw new \Exception('Not Implemented'); + } } diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 468a1a75..3d72a695 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -32,6 +32,8 @@ class Transfer public const GROUP_DOMAINS = 'domains'; + public const GROUP_TEMPLATES = 'templates'; + public const GROUP_AUTH_RESOURCES = [ Resource::TYPE_USER, Resource::TYPE_TEAM, @@ -107,6 +109,10 @@ class Transfer Resource::TYPE_SMTP, ]; + public const GROUP_TEMPLATES_RESOURCES = [ + Resource::TYPE_EMAIL_TEMPLATE, + ]; + public const GROUP_BACKUPS_RESOURCES = [ Resource::TYPE_BACKUP_POLICY, ]; @@ -162,6 +168,9 @@ class Transfer // Domains Resource::TYPE_RULE, + // Templates + Resource::TYPE_EMAIL_TEMPLATE, + // legacy Resource::TYPE_DOCUMENT, Resource::TYPE_ATTRIBUTE, @@ -448,6 +457,7 @@ public static function extractServices(array $services): array self::GROUP_MESSAGING => array_merge($resources, self::GROUP_MESSAGING_RESOURCES), self::GROUP_BACKUPS => array_merge($resources, self::GROUP_BACKUPS_RESOURCES), self::GROUP_DOMAINS => array_merge($resources, self::GROUP_DOMAINS_RESOURCES), + self::GROUP_TEMPLATES => array_merge($resources, self::GROUP_TEMPLATES_RESOURCES), default => throw new \Exception('No service group found'), }; } diff --git a/tests/Migration/Unit/Adapters/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index d3e515c9..d8ae3fc3 100644 --- a/tests/Migration/Unit/Adapters/MockSource.php +++ b/tests/Migration/Unit/Adapters/MockSource.php @@ -251,4 +251,15 @@ protected function exportGroupDomains(int $batchSize, array $resources): void $this->handleResourceTransfer(Transfer::GROUP_DOMAINS, $resource); } } + + protected function exportGroupTemplates(int $batchSize, array $resources): void + { + foreach (Transfer::GROUP_TEMPLATES_RESOURCES as $resource) { + if (!\in_array($resource, $resources)) { + continue; + } + + $this->handleResourceTransfer(Transfer::GROUP_TEMPLATES, $resource); + } + } } From edcb0aa3285b44616e570e5fe092aaac97deb686 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 21 May 2026 17:14:58 +0100 Subject: [PATCH 2/3] Rename EmailTemplate $message to $body to avoid collision with Resource::$message --- src/Migration/Destinations/Appwrite.php | 2 +- src/Migration/Resources/Templates/EmailTemplate.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 8c164d05..cd1d800d 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -3362,7 +3362,7 @@ protected function createEmailTemplate(EmailTemplate $resource): bool $existing = $templates[$key] ?? []; $existing['subject'] = $resource->getSubject(); - $existing['message'] = $resource->getMessage(); + $existing['message'] = $resource->getBody(); $existing['senderName'] = $resource->getSenderName(); $existing['senderEmail'] = $resource->getSenderEmail(); $existing['replyToEmail'] = $resource->getReplyToEmail(); diff --git a/src/Migration/Resources/Templates/EmailTemplate.php b/src/Migration/Resources/Templates/EmailTemplate.php index e5ec2e91..74fa1a8a 100644 --- a/src/Migration/Resources/Templates/EmailTemplate.php +++ b/src/Migration/Resources/Templates/EmailTemplate.php @@ -17,7 +17,7 @@ public function __construct( private readonly string $templateId, private readonly string $locale, private readonly string $subject, - private readonly string $message, + private readonly string $body, private readonly string $senderName = '', private readonly string $senderEmail = '', private readonly string $replyToEmail = '', @@ -60,7 +60,7 @@ public function jsonSerialize(): array 'templateId' => $this->templateId, 'locale' => $this->locale, 'subject' => $this->subject, - 'message' => $this->message, + 'message' => $this->body, 'senderName' => $this->senderName, 'senderEmail' => $this->senderEmail, 'replyToEmail' => $this->replyToEmail, @@ -95,9 +95,9 @@ public function getSubject(): string return $this->subject; } - public function getMessage(): string + public function getBody(): string { - return $this->message; + return $this->body; } public function getSenderName(): string From 0525142c6fcb6350a4e0aea57b935654cc7f7c86 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 1 Jun 2026 11:04:16 +0100 Subject: [PATCH 3/3] Trim email template comments to the critical why --- src/Migration/Destinations/Appwrite.php | 9 +++------ src/Migration/Sources/Appwrite.php | 8 +++----- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 83ed0128..bd899514 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -3316,12 +3316,9 @@ protected function createSMTP(SMTP $resource): bool } /** - * Direct DB write rather than the SDK's updateEmailTemplate — the SDK path - * rejects the call unless custom SMTP is enabled on the project. Templates - * may legitimately migrate before SMTP (different groups, different runs), - * so bypass that check by writing the project.templates map directly. - * Read-then-merge preserves any templates already configured on the - * destination (other locales, untouched template types). + * Direct DB write rather than the SDK's updateEmailTemplate, which rejects + * the call unless custom SMTP is enabled — templates may migrate before SMTP. + * Read-then-merge preserves templates already on the destination. */ protected function createEmailTemplate(EmailTemplate $resource): bool { diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 9a1c1fa9..24c182d2 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -1622,8 +1622,7 @@ private function reportProjects(array $resources, array &$report, array $resourc if (\in_array(Resource::TYPE_PROJECT_EMAIL_TEMPLATE, $resources)) { try { - // listEmailTemplates is paginated by limit/offset (not cursor); pass total: true - // so the report count is accurate even if the page is empty. + // total:true returns the real count without fetching every row. $report[Resource::TYPE_PROJECT_EMAIL_TEMPLATE] = $this->project->listEmailTemplates([Query::limit(1)], total: true)->total; } catch (\Throwable) { $report[Resource::TYPE_PROJECT_EMAIL_TEMPLATE] = 0; @@ -1853,9 +1852,8 @@ private function exportRules(int $batchSize): void */ private function exportEmailTemplates(int $batchSize): void { - // listEmailTemplates is offset-paginated (cursor not supported — the list is - // assembled from a project attribute map, not a collection). Walk the offset - // until a page comes back smaller than the requested limit. + // Offset pagination: templates come from a project attribute map, not a + // collection, so cursor pagination isn't available here. $offset = 0; while (true) {