strapi-plugin-magic-mail 2.2.4 → 2.2.5
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.
- package/dist/server/index.js +1 -1
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -3
- package/admin/jsconfig.json +0 -10
- package/admin/src/components/AddAccountModal.jsx +0 -1943
- package/admin/src/components/Initializer.jsx +0 -14
- package/admin/src/components/LicenseGuard.jsx +0 -475
- package/admin/src/components/PluginIcon.jsx +0 -5
- package/admin/src/hooks/useAuthRefresh.js +0 -44
- package/admin/src/hooks/useLicense.js +0 -158
- package/admin/src/index.js +0 -87
- package/admin/src/pages/Analytics.jsx +0 -762
- package/admin/src/pages/App.jsx +0 -111
- package/admin/src/pages/EmailDesigner/EditorPage.jsx +0 -1424
- package/admin/src/pages/EmailDesigner/TemplateList.jsx +0 -1807
- package/admin/src/pages/HomePage.jsx +0 -1170
- package/admin/src/pages/LicensePage.jsx +0 -430
- package/admin/src/pages/RoutingRules.jsx +0 -1141
- package/admin/src/pages/Settings.jsx +0 -603
- package/admin/src/pluginId.js +0 -3
- package/admin/src/translations/de.json +0 -71
- package/admin/src/translations/en.json +0 -70
- package/admin/src/translations/es.json +0 -71
- package/admin/src/translations/fr.json +0 -71
- package/admin/src/translations/pt.json +0 -71
- package/admin/src/utils/fetchWithRetry.js +0 -123
- package/admin/src/utils/getTranslation.js +0 -5
- package/admin/src/utils/theme.js +0 -85
- package/server/jsconfig.json +0 -10
- package/server/src/bootstrap.js +0 -157
- package/server/src/config/features.js +0 -260
- package/server/src/config/index.js +0 -9
- package/server/src/content-types/email-account/schema.json +0 -93
- package/server/src/content-types/email-event/index.js +0 -8
- package/server/src/content-types/email-event/schema.json +0 -57
- package/server/src/content-types/email-link/index.js +0 -8
- package/server/src/content-types/email-link/schema.json +0 -49
- package/server/src/content-types/email-log/index.js +0 -8
- package/server/src/content-types/email-log/schema.json +0 -106
- package/server/src/content-types/email-template/schema.json +0 -74
- package/server/src/content-types/email-template-version/schema.json +0 -60
- package/server/src/content-types/index.js +0 -33
- package/server/src/content-types/routing-rule/schema.json +0 -59
- package/server/src/controllers/accounts.js +0 -229
- package/server/src/controllers/analytics.js +0 -361
- package/server/src/controllers/controller.js +0 -26
- package/server/src/controllers/email-designer.js +0 -474
- package/server/src/controllers/index.js +0 -21
- package/server/src/controllers/license.js +0 -269
- package/server/src/controllers/oauth.js +0 -474
- package/server/src/controllers/routing-rules.js +0 -129
- package/server/src/controllers/test.js +0 -301
- package/server/src/destroy.js +0 -27
- package/server/src/index.js +0 -25
- package/server/src/middlewares/index.js +0 -3
- package/server/src/policies/index.js +0 -3
- package/server/src/register.js +0 -5
- package/server/src/routes/admin.js +0 -469
- package/server/src/routes/content-api.js +0 -37
- package/server/src/routes/index.js +0 -9
- package/server/src/services/account-manager.js +0 -329
- package/server/src/services/analytics.js +0 -512
- package/server/src/services/email-designer.js +0 -717
- package/server/src/services/email-router.js +0 -1446
- package/server/src/services/index.js +0 -17
- package/server/src/services/license-guard.js +0 -423
- package/server/src/services/oauth.js +0 -515
- package/server/src/services/service.js +0 -7
- package/server/src/utils/encryption.js +0 -81
- 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
|
-
|