backend-manager 5.0.147 → 5.0.149
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/CHANGELOG.md +58 -0
- package/CLAUDE.md +26 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +14 -4
- package/src/cli/commands/test.js +4 -10
- package/src/manager/cron/daily/ghostii-auto-publisher.js +25 -25
- package/src/manager/cron/frequent/abandoned-carts.js +7 -5
- package/src/manager/cron/frequent/email-queue.js +56 -0
- package/src/manager/events/auth/before-signin.js +3 -0
- package/src/manager/events/auth/on-delete.js +8 -0
- package/src/manager/events/firestore/payments-disputes/on-write.js +2 -1
- package/src/manager/events/firestore/payments-webhooks/on-write.js +9 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +7 -21
- package/src/manager/functions/core/actions/api/admin/get-stats.js +2 -2
- package/src/manager/functions/core/actions/api/admin/send-email.js +14 -14
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +22 -318
- package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +1 -1
- package/src/manager/functions/core/actions/api/general/remove-marketing-contact.js +2 -185
- package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
- package/src/manager/functions/core/actions/api/special/setup-electron-manager-client.js +2 -2
- package/src/manager/functions/core/actions/api/test/health.js +1 -0
- package/src/manager/helpers/api-manager.js +2 -2
- package/src/manager/helpers/user.js +3 -1
- package/src/manager/index.js +15 -10
- package/src/manager/libraries/email/constants.js +243 -0
- package/src/manager/libraries/email/index.js +145 -0
- package/src/manager/libraries/email/marketing/index.js +377 -0
- package/src/manager/libraries/email/providers/beehiiv.js +258 -0
- package/src/manager/libraries/email/providers/sendgrid.js +429 -0
- package/src/manager/libraries/{email.js → email/transactional/index.js} +91 -99
- package/src/manager/libraries/email/validation.js +168 -0
- package/src/manager/libraries/infer-contact.js +1 -1
- package/src/manager/routes/admin/cron/post.js +3 -3
- package/src/manager/routes/admin/email/post.js +1 -1
- package/src/manager/routes/admin/stats/get.js +2 -2
- package/src/manager/routes/{app → brand}/get.js +1 -1
- package/src/manager/routes/general/email/templates/download-app-link.js +1 -1
- package/src/manager/routes/marketing/contact/delete.js +2 -164
- package/src/manager/routes/marketing/contact/post.js +45 -298
- package/src/manager/routes/marketing/contact/put.js +39 -0
- package/src/manager/routes/payments/cancel/post.js +11 -0
- package/src/manager/routes/special/electron-client/post.js +3 -3
- package/src/manager/routes/test/health/get.js +1 -0
- package/src/manager/routes/user/data-request/delete.js +2 -2
- package/src/manager/routes/user/data-request/get.js +2 -2
- package/src/manager/routes/user/data-request/post.js +2 -2
- package/src/manager/routes/user/delete.js +1 -1
- package/src/manager/routes/user/feedback/post.js +12 -8
- package/src/manager/routes/user/signup/post.js +48 -37
- package/src/manager/schemas/admin/email/post.js +4 -4
- package/src/manager/schemas/marketing/contact/delete.js +3 -1
- package/src/manager/schemas/marketing/contact/post.js +3 -1
- package/src/manager/schemas/marketing/contact/put.js +6 -0
- package/src/manager/schemas/special/electron-client/post.js +2 -2
- package/src/manager/schemas/user/feedback/post.js +2 -2
- package/src/test/run-tests.js +1 -1
- package/src/test/runner.js +22 -10
- package/src/test/test-accounts.js +9 -0
- package/src/test/utils/extended-mode-warning.js +11 -0
- package/templates/_.env +1 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +11 -0
- package/test/events/payments/journey-payments-trial-cancel.js +11 -0
- package/test/functions/admin/edit-post.js +2 -2
- package/test/functions/admin/write-repo-content.js +2 -2
- package/test/functions/general/add-marketing-contact.js +21 -23
- package/test/helpers/email-validation.js +420 -0
- package/test/helpers/email.js +119 -6
- package/test/helpers/marketing-lifecycle.js +121 -0
- package/test/helpers/user.js +2 -2
- package/test/routes/admin/create-post.js +2 -2
- package/test/routes/admin/post.js +2 -2
- package/test/routes/admin/repo-content.js +2 -2
- package/test/routes/marketing/contact.js +21 -24
- package/test/routes/payments/cancel.js +18 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Transactional email library — build and send individual emails via SendGrid
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
5
|
* const email = Manager.Email(assistant);
|
|
@@ -20,38 +20,17 @@ const md = new MarkdownIt({
|
|
|
20
20
|
linkify: true,
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
'main/order/payment-failed': 'd-e56af0ac62364bfb9e50af02854e2cd3',
|
|
35
|
-
'main/order/payment-recovered': 'd-d6dbd17a260a4755b34a852ba09c2454',
|
|
36
|
-
'main/order/cancellation-requested': 'd-78074f3e8c844146bf263b86fc8d5ecf',
|
|
37
|
-
'main/order/cancelled': 'd-39041132e6b24e5ebf0e95bce2d94dba',
|
|
38
|
-
'main/order/plan-changed': 'd-399086311bbb48b4b77bc90b20fb9d0a',
|
|
39
|
-
'main/order/trial-ending': 'd-af8ab499cbfb4d56918b4118f44343b0',
|
|
40
|
-
'main/order/refunded': 'd-aa47fdbffa2b4ca9b73b6256e963e49f',
|
|
41
|
-
'main/order/abandoned-cart': 'd-d8b3fa67e2b44b398dc280d0576bf1b7',
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
// "default" resolves to the basic card template
|
|
45
|
-
TEMPLATES['default'] = TEMPLATES['main/basic/card'];
|
|
46
|
-
|
|
47
|
-
// Group shortcut map — SendGrid ASM group IDs
|
|
48
|
-
const GROUPS = {
|
|
49
|
-
'default': 24077,
|
|
50
|
-
'marketing': 25927,
|
|
51
|
-
'account': 25928,
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
function Email(assistant) {
|
|
23
|
+
const {
|
|
24
|
+
TEMPLATES,
|
|
25
|
+
GROUPS,
|
|
26
|
+
SENDERS,
|
|
27
|
+
SEND_AT_LIMIT,
|
|
28
|
+
sanitizeImagesForEmail,
|
|
29
|
+
encode,
|
|
30
|
+
errorWithCode,
|
|
31
|
+
} = require('../constants.js');
|
|
32
|
+
|
|
33
|
+
function Transactional(assistant) {
|
|
55
34
|
const self = this;
|
|
56
35
|
|
|
57
36
|
self.assistant = assistant;
|
|
@@ -68,7 +47,7 @@ function Email(assistant) {
|
|
|
68
47
|
* @returns {object} SendGrid-ready email object
|
|
69
48
|
* @throws {Error} On validation failure
|
|
70
49
|
*/
|
|
71
|
-
|
|
50
|
+
Transactional.prototype.build = async function (settings) {
|
|
72
51
|
const self = this;
|
|
73
52
|
const Manager = self.Manager;
|
|
74
53
|
const admin = self.admin;
|
|
@@ -80,15 +59,29 @@ Email.prototype.build = async function (settings) {
|
|
|
80
59
|
let cc = normalizeRecipients(settings.cc);
|
|
81
60
|
let bcc = normalizeRecipients(settings.bcc);
|
|
82
61
|
|
|
83
|
-
// Resolve any
|
|
62
|
+
// Resolve any UID recipients from Firestore
|
|
84
63
|
[to, cc, bcc] = await Promise.all([
|
|
85
64
|
resolveRecipients(to, admin, assistant),
|
|
86
65
|
resolveRecipients(cc, admin, assistant),
|
|
87
66
|
resolveRecipients(bcc, admin, assistant),
|
|
88
67
|
]);
|
|
89
68
|
|
|
90
|
-
//
|
|
91
|
-
const
|
|
69
|
+
// Resolve user template data from the primary recipient's user doc (if available)
|
|
70
|
+
const rawUserDoc = to[0]?._userDoc || {};
|
|
71
|
+
const userProperties = Manager.User(rawUserDoc).properties;
|
|
72
|
+
delete userProperties.api;
|
|
73
|
+
delete userProperties.oauth2;
|
|
74
|
+
delete userProperties.activity;
|
|
75
|
+
delete userProperties.affiliate;
|
|
76
|
+
delete userProperties.attribution;
|
|
77
|
+
delete userProperties.flags;
|
|
78
|
+
|
|
79
|
+
// Clean internal markers from recipients
|
|
80
|
+
for (const list of [to, cc, bcc]) {
|
|
81
|
+
for (const entry of list) {
|
|
82
|
+
delete entry._userDoc;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
92
85
|
|
|
93
86
|
// Get brand config
|
|
94
87
|
const brand = Manager.config?.brand;
|
|
@@ -148,7 +141,23 @@ Email.prototype.build = async function (settings) {
|
|
|
148
141
|
}
|
|
149
142
|
|
|
150
143
|
const templateId = TEMPLATES[settings.template] || settings.template || TEMPLATES['default'];
|
|
151
|
-
|
|
144
|
+
|
|
145
|
+
// Resolve sender category
|
|
146
|
+
const sender = SENDERS[settings.sender] || null;
|
|
147
|
+
const brandDomain = brandData.contact.email.split('@')[1];
|
|
148
|
+
|
|
149
|
+
// From: explicit from > sender-derived > brand default
|
|
150
|
+
const from = settings.from
|
|
151
|
+
|| (sender && {
|
|
152
|
+
email: `${sender.localPart}@${brandDomain}`,
|
|
153
|
+
name: sender.displayName.replace('{brand}', brandData.name),
|
|
154
|
+
})
|
|
155
|
+
|| { email: brandData.contact.email, name: brandData.name };
|
|
156
|
+
|
|
157
|
+
// ASM group: explicit group > sender-derived > default
|
|
158
|
+
const groupId = settings.group != null
|
|
159
|
+
? (GROUPS[settings.group] || settings.group)
|
|
160
|
+
: (sender ? sender.group : GROUPS['account']);
|
|
152
161
|
|
|
153
162
|
// Build categories
|
|
154
163
|
const categories = _.uniq([
|
|
@@ -161,10 +170,8 @@ Email.prototype.build = async function (settings) {
|
|
|
161
170
|
const sendAt = normalizeSendAt(settings.sendAt);
|
|
162
171
|
|
|
163
172
|
// Build unsubscribe URL
|
|
164
|
-
// Generate HMAC signature for unsubscribe link verification
|
|
165
173
|
const crypto = require('crypto');
|
|
166
174
|
const unsubSig = crypto.createHmac('sha256', process.env.UNSUBSCRIBE_HMAC_KEY).update(to[0].email.toLowerCase()).digest('hex');
|
|
167
|
-
|
|
168
175
|
const unsubscribeUrl = `${Manager.project.websiteUrl}/portal/email-preferences?email=${encode(to[0].email)}&asmId=${encode(groupId)}&templateId=${encode(templateId)}&sig=${unsubSig}`;
|
|
169
176
|
|
|
170
177
|
// Build signoff
|
|
@@ -217,19 +224,19 @@ Email.prototype.build = async function (settings) {
|
|
|
217
224
|
to,
|
|
218
225
|
cc,
|
|
219
226
|
bcc,
|
|
220
|
-
from
|
|
221
|
-
replyTo: settings.replyTo ||
|
|
227
|
+
from,
|
|
228
|
+
replyTo: settings.replyTo || from.email,
|
|
222
229
|
subject,
|
|
223
230
|
templateId,
|
|
224
|
-
asm: { groupId },
|
|
225
231
|
categories,
|
|
226
232
|
dynamicTemplateData,
|
|
227
233
|
substitutionWrappers: ['{{', '}}'],
|
|
228
|
-
headers: {
|
|
229
|
-
'List-Unsubscribe': `<${unsubscribeUrl}>`,
|
|
230
|
-
},
|
|
231
234
|
};
|
|
232
235
|
|
|
236
|
+
// ASM group + unsubscribe header (always present on every email)
|
|
237
|
+
email.asm = { groupId };
|
|
238
|
+
email.headers = { 'List-Unsubscribe': `<${unsubscribeUrl}>` };
|
|
239
|
+
|
|
233
240
|
// Set sendAt
|
|
234
241
|
if (sendAt) {
|
|
235
242
|
email.sendAt = sendAt;
|
|
@@ -257,7 +264,7 @@ Email.prototype.build = async function (settings) {
|
|
|
257
264
|
* @returns {{ status: string, options?: object, response?: object }}
|
|
258
265
|
* @throws {Error} With code 400 for validation errors, code 500 for send failures
|
|
259
266
|
*/
|
|
260
|
-
|
|
267
|
+
Transactional.prototype.send = async function (settings) {
|
|
261
268
|
const self = this;
|
|
262
269
|
const Manager = self.Manager;
|
|
263
270
|
const admin = self.admin;
|
|
@@ -272,9 +279,9 @@ Email.prototype.send = async function (settings) {
|
|
|
272
279
|
const sendgrid = Manager.require('@sendgrid/mail');
|
|
273
280
|
sendgrid.setApiKey(process.env.SENDGRID_API_KEY);
|
|
274
281
|
|
|
275
|
-
// If scheduled beyond the limit, queue it
|
|
282
|
+
// If scheduled beyond the limit, queue it for later
|
|
276
283
|
if (email.sendAt && email.sendAt >= moment().add(SEND_AT_LIMIT, 'hours').unix()) {
|
|
277
|
-
await saveToEmailQueue(email, admin, assistant);
|
|
284
|
+
await saveToEmailQueue(settings, email.sendAt, admin, assistant);
|
|
278
285
|
|
|
279
286
|
return {
|
|
280
287
|
status: 'queued',
|
|
@@ -315,8 +322,13 @@ Email.prototype.send = async function (settings) {
|
|
|
315
322
|
// --- Private helpers ---
|
|
316
323
|
|
|
317
324
|
/**
|
|
318
|
-
* Normalize recipient input into a consistent array of { email, name? } objects.
|
|
319
|
-
*
|
|
325
|
+
* Normalize recipient input into a consistent array of { email, name?, _userDoc? } objects.
|
|
326
|
+
*
|
|
327
|
+
* Accepts:
|
|
328
|
+
* - Email string: 'user@example.com'
|
|
329
|
+
* - UID string (no @): 'abc123' — fetched from Firestore in resolveRecipients
|
|
330
|
+
* - Email object: { email: 'user@example.com', name: 'John' }
|
|
331
|
+
* - User doc object: { auth: { email: '...' }, personal: { name: { first: '...' } }, ... }
|
|
320
332
|
*/
|
|
321
333
|
function normalizeRecipients(input) {
|
|
322
334
|
if (!input) {
|
|
@@ -332,11 +344,18 @@ function normalizeRecipients(input) {
|
|
|
332
344
|
}
|
|
333
345
|
|
|
334
346
|
if (typeof item === 'string') {
|
|
335
|
-
if (item.
|
|
336
|
-
result.push({ _uid: item.slice(4) });
|
|
337
|
-
} else {
|
|
347
|
+
if (item.includes('@')) {
|
|
338
348
|
result.push({ email: item });
|
|
349
|
+
} else {
|
|
350
|
+
result.push({ _uid: item });
|
|
339
351
|
}
|
|
352
|
+
} else if (typeof item === 'object' && item.auth?.email) {
|
|
353
|
+
// Full user doc — extract email/name and stash doc for template data
|
|
354
|
+
result.push({
|
|
355
|
+
email: item.auth.email,
|
|
356
|
+
...(item.personal?.name?.first && { name: item.personal.name.first }),
|
|
357
|
+
_userDoc: item,
|
|
358
|
+
});
|
|
340
359
|
} else if (typeof item === 'object' && item.email) {
|
|
341
360
|
result.push({ email: item.email, ...(item.name && { name: item.name }) });
|
|
342
361
|
}
|
|
@@ -346,7 +365,8 @@ function normalizeRecipients(input) {
|
|
|
346
365
|
}
|
|
347
366
|
|
|
348
367
|
/**
|
|
349
|
-
* Resolve any
|
|
368
|
+
* Resolve any UID recipients by fetching user docs from Firestore.
|
|
369
|
+
* Stashes the full user doc as `_userDoc` for template data resolution in build().
|
|
350
370
|
*/
|
|
351
371
|
async function resolveRecipients(recipients, admin, assistant) {
|
|
352
372
|
const uidEntries = recipients.filter(r => r._uid);
|
|
@@ -388,6 +408,7 @@ async function resolveRecipients(recipients, admin, assistant) {
|
|
|
388
408
|
resolved.push({
|
|
389
409
|
email,
|
|
390
410
|
...(data?.personal?.name?.first && { name: data.personal.name.first }),
|
|
411
|
+
_userDoc: data,
|
|
391
412
|
});
|
|
392
413
|
}
|
|
393
414
|
|
|
@@ -456,23 +477,28 @@ function normalizeSendAt(sendAt) {
|
|
|
456
477
|
}
|
|
457
478
|
|
|
458
479
|
/**
|
|
459
|
-
* Save
|
|
480
|
+
* Save original settings to queue for deferred sending (beyond 71h limit).
|
|
481
|
+
* Stores the raw settings so the email can be re-sent through the full
|
|
482
|
+
* build pipeline when the cron picks it up.
|
|
460
483
|
*/
|
|
461
|
-
async function saveToEmailQueue(
|
|
462
|
-
const
|
|
484
|
+
async function saveToEmailQueue(settings, sendAt, admin, assistant) {
|
|
485
|
+
const powertools = require('node-powertools');
|
|
486
|
+
const emailId = powertools.random(32, { type: 'alphanumeric' });
|
|
463
487
|
|
|
464
|
-
// Clone and clean
|
|
465
|
-
const
|
|
488
|
+
// Clone and clean undefined values for Firestore
|
|
489
|
+
const settingsCloned = _.cloneDeepWith(settings, (value) => {
|
|
466
490
|
if (typeof value === 'undefined') {
|
|
467
491
|
return null;
|
|
468
492
|
}
|
|
469
493
|
});
|
|
470
|
-
delete emailCloned.dynamicTemplateData._stringified;
|
|
471
494
|
|
|
472
|
-
assistant.log(`saveToEmailQueue(): Saving
|
|
495
|
+
assistant.log(`saveToEmailQueue(): Saving ${emailId}, sendAt=${sendAt}`);
|
|
473
496
|
|
|
474
|
-
await admin.firestore().doc(`
|
|
475
|
-
.set(
|
|
497
|
+
await admin.firestore().doc(`emails-queue/${emailId}`)
|
|
498
|
+
.set({
|
|
499
|
+
settings: settingsCloned,
|
|
500
|
+
sendAt,
|
|
501
|
+
})
|
|
476
502
|
.then(() => assistant.log(`saveToEmailQueue(): Success ${emailId}`))
|
|
477
503
|
.catch(e => assistant.error(`saveToEmailQueue(): Failed ${emailId}`, e));
|
|
478
504
|
}
|
|
@@ -500,38 +526,4 @@ function saveAuditTrail(email, messageId, admin, assistant) {
|
|
|
500
526
|
.catch(e => assistant.error(`Audit trail failed: ${messageId}`, e));
|
|
501
527
|
}
|
|
502
528
|
|
|
503
|
-
|
|
504
|
-
* Create an Error with a code property for distinguishing build (400) vs send (500) failures.
|
|
505
|
-
*/
|
|
506
|
-
function errorWithCode(message, code) {
|
|
507
|
-
const err = new Error(message);
|
|
508
|
-
err.code = code;
|
|
509
|
-
return err;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/**
|
|
513
|
-
* Convert SVG image URLs to PNG equivalents — email clients don't render SVGs.
|
|
514
|
-
* CDN naming convention: `-x.svg` → `-1024.png`
|
|
515
|
-
*/
|
|
516
|
-
function sanitizeImagesForEmail(images) {
|
|
517
|
-
const result = {};
|
|
518
|
-
|
|
519
|
-
for (const [key, value] of Object.entries(images)) {
|
|
520
|
-
if (typeof value === 'string' && value.endsWith('.svg')) {
|
|
521
|
-
result[key] = value.replace(/-x\.svg$/, '-1024.png');
|
|
522
|
-
} else {
|
|
523
|
-
result[key] = value;
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
return result;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
/**
|
|
531
|
-
* URL-encode a value as base64
|
|
532
|
-
*/
|
|
533
|
-
function encode(s) {
|
|
534
|
-
return encodeURIComponent(Buffer.from(String(s)).toString('base64'));
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
module.exports = Email;
|
|
529
|
+
module.exports = Transactional;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email validation — single entry point for all email quality checks
|
|
3
|
+
*
|
|
4
|
+
* Available checks (run in this order):
|
|
5
|
+
* - format — basic email regex
|
|
6
|
+
* - disposable — checks against known disposable domain list
|
|
7
|
+
* - localPart — blocks spam/junk local parts (test, noreply, all-numeric, etc.)
|
|
8
|
+
* - mailbox — verifies mailbox exists via API (costs money, requires ZEROBOUNCE_API_KEY)
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* validate(email) // All free checks (format + disposable + localPart)
|
|
12
|
+
* validate(email, { checks: ['format', 'disposable'] }) // Only format + disposable
|
|
13
|
+
* validate(email, { checks: ALL_CHECKS }) // Everything including mailbox
|
|
14
|
+
*
|
|
15
|
+
* Used by:
|
|
16
|
+
* - routes/marketing/contact/post.js
|
|
17
|
+
* - functions/core/actions/api/general/add-marketing-contact.js
|
|
18
|
+
* - routes/user/signup/post.js (disposable check only)
|
|
19
|
+
*/
|
|
20
|
+
const fetch = require('wonderful-fetch');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
// Load disposable domains list once at module level
|
|
24
|
+
const DISPOSABLE_DOMAINS = require(path.join(__dirname, '..', 'disposable-domains.json'));
|
|
25
|
+
const DISPOSABLE_SET = new Set(DISPOSABLE_DOMAINS.map(d => d.toLowerCase()));
|
|
26
|
+
|
|
27
|
+
// Spam/junk local parts — exact matches (checked after stripping +suffix)
|
|
28
|
+
const BLOCKED_LOCAL_PARTS = new Set([
|
|
29
|
+
// Generic/test
|
|
30
|
+
'test', 'testing', 'tester', 'test1', 'test123',
|
|
31
|
+
'example', 'sample', 'demo', 'dummy', 'fake', 'temp',
|
|
32
|
+
// System/role addresses
|
|
33
|
+
'noreply', 'no-reply', 'donotreply', 'do-not-reply',
|
|
34
|
+
'mailer-daemon', 'postmaster', 'webmaster', 'hostmaster',
|
|
35
|
+
'abuse', 'spam', 'root',
|
|
36
|
+
// Keyboard walks / junk
|
|
37
|
+
'asdf', 'qwerty', 'zxcv', 'asd', 'qwe',
|
|
38
|
+
'aaa', 'bbb', 'xxx', 'zzz',
|
|
39
|
+
'abc', 'abc123', 'abcdef',
|
|
40
|
+
// Placeholder
|
|
41
|
+
'user', 'email', 'mail', 'hello', 'info',
|
|
42
|
+
'admin', 'administrator', 'support',
|
|
43
|
+
'contact', 'name', 'firstname', 'lastname',
|
|
44
|
+
'foo', 'bar', 'baz', 'foobar',
|
|
45
|
+
'null', 'undefined', 'none', 'anonymous',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// Patterns that indicate junk local parts (checked after stripping +suffix)
|
|
49
|
+
const BLOCKED_LOCAL_PATTERNS = [
|
|
50
|
+
/^\d+$/, // All numeric: 123456
|
|
51
|
+
/^(.)\1{2,}$/, // Repeating single char: aaaa, xxxx
|
|
52
|
+
/^[a-z]{1,2}\d+$/, // Single letter + numbers: a123, x999
|
|
53
|
+
/^test[._-]/, // Starts with test separator: test.user, test_123
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// Format regex
|
|
57
|
+
const EMAIL_FORMAT = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
58
|
+
|
|
59
|
+
// Default checks (all free checks — mailbox excluded because it costs money)
|
|
60
|
+
const DEFAULT_CHECKS = ['format', 'disposable', 'localPart'];
|
|
61
|
+
|
|
62
|
+
// All available checks
|
|
63
|
+
const ALL_CHECKS = ['format', 'disposable', 'localPart', 'mailbox'];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate an email address through selected checks.
|
|
67
|
+
*
|
|
68
|
+
* Returns valid: true on mailbox API errors so emails are never silently dropped.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} email
|
|
71
|
+
* @param {object} [options]
|
|
72
|
+
* @param {Array<string>} [options.checks] - Which checks to run (default: DEFAULT_CHECKS)
|
|
73
|
+
* @returns {{ valid: boolean, checks: { format?: object, disposable?: object, localPart?: object, mailbox?: object } }}
|
|
74
|
+
*/
|
|
75
|
+
async function validate(email, options = {}) {
|
|
76
|
+
const checks = new Set(options.checks || DEFAULT_CHECKS);
|
|
77
|
+
const result = { valid: true, checks: {} };
|
|
78
|
+
|
|
79
|
+
// 1. Format
|
|
80
|
+
if (checks.has('format')) {
|
|
81
|
+
if (!email || !EMAIL_FORMAT.test(email)) {
|
|
82
|
+
result.valid = false;
|
|
83
|
+
result.checks.format = { valid: false, reason: 'Invalid email format' };
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
result.checks.format = { valid: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const [rawLocalPart, domain] = (email || '').toLowerCase().split('@');
|
|
91
|
+
|
|
92
|
+
// 2. Disposable domain
|
|
93
|
+
if (checks.has('disposable') && domain) {
|
|
94
|
+
if (DISPOSABLE_SET.has(domain)) {
|
|
95
|
+
result.valid = false;
|
|
96
|
+
result.checks.disposable = { valid: false, blocked: true, domain };
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
result.checks.disposable = { valid: true, blocked: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. Local part — strip +suffix before checking
|
|
104
|
+
if (checks.has('localPart') && rawLocalPart) {
|
|
105
|
+
const localPart = rawLocalPart.split('+')[0];
|
|
106
|
+
|
|
107
|
+
if (BLOCKED_LOCAL_PARTS.has(localPart)) {
|
|
108
|
+
result.valid = false;
|
|
109
|
+
result.checks.localPart = { valid: false, blocked: true, localPart, reason: 'Blocked local part' };
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const blockedPattern = BLOCKED_LOCAL_PATTERNS.find((p) => p.test(localPart));
|
|
114
|
+
|
|
115
|
+
if (blockedPattern) {
|
|
116
|
+
result.valid = false;
|
|
117
|
+
result.checks.localPart = { valid: false, blocked: true, localPart, reason: 'Matches junk pattern' };
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
result.checks.localPart = { valid: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 4. Mailbox verification (ZeroBounce)
|
|
125
|
+
if (checks.has('mailbox')) {
|
|
126
|
+
if (!process.env.ZEROBOUNCE_API_KEY) {
|
|
127
|
+
result.checks.mailbox = { valid: true, skipped: true, reason: 'No API key' };
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const data = await fetch(
|
|
133
|
+
`https://api.zerobounce.net/v2/validate?api_key=${process.env.ZEROBOUNCE_API_KEY}&email=${encodeURIComponent(email)}`,
|
|
134
|
+
{ response: 'json', timeout: 10000 }
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (data.error) {
|
|
138
|
+
console.error('ZeroBounce API error:', data.error);
|
|
139
|
+
result.checks.mailbox = { valid: true, error: data.error };
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!data.status) {
|
|
144
|
+
console.error('ZeroBounce unexpected response:', data);
|
|
145
|
+
result.checks.mailbox = { valid: true, error: 'Unexpected response format' };
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const zbValid = data.status === 'valid';
|
|
150
|
+
result.checks.mailbox = {
|
|
151
|
+
valid: zbValid,
|
|
152
|
+
status: data.status,
|
|
153
|
+
subStatus: data.sub_status || null,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (!zbValid) {
|
|
157
|
+
result.valid = false;
|
|
158
|
+
}
|
|
159
|
+
} catch (e) {
|
|
160
|
+
console.error('ZeroBounce validation error:', e);
|
|
161
|
+
result.checks.mailbox = { valid: true, error: e.message };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { validate, DEFAULT_CHECKS, ALL_CHECKS };
|
|
@@ -29,7 +29,7 @@ const GENERIC_DOMAINS = new Set([
|
|
|
29
29
|
* @returns {{ firstName: string, lastName: string, company: string, confidence: number, method: string }}
|
|
30
30
|
*/
|
|
31
31
|
async function inferContact(email, assistant) {
|
|
32
|
-
if (process.env.
|
|
32
|
+
if (process.env.BACKEND_MANAGER_OPENAI_API_KEY) {
|
|
33
33
|
const aiResult = await inferContactWithAI(email, assistant);
|
|
34
34
|
if (aiResult && (aiResult.firstName || aiResult.lastName)) {
|
|
35
35
|
return aiResult;
|
|
@@ -22,9 +22,9 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
22
22
|
assistant.log('Running cron job:', settings.id);
|
|
23
23
|
|
|
24
24
|
// Run the cron job
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
).catch(e => e);
|
|
25
|
+
const cronPath = require('path').resolve(__dirname, `../../../cron/${settings.id}.js`);
|
|
26
|
+
const cronHandler = require(cronPath);
|
|
27
|
+
const result = await cronHandler({ Manager, assistant, context: {} }).catch(e => e);
|
|
28
28
|
|
|
29
29
|
if (result instanceof Error) {
|
|
30
30
|
return assistant.respond(result.message, { code: 500 });
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Admin-only endpoint to send transactional emails.
|
|
5
5
|
* Supports flexible recipient formats (string, object, UID, or arrays of mixed).
|
|
6
6
|
*
|
|
7
|
-
* See: src/manager/libraries/email
|
|
7
|
+
* See: src/manager/libraries/email/ for the shared email builder and sender.
|
|
8
8
|
*/
|
|
9
9
|
module.exports = async ({ assistant, user, settings }) => {
|
|
10
10
|
// Require authentication
|
|
@@ -32,7 +32,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
32
32
|
if (!doc.exists) {
|
|
33
33
|
await stats.set({
|
|
34
34
|
users: { total: 0 },
|
|
35
|
-
|
|
35
|
+
brand: Manager.config?.brand?.id || null,
|
|
36
36
|
});
|
|
37
37
|
data = { users: { total: 0 } };
|
|
38
38
|
}
|
|
@@ -63,7 +63,7 @@ async function updateStats(assistant, existingData, update) {
|
|
|
63
63
|
const { admin } = Manager.libraries;
|
|
64
64
|
const stats = admin.firestore().doc('meta/stats');
|
|
65
65
|
const newData = {
|
|
66
|
-
|
|
66
|
+
brand: Manager.config?.brand?.id || null,
|
|
67
67
|
};
|
|
68
68
|
|
|
69
69
|
assistant.log('updateStats(): Starting...');
|
|
@@ -14,10 +14,10 @@ module.exports = function (payload, config) {
|
|
|
14
14
|
email: payload.email,
|
|
15
15
|
name: payload.name,
|
|
16
16
|
},
|
|
17
|
+
sender: 'marketing',
|
|
17
18
|
categories: ['download'],
|
|
18
19
|
subject: `Free ${config.brand.name} download link for ${payload.name || 'you'}!`,
|
|
19
20
|
template: 'main/misc/app-download-link',
|
|
20
|
-
group: 'marketing',
|
|
21
21
|
copy: false,
|
|
22
22
|
data: {},
|
|
23
23
|
}
|