Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/Migration/Destinations/Appwrite.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -3309,6 +3315,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->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
Expand Down
2 changes: 2 additions & 0 deletions src/Migration/Resource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
122 changes: 122 additions & 0 deletions src/Migration/Resources/Templates/EmailTemplate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

namespace Utopia\Migration\Resources\Templates;

use Utopia\Migration\Resource;
use Utopia\Migration\Transfer;

/**
* Custom email template — one row per (templateId, locale) pair. The resource
* `id` follows the storage key format `email.{templateId}-{locale}` so
* destination read-then-merge can address the slot directly.
*/
class EmailTemplate extends Resource
{
public function __construct(
string $id,
private readonly string $templateId,
private readonly string $locale,
private readonly string $subject,
private readonly string $body,
private readonly string $senderName = '',
private readonly string $senderEmail = '',
private readonly string $replyToEmail = '',
private readonly string $replyToName = '',
string $createdAt = '',
string $updatedAt = '',
) {
$this->id = $id;
$this->createdAt = $createdAt;
$this->updatedAt = $updatedAt;
}

/**
* @param array<string, mixed> $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<string, mixed>
*/
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;
}
}
73 changes: 73 additions & 0 deletions src/Migration/Sources/Appwrite.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1617,6 +1619,16 @@ 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 {
// 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_PROJECT_EMAIL_TEMPLATE] = $this->project->listEmailTemplates([Query::limit(1)], total: true)->total;
} catch (\Throwable) {
$report[Resource::TYPE_PROJECT_EMAIL_TEMPLATE] = 0;
}
}
}

/**
Expand Down Expand Up @@ -1681,6 +1693,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
Expand Down Expand Up @@ -1823,6 +1848,54 @@ private function exportRules(int $batchSize): void
}
}

/**
* @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,
);
Comment thread
premtsd-code marked this conversation as resolved.
}

$this->callback($templates);

if (\count($response->templates) < $batchSize) {
break;
}

$offset += $batchSize;
}
}

/**
* @throws AppwriteException
*/
Expand Down
2 changes: 2 additions & 0 deletions src/Migration/Transfer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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,
Expand Down
Loading