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,1420 @@
1
+ 'use strict';
2
+
3
+ const nodemailer = require('nodemailer');
4
+ const { decryptCredentials } = require('../utils/encryption');
5
+
6
+ /**
7
+ * Email Router Service
8
+ * Smart routing of emails to appropriate accounts
9
+ * Handles failover, rate limiting, and load balancing
10
+ */
11
+
12
+ module.exports = ({ strapi }) => ({
13
+ /**
14
+ * Send email with smart routing
15
+ * @param {Object} emailData - { to, from, subject, text, html, attachments, type, priority, templateId, templateData OR data }
16
+ * @returns {Promise<Object>} Send result
17
+ */
18
+ async send(emailData) {
19
+ let {
20
+ to,
21
+ from,
22
+ subject,
23
+ text,
24
+ html,
25
+ replyTo,
26
+ attachments = [], // Array of attachment objects
27
+ type = 'transactional', // transactional, marketing, notification
28
+ priority = 'normal', // high, normal, low
29
+ accountName = null, // Force specific account
30
+ templateId = null, // Template Reference ID
31
+ templateData, // Data for template rendering
32
+ data, // Alias for templateData (for native Strapi compatibility)
33
+ } = emailData;
34
+
35
+ // Support both 'data' and 'templateData' for backward compatibility
36
+ if (!templateData && data) {
37
+ templateData = data;
38
+ }
39
+
40
+ // NEW: If templateId/templateReferenceId provided, render template
41
+ let renderedTemplate = null;
42
+ if (templateId || emailData.templateReferenceId) {
43
+ try {
44
+ let resolvedTemplateReferenceId = null;
45
+ let templateRecord = null;
46
+
47
+ if (emailData.templateReferenceId) {
48
+ resolvedTemplateReferenceId = String(emailData.templateReferenceId).trim();
49
+ strapi.log.info(`[magic-mail] 🧩 Using provided templateReferenceId="${resolvedTemplateReferenceId}"`);
50
+ }
51
+
52
+ if (!resolvedTemplateReferenceId && templateId) {
53
+ const numericTemplateId = Number(templateId);
54
+
55
+ if (!Number.isNaN(numericTemplateId) && Number.isInteger(numericTemplateId)) {
56
+ strapi.log.info(`[magic-mail] 🔍 Looking up template by ID: ${numericTemplateId}`);
57
+ templateRecord = await strapi
58
+ .plugin('magic-mail')
59
+ .service('email-designer')
60
+ .findOne(numericTemplateId);
61
+
62
+ if (!templateRecord) {
63
+ strapi.log.error(`[magic-mail] ❌ Template with ID ${numericTemplateId} not found in database`);
64
+ throw new Error(`Template with ID ${numericTemplateId} not found`);
65
+ }
66
+
67
+ if (!templateRecord.templateReferenceId) {
68
+ throw new Error(`Template ${numericTemplateId} has no reference ID set`);
69
+ }
70
+
71
+ resolvedTemplateReferenceId = String(templateRecord.templateReferenceId).trim();
72
+ strapi.log.info(
73
+ `[magic-mail] ✅ Found template: ID=${templateRecord.id}, referenceId="${resolvedTemplateReferenceId}", name="${templateRecord.name}"`
74
+ );
75
+ } else {
76
+ // templateId was provided but not numeric; treat it directly as reference ID
77
+ resolvedTemplateReferenceId = String(templateId).trim();
78
+ strapi.log.info(`[magic-mail] 🧩 Treating templateId value as referenceId="${resolvedTemplateReferenceId}"`);
79
+ }
80
+ }
81
+
82
+ if (!resolvedTemplateReferenceId) {
83
+ throw new Error('No template reference ID could be resolved');
84
+ }
85
+
86
+ // Now render using the templateReferenceId
87
+ renderedTemplate = await strapi
88
+ .plugin('magic-mail')
89
+ .service('email-designer')
90
+ .renderTemplate(resolvedTemplateReferenceId, templateData || {});
91
+
92
+ // Override with rendered content
93
+ html = renderedTemplate.html;
94
+ text = renderedTemplate.text;
95
+ subject = subject || renderedTemplate.subject; // Use provided subject or template subject
96
+ type = type || renderedTemplate.category; // Use template category if not specified
97
+
98
+ strapi.log.info(
99
+ `[magic-mail] 📧 Rendered template reference "${resolvedTemplateReferenceId}" (requested ID: ${templateId ?? 'n/a'}): ${renderedTemplate.templateName}`
100
+ );
101
+
102
+ // Ensure templateId/templateName are populated for logging/analytics
103
+ emailData.templateReferenceId = resolvedTemplateReferenceId;
104
+ if (!emailData.templateName) {
105
+ emailData.templateName = templateRecord?.name || renderedTemplate.templateName;
106
+ }
107
+ } catch (error) {
108
+ strapi.log.error(`[magic-mail] ❌ Template rendering failed: ${error.message}`);
109
+ throw new Error(`Template rendering failed: ${error.message}`);
110
+ }
111
+ }
112
+
113
+ // NEW: Email Tracking - Create log & inject tracking
114
+ let emailLog = null;
115
+ let recipientHash = null;
116
+ const enableTracking = emailData.enableTracking !== false; // Enabled by default
117
+
118
+ if (enableTracking && html) {
119
+ try {
120
+ const analyticsService = strapi.plugin('magic-mail').service('analytics');
121
+
122
+ // Create email log entry
123
+ emailLog = await analyticsService.createEmailLog({
124
+ to,
125
+ userId: emailData.userId || null,
126
+ recipientName: emailData.recipientName || null,
127
+ subject,
128
+ // Use provided templateId/Name OR from renderedTemplate (if template was rendered here)
129
+ templateId: emailData.templateId || renderedTemplate?.templateReferenceId || null,
130
+ templateName: emailData.templateName || renderedTemplate?.templateName || null,
131
+ accountId: null, // Will be set after account selection
132
+ accountName: null,
133
+ metadata: {
134
+ type,
135
+ priority,
136
+ hasAttachments: attachments.length > 0,
137
+ },
138
+ });
139
+
140
+ recipientHash = analyticsService.generateRecipientHash(emailLog.emailId, to);
141
+
142
+ // Inject tracking pixel
143
+ html = analyticsService.injectTrackingPixel(html, emailLog.emailId, recipientHash);
144
+
145
+ // Rewrite links for click tracking (now async)
146
+ html = await analyticsService.rewriteLinksForTracking(html, emailLog.emailId, recipientHash);
147
+
148
+ strapi.log.info(`[magic-mail] 📊 Tracking enabled for email: ${emailLog.emailId}`);
149
+ } catch (error) {
150
+ strapi.log.error(`[magic-mail] ⚠️ Tracking setup failed (continuing without tracking):`, error.message);
151
+ // Continue sending email even if tracking fails
152
+ }
153
+ }
154
+
155
+ // Update email data with tracked HTML
156
+ emailData.html = html;
157
+ emailData.text = text;
158
+ emailData.subject = subject;
159
+
160
+ try {
161
+ // License check for premium features
162
+ const licenseGuard = strapi.plugin('magic-mail').service('license-guard');
163
+
164
+ // Check if priority headers are allowed (Advanced+)
165
+ if (priority === 'high') {
166
+ const hasFeature = await licenseGuard.hasFeature('priority-headers');
167
+ if (!hasFeature) {
168
+ strapi.log.warn('[magic-mail] ⚠️ High priority emails require Advanced license - using normal priority');
169
+ emailData.priority = 'normal';
170
+ }
171
+ }
172
+
173
+ // Get account to use
174
+ const account = accountName
175
+ ? await this.getAccountByName(accountName)
176
+ : await this.selectAccount(type, priority, [], emailData);
177
+
178
+ if (!account) {
179
+ throw new Error('No email account available');
180
+ }
181
+
182
+ // Check if account's provider is allowed by license
183
+ const providerAllowed = await licenseGuard.isProviderAllowed(account.provider);
184
+ if (!providerAllowed) {
185
+ throw new Error(`Provider "${account.provider}" requires a higher license tier. Please upgrade or use a different account.`);
186
+ }
187
+
188
+ // Check rate limits
189
+ const canSend = await this.checkRateLimits(account);
190
+ if (!canSend) {
191
+ // Try failover
192
+ const fallbackAccount = await this.selectAccount(type, priority, [account.id], emailData);
193
+ if (fallbackAccount) {
194
+ strapi.log.info(`[magic-mail] Rate limit hit on ${account.name}, using fallback: ${fallbackAccount.name}`);
195
+ return await this.sendViaAccount(fallbackAccount, emailData);
196
+ }
197
+ throw new Error(`Rate limit exceeded on ${account.name} and no fallback available`);
198
+ }
199
+
200
+ // Send via selected account
201
+ const result = await this.sendViaAccount(account, emailData);
202
+
203
+ // Update email log with account info (if tracking enabled)
204
+ if (emailLog) {
205
+ try {
206
+ await strapi.db.query('plugin::magic-mail.email-log').update({
207
+ where: { id: emailLog.id },
208
+ data: {
209
+ accountId: account.id,
210
+ accountName: account.name,
211
+ deliveredAt: new Date(),
212
+ },
213
+ });
214
+ } catch (error) {
215
+ strapi.log.error('[magic-mail] Failed to update email log:', error.message);
216
+ }
217
+ }
218
+
219
+ // Update stats
220
+ await this.updateAccountStats(account.id);
221
+
222
+ strapi.log.info(`[magic-mail] ✅ Email sent to ${to} via ${account.name}`);
223
+
224
+ return {
225
+ success: true,
226
+ accountUsed: account.name,
227
+ messageId: result.messageId,
228
+ };
229
+ } catch (error) {
230
+ strapi.log.error('[magic-mail] ❌ Email send failed:', error);
231
+
232
+ throw error;
233
+ }
234
+ },
235
+
236
+ /**
237
+ * Select best account based on rules
238
+ */
239
+ async selectAccount(type, priority, excludeIds = [], emailData = {}) {
240
+ // Get all active accounts
241
+ const accounts = await strapi.entityService.findMany('plugin::magic-mail.email-account', {
242
+ filters: {
243
+ isActive: true,
244
+ id: { $notIn: excludeIds },
245
+ },
246
+ sort: { priority: 'desc' },
247
+ });
248
+
249
+ if (!accounts || accounts.length === 0) {
250
+ return null;
251
+ }
252
+
253
+ // Get all active routing rules
254
+ const allRules = await strapi.entityService.findMany('plugin::magic-mail.routing-rule', {
255
+ filters: {
256
+ isActive: true,
257
+ },
258
+ sort: { priority: 'desc' },
259
+ });
260
+
261
+ // Check routing rules with different match types
262
+ for (const rule of allRules) {
263
+ let matches = false;
264
+
265
+ switch (rule.matchType) {
266
+ case 'emailType':
267
+ matches = rule.matchValue === type;
268
+ break;
269
+
270
+ case 'recipient':
271
+ // Match if recipient email contains the match value
272
+ matches = emailData.to && emailData.to.toLowerCase().includes(rule.matchValue.toLowerCase());
273
+ break;
274
+
275
+ case 'subject':
276
+ // Match if subject contains the match value
277
+ matches = emailData.subject && emailData.subject.toLowerCase().includes(rule.matchValue.toLowerCase());
278
+ break;
279
+
280
+ case 'template':
281
+ // Match if template name equals match value
282
+ matches = emailData.template && emailData.template === rule.matchValue;
283
+ break;
284
+
285
+ case 'custom':
286
+ // Custom matching - evaluate matchValue as a condition
287
+ // For now, just exact match on a custom field
288
+ matches = emailData.customField && emailData.customField === rule.matchValue;
289
+ break;
290
+ }
291
+
292
+ if (matches) {
293
+ const account = accounts.find(a => a.name === rule.accountName);
294
+ if (account) {
295
+ strapi.log.info(`[magic-mail] 🎯 Routing rule matched: ${rule.name} → ${account.name}`);
296
+ return account;
297
+ }
298
+ // If primary account not found, try fallback
299
+ if (rule.fallbackAccountName) {
300
+ const fallbackAccount = accounts.find(a => a.name === rule.fallbackAccountName);
301
+ if (fallbackAccount) {
302
+ strapi.log.info(`[magic-mail] 🔄 Using fallback account: ${fallbackAccount.name}`);
303
+ return fallbackAccount;
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ // Fallback: Use primary or first active account
310
+ const primaryAccount = accounts.find(a => a.isPrimary);
311
+ return primaryAccount || accounts[0];
312
+ },
313
+
314
+ /**
315
+ * Send email via specific account
316
+ */
317
+ async sendViaAccount(account, emailData) {
318
+ const { to, subject, text, html, replyTo, attachments } = emailData;
319
+
320
+ if (account.provider === 'gmail-oauth') {
321
+ return await this.sendViaGmailOAuth(account, emailData);
322
+ } else if (account.provider === 'microsoft-oauth') {
323
+ return await this.sendViaMicrosoftOAuth(account, emailData);
324
+ } else if (account.provider === 'yahoo-oauth') {
325
+ return await this.sendViaYahooOAuth(account, emailData);
326
+ } else if (account.provider === 'nodemailer' || account.provider === 'smtp') {
327
+ return await this.sendViaSMTP(account, emailData);
328
+ } else if (account.provider === 'sendgrid') {
329
+ return await this.sendViaSendGrid(account, emailData);
330
+ } else if (account.provider === 'mailgun') {
331
+ return await this.sendViaMailgun(account, emailData);
332
+ }
333
+
334
+ throw new Error(`Unsupported provider: ${account.provider}`);
335
+ },
336
+
337
+ /**
338
+ * Send via SMTP (Nodemailer)
339
+ * With enhanced security: DKIM, proper headers, TLS enforcement
340
+ */
341
+ async sendViaSMTP(account, emailData) {
342
+ const config = decryptCredentials(account.config);
343
+
344
+ // Enhanced SMTP configuration with security features
345
+ const transportConfig = {
346
+ host: config.host,
347
+ port: config.port || 587,
348
+ secure: config.secure || false,
349
+ auth: {
350
+ user: config.user,
351
+ pass: config.pass,
352
+ },
353
+ // Security enhancements
354
+ requireTLS: true, // Enforce TLS encryption
355
+ tls: {
356
+ rejectUnauthorized: true, // Verify server certificates
357
+ minVersion: 'TLSv1.2', // Minimum TLS 1.2
358
+ },
359
+ };
360
+
361
+ // Add DKIM signing if configured
362
+ if (config.dkim) {
363
+ transportConfig.dkim = {
364
+ domainName: config.dkim.domainName,
365
+ keySelector: config.dkim.keySelector,
366
+ privateKey: config.dkim.privateKey,
367
+ };
368
+ strapi.log.info('[magic-mail] DKIM signing enabled');
369
+ }
370
+
371
+ const transporter = nodemailer.createTransport(transportConfig);
372
+
373
+ // Build mail options with comprehensive security headers
374
+ const mailOptions = {
375
+ from: emailData.from || `${account.fromName || 'MagicMail'} <${account.fromEmail}>`,
376
+ to: emailData.to,
377
+ replyTo: emailData.replyTo || account.replyTo,
378
+ subject: emailData.subject,
379
+ text: emailData.text,
380
+ html: emailData.html,
381
+ attachments: emailData.attachments || [],
382
+
383
+ // RFC 5322 required headers
384
+ date: new Date(),
385
+ messageId: `<${Date.now()}.${Math.random().toString(36).substring(7)}@${account.fromEmail.split('@')[1]}>`,
386
+
387
+ // Security and deliverability headers (2025 standards)
388
+ headers: {
389
+ // Client identification (RFC 5321)
390
+ 'X-Mailer': 'MagicMail/1.0',
391
+
392
+ // Priority headers (RFC 2156)
393
+ 'X-Priority': emailData.priority === 'high' ? '1 (Highest)' : '3 (Normal)',
394
+ 'Importance': emailData.priority === 'high' ? 'high' : 'normal',
395
+
396
+ // Email type classification
397
+ 'X-Email-Type': emailData.type || 'transactional',
398
+
399
+ // Auto-submitted header (RFC 3834) - prevents auto-responders from replying
400
+ 'Auto-Submitted': emailData.type === 'notification' ? 'auto-generated' : 'no',
401
+
402
+ // Content security (prevents MIME sniffing attacks)
403
+ 'X-Content-Type-Options': 'nosniff',
404
+
405
+ // Tracking and reference
406
+ 'X-Entity-Ref-ID': `magicmail-${Date.now()}`,
407
+
408
+ // Sender policy (helps with SPF validation)
409
+ 'Sender': account.fromEmail,
410
+
411
+ // Content transfer encoding recommendation
412
+ 'Content-Transfer-Encoding': '8bit',
413
+ },
414
+
415
+ // Encoding (UTF-8 for international characters)
416
+ encoding: 'utf-8',
417
+
418
+ // Text encoding for proper character handling
419
+ textEncoding: 'base64',
420
+ };
421
+
422
+ // Add List-Unsubscribe header for marketing emails (RFC 8058 - GDPR/CAN-SPAM)
423
+ if (emailData.type === 'marketing') {
424
+ if (emailData.unsubscribeUrl) {
425
+ mailOptions.headers['List-Unsubscribe'] = `<${emailData.unsubscribeUrl}>`;
426
+ mailOptions.headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
427
+ mailOptions.headers['Precedence'] = 'bulk'; // Mark as bulk mail
428
+ } else {
429
+ strapi.log.warn('[magic-mail] ⚠️ Marketing email without unsubscribe URL - may violate GDPR/CAN-SPAM');
430
+ }
431
+ }
432
+
433
+ // Add custom headers if provided
434
+ if (emailData.headers && typeof emailData.headers === 'object') {
435
+ Object.assign(mailOptions.headers, emailData.headers);
436
+ }
437
+
438
+ if (mailOptions.attachments.length > 0) {
439
+ strapi.log.info(`[magic-mail] Sending email with ${mailOptions.attachments.length} attachment(s)`);
440
+ }
441
+
442
+ return await transporter.sendMail(mailOptions);
443
+ },
444
+
445
+ /**
446
+ * Send via Gmail OAuth
447
+ * With enhanced security headers and proper formatting
448
+ */
449
+ async sendViaGmailOAuth(account, emailData) {
450
+ // Check if OAuth tokens are available
451
+ if (!account.oauth) {
452
+ throw new Error('Gmail OAuth account not fully configured. Please complete the OAuth flow first.');
453
+ }
454
+
455
+ const oauth = decryptCredentials(account.oauth);
456
+ const config = decryptCredentials(account.config);
457
+
458
+ strapi.log.info(`[magic-mail] Sending via Gmail OAuth for account: ${account.name}`);
459
+ strapi.log.info(`[magic-mail] Has oauth.email: ${!!oauth.email} (${oauth.email || 'none'})`);
460
+ strapi.log.info(`[magic-mail] Has oauth.accessToken: ${!!oauth.accessToken}`);
461
+ strapi.log.info(`[magic-mail] Has config.clientId: ${!!config.clientId}`);
462
+
463
+ if (!oauth.email || !oauth.accessToken) {
464
+ throw new Error('Missing OAuth credentials. Please re-authenticate this account.');
465
+ }
466
+
467
+ if (!config.clientId || !config.clientSecret) {
468
+ throw new Error('Missing OAuth client credentials.');
469
+ }
470
+
471
+ // Validate email content for security
472
+ this.validateEmailSecurity(emailData);
473
+
474
+ // Check if token is expired and refresh if needed
475
+ let currentAccessToken = oauth.accessToken;
476
+
477
+ if (oauth.expiresAt && new Date(oauth.expiresAt) < new Date()) {
478
+ strapi.log.info('[magic-mail] Access token expired, refreshing...');
479
+
480
+ if (!oauth.refreshToken) {
481
+ throw new Error('Access token expired and no refresh token available. Please re-authenticate.');
482
+ }
483
+
484
+ try {
485
+ const oauthService = strapi.plugin('magic-mail').service('oauth');
486
+ const newTokens = await oauthService.refreshGmailTokens(
487
+ oauth.refreshToken,
488
+ config.clientId,
489
+ config.clientSecret
490
+ );
491
+
492
+ currentAccessToken = newTokens.accessToken;
493
+ strapi.log.info('[magic-mail] ✅ Token refreshed successfully');
494
+
495
+ // Update stored tokens
496
+ const { encryptCredentials } = require('../utils/encryption');
497
+ const updatedOAuth = encryptCredentials({
498
+ ...oauth,
499
+ accessToken: newTokens.accessToken,
500
+ expiresAt: newTokens.expiresAt,
501
+ });
502
+
503
+ await strapi.entityService.update('plugin::magic-mail.email-account', account.id, {
504
+ data: { oauth: updatedOAuth },
505
+ });
506
+ } catch (refreshErr) {
507
+ strapi.log.error('[magic-mail] Token refresh failed:', refreshErr);
508
+ throw new Error('Access token expired and refresh failed. Please re-authenticate this account.');
509
+ }
510
+ }
511
+
512
+ // Use Gmail API directly instead of SMTP with OAuth
513
+ strapi.log.info('[magic-mail] Using Gmail API to send email...');
514
+
515
+ try {
516
+ // Create email in RFC 2822 format with MIME multipart for attachments
517
+ const boundary = `----=_Part_${Date.now()}`;
518
+ const attachments = emailData.attachments || [];
519
+
520
+ let emailContent = '';
521
+
522
+ if (attachments.length > 0) {
523
+ // Multipart email with attachments
524
+ const emailLines = [
525
+ `From: ${account.fromName ? `"${account.fromName}" ` : ''}<${account.fromEmail}>`,
526
+ `To: ${emailData.to}`,
527
+ `Subject: ${emailData.subject}`,
528
+ `Date: ${new Date().toUTCString()}`,
529
+ `Message-ID: <${Date.now()}.${Math.random().toString(36).substring(7)}@${account.fromEmail.split('@')[1]}>`,
530
+ 'MIME-Version: 1.0',
531
+ `Content-Type: multipart/mixed; boundary="${boundary}"`,
532
+ 'X-Mailer: MagicMail/1.0',
533
+ ];
534
+
535
+ // Add priority headers if high priority
536
+ if (emailData.priority === 'high') {
537
+ emailLines.push('X-Priority: 1 (Highest)');
538
+ emailLines.push('Importance: high');
539
+ }
540
+
541
+ // Add List-Unsubscribe for marketing emails
542
+ if (emailData.type === 'marketing' && emailData.unsubscribeUrl) {
543
+ emailLines.push(`List-Unsubscribe: <${emailData.unsubscribeUrl}>`);
544
+ emailLines.push('List-Unsubscribe-Post: List-Unsubscribe=One-Click');
545
+ }
546
+
547
+ emailLines.push('');
548
+ emailLines.push(`--${boundary}`);
549
+ emailLines.push('Content-Type: text/html; charset=utf-8');
550
+ emailLines.push('');
551
+ emailLines.push(emailData.html || emailData.text || '');
552
+
553
+ // Add each attachment
554
+ const fs = require('fs');
555
+ const path = require('path');
556
+
557
+ for (const attachment of attachments) {
558
+ emailLines.push(`--${boundary}`);
559
+
560
+ let fileContent;
561
+ let filename;
562
+ let contentType = attachment.contentType || 'application/octet-stream';
563
+
564
+ if (attachment.content) {
565
+ // Content provided as buffer or string
566
+ fileContent = Buffer.isBuffer(attachment.content)
567
+ ? attachment.content
568
+ : Buffer.from(attachment.content);
569
+ filename = attachment.filename || 'attachment';
570
+ } else if (attachment.path) {
571
+ // Read from file path
572
+ fileContent = fs.readFileSync(attachment.path);
573
+ filename = attachment.filename || path.basename(attachment.path);
574
+
575
+ // Detect content type if not provided
576
+ if (!attachment.contentType) {
577
+ const ext = path.extname(filename).toLowerCase();
578
+ const types = {
579
+ '.pdf': 'application/pdf',
580
+ '.png': 'image/png',
581
+ '.jpg': 'image/jpeg',
582
+ '.jpeg': 'image/jpeg',
583
+ '.gif': 'image/gif',
584
+ '.txt': 'text/plain',
585
+ '.csv': 'text/csv',
586
+ '.doc': 'application/msword',
587
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
588
+ '.xls': 'application/vnd.ms-excel',
589
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
590
+ };
591
+ contentType = types[ext] || 'application/octet-stream';
592
+ }
593
+ } else {
594
+ continue; // Skip invalid attachment
595
+ }
596
+
597
+ emailLines.push(`Content-Type: ${contentType}; name="${filename}"`);
598
+ emailLines.push(`Content-Disposition: attachment; filename="${filename}"`);
599
+ emailLines.push('Content-Transfer-Encoding: base64');
600
+ emailLines.push('');
601
+ emailLines.push(fileContent.toString('base64'));
602
+ }
603
+
604
+ emailLines.push(`--${boundary}--`);
605
+ emailContent = emailLines.join('\r\n');
606
+
607
+ strapi.log.info(`[magic-mail] Email with ${attachments.length} attachment(s) prepared`);
608
+ } else {
609
+ // Simple email without attachments
610
+ const emailLines = [
611
+ `From: ${account.fromName ? `"${account.fromName}" ` : ''}<${account.fromEmail}>`,
612
+ `To: ${emailData.to}`,
613
+ `Subject: ${emailData.subject}`,
614
+ `Date: ${new Date().toUTCString()}`,
615
+ `Message-ID: <${Date.now()}.${Math.random().toString(36).substring(7)}@${account.fromEmail.split('@')[1]}>`,
616
+ 'MIME-Version: 1.0',
617
+ 'Content-Type: text/html; charset=utf-8',
618
+ 'X-Mailer: MagicMail/1.0',
619
+ ];
620
+
621
+ // Add priority headers if high priority
622
+ if (emailData.priority === 'high') {
623
+ emailLines.push('X-Priority: 1 (Highest)');
624
+ emailLines.push('Importance: high');
625
+ }
626
+
627
+ // Add List-Unsubscribe for marketing emails
628
+ if (emailData.type === 'marketing' && emailData.unsubscribeUrl) {
629
+ emailLines.push(`List-Unsubscribe: <${emailData.unsubscribeUrl}>`);
630
+ emailLines.push('List-Unsubscribe-Post: List-Unsubscribe=One-Click');
631
+ }
632
+
633
+ emailLines.push('');
634
+ emailLines.push(emailData.html || emailData.text || '');
635
+
636
+ emailContent = emailLines.join('\r\n');
637
+ }
638
+
639
+ const encodedEmail = Buffer.from(emailContent)
640
+ .toString('base64')
641
+ .replace(/\+/g, '-')
642
+ .replace(/\//g, '_')
643
+ .replace(/=+$/, '');
644
+
645
+ // Send via Gmail API
646
+ const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
647
+ method: 'POST',
648
+ headers: {
649
+ 'Authorization': `Bearer ${currentAccessToken}`,
650
+ 'Content-Type': 'application/json',
651
+ },
652
+ body: JSON.stringify({
653
+ raw: encodedEmail,
654
+ }),
655
+ });
656
+
657
+ if (!response.ok) {
658
+ const errorData = await response.json();
659
+ strapi.log.error('[magic-mail] Gmail API error:', errorData);
660
+ throw new Error(`Gmail API error: ${errorData.error?.message || response.statusText}`);
661
+ }
662
+
663
+ const result = await response.json();
664
+ strapi.log.info('[magic-mail] ✅ Email sent via Gmail API');
665
+
666
+ return {
667
+ messageId: result.id,
668
+ response: 'OK',
669
+ };
670
+ } catch (err) {
671
+ strapi.log.error('[magic-mail] Gmail API send failed:', err);
672
+ throw err;
673
+ }
674
+ },
675
+
676
+ /**
677
+ * Send via Microsoft OAuth (Outlook/Exchange Online)
678
+ * With enhanced security and Graph API best practices
679
+ */
680
+ async sendViaMicrosoftOAuth(account, emailData) {
681
+ // Check if OAuth tokens are available
682
+ if (!account.oauth) {
683
+ throw new Error('Microsoft OAuth account not fully configured. Please complete the OAuth flow first.');
684
+ }
685
+
686
+ const oauth = decryptCredentials(account.oauth);
687
+ const config = decryptCredentials(account.config);
688
+
689
+ strapi.log.info(`[magic-mail] Sending via Microsoft OAuth for account: ${account.name}`);
690
+ strapi.log.info(`[magic-mail] Has oauth.email: ${!!oauth.email} (${oauth.email || 'none'})`);
691
+ strapi.log.info(`[magic-mail] Has oauth.accessToken: ${!!oauth.accessToken}`);
692
+ strapi.log.info(`[magic-mail] Has config.clientId: ${!!config.clientId}`);
693
+
694
+ if (!oauth.email || !oauth.accessToken) {
695
+ throw new Error('Missing OAuth credentials. Please re-authenticate this account.');
696
+ }
697
+
698
+ if (!config.clientId || !config.clientSecret) {
699
+ throw new Error('Missing OAuth client credentials.');
700
+ }
701
+
702
+ // Validate email content for security
703
+ this.validateEmailSecurity(emailData);
704
+
705
+ // Check if token is expired and refresh if needed
706
+ let currentAccessToken = oauth.accessToken;
707
+
708
+ if (oauth.expiresAt && new Date(oauth.expiresAt) < new Date()) {
709
+ strapi.log.info('[magic-mail] Access token expired, refreshing...');
710
+
711
+ if (!oauth.refreshToken) {
712
+ throw new Error('Access token expired and no refresh token available. Please re-authenticate.');
713
+ }
714
+
715
+ try {
716
+ if (!config.tenantId) {
717
+ throw new Error('Tenant ID not found in config. Please re-configure this account.');
718
+ }
719
+
720
+ const oauthService = strapi.plugin('magic-mail').service('oauth');
721
+ const newTokens = await oauthService.refreshMicrosoftTokens(
722
+ oauth.refreshToken,
723
+ config.clientId,
724
+ config.clientSecret,
725
+ config.tenantId
726
+ );
727
+
728
+ currentAccessToken = newTokens.accessToken;
729
+ strapi.log.info('[magic-mail] ✅ Microsoft token refreshed successfully');
730
+
731
+ // Update stored tokens
732
+ const { encryptCredentials } = require('../utils/encryption');
733
+ const updatedOAuth = encryptCredentials({
734
+ ...oauth,
735
+ accessToken: newTokens.accessToken,
736
+ expiresAt: newTokens.expiresAt,
737
+ });
738
+
739
+ await strapi.entityService.update('plugin::magic-mail.email-account', account.id, {
740
+ data: { oauth: updatedOAuth },
741
+ });
742
+ } catch (refreshErr) {
743
+ strapi.log.error('[magic-mail] Token refresh failed:', refreshErr);
744
+ throw new Error('Access token expired and refresh failed. Please re-authenticate this account.');
745
+ }
746
+ }
747
+
748
+ // Use Microsoft Graph API with MIME format
749
+ // Key: Let Microsoft add From/DKIM headers automatically for DMARC compliance
750
+ strapi.log.info('[magic-mail] Using Microsoft Graph API with MIME format (DMARC-safe)...');
751
+
752
+ try {
753
+ // Build MIME content WITHOUT From header (Microsoft adds it with proper DKIM)
754
+ const boundary = `----=_Part_${Date.now()}`;
755
+ const attachments = emailData.attachments || [];
756
+
757
+ let mimeContent = '';
758
+
759
+ if (attachments.length > 0) {
760
+ // Multipart MIME with attachments
761
+ const mimeLines = [
762
+ // DON'T include From - Microsoft adds it with DKIM!
763
+ `To: ${emailData.to}`,
764
+ `Subject: ${emailData.subject}`,
765
+ `Date: ${new Date().toUTCString()}`,
766
+ `Message-ID: <${Date.now()}.${Math.random().toString(36).substring(7)}@${account.fromEmail.split('@')[1]}>`,
767
+ 'MIME-Version: 1.0',
768
+ `Content-Type: multipart/mixed; boundary="${boundary}"`,
769
+ 'X-Mailer: MagicMail/1.0',
770
+ ];
771
+
772
+ // Priority headers
773
+ if (emailData.priority === 'high') {
774
+ mimeLines.push('X-Priority: 1 (Highest)');
775
+ mimeLines.push('Importance: high');
776
+ }
777
+
778
+ // List-Unsubscribe for marketing
779
+ if (emailData.type === 'marketing' && emailData.unsubscribeUrl) {
780
+ mimeLines.push(`List-Unsubscribe: <${emailData.unsubscribeUrl}>`);
781
+ mimeLines.push('List-Unsubscribe-Post: List-Unsubscribe=One-Click');
782
+ }
783
+
784
+ // Reply-To
785
+ if (emailData.replyTo || account.replyTo) {
786
+ mimeLines.push(`Reply-To: ${emailData.replyTo || account.replyTo}`);
787
+ }
788
+
789
+ mimeLines.push('');
790
+ mimeLines.push(`--${boundary}`);
791
+ mimeLines.push('Content-Type: text/html; charset=utf-8');
792
+ mimeLines.push('');
793
+ mimeLines.push(emailData.html || emailData.text || '');
794
+
795
+ // Add attachments
796
+ const fs = require('fs');
797
+ const path = require('path');
798
+
799
+ for (const attachment of attachments) {
800
+ mimeLines.push(`--${boundary}`);
801
+
802
+ let fileContent;
803
+ let filename;
804
+ let contentType = attachment.contentType || 'application/octet-stream';
805
+
806
+ if (attachment.content) {
807
+ fileContent = Buffer.isBuffer(attachment.content)
808
+ ? attachment.content
809
+ : Buffer.from(attachment.content);
810
+ filename = attachment.filename || 'attachment';
811
+ } else if (attachment.path) {
812
+ fileContent = fs.readFileSync(attachment.path);
813
+ filename = attachment.filename || path.basename(attachment.path);
814
+ } else {
815
+ continue;
816
+ }
817
+
818
+ mimeLines.push(`Content-Type: ${contentType}; name="${filename}"`);
819
+ mimeLines.push(`Content-Disposition: attachment; filename="${filename}"`);
820
+ mimeLines.push('Content-Transfer-Encoding: base64');
821
+ mimeLines.push('');
822
+ mimeLines.push(fileContent.toString('base64'));
823
+ }
824
+
825
+ mimeLines.push(`--${boundary}--`);
826
+ mimeContent = mimeLines.join('\r\n');
827
+ } else {
828
+ // Simple MIME email without attachments
829
+ const mimeLines = [
830
+ // DON'T include From - Microsoft adds it with DKIM!
831
+ `To: ${emailData.to}`,
832
+ `Subject: ${emailData.subject}`,
833
+ `Date: ${new Date().toUTCString()}`,
834
+ `Message-ID: <${Date.now()}.${Math.random().toString(36).substring(7)}@${account.fromEmail.split('@')[1]}>`,
835
+ 'MIME-Version: 1.0',
836
+ 'Content-Type: text/html; charset=utf-8',
837
+ 'X-Mailer: MagicMail/1.0',
838
+ ];
839
+
840
+ // Priority headers
841
+ if (emailData.priority === 'high') {
842
+ mimeLines.push('X-Priority: 1 (Highest)');
843
+ mimeLines.push('Importance: high');
844
+ }
845
+
846
+ // List-Unsubscribe for marketing
847
+ if (emailData.type === 'marketing' && emailData.unsubscribeUrl) {
848
+ mimeLines.push(`List-Unsubscribe: <${emailData.unsubscribeUrl}>`);
849
+ mimeLines.push('List-Unsubscribe-Post: List-Unsubscribe=One-Click');
850
+ }
851
+
852
+ // Reply-To
853
+ if (emailData.replyTo || account.replyTo) {
854
+ mimeLines.push(`Reply-To: ${emailData.replyTo || account.replyTo}`);
855
+ }
856
+
857
+ mimeLines.push('');
858
+ mimeLines.push(emailData.html || emailData.text || '');
859
+
860
+ mimeContent = mimeLines.join('\r\n');
861
+ }
862
+
863
+ // Encode MIME to base64
864
+ const base64Mime = Buffer.from(mimeContent).toString('base64');
865
+
866
+ // Send via Microsoft Graph using MIME format
867
+ const response = await fetch('https://graph.microsoft.com/v1.0/me/sendMail', {
868
+ method: 'POST',
869
+ headers: {
870
+ 'Authorization': `Bearer ${currentAccessToken}`,
871
+ 'Content-Type': 'text/plain', // MIME format!
872
+ },
873
+ body: base64Mime,
874
+ });
875
+
876
+ // Microsoft Graph returns 202 Accepted on success
877
+ if (response.status !== 202) {
878
+ let errorData = 'Unknown error';
879
+ try {
880
+ errorData = await response.text();
881
+ } catch (e) {
882
+ // Ignore
883
+ }
884
+ strapi.log.error('[magic-mail] Microsoft Graph MIME error:', errorData);
885
+ strapi.log.error('[magic-mail] Response status:', response.status);
886
+ throw new Error(`Microsoft Graph API error: ${response.status} - ${response.statusText}`);
887
+ }
888
+
889
+ strapi.log.info('[magic-mail] ✅ Email sent via Microsoft Graph API with MIME + custom headers');
890
+ strapi.log.info('[magic-mail] Microsoft adds From/DKIM automatically for DMARC compliance');
891
+
892
+ return {
893
+ messageId: `microsoft-${Date.now()}`,
894
+ response: 'Accepted',
895
+ };
896
+ } catch (err) {
897
+ strapi.log.error('[magic-mail] Microsoft Graph API send failed:', err);
898
+ throw err;
899
+ }
900
+ },
901
+
902
+ /**
903
+ * Send via Yahoo OAuth
904
+ * With enhanced security and SMTP OAuth2 best practices
905
+ */
906
+ async sendViaYahooOAuth(account, emailData) {
907
+ // Check if OAuth tokens are available
908
+ if (!account.oauth) {
909
+ throw new Error('Yahoo OAuth account not fully configured. Please complete the OAuth flow first.');
910
+ }
911
+
912
+ const oauth = decryptCredentials(account.oauth);
913
+ const config = decryptCredentials(account.config);
914
+
915
+ strapi.log.info(`[magic-mail] Sending via Yahoo OAuth for account: ${account.name}`);
916
+ strapi.log.info(`[magic-mail] Has oauth.email: ${!!oauth.email} (${oauth.email || 'none'})`);
917
+ strapi.log.info(`[magic-mail] Has oauth.accessToken: ${!!oauth.accessToken}`);
918
+
919
+ if (!oauth.email || !oauth.accessToken) {
920
+ throw new Error('Missing OAuth credentials. Please re-authenticate this account.');
921
+ }
922
+
923
+ // Validate email content for security
924
+ this.validateEmailSecurity(emailData);
925
+
926
+ // Check if token is expired and refresh if needed
927
+ let currentAccessToken = oauth.accessToken;
928
+
929
+ if (oauth.expiresAt && new Date(oauth.expiresAt) < new Date()) {
930
+ strapi.log.info('[magic-mail] Access token expired, refreshing...');
931
+
932
+ if (!oauth.refreshToken) {
933
+ throw new Error('Access token expired and no refresh token available. Please re-authenticate.');
934
+ }
935
+
936
+ try {
937
+ const oauthService = strapi.plugin('magic-mail').service('oauth');
938
+ const newTokens = await oauthService.refreshYahooTokens(
939
+ oauth.refreshToken,
940
+ config.clientId,
941
+ config.clientSecret
942
+ );
943
+
944
+ currentAccessToken = newTokens.accessToken;
945
+ strapi.log.info('[magic-mail] ✅ Token refreshed successfully');
946
+
947
+ // Update stored tokens
948
+ const { encryptCredentials } = require('../utils/encryption');
949
+ const updatedOAuth = encryptCredentials({
950
+ ...oauth,
951
+ accessToken: newTokens.accessToken,
952
+ expiresAt: newTokens.expiresAt,
953
+ });
954
+
955
+ await strapi.entityService.update('plugin::magic-mail.email-account', account.id, {
956
+ data: { oauth: updatedOAuth },
957
+ });
958
+ } catch (refreshErr) {
959
+ strapi.log.error('[magic-mail] Token refresh failed:', refreshErr);
960
+ throw new Error('Access token expired and refresh failed. Please re-authenticate this account.');
961
+ }
962
+ }
963
+
964
+ // Yahoo Mail API uses SMTP with OAuth token as password
965
+ // We use nodemailer with XOAUTH2
966
+ const nodemailer = require('nodemailer');
967
+
968
+ strapi.log.info('[magic-mail] Using Yahoo SMTP with OAuth...');
969
+
970
+ try {
971
+ const transporter = nodemailer.createTransport({
972
+ host: 'smtp.mail.yahoo.com',
973
+ port: 465,
974
+ secure: true,
975
+ auth: {
976
+ type: 'OAuth2',
977
+ user: oauth.email,
978
+ accessToken: currentAccessToken,
979
+ },
980
+ });
981
+
982
+ const mailOptions = {
983
+ from: `${account.fromName || 'Yahoo Mail'} <${account.fromEmail}>`,
984
+ to: emailData.to,
985
+ replyTo: emailData.replyTo || account.replyTo,
986
+ subject: emailData.subject,
987
+ text: emailData.text,
988
+ html: emailData.html,
989
+ attachments: emailData.attachments || [],
990
+
991
+ // Security and deliverability headers
992
+ headers: {
993
+ 'X-Mailer': 'MagicMail/1.0',
994
+ 'X-Priority': emailData.priority === 'high' ? '1' : '3',
995
+ },
996
+
997
+ // Generate proper Message-ID
998
+ messageId: `<${Date.now()}.${Math.random().toString(36).substring(7)}@${account.fromEmail.split('@')[1]}>`,
999
+ date: new Date(),
1000
+ };
1001
+
1002
+ // Add List-Unsubscribe for marketing emails
1003
+ if (emailData.type === 'marketing' && emailData.unsubscribeUrl) {
1004
+ mailOptions.headers['List-Unsubscribe'] = `<${emailData.unsubscribeUrl}>`;
1005
+ mailOptions.headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
1006
+ }
1007
+
1008
+ if (mailOptions.attachments.length > 0) {
1009
+ strapi.log.info(`[magic-mail] Sending email with ${mailOptions.attachments.length} attachment(s)`);
1010
+ }
1011
+
1012
+ const result = await transporter.sendMail(mailOptions);
1013
+ strapi.log.info('[magic-mail] ✅ Email sent via Yahoo OAuth');
1014
+
1015
+ return {
1016
+ messageId: result.messageId,
1017
+ response: result.response,
1018
+ };
1019
+ } catch (err) {
1020
+ strapi.log.error('[magic-mail] Yahoo OAuth send failed:', err);
1021
+ throw err;
1022
+ }
1023
+ },
1024
+
1025
+ /**
1026
+ * Send via SendGrid API
1027
+ * With enhanced security and proper headers
1028
+ */
1029
+ async sendViaSendGrid(account, emailData) {
1030
+ const config = decryptCredentials(account.config);
1031
+
1032
+ if (!config.apiKey) {
1033
+ throw new Error('SendGrid API key not configured');
1034
+ }
1035
+
1036
+ // Validate email content for security
1037
+ this.validateEmailSecurity(emailData);
1038
+
1039
+ strapi.log.info(`[magic-mail] Sending via SendGrid for account: ${account.name}`);
1040
+
1041
+ try {
1042
+ // Build message object for SendGrid
1043
+ const msg = {
1044
+ to: emailData.to,
1045
+ from: {
1046
+ email: account.fromEmail,
1047
+ name: account.fromName || account.fromEmail,
1048
+ },
1049
+ subject: emailData.subject,
1050
+ text: emailData.text,
1051
+ html: emailData.html,
1052
+
1053
+ // Security and tracking headers
1054
+ customArgs: {
1055
+ 'magicmail_version': '1.0',
1056
+ 'email_type': emailData.type || 'transactional',
1057
+ 'priority': emailData.priority || 'normal',
1058
+ },
1059
+
1060
+ // Headers object for custom headers
1061
+ headers: {
1062
+ 'X-Mailer': 'MagicMail/1.0',
1063
+ },
1064
+ };
1065
+
1066
+ // Add priority headers if high priority
1067
+ if (emailData.priority === 'high') {
1068
+ msg.headers['X-Priority'] = '1 (Highest)';
1069
+ msg.headers['Importance'] = 'high';
1070
+ }
1071
+
1072
+ // Add ReplyTo if provided
1073
+ if (emailData.replyTo || account.replyTo) {
1074
+ msg.replyTo = {
1075
+ email: emailData.replyTo || account.replyTo,
1076
+ };
1077
+ }
1078
+
1079
+ // Add List-Unsubscribe for marketing emails (GDPR/CAN-SPAM compliance)
1080
+ if (emailData.type === 'marketing' && emailData.unsubscribeUrl) {
1081
+ msg.headers['List-Unsubscribe'] = `<${emailData.unsubscribeUrl}>`;
1082
+ msg.headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
1083
+ }
1084
+
1085
+ // Add attachments if provided
1086
+ const attachments = emailData.attachments || [];
1087
+ if (attachments.length > 0) {
1088
+ const fs = require('fs');
1089
+ const path = require('path');
1090
+
1091
+ msg.attachments = [];
1092
+
1093
+ for (const attachment of attachments) {
1094
+ let fileContent;
1095
+ let filename;
1096
+ let contentType = attachment.contentType || 'application/octet-stream';
1097
+
1098
+ if (attachment.content) {
1099
+ // Content provided as buffer or string
1100
+ fileContent = Buffer.isBuffer(attachment.content)
1101
+ ? attachment.content
1102
+ : Buffer.from(attachment.content);
1103
+ filename = attachment.filename || 'attachment';
1104
+ } else if (attachment.path) {
1105
+ // Read from file path
1106
+ fileContent = fs.readFileSync(attachment.path);
1107
+ filename = attachment.filename || path.basename(attachment.path);
1108
+ } else {
1109
+ continue;
1110
+ }
1111
+
1112
+ msg.attachments.push({
1113
+ content: fileContent.toString('base64'),
1114
+ filename: filename,
1115
+ type: contentType,
1116
+ disposition: 'attachment',
1117
+ });
1118
+ }
1119
+
1120
+ strapi.log.info(`[magic-mail] Email with ${attachments.length} attachment(s) prepared`);
1121
+ }
1122
+
1123
+ // Send via SendGrid API
1124
+ const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
1125
+ method: 'POST',
1126
+ headers: {
1127
+ 'Authorization': `Bearer ${config.apiKey}`,
1128
+ 'Content-Type': 'application/json',
1129
+ },
1130
+ body: JSON.stringify(msg),
1131
+ });
1132
+
1133
+ if (!response.ok) {
1134
+ const errorText = await response.text();
1135
+ strapi.log.error('[magic-mail] SendGrid API error:', errorText);
1136
+ throw new Error(`SendGrid API error: ${response.statusText}`);
1137
+ }
1138
+
1139
+ strapi.log.info('[magic-mail] ✅ Email sent via SendGrid API');
1140
+
1141
+ // SendGrid returns 202 Accepted with no body on success
1142
+ return {
1143
+ messageId: response.headers.get('x-message-id') || `sendgrid-${Date.now()}`,
1144
+ response: 'Accepted',
1145
+ };
1146
+ } catch (err) {
1147
+ strapi.log.error('[magic-mail] SendGrid send failed:', err);
1148
+ throw err;
1149
+ }
1150
+ },
1151
+
1152
+ /**
1153
+ * Send via Mailgun API
1154
+ * With enhanced security and compliance headers
1155
+ */
1156
+ async sendViaMailgun(account, emailData) {
1157
+ const config = decryptCredentials(account.config);
1158
+
1159
+ if (!config.apiKey || !config.domain) {
1160
+ throw new Error('Mailgun API key and domain not configured');
1161
+ }
1162
+
1163
+ // Validate email content for security
1164
+ this.validateEmailSecurity(emailData);
1165
+
1166
+ strapi.log.info(`[magic-mail] Sending via Mailgun for account: ${account.name}`);
1167
+ strapi.log.info(`[magic-mail] Domain: ${config.domain}`);
1168
+
1169
+ try {
1170
+ // Build FormData for Mailgun API
1171
+ const FormData = require('form-data');
1172
+ const form = new FormData();
1173
+
1174
+ // Required fields
1175
+ form.append('from', account.fromName
1176
+ ? `${account.fromName} <${account.fromEmail}>`
1177
+ : account.fromEmail
1178
+ );
1179
+ form.append('to', emailData.to);
1180
+ form.append('subject', emailData.subject);
1181
+
1182
+ // Add text or html content
1183
+ if (emailData.html) {
1184
+ form.append('html', emailData.html);
1185
+ }
1186
+ if (emailData.text) {
1187
+ form.append('text', emailData.text);
1188
+ }
1189
+
1190
+ // Add ReplyTo if provided
1191
+ if (emailData.replyTo || account.replyTo) {
1192
+ form.append('h:Reply-To', emailData.replyTo || account.replyTo);
1193
+ }
1194
+
1195
+ // Add custom headers for tracking and security
1196
+ form.append('h:X-Mailer', 'MagicMail/1.0');
1197
+ form.append('h:X-Email-Type', emailData.type || 'transactional');
1198
+
1199
+ // Add List-Unsubscribe for marketing emails (GDPR/CAN-SPAM compliance)
1200
+ if (emailData.type === 'marketing' && emailData.unsubscribeUrl) {
1201
+ form.append('h:List-Unsubscribe', `<${emailData.unsubscribeUrl}>`);
1202
+ form.append('h:List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
1203
+ }
1204
+
1205
+ // Add attachments if provided
1206
+ const attachments = emailData.attachments || [];
1207
+ if (attachments.length > 0) {
1208
+ const fs = require('fs');
1209
+ const path = require('path');
1210
+
1211
+ for (const attachment of attachments) {
1212
+ let fileContent;
1213
+ let filename;
1214
+
1215
+ if (attachment.content) {
1216
+ // Content provided as buffer or string
1217
+ fileContent = Buffer.isBuffer(attachment.content)
1218
+ ? attachment.content
1219
+ : Buffer.from(attachment.content);
1220
+ filename = attachment.filename || 'attachment';
1221
+ } else if (attachment.path) {
1222
+ // Read from file path
1223
+ fileContent = fs.readFileSync(attachment.path);
1224
+ filename = attachment.filename || path.basename(attachment.path);
1225
+ } else {
1226
+ continue;
1227
+ }
1228
+
1229
+ // Mailgun expects attachments as form data with buffer
1230
+ form.append('attachment', fileContent, {
1231
+ filename: filename,
1232
+ contentType: attachment.contentType || 'application/octet-stream',
1233
+ });
1234
+ }
1235
+
1236
+ strapi.log.info(`[magic-mail] Email with ${attachments.length} attachment(s) prepared`);
1237
+ }
1238
+
1239
+ // Send via Mailgun API
1240
+ const response = await fetch(`https://api.mailgun.net/v3/${config.domain}/messages`, {
1241
+ method: 'POST',
1242
+ headers: {
1243
+ 'Authorization': `Basic ${Buffer.from(`api:${config.apiKey}`).toString('base64')}`,
1244
+ ...form.getHeaders(),
1245
+ },
1246
+ body: form,
1247
+ });
1248
+
1249
+ if (!response.ok) {
1250
+ const errorData = await response.text();
1251
+ strapi.log.error('[magic-mail] Mailgun API error:', errorData);
1252
+ throw new Error(`Mailgun API error: ${response.statusText}`);
1253
+ }
1254
+
1255
+ const result = await response.json();
1256
+ strapi.log.info('[magic-mail] ✅ Email sent via Mailgun API');
1257
+
1258
+ return {
1259
+ messageId: result.id || `mailgun-${Date.now()}`,
1260
+ response: result.message || 'Queued',
1261
+ };
1262
+ } catch (err) {
1263
+ strapi.log.error('[magic-mail] Mailgun send failed:', err);
1264
+ throw err;
1265
+ }
1266
+ },
1267
+
1268
+ /**
1269
+ * Check if account is within rate limits
1270
+ */
1271
+ async checkRateLimits(account) {
1272
+ if (account.dailyLimit > 0 && account.emailsSentToday >= account.dailyLimit) {
1273
+ return false;
1274
+ }
1275
+ if (account.hourlyLimit > 0 && account.emailsSentThisHour >= account.hourlyLimit) {
1276
+ return false;
1277
+ }
1278
+ return true;
1279
+ },
1280
+
1281
+ /**
1282
+ * Update account statistics
1283
+ */
1284
+ async updateAccountStats(accountId) {
1285
+ const account = await strapi.entityService.findOne('plugin::magic-mail.email-account', accountId);
1286
+
1287
+ await strapi.entityService.update('plugin::magic-mail.email-account', accountId, {
1288
+ data: {
1289
+ emailsSentToday: (account.emailsSentToday || 0) + 1,
1290
+ emailsSentThisHour: (account.emailsSentThisHour || 0) + 1,
1291
+ totalEmailsSent: (account.totalEmailsSent || 0) + 1,
1292
+ lastUsed: new Date(),
1293
+ },
1294
+ });
1295
+ },
1296
+
1297
+ /**
1298
+ * Log email to database (DEPRECATED - now handled by Analytics)
1299
+ * This function previously created duplicate logs
1300
+ * Now it's a no-op since email-log creation is handled in the analytics service
1301
+ */
1302
+ async logEmail(logData) {
1303
+ // Email logging is now handled by the Analytics service (createEmailLog)
1304
+ // This function is kept for backward compatibility but does nothing
1305
+ // The analytics log is created earlier in the send() flow with proper tracking data
1306
+ strapi.log.debug('[magic-mail] Email already logged via Analytics service');
1307
+ },
1308
+
1309
+ /**
1310
+ * Get account by name
1311
+ */
1312
+ async getAccountByName(name) {
1313
+ const accounts = await strapi.entityService.findMany('plugin::magic-mail.email-account', {
1314
+ filters: { name, isActive: true },
1315
+ limit: 1,
1316
+ });
1317
+
1318
+ return accounts && accounts.length > 0 ? accounts[0] : null;
1319
+ },
1320
+
1321
+ /**
1322
+ * Validate email content for security best practices
1323
+ * Prevents common security issues and spam triggers
1324
+ */
1325
+ validateEmailSecurity(emailData) {
1326
+ const { to, subject, html, text } = emailData;
1327
+
1328
+ // 1. Validate recipient email format
1329
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1330
+ if (!emailRegex.test(to)) {
1331
+ throw new Error(`Invalid recipient email format: ${to}`);
1332
+ }
1333
+
1334
+ // 2. Prevent empty subject (spam trigger)
1335
+ if (!subject || subject.trim().length === 0) {
1336
+ throw new Error('Email subject is required for security and deliverability');
1337
+ }
1338
+
1339
+ // 3. Prevent excessively long subjects (spam trigger)
1340
+ if (subject.length > 200) {
1341
+ strapi.log.warn('[magic-mail] Subject line exceeds 200 characters - may trigger spam filters');
1342
+ }
1343
+
1344
+ // 4. Require either text or html content
1345
+ if (!html && !text) {
1346
+ throw new Error('Email must have either text or html content');
1347
+ }
1348
+
1349
+ // 5. Check for common spam trigger patterns in subject
1350
+ const spamTriggers = [
1351
+ /\bfree\b.*\bmoney\b/i,
1352
+ /\b100%\s*free\b/i,
1353
+ /\bclaim.*\bprize\b/i,
1354
+ /\bclick\s*here\s*now\b/i,
1355
+ /\bviagra\b/i,
1356
+ /\bcasino\b/i,
1357
+ ];
1358
+
1359
+ for (const pattern of spamTriggers) {
1360
+ if (pattern.test(subject)) {
1361
+ strapi.log.warn(`[magic-mail] Subject contains potential spam trigger: "${subject}"`);
1362
+ break;
1363
+ }
1364
+ }
1365
+
1366
+ // 6. Validate HTML doesn't contain dangerous scripts
1367
+ if (html) {
1368
+ if (/<script[^>]*>.*?<\/script>/i.test(html)) {
1369
+ throw new Error('Email HTML must not contain <script> tags for security');
1370
+ }
1371
+
1372
+ if (/javascript:/i.test(html)) {
1373
+ throw new Error('Email HTML must not contain javascript: protocol for security');
1374
+ }
1375
+ }
1376
+
1377
+ // 7. Check for proper content balance (text vs html)
1378
+ if (html && !text) {
1379
+ strapi.log.warn('[magic-mail] Email has HTML but no text alternative - may reduce deliverability');
1380
+ }
1381
+
1382
+ strapi.log.info('[magic-mail] ✅ Email security validation passed');
1383
+ },
1384
+
1385
+ /**
1386
+ * Add security headers to email data
1387
+ * Returns enhanced email data with security headers
1388
+ */
1389
+ addSecurityHeaders(emailData, account) {
1390
+ const headers = {
1391
+ 'X-Mailer': 'MagicMail/1.0',
1392
+ 'X-Entity-Ref-ID': `magicmail-${Date.now()}`,
1393
+ };
1394
+
1395
+ // Add priority headers if specified
1396
+ if (emailData.priority === 'high') {
1397
+ headers['X-Priority'] = '1';
1398
+ headers['Importance'] = 'high';
1399
+ }
1400
+
1401
+ // Add List-Unsubscribe for marketing emails (RFC 8058)
1402
+ if (emailData.type === 'marketing') {
1403
+ if (emailData.unsubscribeUrl) {
1404
+ headers['List-Unsubscribe'] = `<${emailData.unsubscribeUrl}>`;
1405
+ headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
1406
+ } else {
1407
+ strapi.log.warn('[magic-mail] Marketing email without unsubscribe URL - may violate regulations');
1408
+ }
1409
+ }
1410
+
1411
+ return {
1412
+ ...emailData,
1413
+ headers: {
1414
+ ...emailData.headers,
1415
+ ...headers,
1416
+ },
1417
+ };
1418
+ },
1419
+ });
1420
+