diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 42050b61..bd899514 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -75,6 +75,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 @@ -305,6 +306,7 @@ public static function getSupportedResources(): array Resource::TYPE_PROJECT_PROTOCOLS, Resource::TYPE_PROJECT_LABELS, Resource::TYPE_PROJECT_SERVICES, + Resource::TYPE_PROJECT_EMAIL_TEMPLATE, // Backups Resource::TYPE_BACKUP_POLICY, @@ -3157,6 +3159,10 @@ public function importProjectsResource(Resource $resource): Resource /** @var ServicesResource $resource */ $this->createServices($resource); break; + case Resource::TYPE_PROJECT_EMAIL_TEMPLATE: + /** @var EmailTemplate $resource */ + $this->createEmailTemplate($resource); + break; } if ($resource->getStatus() !== Resource::STATUS_SKIPPED) { @@ -3309,6 +3315,39 @@ protected function createSMTP(SMTP $resource): bool return true; } + /** + * 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 + { + $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->getBody(); + $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 242e2e87..128278a8 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -86,6 +86,7 @@ abstract class Resource implements \JsonSerializable public const TYPE_PROJECT_PROTOCOLS = 'project-protocols'; public const TYPE_PROJECT_LABELS = 'project-labels'; public const TYPE_PROJECT_SERVICES = 'project-services'; + public const TYPE_PROJECT_EMAIL_TEMPLATE = 'project-email-template'; // Domains public const TYPE_RULE = 'rule'; @@ -138,6 +139,7 @@ abstract class Resource implements \JsonSerializable self::TYPE_PROJECT_PROTOCOLS, self::TYPE_PROJECT_LABELS, self::TYPE_PROJECT_SERVICES, + self::TYPE_PROJECT_EMAIL_TEMPLATE, self::TYPE_RULE, self::TYPE_PROVIDER, self::TYPE_TOPIC, diff --git a/src/Migration/Resources/Templates/EmailTemplate.php b/src/Migration/Resources/Templates/EmailTemplate.php new file mode 100644 index 00000000..4a1325c8 --- /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->body, + '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_PROJECT_EMAIL_TEMPLATE; + } + + public function getGroup(): string + { + return Transfer::GROUP_PROJECTS; + } + + public function getTemplateId(): string + { + return $this->templateId; + } + + public function getLocale(): string + { + return $this->locale; + } + + public function getSubject(): string + { + return $this->subject; + } + + public function getBody(): string + { + return $this->body; + } + + 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/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index c51230dd..24c182d2 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -80,6 +80,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\Source; use Utopia\Migration\Sources\Appwrite\Reader; use Utopia\Migration\Sources\Appwrite\Reader\API as APIReader; @@ -242,6 +243,7 @@ public static function getSupportedResources(): array Resource::TYPE_PROJECT_PROTOCOLS, Resource::TYPE_PROJECT_LABELS, Resource::TYPE_PROJECT_SERVICES, + Resource::TYPE_PROJECT_EMAIL_TEMPLATE, // Domains Resource::TYPE_RULE, @@ -1617,6 +1619,15 @@ private function reportProjects(array $resources, array &$report, array $resourc // Singleton — one services config per project. $report[Resource::TYPE_PROJECT_SERVICES] = 1; } + + if (\in_array(Resource::TYPE_PROJECT_EMAIL_TEMPLATE, $resources)) { + try { + // 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; + } + } } /** @@ -1681,6 +1692,19 @@ protected function exportGroupProjects(int $batchSize, array $resources): void )); } + if (\in_array(Resource::TYPE_PROJECT_EMAIL_TEMPLATE, $resources)) { + try { + $this->exportEmailTemplates($batchSize); + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_PROJECT_EMAIL_TEMPLATE, + Transfer::GROUP_PROJECTS, + message: $e->getMessage(), + code: (int) $e->getCode() ?: Exception::CODE_INTERNAL, + previous: $e + )); + } + } } private function exportServices(): void @@ -1823,6 +1847,53 @@ private function exportRules(int $batchSize): void } } + /** + * @throws AppwriteException + */ + private function exportEmailTemplates(int $batchSize): void + { + // Offset pagination: templates come from a project attribute map, not a + // collection, so cursor pagination isn't available here. + $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/Transfer.php b/src/Migration/Transfer.php index 8b3d1638..84750c06 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -105,6 +105,7 @@ class Transfer Resource::TYPE_PROJECT_PROTOCOLS, Resource::TYPE_PROJECT_LABELS, Resource::TYPE_PROJECT_SERVICES, + Resource::TYPE_PROJECT_EMAIL_TEMPLATE, ]; public const GROUP_BACKUPS_RESOURCES = [ @@ -158,6 +159,7 @@ class Transfer Resource::TYPE_PROJECT_PROTOCOLS, Resource::TYPE_PROJECT_LABELS, Resource::TYPE_PROJECT_SERVICES, + Resource::TYPE_PROJECT_EMAIL_TEMPLATE, // Domains Resource::TYPE_RULE,