strapi-plugin-magic-mail 2.2.4 → 2.2.6

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