strapi-plugin-magic-mail 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/COPYRIGHT_NOTICE.txt +13 -0
  2. package/LICENSE +22 -0
  3. package/README.md +1420 -0
  4. package/admin/jsconfig.json +10 -0
  5. package/admin/src/components/AddAccountModal.jsx +1943 -0
  6. package/admin/src/components/Initializer.jsx +14 -0
  7. package/admin/src/components/LicenseGuard.jsx +475 -0
  8. package/admin/src/components/PluginIcon.jsx +5 -0
  9. package/admin/src/hooks/useAuthRefresh.js +44 -0
  10. package/admin/src/hooks/useLicense.js +158 -0
  11. package/admin/src/index.js +86 -0
  12. package/admin/src/pages/Analytics.jsx +762 -0
  13. package/admin/src/pages/App.jsx +111 -0
  14. package/admin/src/pages/EmailDesigner/EditorPage.jsx +1405 -0
  15. package/admin/src/pages/EmailDesigner/TemplateList.jsx +1807 -0
  16. package/admin/src/pages/HomePage.jsx +1233 -0
  17. package/admin/src/pages/LicensePage.jsx +424 -0
  18. package/admin/src/pages/RoutingRules.jsx +1141 -0
  19. package/admin/src/pages/Settings.jsx +603 -0
  20. package/admin/src/pluginId.js +3 -0
  21. package/admin/src/translations/de.json +71 -0
  22. package/admin/src/translations/en.json +70 -0
  23. package/admin/src/translations/es.json +71 -0
  24. package/admin/src/translations/fr.json +71 -0
  25. package/admin/src/translations/pt.json +71 -0
  26. package/admin/src/utils/fetchWithRetry.js +123 -0
  27. package/admin/src/utils/getTranslation.js +5 -0
  28. package/dist/_chunks/App-B-Gp4Vbr.js +7568 -0
  29. package/dist/_chunks/App-BymMjoGM.mjs +7543 -0
  30. package/dist/_chunks/LicensePage-Bl02myMx.mjs +342 -0
  31. package/dist/_chunks/LicensePage-CJXwPnEe.js +344 -0
  32. package/dist/_chunks/Settings-C_TmKwcz.mjs +400 -0
  33. package/dist/_chunks/Settings-zuFQ3pnn.js +402 -0
  34. package/dist/_chunks/de-CN-G9j1S.js +64 -0
  35. package/dist/_chunks/de-DS04rP54.mjs +64 -0
  36. package/dist/_chunks/en-BDc7Jk8u.js +64 -0
  37. package/dist/_chunks/en-BEFQJXvR.mjs +64 -0
  38. package/dist/_chunks/es-BpV1MIdm.js +64 -0
  39. package/dist/_chunks/es-DQHwzPpP.mjs +64 -0
  40. package/dist/_chunks/fr-BG1WfEVm.mjs +64 -0
  41. package/dist/_chunks/fr-vpziIpRp.js +64 -0
  42. package/dist/_chunks/pt-CMoGrOib.mjs +64 -0
  43. package/dist/_chunks/pt-ODpAhDNa.js +64 -0
  44. package/dist/admin/index.js +89 -0
  45. package/dist/admin/index.mjs +90 -0
  46. package/dist/server/index.js +6214 -0
  47. package/dist/server/index.mjs +6208 -0
  48. package/package.json +113 -0
  49. package/server/jsconfig.json +10 -0
  50. package/server/src/bootstrap.js +153 -0
  51. package/server/src/config/features.js +260 -0
  52. package/server/src/config/index.js +6 -0
  53. package/server/src/content-types/email-account/schema.json +93 -0
  54. package/server/src/content-types/email-event/index.js +8 -0
  55. package/server/src/content-types/email-event/schema.json +57 -0
  56. package/server/src/content-types/email-link/index.js +8 -0
  57. package/server/src/content-types/email-link/schema.json +49 -0
  58. package/server/src/content-types/email-log/index.js +8 -0
  59. package/server/src/content-types/email-log/schema.json +106 -0
  60. package/server/src/content-types/email-template/schema.json +74 -0
  61. package/server/src/content-types/email-template-version/schema.json +60 -0
  62. package/server/src/content-types/index.js +33 -0
  63. package/server/src/content-types/routing-rule/schema.json +59 -0
  64. package/server/src/controllers/accounts.js +220 -0
  65. package/server/src/controllers/analytics.js +347 -0
  66. package/server/src/controllers/controller.js +26 -0
  67. package/server/src/controllers/email-designer.js +474 -0
  68. package/server/src/controllers/index.js +21 -0
  69. package/server/src/controllers/license.js +267 -0
  70. package/server/src/controllers/oauth.js +474 -0
  71. package/server/src/controllers/routing-rules.js +122 -0
  72. package/server/src/controllers/test.js +383 -0
  73. package/server/src/destroy.js +23 -0
  74. package/server/src/index.js +25 -0
  75. package/server/src/middlewares/index.js +3 -0
  76. package/server/src/policies/index.js +3 -0
  77. package/server/src/register.js +5 -0
  78. package/server/src/routes/admin.js +469 -0
  79. package/server/src/routes/content-api.js +37 -0
  80. package/server/src/routes/index.js +9 -0
  81. package/server/src/services/account-manager.js +277 -0
  82. package/server/src/services/analytics.js +496 -0
  83. package/server/src/services/email-designer.js +870 -0
  84. package/server/src/services/email-router.js +1420 -0
  85. package/server/src/services/index.js +17 -0
  86. package/server/src/services/license-guard.js +418 -0
  87. package/server/src/services/oauth.js +515 -0
  88. package/server/src/services/service.js +7 -0
  89. package/server/src/utils/encryption.js +81 -0
  90. package/strapi-admin.js +4 -0
  91. package/strapi-server.js +4 -0
@@ -0,0 +1,870 @@
1
+ /**
2
+ * Email Designer Service
3
+ *
4
+ * Handles email template creation, updates, versioning, and rendering
5
+ *
6
+ * CRITICAL: Uses ONLY entityService for all database operations
7
+ * This ensures proper relation handling in Strapi v5
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const Mustache = require('mustache');
13
+ const htmlToTextLib = require('html-to-text');
14
+ const decode = require('decode-html');
15
+
16
+ // ============================================================
17
+ // HELPER FUNCTIONS
18
+ // ============================================================
19
+
20
+ /**
21
+ * Safely convert HTML to plain text
22
+ * Handles various html-to-text library versions
23
+ */
24
+ const convertHtmlToText = (html, options = { wordwrap: 130 }) => {
25
+ try {
26
+ if (!html || typeof html !== 'string') {
27
+ return '';
28
+ }
29
+
30
+ if (!htmlToTextLib) {
31
+ return html.replace(/<[^>]*>/g, '');
32
+ }
33
+
34
+ // Try different API styles
35
+ if (htmlToTextLib.htmlToText && typeof htmlToTextLib.htmlToText === 'function') {
36
+ return htmlToTextLib.htmlToText(html, options);
37
+ } else if (htmlToTextLib.convert && typeof htmlToTextLib.convert === 'function') {
38
+ return htmlToTextLib.convert(html, options);
39
+ } else if (typeof htmlToTextLib === 'function') {
40
+ return htmlToTextLib(html, options);
41
+ } else if (htmlToTextLib.default) {
42
+ if (typeof htmlToTextLib.default.htmlToText === 'function') {
43
+ return htmlToTextLib.default.htmlToText(html, options);
44
+ } else if (typeof htmlToTextLib.default.convert === 'function') {
45
+ return htmlToTextLib.default.convert(html, options);
46
+ } else if (typeof htmlToTextLib.default === 'function') {
47
+ return htmlToTextLib.default(html, options);
48
+ }
49
+ }
50
+
51
+ // Fallback
52
+ return html.replace(/<[^>]*>/g, '');
53
+ } catch (error) {
54
+ strapi.log.error('[magic-mail] Error converting HTML to text:', error);
55
+ return (html || '').replace(/<[^>]*>/g, '');
56
+ }
57
+ };
58
+
59
+ // ============================================================
60
+ // SERVICE
61
+ // ============================================================
62
+
63
+ module.exports = ({ strapi }) => ({
64
+
65
+ // ============================================================
66
+ // TEMPLATE CRUD OPERATIONS
67
+ // ============================================================
68
+
69
+ /**
70
+ * Get all templates
71
+ */
72
+ async findAll(filters = {}) {
73
+ return strapi.entityService.findMany('plugin::magic-mail.email-template', {
74
+ filters,
75
+ sort: { createdAt: 'desc' },
76
+ });
77
+ },
78
+
79
+ /**
80
+ * Get template by ID with populated versions
81
+ */
82
+ async findOne(id) {
83
+ return strapi.entityService.findOne('plugin::magic-mail.email-template', id, {
84
+ populate: ['versions'],
85
+ });
86
+ },
87
+
88
+ /**
89
+ * Get template by reference ID
90
+ */
91
+ async findByReferenceId(templateReferenceId) {
92
+ const results = await strapi.entityService.findMany('plugin::magic-mail.email-template', {
93
+ filters: { templateReferenceId },
94
+ limit: 1,
95
+ });
96
+ return results.length > 0 ? results[0] : null;
97
+ },
98
+
99
+ /**
100
+ * Create new template with automatic initial version
101
+ *
102
+ * Steps:
103
+ * 1. Check license limits
104
+ * 2. Validate reference ID is unique
105
+ * 3. Create template
106
+ * 4. Create initial version (if versioning enabled)
107
+ */
108
+ async create(data) {
109
+ strapi.log.info('[magic-mail] 📝 Creating new template...');
110
+
111
+ // 1. Check license limits
112
+ const maxTemplates = await strapi
113
+ .plugin('magic-mail')
114
+ .service('license-guard')
115
+ .getMaxEmailTemplates();
116
+
117
+ const currentCount = await strapi.db
118
+ .query('plugin::magic-mail.email-template')
119
+ .count();
120
+
121
+ if (maxTemplates !== -1 && currentCount >= maxTemplates) {
122
+ throw new Error(
123
+ `Template limit reached (${maxTemplates}). Upgrade your license to create more templates.`
124
+ );
125
+ }
126
+
127
+ // 2. Validate reference ID is unique
128
+ if (data.templateReferenceId) {
129
+ const existing = await this.findByReferenceId(data.templateReferenceId);
130
+ if (existing) {
131
+ throw new Error(`Template with reference ID ${data.templateReferenceId} already exists`);
132
+ }
133
+ }
134
+
135
+ // 3. Create template
136
+ const template = await strapi.entityService.create('plugin::magic-mail.email-template', {
137
+ data: {
138
+ ...data,
139
+ isActive: data.isActive !== undefined ? data.isActive : true,
140
+ },
141
+ });
142
+
143
+ strapi.log.info(`[magic-mail] ✅ Template created: ID=${template.id}, name="${template.name}"`);
144
+
145
+ // 4. Create initial version if versioning enabled
146
+ const hasVersioning = await strapi
147
+ .plugin('magic-mail')
148
+ .service('license-guard')
149
+ .hasFeature('email-designer-versioning');
150
+
151
+ if (hasVersioning) {
152
+ strapi.log.info('[magic-mail] 💾 Creating initial version...');
153
+
154
+ await this.createVersion(template.id, {
155
+ name: data.name,
156
+ subject: data.subject,
157
+ design: data.design,
158
+ bodyHtml: data.bodyHtml,
159
+ bodyText: data.bodyText,
160
+ tags: data.tags,
161
+ });
162
+
163
+ strapi.log.info('[magic-mail] ✅ Initial version created');
164
+ } else {
165
+ strapi.log.info('[magic-mail] ⏭️ Versioning not enabled, skipping initial version');
166
+ }
167
+
168
+ return template;
169
+ },
170
+
171
+ /**
172
+ * Update template with automatic version snapshot
173
+ *
174
+ * Steps:
175
+ * 1. Load existing template
176
+ * 2. Create version snapshot of CURRENT state (before update)
177
+ * 3. Update template with new data
178
+ *
179
+ * IMPORTANT: Version is created BEFORE update to preserve old state!
180
+ */
181
+ async update(id, data) {
182
+ strapi.log.info(`[magic-mail] 🔄 Updating template ID: ${id}`);
183
+
184
+ // 1. Load existing template
185
+ const template = await this.findOne(id);
186
+ if (!template) {
187
+ throw new Error('Template not found');
188
+ }
189
+
190
+ strapi.log.info(`[magic-mail] 📋 Found template: ID=${template.id}, name="${template.name}"`);
191
+
192
+ // 2. Create version snapshot BEFORE update (if versioning enabled)
193
+ const hasVersioning = await strapi
194
+ .plugin('magic-mail')
195
+ .service('license-guard')
196
+ .hasFeature('email-designer-versioning');
197
+
198
+ if (hasVersioning) {
199
+ strapi.log.info('[magic-mail] 💾 Creating version snapshot before update...');
200
+
201
+ // Save CURRENT state as new version
202
+ await this.createVersion(template.id, {
203
+ name: template.name,
204
+ subject: template.subject,
205
+ design: template.design,
206
+ bodyHtml: template.bodyHtml,
207
+ bodyText: template.bodyText,
208
+ tags: template.tags,
209
+ });
210
+
211
+ strapi.log.info('[magic-mail] ✅ Version snapshot created');
212
+ } else {
213
+ strapi.log.info('[magic-mail] ⏭️ Versioning not enabled, skipping version snapshot');
214
+ }
215
+
216
+ // 3. Update template
217
+ // CRITICAL: Do NOT pass versions in data, it would overwrite the relation!
218
+ // Only update the fields that are explicitly provided
219
+ const updateData = { ...data };
220
+
221
+ // Remove any versions field if it exists (shouldn't, but be safe)
222
+ if ('versions' in updateData) {
223
+ delete updateData.versions;
224
+ strapi.log.warn('[magic-mail] ⚠️ Removed versions field from update data to prevent relation overwrite');
225
+ }
226
+
227
+ const updated = await strapi.entityService.update('plugin::magic-mail.email-template', id, {
228
+ data: updateData,
229
+ });
230
+
231
+ strapi.log.info(`[magic-mail] ✅ Template updated: ID=${updated.id}, versions preserved`);
232
+ return updated;
233
+ },
234
+
235
+ /**
236
+ * Delete template and all its versions
237
+ *
238
+ * Uses Document Service for version deletion to ensure cascading delete
239
+ */
240
+ async delete(id) {
241
+ strapi.log.info(`[magic-mail] 🗑️ Deleting template ID: ${id}`);
242
+
243
+ // Get template first
244
+ const template = await this.findOne(id);
245
+ if (!template) {
246
+ throw new Error('Template not found');
247
+ }
248
+
249
+ strapi.log.info(`[magic-mail] 🗑️ Template: ID=${template.id}, name="${template.name}"`);
250
+
251
+ // Delete all versions using Document Service (supports documentId filtering)
252
+ const allVersions = await strapi.documents('plugin::magic-mail.email-template-version').findMany({
253
+ filters: {
254
+ template: {
255
+ documentId: template.documentId,
256
+ },
257
+ },
258
+ });
259
+
260
+ strapi.log.info(`[magic-mail] 🗑️ Found ${allVersions.length} versions to delete`);
261
+
262
+ // Delete each version
263
+ for (const version of allVersions) {
264
+ try {
265
+ await strapi
266
+ .documents('plugin::magic-mail.email-template-version')
267
+ .delete({ documentId: version.documentId });
268
+
269
+ strapi.log.info(`[magic-mail] 🗑️ Deleted version #${version.versionNumber}`);
270
+ } catch (versionError) {
271
+ strapi.log.warn(`[magic-mail] ⚠️ Failed to delete version ${version.id}: ${versionError.message}`);
272
+ }
273
+ }
274
+
275
+ // Delete template
276
+ const result = await strapi.entityService.delete('plugin::magic-mail.email-template', id);
277
+
278
+ strapi.log.info(`[magic-mail] ✅ Template "${template.name}" and ${allVersions.length} versions deleted`);
279
+
280
+ return result;
281
+ },
282
+
283
+ /**
284
+ * Duplicate template
285
+ *
286
+ * Creates a copy of an existing template with " copy" suffix
287
+ * Does NOT copy versions, starts fresh
288
+ */
289
+ async duplicate(id) {
290
+ strapi.log.info(`[magic-mail] 📋 Duplicating template ID: ${id}`);
291
+
292
+ // Get original template
293
+ const original = await this.findOne(id);
294
+ if (!original) {
295
+ throw new Error('Template not found');
296
+ }
297
+
298
+ strapi.log.info(`[magic-mail] 📦 Original template: ID=${original.id}, name="${original.name}"`);
299
+
300
+ // Prepare duplicate data (remove system fields)
301
+ const duplicateData = {
302
+ name: `${original.name} copy`,
303
+ subject: original.subject,
304
+ design: original.design,
305
+ bodyHtml: original.bodyHtml,
306
+ bodyText: original.bodyText,
307
+ category: original.category,
308
+ tags: original.tags,
309
+ isActive: original.isActive,
310
+ // Generate new templateReferenceId (unique ID)
311
+ templateReferenceId: Date.now() + Math.floor(Math.random() * 1000),
312
+ };
313
+
314
+ // Create duplicate
315
+ const duplicated = await this.create(duplicateData);
316
+
317
+ strapi.log.info(`[magic-mail] ✅ Template duplicated: ID=${duplicated.id}, name="${duplicated.name}"`);
318
+
319
+ return duplicated;
320
+ },
321
+
322
+ // ============================================================
323
+ // VERSIONING OPERATIONS
324
+ // ============================================================
325
+
326
+ /**
327
+ * Create a new version for a template
328
+ *
329
+ * CRITICAL: This is THE ONLY function that creates versions!
330
+ *
331
+ * Steps:
332
+ * 1. Verify template exists
333
+ * 2. Calculate next version number
334
+ * 3. Create version WITH template relation
335
+ *
336
+ * @param {number} templateId - Numeric ID of template
337
+ * @param {object} data - Version data (name, subject, bodyHtml, etc)
338
+ * @returns {object} Created version
339
+ */
340
+ async createVersion(templateId, data) {
341
+ strapi.log.info(`[magic-mail] 📸 Creating version for template ID: ${templateId}`);
342
+
343
+ // 1. Verify template exists
344
+ const template = await strapi.entityService.findOne('plugin::magic-mail.email-template', templateId);
345
+ if (!template) {
346
+ throw new Error(`Template ${templateId} not found`);
347
+ }
348
+
349
+ strapi.log.info(`[magic-mail] 📦 Template found: ID=${template.id}, name="${template.name}"`);
350
+
351
+ // 2. Calculate next version number
352
+ const existingVersions = await strapi.entityService.findMany('plugin::magic-mail.email-template-version', {
353
+ filters: {
354
+ template: {
355
+ id: templateId, // Use numeric ID in filter
356
+ },
357
+ },
358
+ sort: { versionNumber: 'desc' },
359
+ });
360
+
361
+ const versionNumber = existingVersions.length > 0
362
+ ? Math.max(...existingVersions.map(v => v.versionNumber || 0)) + 1
363
+ : 1;
364
+
365
+ strapi.log.info(`[magic-mail] 📊 Existing versions: ${existingVersions.length} → Next version: #${versionNumber}`);
366
+
367
+ // 3. Create version WITH template relation
368
+ // In Strapi v5, we need to use connect for relations!
369
+ const createdVersion = await strapi.entityService.create('plugin::magic-mail.email-template-version', {
370
+ data: {
371
+ versionNumber,
372
+ ...data,
373
+ template: {
374
+ connect: [templateId], // ✅ Use connect array for Strapi v5!
375
+ },
376
+ },
377
+ });
378
+
379
+ strapi.log.info(`[magic-mail] 📝 Version created with connect: ID=${createdVersion.id}`);
380
+
381
+ // 4. Verify the relation was created by loading with populate
382
+ const verifiedVersion = await strapi.entityService.findOne(
383
+ 'plugin::magic-mail.email-template-version',
384
+ createdVersion.id,
385
+ {
386
+ populate: ['template'],
387
+ }
388
+ );
389
+
390
+ strapi.log.info(
391
+ `[magic-mail] ✅ Version created successfully:\n` +
392
+ ` - Version ID: ${createdVersion.id}\n` +
393
+ ` - Version #: ${versionNumber}\n` +
394
+ ` - Template ID: ${templateId}\n` +
395
+ ` - Has template relation: ${!!verifiedVersion.template}\n` +
396
+ ` - Template in DB: ${verifiedVersion.template?.id || 'NULL'}`
397
+ );
398
+
399
+ if (!verifiedVersion.template) {
400
+ strapi.log.error(
401
+ `[magic-mail] ❌ CRITICAL: Version ${createdVersion.id} was created but template relation was NOT set!\n` +
402
+ ` This is a Strapi v5 relation bug. Investigating...`
403
+ );
404
+ }
405
+
406
+ return verifiedVersion;
407
+ },
408
+
409
+ /**
410
+ * Get all versions for a template
411
+ */
412
+ async getVersions(templateId) {
413
+ strapi.log.info(`[magic-mail] 📜 Fetching versions for template ID: ${templateId}`);
414
+
415
+ // Verify template exists
416
+ const template = await strapi.entityService.findOne('plugin::magic-mail.email-template', templateId, {
417
+ populate: ['versions'],
418
+ });
419
+
420
+ if (!template) {
421
+ throw new Error('Template not found');
422
+ }
423
+
424
+ strapi.log.info(`[magic-mail] 📦 Template has ${template.versions?.length || 0} versions in relation`);
425
+
426
+ // OPTION 1: Return versions from template populate (most reliable!)
427
+ if (template.versions && template.versions.length > 0) {
428
+ // Sort by version number
429
+ const sortedVersions = [...template.versions].sort((a, b) => b.versionNumber - a.versionNumber);
430
+
431
+ strapi.log.info(`[magic-mail] ✅ Returning ${sortedVersions.length} versions from template populate`);
432
+ return sortedVersions;
433
+ }
434
+
435
+ // OPTION 2: Fallback - try finding with filter (shouldn't be needed)
436
+ strapi.log.warn(`[magic-mail] ⚠️ Template has no populated versions, trying filter...`);
437
+
438
+ const versions = await strapi.entityService.findMany('plugin::magic-mail.email-template-version', {
439
+ filters: {
440
+ template: {
441
+ id: templateId,
442
+ },
443
+ },
444
+ sort: { versionNumber: 'desc' },
445
+ populate: ['template'],
446
+ });
447
+
448
+ strapi.log.info(`[magic-mail] ✅ Found ${versions.length} versions via filter`);
449
+
450
+ return versions;
451
+ },
452
+
453
+ /**
454
+ * Restore template from a specific version
455
+ *
456
+ * Updates the template with data from the version
457
+ * This will create a NEW version snapshot (via update logic)
458
+ */
459
+ async restoreVersion(templateId, versionId) {
460
+ strapi.log.info(`[magic-mail] 🔄 Restoring template ${templateId} from version ${versionId}`);
461
+
462
+ // Get version
463
+ const version = await strapi.entityService.findOne('plugin::magic-mail.email-template-version', versionId, {
464
+ populate: ['template'],
465
+ });
466
+
467
+ if (!version) {
468
+ throw new Error('Version not found');
469
+ }
470
+
471
+ strapi.log.info(`[magic-mail] 📦 Version found: ID=${version.id}, versionNumber=${version.versionNumber}, has template: ${!!version.template}`);
472
+
473
+ // Verify version belongs to this template
474
+ // For new versions (with relation): check template.id
475
+ if (version.template?.id) {
476
+ if (version.template.id !== parseInt(templateId)) {
477
+ strapi.log.error(`[magic-mail] ❌ Version ${versionId} belongs to template ${version.template.id}, not ${templateId}`);
478
+ throw new Error('Version does not belong to this template');
479
+ }
480
+ strapi.log.info(`[magic-mail] ✅ Version has correct template relation`);
481
+ } else {
482
+ // For old versions (without relation): verify via template's versions array
483
+ strapi.log.warn(`[magic-mail] ⚠️ Version ${versionId} has no template relation, checking template's versions array...`);
484
+
485
+ const template = await strapi.entityService.findOne('plugin::magic-mail.email-template', templateId, {
486
+ populate: ['versions'],
487
+ });
488
+
489
+ const versionExists = template.versions && template.versions.some(v => v.id === parseInt(versionId));
490
+
491
+ if (!versionExists) {
492
+ strapi.log.error(`[magic-mail] ❌ Version ${versionId} not found in template ${templateId} versions array`);
493
+ throw new Error('Version does not belong to this template');
494
+ }
495
+
496
+ strapi.log.info(`[magic-mail] ✅ Version ${versionId} found in template's versions array (old version without relation)`);
497
+ }
498
+
499
+ // Update template with version data
500
+ // This will automatically create a snapshot version via update()
501
+ const restored = await this.update(templateId, {
502
+ name: version.name,
503
+ subject: version.subject,
504
+ design: version.design,
505
+ bodyHtml: version.bodyHtml,
506
+ bodyText: version.bodyText,
507
+ tags: version.tags,
508
+ });
509
+
510
+ strapi.log.info(`[magic-mail] ✅ Template restored from version #${version.versionNumber}`);
511
+
512
+ return restored;
513
+ },
514
+
515
+ /**
516
+ * Delete a single version
517
+ *
518
+ * @param {number} templateId - Template ID (for verification)
519
+ * @param {number} versionId - Version ID to delete
520
+ */
521
+ async deleteVersion(templateId, versionId) {
522
+ strapi.log.info(`[magic-mail] 🗑️ Deleting version ${versionId} from template ${templateId}`);
523
+
524
+ // Get version
525
+ const version = await strapi.entityService.findOne('plugin::magic-mail.email-template-version', versionId, {
526
+ populate: ['template'],
527
+ });
528
+
529
+ if (!version) {
530
+ throw new Error('Version not found');
531
+ }
532
+
533
+ // Verify version belongs to this template (same logic as restore)
534
+ if (version.template?.id) {
535
+ if (version.template.id !== parseInt(templateId)) {
536
+ strapi.log.error(`[magic-mail] ❌ Version ${versionId} belongs to template ${version.template.id}, not ${templateId}`);
537
+ throw new Error('Version does not belong to this template');
538
+ }
539
+ strapi.log.info(`[magic-mail] ✅ Version has correct template relation`);
540
+ } else {
541
+ // Check via template's versions array for old versions
542
+ strapi.log.warn(`[magic-mail] ⚠️ Version ${versionId} has no template relation, checking template's versions array...`);
543
+
544
+ const template = await strapi.entityService.findOne('plugin::magic-mail.email-template', templateId, {
545
+ populate: ['versions'],
546
+ });
547
+
548
+ const versionExists = template.versions && template.versions.some(v => v.id === parseInt(versionId));
549
+ if (!versionExists) {
550
+ strapi.log.error(`[magic-mail] ❌ Version ${versionId} not found in template ${templateId} versions array`);
551
+ throw new Error('Version does not belong to this template');
552
+ }
553
+
554
+ strapi.log.info(`[magic-mail] ✅ Version ${versionId} found in template's versions array`);
555
+ }
556
+
557
+ // Delete version
558
+ await strapi.entityService.delete('plugin::magic-mail.email-template-version', versionId);
559
+
560
+ strapi.log.info(`[magic-mail] ✅ Version ${versionId} (v${version.versionNumber}) deleted successfully`);
561
+
562
+ return { success: true, message: 'Version deleted' };
563
+ },
564
+
565
+ /**
566
+ * Delete all versions for a template
567
+ *
568
+ * @param {number} templateId - Template ID
569
+ */
570
+ async deleteAllVersions(templateId) {
571
+ strapi.log.info(`[magic-mail] 🗑️ Deleting all versions for template ${templateId}`);
572
+
573
+ // Get template with versions
574
+ const template = await strapi.entityService.findOne('plugin::magic-mail.email-template', templateId, {
575
+ populate: ['versions'],
576
+ });
577
+
578
+ if (!template) {
579
+ throw new Error('Template not found');
580
+ }
581
+
582
+ const versionCount = template.versions?.length || 0;
583
+ strapi.log.info(`[magic-mail] 📊 Found ${versionCount} versions to delete`);
584
+
585
+ if (versionCount === 0) {
586
+ return { success: true, message: 'No versions to delete', deletedCount: 0 };
587
+ }
588
+
589
+ // Delete each version
590
+ let deletedCount = 0;
591
+ const errors = [];
592
+
593
+ for (const version of template.versions) {
594
+ try {
595
+ await strapi.entityService.delete('plugin::magic-mail.email-template-version', version.id);
596
+ deletedCount++;
597
+ strapi.log.info(`[magic-mail] 🗑️ Deleted version #${version.versionNumber} (ID: ${version.id})`);
598
+ } catch (error) {
599
+ strapi.log.error(`[magic-mail] ❌ Failed to delete version ${version.id}: ${error.message}`);
600
+ errors.push({ versionId: version.id, error: error.message });
601
+ }
602
+ }
603
+
604
+ strapi.log.info(`[magic-mail] ✅ Deleted ${deletedCount}/${versionCount} versions`);
605
+
606
+ return {
607
+ success: true,
608
+ message: `Deleted ${deletedCount} of ${versionCount} versions`,
609
+ deletedCount,
610
+ failedCount: versionCount - deletedCount,
611
+ errors: errors.length > 0 ? errors : undefined
612
+ };
613
+ },
614
+
615
+ // ============================================================
616
+ // RENDERING
617
+ // ============================================================
618
+
619
+ /**
620
+ * Render template with dynamic data using Mustache
621
+ *
622
+ * @param {number} templateReferenceId - Template reference ID
623
+ * @param {object} data - Dynamic data for Mustache
624
+ * @returns {object} Rendered HTML, text, and subject
625
+ */
626
+ async renderTemplate(templateReferenceId, data = {}) {
627
+ const template = await this.findByReferenceId(templateReferenceId);
628
+
629
+ if (!template) {
630
+ throw new Error(`Template with reference ID ${templateReferenceId} not found`);
631
+ }
632
+
633
+ if (!template.isActive) {
634
+ throw new Error(`Template ${template.name} is inactive`);
635
+ }
636
+
637
+ let { bodyHtml = '', bodyText = '', subject = '' } = template;
638
+
639
+ // Convert <% %> to {{ }} for Mustache (backward compatibility)
640
+ bodyHtml = bodyHtml.replace(/<%/g, '{{').replace(/%>/g, '}}');
641
+ bodyText = bodyText.replace(/<%/g, '{{').replace(/%>/g, '}}');
642
+ subject = subject.replace(/<%/g, '{{').replace(/%>/g, '}}');
643
+
644
+ // Generate text version from HTML if not provided
645
+ if ((!bodyText || !bodyText.length) && bodyHtml && bodyHtml.length) {
646
+ bodyText = convertHtmlToText(bodyHtml, { wordwrap: 130 });
647
+ }
648
+
649
+ // Decode HTML entities
650
+ const decodedHtml = decode(bodyHtml);
651
+ const decodedText = decode(bodyText);
652
+ const decodedSubject = decode(subject);
653
+
654
+ // Render with Mustache
655
+ const renderedHtml = Mustache.render(decodedHtml, data);
656
+ const renderedText = Mustache.render(decodedText, data);
657
+ const renderedSubject = Mustache.render(decodedSubject, data);
658
+
659
+ return {
660
+ html: renderedHtml,
661
+ text: renderedText,
662
+ subject: renderedSubject,
663
+ templateName: template.name,
664
+ category: template.category,
665
+ };
666
+ },
667
+
668
+ // ============================================================
669
+ // IMPORT/EXPORT (Advanced+ License)
670
+ // ============================================================
671
+
672
+ /**
673
+ * Export templates as JSON
674
+ */
675
+ async exportTemplates(templateIds = []) {
676
+ strapi.log.info('[magic-mail] 📤 Exporting templates...');
677
+
678
+ let templates;
679
+ if (templateIds.length > 0) {
680
+ templates = await strapi.entityService.findMany('plugin::magic-mail.email-template', {
681
+ filters: { id: { $in: templateIds } },
682
+ });
683
+ } else {
684
+ templates = await this.findAll();
685
+ }
686
+
687
+ const exported = templates.map((template) => ({
688
+ templateReferenceId: template.templateReferenceId,
689
+ name: template.name,
690
+ subject: template.subject,
691
+ design: template.design,
692
+ bodyHtml: template.bodyHtml,
693
+ bodyText: template.bodyText,
694
+ category: template.category,
695
+ tags: template.tags,
696
+ }));
697
+
698
+ strapi.log.info(`[magic-mail] ✅ Exported ${exported.length} templates`);
699
+ return exported;
700
+ },
701
+
702
+ /**
703
+ * Import templates from JSON
704
+ */
705
+ async importTemplates(templates) {
706
+ strapi.log.info(`[magic-mail] 📥 Importing ${templates.length} templates...`);
707
+
708
+ const results = [];
709
+
710
+ for (const templateData of templates) {
711
+ try {
712
+ const existing = await this.findByReferenceId(templateData.templateReferenceId);
713
+
714
+ if (existing) {
715
+ // Update existing
716
+ const updated = await this.update(existing.id, templateData);
717
+ results.push({ success: true, action: 'updated', template: updated });
718
+ strapi.log.info(`[magic-mail] ✅ Updated template: ${templateData.name}`);
719
+ } else {
720
+ // Create new
721
+ const created = await this.create(templateData);
722
+ results.push({ success: true, action: 'created', template: created });
723
+ strapi.log.info(`[magic-mail] ✅ Created template: ${templateData.name}`);
724
+ }
725
+ } catch (error) {
726
+ results.push({
727
+ success: false,
728
+ action: 'failed',
729
+ error: error.message,
730
+ templateName: templateData.name,
731
+ });
732
+ strapi.log.error(`[magic-mail] ❌ Failed to import ${templateData.name}: ${error.message}`);
733
+ }
734
+ }
735
+
736
+ const successful = results.filter(r => r.success).length;
737
+ strapi.log.info(`[magic-mail] ✅ Import completed: ${successful}/${templates.length} templates`);
738
+
739
+ return results;
740
+ },
741
+
742
+ // ============================================================
743
+ // STATISTICS
744
+ // ============================================================
745
+
746
+ /**
747
+ * Get template statistics
748
+ */
749
+ async getStats() {
750
+ const allTemplates = await strapi.entityService.findMany('plugin::magic-mail.email-template', {
751
+ fields: ['isActive', 'category'],
752
+ });
753
+
754
+ const total = allTemplates.length;
755
+ const active = allTemplates.filter(t => t.isActive === true).length;
756
+
757
+ // Group by category
758
+ const categoryMap = allTemplates.reduce((acc, template) => {
759
+ const category = template.category || 'custom';
760
+ acc[category] = (acc[category] || 0) + 1;
761
+ return acc;
762
+ }, {});
763
+
764
+ const byCategory = Object.entries(categoryMap).map(([category, count]) => ({ category, count }));
765
+
766
+ const maxTemplates = await strapi
767
+ .plugin('magic-mail')
768
+ .service('license-guard')
769
+ .getMaxEmailTemplates();
770
+
771
+ return {
772
+ total,
773
+ active,
774
+ inactive: total - active,
775
+ byCategory,
776
+ maxTemplates,
777
+ remaining: maxTemplates === -1 ? -1 : Math.max(0, maxTemplates - total),
778
+ };
779
+ },
780
+
781
+ // ============================================================
782
+ // STRAPI CORE EMAIL TEMPLATES
783
+ // ============================================================
784
+
785
+ /**
786
+ * Get Strapi core email template (reset-password, email-confirmation)
787
+ *
788
+ * These are stored in users-permissions plugin store
789
+ */
790
+ async getCoreTemplate(coreEmailType) {
791
+ // Validate type
792
+ if (!['reset-password', 'email-confirmation'].includes(coreEmailType)) {
793
+ throw new Error('Invalid core email type');
794
+ }
795
+
796
+ const pluginStoreEmailKey =
797
+ coreEmailType === 'email-confirmation' ? 'email_confirmation' : 'reset_password';
798
+
799
+ const pluginStore = await strapi.store({
800
+ environment: '',
801
+ type: 'plugin',
802
+ name: 'users-permissions',
803
+ });
804
+
805
+ // Get email config
806
+ const emailConfig = await pluginStore.get({ key: 'email' });
807
+
808
+ let data = null;
809
+ if (emailConfig && emailConfig[pluginStoreEmailKey]) {
810
+ data = emailConfig[pluginStoreEmailKey];
811
+ }
812
+
813
+ // Convert <%= %> to {{ }} for Mustache
814
+ const messageConverted = data && data.options && data.options.message
815
+ ? data.options.message.replace(/<%|&#x3C;%/g, '{{').replace(/%>|%&#x3E;/g, '}}')
816
+ : '';
817
+
818
+ const subjectConverted = data && data.options && data.options.object
819
+ ? data.options.object.replace(/<%|&#x3C;%/g, '{{').replace(/%>|%&#x3E;/g, '}}')
820
+ : '';
821
+
822
+ return {
823
+ from: data?.options?.from || null,
824
+ message: messageConverted || '',
825
+ subject: subjectConverted || '',
826
+ bodyHtml: messageConverted || '',
827
+ bodyText: messageConverted ? convertHtmlToText(messageConverted, { wordwrap: 130 }) : '',
828
+ coreEmailType,
829
+ design: data?.design || null,
830
+ };
831
+ },
832
+
833
+ /**
834
+ * Update Strapi core email template
835
+ */
836
+ async updateCoreTemplate(coreEmailType, data) {
837
+ // Validate type
838
+ if (!['reset-password', 'email-confirmation'].includes(coreEmailType)) {
839
+ throw new Error('Invalid core email type');
840
+ }
841
+
842
+ const pluginStoreEmailKey =
843
+ coreEmailType === 'email-confirmation' ? 'email_confirmation' : 'reset_password';
844
+
845
+ const pluginStore = await strapi.store({
846
+ environment: '',
847
+ type: 'plugin',
848
+ name: 'users-permissions',
849
+ });
850
+
851
+ const emailsConfig = await pluginStore.get({ key: 'email' });
852
+
853
+ // Convert {{ }} back to <%= %> for Strapi
854
+ emailsConfig[pluginStoreEmailKey] = {
855
+ ...emailsConfig[pluginStoreEmailKey],
856
+ options: {
857
+ ...(emailsConfig[pluginStoreEmailKey] ? emailsConfig[pluginStoreEmailKey].options : {}),
858
+ message: data.message.replace(/{{/g, '<%').replace(/}}/g, '%>'),
859
+ object: data.subject.replace(/{{/g, '<%').replace(/}}/g, '%>'),
860
+ },
861
+ design: data.design,
862
+ };
863
+
864
+ await pluginStore.set({ key: 'email', value: emailsConfig });
865
+
866
+ strapi.log.info(`[magic-mail] ✅ Core email template updated: ${pluginStoreEmailKey}`);
867
+
868
+ return { message: 'Saved' };
869
+ },
870
+ });