Skip to content

Commit 0395af6

Browse files
authored
Merge pull request #190 from utopia-php/add-email-templates-migration
Add Email Templates migration
2 parents 3e53ad3 + 1063458 commit 0395af6

7 files changed

Lines changed: 236 additions & 0 deletions

File tree

src/Migration/Destinations/Appwrite.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
use Utopia\Migration\Resources\Sites\Site;
7676
use Utopia\Migration\Resources\Storage\Bucket;
7777
use Utopia\Migration\Resources\Storage\File;
78+
use Utopia\Migration\Resources\Templates\EmailTemplate;
7879
use Utopia\Migration\Transfer;
7980

8081
class Appwrite extends Destination
@@ -305,6 +306,7 @@ public static function getSupportedResources(): array
305306
Resource::TYPE_PROJECT_PROTOCOLS,
306307
Resource::TYPE_PROJECT_LABELS,
307308
Resource::TYPE_PROJECT_SERVICES,
309+
Resource::TYPE_PROJECT_EMAIL_TEMPLATE,
308310

309311
// Backups
310312
Resource::TYPE_BACKUP_POLICY,
@@ -3157,6 +3159,10 @@ public function importProjectsResource(Resource $resource): Resource
31573159
/** @var ServicesResource $resource */
31583160
$this->createServices($resource);
31593161
break;
3162+
case Resource::TYPE_PROJECT_EMAIL_TEMPLATE:
3163+
/** @var EmailTemplate $resource */
3164+
$this->createEmailTemplate($resource);
3165+
break;
31603166
}
31613167

31623168
if ($resource->getStatus() !== Resource::STATUS_SKIPPED) {
@@ -3309,6 +3315,37 @@ protected function createSMTP(SMTP $resource): bool
33093315
return true;
33103316
}
33113317

3318+
/**
3319+
* Direct DB write rather than the SDK's updateEmailTemplate, which rejects
3320+
* the call unless custom SMTP is enabled — templates may migrate before SMTP.
3321+
* Read-then-merge preserves templates already on the destination.
3322+
*/
3323+
protected function createEmailTemplate(EmailTemplate $resource): bool
3324+
{
3325+
$project = $this->dbForPlatform->getDocument('projects', $this->projectId);
3326+
$templates = $project->getAttribute('templates', []);
3327+
3328+
$key = 'email.' . $resource->getTemplateId() . '-' . $resource->getLocale();
3329+
$existing = $templates[$key] ?? [];
3330+
3331+
$existing['subject'] = $resource->getSubject();
3332+
$existing['message'] = $resource->getBody();
3333+
$existing['senderName'] = $resource->getSenderName();
3334+
$existing['senderEmail'] = $resource->getSenderEmail();
3335+
$existing['replyToEmail'] = $resource->getReplyToEmail();
3336+
$existing['replyToName'] = $resource->getReplyToName();
3337+
3338+
$templates[$key] = $existing;
3339+
3340+
$this->dbForPlatform->getAuthorization()->skip(fn () => $this->dbForPlatform->updateDocument(
3341+
'projects',
3342+
$this->projectId,
3343+
new UtopiaDocument(['templates' => $templates]),
3344+
));
3345+
3346+
return true;
3347+
}
3348+
33123349
/**
33133350
* Auto-generated rules (default `.appwrite.network` domains for functions/sites)
33143351
* are recreated automatically on the destination when the parent Function/Site

src/Migration/Resource.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ abstract class Resource implements \JsonSerializable
8686
public const TYPE_PROJECT_PROTOCOLS = 'project-protocols';
8787
public const TYPE_PROJECT_LABELS = 'project-labels';
8888
public const TYPE_PROJECT_SERVICES = 'project-services';
89+
public const TYPE_PROJECT_EMAIL_TEMPLATE = 'project-email-template';
8990

9091
// Domains
9192
public const TYPE_RULE = 'rule';
@@ -138,6 +139,7 @@ abstract class Resource implements \JsonSerializable
138139
self::TYPE_PROJECT_PROTOCOLS,
139140
self::TYPE_PROJECT_LABELS,
140141
self::TYPE_PROJECT_SERVICES,
142+
self::TYPE_PROJECT_EMAIL_TEMPLATE,
141143
self::TYPE_RULE,
142144
self::TYPE_PROVIDER,
143145
self::TYPE_TOPIC,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
namespace Utopia\Migration\Resources\Templates;
4+
5+
use Utopia\Migration\Resource;
6+
use Utopia\Migration\Transfer;
7+
8+
/**
9+
* Custom email template — one row per (templateId, locale) pair. The resource
10+
* `id` follows the storage key format `email.{templateId}-{locale}` so
11+
* destination read-then-merge can address the slot directly.
12+
*/
13+
class EmailTemplate extends Resource
14+
{
15+
public function __construct(
16+
string $id,
17+
private readonly string $templateId,
18+
private readonly string $locale,
19+
private readonly string $subject,
20+
private readonly string $body,
21+
private readonly string $senderName = '',
22+
private readonly string $senderEmail = '',
23+
private readonly string $replyToEmail = '',
24+
private readonly string $replyToName = '',
25+
string $createdAt = '',
26+
string $updatedAt = '',
27+
) {
28+
$this->id = $id;
29+
$this->createdAt = $createdAt;
30+
$this->updatedAt = $updatedAt;
31+
}
32+
33+
/**
34+
* @param array<string, mixed> $array
35+
*/
36+
public static function fromArray(array $array): self
37+
{
38+
return new self(
39+
$array['id'],
40+
(string) $array['templateId'],
41+
(string) $array['locale'],
42+
(string) ($array['subject'] ?? ''),
43+
(string) ($array['message'] ?? ''),
44+
(string) ($array['senderName'] ?? ''),
45+
(string) ($array['senderEmail'] ?? ''),
46+
(string) ($array['replyToEmail'] ?? ''),
47+
(string) ($array['replyToName'] ?? ''),
48+
createdAt: $array['createdAt'] ?? '',
49+
updatedAt: $array['updatedAt'] ?? '',
50+
);
51+
}
52+
53+
/**
54+
* @return array<string, mixed>
55+
*/
56+
public function jsonSerialize(): array
57+
{
58+
return [
59+
'id' => $this->id,
60+
'templateId' => $this->templateId,
61+
'locale' => $this->locale,
62+
'subject' => $this->subject,
63+
'message' => $this->body,
64+
'senderName' => $this->senderName,
65+
'senderEmail' => $this->senderEmail,
66+
'replyToEmail' => $this->replyToEmail,
67+
'replyToName' => $this->replyToName,
68+
'createdAt' => $this->createdAt,
69+
'updatedAt' => $this->updatedAt,
70+
];
71+
}
72+
73+
public static function getName(): string
74+
{
75+
return Resource::TYPE_PROJECT_EMAIL_TEMPLATE;
76+
}
77+
78+
public function getGroup(): string
79+
{
80+
return Transfer::GROUP_PROJECTS;
81+
}
82+
83+
public function getTemplateId(): string
84+
{
85+
return $this->templateId;
86+
}
87+
88+
public function getLocale(): string
89+
{
90+
return $this->locale;
91+
}
92+
93+
public function getSubject(): string
94+
{
95+
return $this->subject;
96+
}
97+
98+
public function getBody(): string
99+
{
100+
return $this->body;
101+
}
102+
103+
public function getSenderName(): string
104+
{
105+
return $this->senderName;
106+
}
107+
108+
public function getSenderEmail(): string
109+
{
110+
return $this->senderEmail;
111+
}
112+
113+
public function getReplyToEmail(): string
114+
{
115+
return $this->replyToEmail;
116+
}
117+
118+
public function getReplyToName(): string
119+
{
120+
return $this->replyToName;
121+
}
122+
}

src/Migration/Sources/Appwrite.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
use Utopia\Migration\Resources\Sites\Site;
8181
use Utopia\Migration\Resources\Storage\Bucket;
8282
use Utopia\Migration\Resources\Storage\File;
83+
use Utopia\Migration\Resources\Templates\EmailTemplate;
8384
use Utopia\Migration\Source;
8485
use Utopia\Migration\Sources\Appwrite\Reader;
8586
use Utopia\Migration\Sources\Appwrite\Reader\API as APIReader;
@@ -242,6 +243,7 @@ public static function getSupportedResources(): array
242243
Resource::TYPE_PROJECT_PROTOCOLS,
243244
Resource::TYPE_PROJECT_LABELS,
244245
Resource::TYPE_PROJECT_SERVICES,
246+
Resource::TYPE_PROJECT_EMAIL_TEMPLATE,
245247

246248
// Domains
247249
Resource::TYPE_RULE,
@@ -1617,6 +1619,15 @@ private function reportProjects(array $resources, array &$report, array $resourc
16171619
// Singleton — one services config per project.
16181620
$report[Resource::TYPE_PROJECT_SERVICES] = 1;
16191621
}
1622+
1623+
if (\in_array(Resource::TYPE_PROJECT_EMAIL_TEMPLATE, $resources)) {
1624+
try {
1625+
// total:true returns the real count without fetching every row.
1626+
$report[Resource::TYPE_PROJECT_EMAIL_TEMPLATE] = $this->project->listEmailTemplates([Query::limit(1)], total: true)->total;
1627+
} catch (\Throwable) {
1628+
$report[Resource::TYPE_PROJECT_EMAIL_TEMPLATE] = 0;
1629+
}
1630+
}
16201631
}
16211632

16221633
/**
@@ -1681,6 +1692,19 @@ protected function exportGroupProjects(int $batchSize, array $resources): void
16811692
));
16821693
}
16831694

1695+
if (\in_array(Resource::TYPE_PROJECT_EMAIL_TEMPLATE, $resources)) {
1696+
try {
1697+
$this->exportEmailTemplates($batchSize);
1698+
} catch (\Throwable $e) {
1699+
$this->addError(new Exception(
1700+
Resource::TYPE_PROJECT_EMAIL_TEMPLATE,
1701+
Transfer::GROUP_PROJECTS,
1702+
message: $e->getMessage(),
1703+
code: (int) $e->getCode() ?: Exception::CODE_INTERNAL,
1704+
previous: $e
1705+
));
1706+
}
1707+
}
16841708
}
16851709

16861710
private function exportServices(): void
@@ -1823,6 +1847,53 @@ private function exportRules(int $batchSize): void
18231847
}
18241848
}
18251849

1850+
/**
1851+
* @throws AppwriteException
1852+
*/
1853+
private function exportEmailTemplates(int $batchSize): void
1854+
{
1855+
// Offset pagination: templates come from a project attribute map, not a
1856+
// collection, so cursor pagination isn't available here.
1857+
$offset = 0;
1858+
1859+
while (true) {
1860+
$response = $this->project->listEmailTemplates([
1861+
Query::limit($batchSize),
1862+
Query::offset($offset),
1863+
]);
1864+
1865+
if (\count($response->templates) === 0) {
1866+
break;
1867+
}
1868+
1869+
$templates = [];
1870+
1871+
foreach ($response->templates as $template) {
1872+
$id = 'email.' . $template->templateId . '-' . $template->locale;
1873+
1874+
$templates[] = new EmailTemplate(
1875+
$id,
1876+
$template->templateId,
1877+
$template->locale,
1878+
$template->subject,
1879+
$template->message,
1880+
$template->senderName,
1881+
$template->senderEmail,
1882+
$template->replyToEmail,
1883+
$template->replyToName,
1884+
);
1885+
}
1886+
1887+
$this->callback($templates);
1888+
1889+
if (\count($response->templates) < $batchSize) {
1890+
break;
1891+
}
1892+
1893+
$offset += $batchSize;
1894+
}
1895+
}
1896+
18261897
/**
18271898
* @throws AppwriteException
18281899
*/

src/Migration/Transfer.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ class Transfer
105105
Resource::TYPE_PROJECT_PROTOCOLS,
106106
Resource::TYPE_PROJECT_LABELS,
107107
Resource::TYPE_PROJECT_SERVICES,
108+
Resource::TYPE_PROJECT_EMAIL_TEMPLATE,
108109
];
109110

110111
public const GROUP_BACKUPS_RESOURCES = [
@@ -158,6 +159,7 @@ class Transfer
158159
Resource::TYPE_PROJECT_PROTOCOLS,
159160
Resource::TYPE_PROJECT_LABELS,
160161
Resource::TYPE_PROJECT_SERVICES,
162+
Resource::TYPE_PROJECT_EMAIL_TEMPLATE,
161163

162164
// Domains
163165
Resource::TYPE_RULE,

tests/Migration/Unit/Adapters/MockDestination.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ public static function getSupportedResources(): array
6161
Resource::TYPE_PROJECT_PROTOCOLS,
6262
Resource::TYPE_PROJECT_LABELS,
6363
Resource::TYPE_PROJECT_SERVICES,
64+
Resource::TYPE_PROJECT_EMAIL_TEMPLATE,
6465
Resource::TYPE_PROVIDER,
6566
Resource::TYPE_TOPIC,
6667
Resource::TYPE_SUBSCRIBER,

tests/Migration/Unit/Adapters/MockSource.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ public static function getSupportedResources(): array
9090
Resource::TYPE_PROJECT_PROTOCOLS,
9191
Resource::TYPE_PROJECT_LABELS,
9292
Resource::TYPE_PROJECT_SERVICES,
93+
Resource::TYPE_PROJECT_EMAIL_TEMPLATE,
9394
Resource::TYPE_PROVIDER,
9495
Resource::TYPE_TOPIC,
9596
Resource::TYPE_SUBSCRIBER,

0 commit comments

Comments
 (0)