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.
Files changed (74) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/CLAUDE.md +26 -0
  3. package/package.json +1 -1
  4. package/src/cli/commands/emulator.js +14 -4
  5. package/src/cli/commands/test.js +4 -10
  6. package/src/manager/cron/daily/ghostii-auto-publisher.js +25 -25
  7. package/src/manager/cron/frequent/abandoned-carts.js +7 -5
  8. package/src/manager/cron/frequent/email-queue.js +56 -0
  9. package/src/manager/events/auth/before-signin.js +3 -0
  10. package/src/manager/events/auth/on-delete.js +8 -0
  11. package/src/manager/events/firestore/payments-disputes/on-write.js +2 -1
  12. package/src/manager/events/firestore/payments-webhooks/on-write.js +9 -0
  13. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +7 -21
  14. package/src/manager/functions/core/actions/api/admin/get-stats.js +2 -2
  15. package/src/manager/functions/core/actions/api/admin/send-email.js +14 -14
  16. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +22 -318
  17. package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +1 -1
  18. package/src/manager/functions/core/actions/api/general/remove-marketing-contact.js +2 -185
  19. package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
  20. package/src/manager/functions/core/actions/api/special/setup-electron-manager-client.js +2 -2
  21. package/src/manager/functions/core/actions/api/test/health.js +1 -0
  22. package/src/manager/helpers/api-manager.js +2 -2
  23. package/src/manager/helpers/user.js +3 -1
  24. package/src/manager/index.js +15 -10
  25. package/src/manager/libraries/email/constants.js +243 -0
  26. package/src/manager/libraries/email/index.js +145 -0
  27. package/src/manager/libraries/email/marketing/index.js +377 -0
  28. package/src/manager/libraries/email/providers/beehiiv.js +258 -0
  29. package/src/manager/libraries/email/providers/sendgrid.js +429 -0
  30. package/src/manager/libraries/{email.js → email/transactional/index.js} +91 -99
  31. package/src/manager/libraries/email/validation.js +168 -0
  32. package/src/manager/libraries/infer-contact.js +1 -1
  33. package/src/manager/routes/admin/cron/post.js +3 -3
  34. package/src/manager/routes/admin/email/post.js +1 -1
  35. package/src/manager/routes/admin/stats/get.js +2 -2
  36. package/src/manager/routes/{app → brand}/get.js +1 -1
  37. package/src/manager/routes/general/email/templates/download-app-link.js +1 -1
  38. package/src/manager/routes/marketing/contact/delete.js +2 -164
  39. package/src/manager/routes/marketing/contact/post.js +45 -298
  40. package/src/manager/routes/marketing/contact/put.js +39 -0
  41. package/src/manager/routes/payments/cancel/post.js +11 -0
  42. package/src/manager/routes/special/electron-client/post.js +3 -3
  43. package/src/manager/routes/test/health/get.js +1 -0
  44. package/src/manager/routes/user/data-request/delete.js +2 -2
  45. package/src/manager/routes/user/data-request/get.js +2 -2
  46. package/src/manager/routes/user/data-request/post.js +2 -2
  47. package/src/manager/routes/user/delete.js +1 -1
  48. package/src/manager/routes/user/feedback/post.js +12 -8
  49. package/src/manager/routes/user/signup/post.js +48 -37
  50. package/src/manager/schemas/admin/email/post.js +4 -4
  51. package/src/manager/schemas/marketing/contact/delete.js +3 -1
  52. package/src/manager/schemas/marketing/contact/post.js +3 -1
  53. package/src/manager/schemas/marketing/contact/put.js +6 -0
  54. package/src/manager/schemas/special/electron-client/post.js +2 -2
  55. package/src/manager/schemas/user/feedback/post.js +2 -2
  56. package/src/test/run-tests.js +1 -1
  57. package/src/test/runner.js +22 -10
  58. package/src/test/test-accounts.js +9 -0
  59. package/src/test/utils/extended-mode-warning.js +11 -0
  60. package/templates/_.env +1 -0
  61. package/test/events/payments/journey-payments-cancel-endpoint.js +11 -0
  62. package/test/events/payments/journey-payments-trial-cancel.js +11 -0
  63. package/test/functions/admin/edit-post.js +2 -2
  64. package/test/functions/admin/write-repo-content.js +2 -2
  65. package/test/functions/general/add-marketing-contact.js +21 -23
  66. package/test/helpers/email-validation.js +420 -0
  67. package/test/helpers/email.js +119 -6
  68. package/test/helpers/marketing-lifecycle.js +121 -0
  69. package/test/helpers/user.js +2 -2
  70. package/test/routes/admin/create-post.js +2 -2
  71. package/test/routes/admin/post.js +2 -2
  72. package/test/routes/admin/repo-content.js +2 -2
  73. package/test/routes/marketing/contact.js +21 -24
  74. package/test/routes/payments/cancel.js +18 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Shared email library for building and sending emails via SendGrid
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
- // SendGrid limit for scheduled emails (72 hours, but use 71 for buffer)
24
- const SEND_AT_LIMIT = 71;
25
-
26
- // Template shortcut map — callers use readable paths instead of SendGrid IDs
27
- // Paths mirror the email website structure: {category}/{subcategory}/{name}
28
- const TEMPLATES = {
29
- // v2 templates
30
- 'main/basic/card': 'd-1cd2eee44b6340268c964cd7971d49b9',
31
- 'main/engagement/feedback': 'd-319ab5c9d5074b21926a93562d6f41f6',
32
- 'main/misc/app-download-link': 'd-fc8b4834d7e1472896fe7e46152029f4',
33
- 'main/order/confirmation': 'd-5371ac2b4e3b490bbce51bfc2922ece8',
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
- Email.prototype.build = async function (settings) {
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 uid: prefixed recipients from Firestore
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
- // Build user template data from settings.user (for legacy callers that pass user object)
91
- const userProperties = Manager.User(settings.user || {}).properties;
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
- const groupId = GROUPS[settings.group] || settings.group || GROUPS['default'];
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: settings.from || { email: brandData.contact.email, name: brandData.name },
221
- replyTo: settings.replyTo || brandData.contact.email,
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
- Email.prototype.send = async function (settings) {
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
- * Entries with a `uid:` prefix are marked with `_uid` for later Firestore resolution.
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.startsWith('uid:')) {
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 uid-prefixed recipients by fetching user docs from Firestore.
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 email to queue for deferred sending (beyond 71h limit)
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(email, admin, assistant) {
462
- const emailId = email.dynamicTemplateData.email.id;
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 before storage
465
- const emailCloned = _.cloneDeepWith(email, (value) => {
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 email ${emailId}`);
495
+ assistant.log(`saveToEmailQueue(): Saving ${emailId}, sendAt=${sendAt}`);
473
496
 
474
- await admin.firestore().doc(`email-queue/${emailId}`)
475
- .set(emailCloned)
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.OPENAI_API_KEY) {
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 result = await Manager._process(
26
- (new (require(`../../functions/core/cron/${settings.id}.js`))()).init(Manager, { context: {} })
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.js for the shared email builder and sender.
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
- app: Manager.config?.app?.id || null,
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
- app: Manager.config?.app?.id || null,
66
+ brand: Manager.config?.brand?.id || null,
67
67
  };
68
68
 
69
69
  assistant.log('updateStats(): Starting...');
@@ -1,5 +1,5 @@
1
1
  /**
2
- * GET /app - Public app configuration
2
+ * GET /brand - Public brand configuration
3
3
  * Returns a safe subset of the project's config (no secrets)
4
4
  */
5
5
  module.exports = async ({ assistant, Manager }) => {
@@ -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
  }