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,496 @@
1
+ /**
2
+ * Analytics Service
3
+ * Handles email tracking, statistics, and user activity
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const crypto = require('crypto');
9
+
10
+ module.exports = ({ strapi }) => ({
11
+ /**
12
+ * Generate unique email ID for tracking
13
+ */
14
+ generateEmailId() {
15
+ return crypto.randomBytes(16).toString('hex');
16
+ },
17
+
18
+ /**
19
+ * Generate secure hash for recipient (for tracking URLs)
20
+ */
21
+ generateRecipientHash(emailId, recipient) {
22
+ return crypto
23
+ .createHash('sha256')
24
+ .update(`${emailId}-${recipient}-${process.env.APP_KEYS || 'secret'}`)
25
+ .digest('hex')
26
+ .substring(0, 16);
27
+ },
28
+
29
+ /**
30
+ * Create email log entry
31
+ */
32
+ async createEmailLog(data) {
33
+ const emailId = this.generateEmailId();
34
+
35
+ const logEntry = await strapi.db.query('plugin::magic-mail.email-log').create({
36
+ data: {
37
+ emailId,
38
+ user: data.userId || null,
39
+ recipient: data.to,
40
+ recipientName: data.recipientName || null,
41
+ subject: data.subject,
42
+ templateId: data.templateId || null,
43
+ templateName: data.templateName || null,
44
+ accountId: data.accountId || null,
45
+ accountName: data.accountName || null,
46
+ sentAt: new Date(),
47
+ metadata: data.metadata || {},
48
+ },
49
+ });
50
+
51
+ strapi.log.info(`[magic-mail] ✅ Email log created: ${emailId}`);
52
+ if (data.templateId) {
53
+ strapi.log.info(`[magic-mail] 📋 Template tracked: ${data.templateName || 'Unknown'} (ID: ${data.templateId})`);
54
+ }
55
+ return logEntry;
56
+ },
57
+
58
+ /**
59
+ * Record email open event
60
+ */
61
+ async recordOpen(emailId, recipientHash, req) {
62
+ try {
63
+ // Find email log
64
+ const emailLog = await strapi.db.query('plugin::magic-mail.email-log').findOne({
65
+ where: { emailId },
66
+ });
67
+
68
+ if (!emailLog) {
69
+ strapi.log.warn(`[magic-mail] Email log not found: ${emailId}`);
70
+ return null;
71
+ }
72
+
73
+ // Verify recipient hash
74
+ const validHash = this.generateRecipientHash(emailId, emailLog.recipient);
75
+ if (recipientHash !== validHash) {
76
+ strapi.log.warn(`[magic-mail] Invalid recipient hash for: ${emailId}`);
77
+ return null;
78
+ }
79
+
80
+ const now = new Date();
81
+
82
+ // Update email log counters
83
+ await strapi.db.query('plugin::magic-mail.email-log').update({
84
+ where: { id: emailLog.id },
85
+ data: {
86
+ openCount: emailLog.openCount + 1,
87
+ firstOpenedAt: emailLog.firstOpenedAt || now,
88
+ lastOpenedAt: now,
89
+ },
90
+ });
91
+
92
+ // Create event record
93
+ const event = await strapi.db.query('plugin::magic-mail.email-event').create({
94
+ data: {
95
+ emailLog: emailLog.id,
96
+ type: 'open',
97
+ timestamp: now,
98
+ ipAddress: req.ip || req.headers['x-forwarded-for'] || null,
99
+ userAgent: req.headers['user-agent'] || null,
100
+ location: this.parseLocation(req),
101
+ },
102
+ });
103
+
104
+ strapi.log.info(`[magic-mail] 📧 Email opened: ${emailId} (count: ${emailLog.openCount + 1})`);
105
+ return event;
106
+ } catch (error) {
107
+ strapi.log.error('[magic-mail] Error recording open:', error);
108
+ return null;
109
+ }
110
+ },
111
+
112
+ /**
113
+ * Record email click event
114
+ */
115
+ async recordClick(emailId, linkHash, recipientHash, targetUrl, req) {
116
+ try {
117
+ // Find email log
118
+ const emailLog = await strapi.db.query('plugin::magic-mail.email-log').findOne({
119
+ where: { emailId },
120
+ });
121
+
122
+ if (!emailLog) {
123
+ return null;
124
+ }
125
+
126
+ // Verify recipient hash
127
+ const validHash = this.generateRecipientHash(emailId, emailLog.recipient);
128
+ if (recipientHash !== validHash) {
129
+ return null;
130
+ }
131
+
132
+ const now = new Date();
133
+
134
+ // Update click count
135
+ await strapi.db.query('plugin::magic-mail.email-log').update({
136
+ where: { id: emailLog.id },
137
+ data: {
138
+ clickCount: emailLog.clickCount + 1,
139
+ },
140
+ });
141
+
142
+ // Create event record
143
+ const event = await strapi.db.query('plugin::magic-mail.email-event').create({
144
+ data: {
145
+ emailLog: emailLog.id,
146
+ type: 'click',
147
+ timestamp: now,
148
+ ipAddress: req.ip || req.headers['x-forwarded-for'] || null,
149
+ userAgent: req.headers['user-agent'] || null,
150
+ location: this.parseLocation(req),
151
+ linkUrl: targetUrl,
152
+ },
153
+ });
154
+
155
+ strapi.log.info(`[magic-mail] 🖱️ Link clicked: ${emailId} → ${targetUrl}`);
156
+ return event;
157
+ } catch (error) {
158
+ strapi.log.error('[magic-mail] Error recording click:', error);
159
+ return null;
160
+ }
161
+ },
162
+
163
+ /**
164
+ * Get analytics statistics
165
+ */
166
+ async getStats(filters = {}) {
167
+ const where = {};
168
+
169
+ if (filters.userId) {
170
+ where.user = filters.userId;
171
+ }
172
+ if (filters.templateId) {
173
+ where.templateId = filters.templateId;
174
+ }
175
+ if (filters.accountId) {
176
+ where.accountId = filters.accountId;
177
+ }
178
+ if (filters.dateFrom) {
179
+ where.sentAt = { $gte: new Date(filters.dateFrom) };
180
+ }
181
+ if (filters.dateTo) {
182
+ where.sentAt = { ...where.sentAt, $lte: new Date(filters.dateTo) };
183
+ }
184
+
185
+ const [totalSent, totalOpened, totalClicked, totalBounced] = await Promise.all([
186
+ strapi.db.query('plugin::magic-mail.email-log').count({ where }),
187
+ strapi.db.query('plugin::magic-mail.email-log').count({
188
+ where: { ...where, openCount: { $gt: 0 } },
189
+ }),
190
+ strapi.db.query('plugin::magic-mail.email-log').count({
191
+ where: { ...where, clickCount: { $gt: 0 } },
192
+ }),
193
+ strapi.db.query('plugin::magic-mail.email-log').count({
194
+ where: { ...where, bounced: true },
195
+ }),
196
+ ]);
197
+
198
+ const openRate = totalSent > 0 ? (totalOpened / totalSent) * 100 : 0;
199
+ const clickRate = totalOpened > 0 ? (totalClicked / totalOpened) * 100 : 0;
200
+ const bounceRate = totalSent > 0 ? (totalBounced / totalSent) * 100 : 0;
201
+
202
+ return {
203
+ totalSent,
204
+ totalOpened,
205
+ totalClicked,
206
+ totalBounced,
207
+ openRate: Math.round(openRate * 10) / 10,
208
+ clickRate: Math.round(clickRate * 10) / 10,
209
+ bounceRate: Math.round(bounceRate * 10) / 10,
210
+ };
211
+ },
212
+
213
+ /**
214
+ * Get email logs with pagination
215
+ */
216
+ async getEmailLogs(filters = {}, pagination = {}) {
217
+ const where = {};
218
+
219
+ if (filters.userId) {
220
+ where.user = filters.userId;
221
+ }
222
+ if (filters.templateId) {
223
+ where.templateId = filters.templateId;
224
+ }
225
+ if (filters.search) {
226
+ where.$or = [
227
+ { recipient: { $containsi: filters.search } },
228
+ { subject: { $containsi: filters.search } },
229
+ { recipientName: { $containsi: filters.search } },
230
+ ];
231
+ }
232
+
233
+ const page = pagination.page || 1;
234
+ const pageSize = pagination.pageSize || 25;
235
+
236
+ const [logs, total] = await Promise.all([
237
+ strapi.db.query('plugin::magic-mail.email-log').findMany({
238
+ where,
239
+ orderBy: { sentAt: 'desc' },
240
+ limit: pageSize,
241
+ offset: (page - 1) * pageSize,
242
+ populate: ['user'],
243
+ }),
244
+ strapi.db.query('plugin::magic-mail.email-log').count({ where }),
245
+ ]);
246
+
247
+ return {
248
+ data: logs,
249
+ pagination: {
250
+ page,
251
+ pageSize,
252
+ pageCount: Math.ceil(total / pageSize),
253
+ total,
254
+ },
255
+ };
256
+ },
257
+
258
+ /**
259
+ * Get email log details with events
260
+ */
261
+ async getEmailLogDetails(emailId) {
262
+ const emailLog = await strapi.db.query('plugin::magic-mail.email-log').findOne({
263
+ where: { emailId },
264
+ populate: ['user', 'events'],
265
+ });
266
+
267
+ return emailLog;
268
+ },
269
+
270
+ /**
271
+ * Get user email activity
272
+ */
273
+ async getUserActivity(userId) {
274
+ const emailLogs = await strapi.db.query('plugin::magic-mail.email-log').findMany({
275
+ where: { user: userId },
276
+ orderBy: { sentAt: 'desc' },
277
+ limit: 50,
278
+ });
279
+
280
+ const stats = await this.getStats({ userId });
281
+
282
+ return {
283
+ stats,
284
+ recentEmails: emailLogs,
285
+ };
286
+ },
287
+
288
+ /**
289
+ * Parse location from request (basic implementation)
290
+ */
291
+ parseLocation(req) {
292
+ // You can integrate with a GeoIP service here
293
+ return {
294
+ ip: req.ip || req.headers['x-forwarded-for'] || null,
295
+ // country: null,
296
+ // city: null,
297
+ };
298
+ },
299
+
300
+ /**
301
+ * Inject tracking pixel into HTML
302
+ */
303
+ injectTrackingPixel(html, emailId, recipientHash) {
304
+ // Use /api/ path for content-api routes (publicly accessible)
305
+ const baseUrl = strapi.config.get('server.url') || 'http://localhost:1337';
306
+
307
+ // Add random parameter to prevent email client caching
308
+ // This ensures each email open loads the pixel fresh
309
+ const randomToken = crypto.randomBytes(8).toString('hex');
310
+ const trackingUrl = `${baseUrl}/api/magic-mail/track/open/${emailId}/${recipientHash}?r=${randomToken}`;
311
+ const trackingPixel = `<img src="${trackingUrl}" width="1" height="1" style="display:none;" alt="" />`;
312
+
313
+ strapi.log.info(`[magic-mail] 📍 Tracking pixel URL: ${trackingUrl}`);
314
+
315
+ // Try to inject before </body>, otherwise append at the end
316
+ if (html.includes('</body>')) {
317
+ return html.replace('</body>', `${trackingPixel}</body>`);
318
+ }
319
+ return `${html}${trackingPixel}`;
320
+ },
321
+
322
+ /**
323
+ * Rewrite links for click tracking
324
+ */
325
+ async rewriteLinksForTracking(html, emailId, recipientHash) {
326
+ const baseUrl = strapi.config.get('server.url') || 'http://localhost:1337';
327
+
328
+ // Get the email log for storing link associations
329
+ const emailLog = await strapi.db.query('plugin::magic-mail.email-log').findOne({
330
+ where: { emailId },
331
+ });
332
+
333
+ if (!emailLog) {
334
+ strapi.log.error(`[magic-mail] Cannot rewrite links: Email log not found for ${emailId}`);
335
+ return html;
336
+ }
337
+
338
+ // More flexible regex to find links, including those with newlines/whitespace in attributes
339
+ const linkRegex = /<a\s+[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gis;
340
+
341
+ // Collect all link mappings to store
342
+ const linkMappings = [];
343
+ const replacements = [];
344
+
345
+ let linkCount = 0;
346
+ let match;
347
+
348
+ // First pass: collect all links and their replacements
349
+ while ((match = linkRegex.exec(html)) !== null) {
350
+ const fullMatch = match[0];
351
+ const originalUrl = match[1];
352
+
353
+ // Debug: Log what we found
354
+ strapi.log.debug(`[magic-mail] 🔍 Found link: ${originalUrl.substring(0, 100)}${originalUrl.length > 100 ? '...' : ''}`);
355
+
356
+ // Skip if already a tracking link or anchor
357
+ if (originalUrl.startsWith('#') || originalUrl.includes('/track/click/')) {
358
+ strapi.log.debug(`[magic-mail] ⏭️ Skipping (anchor or already tracked)`);
359
+ continue;
360
+ }
361
+
362
+ // Skip relative URLs without protocol (internal anchors, relative paths)
363
+ if (!originalUrl.match(/^https?:\/\//i) && !originalUrl.startsWith('/')) {
364
+ strapi.log.debug(`[magic-mail] ⏭️ Skipping relative URL: ${originalUrl}`);
365
+ continue;
366
+ }
367
+
368
+ // Create link hash - hash the full URL including any query params
369
+ const linkHash = crypto.createHash('md5').update(originalUrl).digest('hex').substring(0, 8);
370
+
371
+ // Store for database insert
372
+ linkMappings.push({
373
+ linkHash,
374
+ originalUrl,
375
+ });
376
+
377
+ // Create tracking URL WITHOUT the url query parameter (we'll look it up in the DB instead)
378
+ const trackingUrl = `${baseUrl}/api/magic-mail/track/click/${emailId}/${linkHash}/${recipientHash}`;
379
+
380
+ linkCount++;
381
+ strapi.log.info(`[magic-mail] 🔗 Link ${linkCount}: ${originalUrl} → ${trackingUrl}`);
382
+
383
+ // Store replacement info
384
+ replacements.push({
385
+ from: originalUrl,
386
+ to: trackingUrl,
387
+ });
388
+ }
389
+
390
+ // Store all link mappings in database
391
+ for (const mapping of linkMappings) {
392
+ try {
393
+ await this.storeLinkMapping(emailLog.id, mapping.linkHash, mapping.originalUrl);
394
+ } catch (err) {
395
+ strapi.log.error('[magic-mail] Error storing link mapping:', err);
396
+ }
397
+ }
398
+
399
+ // Apply all replacements
400
+ let result = html;
401
+ for (const replacement of replacements) {
402
+ result = result.replace(replacement.from, replacement.to);
403
+ }
404
+
405
+ if (linkCount > 0) {
406
+ strapi.log.info(`[magic-mail] ✅ Rewrote ${linkCount} links for click tracking`);
407
+ } else {
408
+ strapi.log.warn(`[magic-mail] ⚠️ No links found in email HTML for tracking!`);
409
+ }
410
+
411
+ return result;
412
+ },
413
+
414
+ /**
415
+ * Store link mapping in database
416
+ */
417
+ async storeLinkMapping(emailLogId, linkHash, originalUrl) {
418
+ try {
419
+ // Check if link already exists
420
+ const existing = await strapi.db.query('plugin::magic-mail.email-link').findOne({
421
+ where: {
422
+ emailLog: emailLogId,
423
+ linkHash,
424
+ },
425
+ });
426
+
427
+ if (existing) {
428
+ strapi.log.debug(`[magic-mail] Link mapping already exists for ${linkHash}`);
429
+ return existing;
430
+ }
431
+
432
+ // Create new link mapping
433
+ const linkMapping = await strapi.db.query('plugin::magic-mail.email-link').create({
434
+ data: {
435
+ emailLog: emailLogId,
436
+ linkHash,
437
+ originalUrl,
438
+ clickCount: 0,
439
+ },
440
+ });
441
+
442
+ strapi.log.debug(`[magic-mail] 💾 Stored link mapping: ${linkHash} → ${originalUrl}`);
443
+ return linkMapping;
444
+ } catch (error) {
445
+ strapi.log.error('[magic-mail] Error storing link mapping:', error);
446
+ throw error;
447
+ }
448
+ },
449
+
450
+ /**
451
+ * Get original URL from link hash
452
+ */
453
+ async getOriginalUrlFromHash(emailId, linkHash) {
454
+ try {
455
+ // Find the email log
456
+ const emailLog = await strapi.db.query('plugin::magic-mail.email-log').findOne({
457
+ where: { emailId },
458
+ });
459
+
460
+ if (!emailLog) {
461
+ strapi.log.warn(`[magic-mail] Email log not found: ${emailId}`);
462
+ return null;
463
+ }
464
+
465
+ // Find the link mapping
466
+ const linkMapping = await strapi.db.query('plugin::magic-mail.email-link').findOne({
467
+ where: {
468
+ emailLog: emailLog.id,
469
+ linkHash,
470
+ },
471
+ });
472
+
473
+ if (!linkMapping) {
474
+ strapi.log.warn(`[magic-mail] Link mapping not found: ${emailId}/${linkHash}`);
475
+ return null;
476
+ }
477
+
478
+ // Update click tracking on the link itself
479
+ const now = new Date();
480
+ await strapi.db.query('plugin::magic-mail.email-link').update({
481
+ where: { id: linkMapping.id },
482
+ data: {
483
+ clickCount: linkMapping.clickCount + 1,
484
+ firstClickedAt: linkMapping.firstClickedAt || now,
485
+ lastClickedAt: now,
486
+ },
487
+ });
488
+
489
+ return linkMapping.originalUrl;
490
+ } catch (error) {
491
+ strapi.log.error('[magic-mail] Error getting original URL:', error);
492
+ return null;
493
+ }
494
+ },
495
+ });
496
+