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
package/CHANGELOG.md CHANGED
@@ -14,6 +14,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.0.149] - 2026-03-14
18
+ ### Added
19
+ - Modular email library (`libraries/email/`) — replaces monolithic `libraries/email.js` with provider-based architecture
20
+ - Marketing contact providers: SendGrid (`providers/sendgrid.js`) and Beehiiv (`providers/beehiiv.js`) with add/remove/sync operations
21
+ - Email validation library (`libraries/email/validation.js`) — format, local-part, and disposable domain checks with configurable check selection
22
+ - Runtime SendGrid custom field ID resolution — fetches field definitions from API and caches name→ID mapping (no hardcoded IDs)
23
+ - 15 marketing custom fields synced to SendGrid/Beehiiv: brand, auth, subscription, payment, and attribution data
24
+ - `PUT /marketing/contact` admin route for triggering contact sync by UID
25
+ - `POST /marketing/contact` now syncs full custom field data on signup
26
+ - Marketing contact sync in payment webhook pipeline — subscription changes automatically update SendGrid/Beehiiv custom fields
27
+ - `mailer.sync(uid)` method for full contact re-sync from Firestore user doc
28
+ - `resolveFieldValues()` in `constants.js` — SSOT for building custom field payloads from user docs
29
+ - `User.resolveSubscription()` now includes `everPaid` field for marketing segmentation
30
+ - `TEST_EXTENDED_MODE` propagation from emulator to Firebase function workers
31
+ - `TEST_EXTENDED_MODE` mismatch detection between test runner and emulator via health check
32
+ - Email queue cron processor (`cron/frequent/email-queue.js`) — processes deferred emails every 10 minutes via the full `email.send()` pipeline
33
+ - Feedback route review URL builder with full site URLs
34
+ - 28 email validation unit tests, 7 marketing contact route tests, 5 marketing lifecycle integration tests
35
+
36
+ ### Changed
37
+ - Refactored `libraries/email.js` into modular `libraries/email/` directory (index, constants, validation, providers)
38
+ - `POST /marketing/contact` validation now uses configurable check selection instead of boolean `skipValidation`
39
+ - `DELETE /marketing/contact` uses new provider-based removal
40
+ - Marketing contact schemas updated to match new validation options
41
+ - `on-delete` auth event now uses new email library for contact removal
42
+ - `saveToEmailQueue` now stores raw settings instead of pre-built SendGrid email, so queued emails re-enter the full build pipeline
43
+ - Renamed `email-queue` collection to `emails-queue`
44
+ - Feedback schema: renamed `like`/`dislike` fields to `positive`/`negative`
45
+ - Feedback review prompt logic now checks total positive feedback length (50+ chars)
46
+ - Renamed `GET /app` route to `GET /brand` (completes app→brand migration)
47
+
48
+ ### Removed
49
+ - Monolithic `libraries/email.js` — replaced by modular `libraries/email/` directory
50
+
51
+ # [5.0.148] - 2026-03-14
52
+ ### Added
53
+ - Semantic email sender system — pass `sender: 'orders'` to `Email.send()` to auto-resolve from address, display name, and SendGrid ASM group
54
+ - 7 sender categories: `orders`, `hello`, `account`, `marketing`, `security`, `newsletter`, `internal`
55
+ - 7 dedicated SendGrid ASM groups for granular unsubscribe control
56
+ - 4 new email tests for sender resolution, override precedence, and fallback behavior
57
+
58
+ ### Changed
59
+ - Migrated all email call sites from `group:` to `sender:` parameter
60
+ - `sendOrderEmail()` now accepts optional `sender` parameter (defaults to `'orders'`)
61
+ - `replyTo` now defaults to the resolved from address instead of brand default
62
+
63
+ # [5.0.147] - 2026-03-14
64
+ ### Added
65
+ - 24-hour cancellation guard on `POST /payments/cancel` — blocks cancellations for subscriptions younger than 24 hours
66
+
67
+ # [5.0.146] - 2026-03-13
68
+ ### Added
69
+ - Promo discount support in payment analytics — `resolveActualValue()` computes effective price accounting for trials ($0) and percentage discounts
70
+ - Promo discount details (code, percent, savings, totalToday) in new-subscription order confirmation emails
71
+
72
+ ### Changed
73
+ - `trackPayment()` and `resolvePaymentEvent()` now accept `order` parameter to access discount data
74
+
17
75
  # [5.0.144] - 2026-03-13
18
76
  ### Added
19
77
  - `User.resolveSubscription()` static method that derives calculated subscription fields (plan, active, trialing, cancelling) from raw user data
package/CLAUDE.md CHANGED
@@ -1058,6 +1058,32 @@ Key rules:
1058
1058
 
1059
1059
  The `test` processor generates Stripe-shaped data and auto-fires webhooks to the local server. Only available in non-production environments. Use `processor: 'test'` in intent requests during testing. The test webhook processor delegates to Stripe's parser since it generates Stripe-shaped payloads.
1060
1060
 
1061
+ ## Marketing Custom Fields
1062
+
1063
+ BEM syncs user data to marketing providers (SendGrid, Beehiiv) as custom fields. Field definitions live in a single dictionary; OMEGA provisions them in each provider.
1064
+
1065
+ ### Adding a New Field
1066
+
1067
+ 1. Add the field to `FIELDS` in `src/manager/libraries/email/constants.js` — the key IS the field name in both providers. Set `source`, `path`, `type`.
1068
+ 2. Add matching entry in OMEGA's `src/lib/bem-fields.js` with `name`, `display`, `type`. If Beehiiv has it built-in (e.g., country, utm_source), set `beehiivBuiltIn: true`.
1069
+ 3. Run OMEGA: `npm start -- --service=sendgrid,beehiiv --brand=X`
1070
+ 4. BEM resolves field IDs at runtime — no provider code changes needed.
1071
+
1072
+ ### How It Works
1073
+
1074
+ - **SendGrid**: `resolveFieldIds()` fetches field definitions from the SendGrid API, builds a name-to-ID cache, and maps values to SendGrid's auto-generated IDs (e.g., `brand_id` maps to `e35_T`).
1075
+ - **Beehiiv**: BEM uses the key directly as the custom field name — no ID resolution needed.
1076
+ - **OMEGA**: The `ensure/custom-fields.js` handlers are idempotent — they fetch existing fields and only create what is missing.
1077
+
1078
+ ### Key Files
1079
+
1080
+ | Purpose | File |
1081
+ |---------|------|
1082
+ | Field dictionary (BEM SSOT) | `src/manager/libraries/email/constants.js` |
1083
+ | Field provisioning list (OMEGA SSOT) | `omega-manager/src/lib/bem-fields.js` |
1084
+ | SendGrid provisioning | `omega-manager/src/services/sendgrid/ensure/custom-fields.js` |
1085
+ | Beehiiv provisioning | `omega-manager/src/services/beehiiv/ensure/custom-fields.js` |
1086
+
1061
1087
  ## Common Mistakes to Avoid
1062
1088
 
1063
1089
  1. **Don't modify Manager internals directly** - Use factory methods and public APIs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.147",
3
+ "version": "5.0.149",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -7,6 +7,7 @@ const JSON5 = require('json5');
7
7
  const powertools = require('node-powertools');
8
8
  const WatchCommand = require('./watch');
9
9
  const { DEFAULT_EMULATOR_PORTS } = require('./setup-tests/emulator-config');
10
+ const { EXTENDED_MODE_WARNING } = require('../../test/utils/extended-mode-warning');
10
11
 
11
12
  class EmulatorCommand extends BaseCommand {
12
13
  async execute() {
@@ -15,9 +16,9 @@ class EmulatorCommand extends BaseCommand {
15
16
 
16
17
  // Warn if TEST_EXTENDED_MODE is enabled
17
18
  if (process.env.TEST_EXTENDED_MODE) {
18
- this.log(chalk.yellow.bold('\n ⚠️⚠️⚠️ WARNING: TEST_EXTENDED_MODE IS TRUE ⚠️⚠️⚠️'));
19
- this.log(chalk.yellow(' External API calls (emails, SendGrid, etc.) are ENABLED!'));
20
- this.log(chalk.yellow(' This will send real emails and make real API calls.\n'));
19
+ this.log(chalk.yellow.bold(`\n ${EXTENDED_MODE_WARNING[0]}`));
20
+ EXTENDED_MODE_WARNING.slice(1).forEach((line) => this.log(chalk.yellow(` ${line}`)));
21
+ this.log('');
21
22
  }
22
23
 
23
24
  // Start BEM watcher in background
@@ -59,13 +60,22 @@ class EmulatorCommand extends BaseCommand {
59
60
  // hosting is included so localhost:5002 rewrites work (e.g., /backend-manager -> bm_api)
60
61
  // pubsub is included so scheduled functions (bm_cronDaily) can be triggered in tests
61
62
  // Use double quotes for command wrapper since the command may contain single quotes (JSON strings)
62
- const emulatorCommand = `BEM_TESTING=true firebase emulators:exec --only functions,firestore,auth,database,hosting,pubsub --ui "${command}"`;
63
+ const envPrefix = process.env.TEST_EXTENDED_MODE
64
+ ? 'BEM_TESTING=true TEST_EXTENDED_MODE=true'
65
+ : 'BEM_TESTING=true';
66
+ const emulatorCommand = `${envPrefix} firebase emulators:exec --only functions,firestore,auth,database,hosting,pubsub --ui "${command}"`;
63
67
 
64
68
  // Set up log file in the project directory
65
69
  const logPath = path.join(projectDir, 'functions', 'emulator.log');
66
70
  const logStream = fs.createWriteStream(logPath, { flags: 'w' });
67
71
  const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
68
72
 
73
+ // Write pre-emulator info to log file
74
+ if (process.env.TEST_EXTENDED_MODE) {
75
+ EXTENDED_MODE_WARNING.forEach((line) => logStream.write(`${line}\n`));
76
+ logStream.write('\n');
77
+ }
78
+
69
79
  this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
70
80
 
71
81
  await powertools.execute(emulatorCommand, {
@@ -103,15 +103,13 @@ class TestCommand extends BaseCommand {
103
103
  return null;
104
104
  }
105
105
 
106
- // Derive convenience values
107
- const projectId = config.firebaseConfig?.projectId;
106
+ // Derive computed values (not in config file)
108
107
  const backendManagerKey = argv.key || process.env.BACKEND_MANAGER_KEY;
109
- const appId = config.brand?.id;
110
108
  const contactEmail = config.brand?.contact?.email || '';
111
109
  const domain = contactEmail.includes('@') ? contactEmail.split('@')[1] : '';
112
110
 
113
111
  // Validate required configuration
114
- if (!projectId) {
112
+ if (!config.firebaseConfig?.projectId) {
115
113
  this.logError('Error: Missing firebaseConfig.projectId in backend-manager-config.json');
116
114
  return null;
117
115
  }
@@ -122,7 +120,7 @@ class TestCommand extends BaseCommand {
122
120
  return null;
123
121
  }
124
122
 
125
- if (!appId) {
123
+ if (!config.brand?.id) {
126
124
  this.logError('Error: Missing brand.id in backend-manager-config.json');
127
125
  return null;
128
126
  }
@@ -132,15 +130,11 @@ class TestCommand extends BaseCommand {
132
130
  return null;
133
131
  }
134
132
 
135
- // Pass entire config + convenience aliases used by runner/helpers
133
+ // Pass entire config + computed values not in config file
136
134
  return {
137
135
  ...config,
138
- appId,
139
- projectId,
140
136
  backendManagerKey,
141
137
  domain,
142
- brandName: config.brand?.name,
143
- githubRepoWebsite: config.github?.repo_website,
144
138
  };
145
139
  }
146
140
 
@@ -4,7 +4,7 @@ const moment = require('moment');
4
4
  const JSON5 = require('json5');
5
5
 
6
6
  const PROMPT = `
7
- Company: {app.brand.name}: {app.brand.description}
7
+ Company: {brand.brand.name}: {brand.brand.description}
8
8
  Date: {date}
9
9
  Instructions: {prompt}
10
10
 
@@ -29,11 +29,11 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
29
29
  // Set post ID
30
30
  postId = moment().unix();
31
31
 
32
- // Build app object from local config
33
- const appObject = buildAppObject(Manager.config);
32
+ // Build brand config from local config
33
+ const brandConfig = buildBrandConfig(Manager.config);
34
34
 
35
35
  // Log
36
- assistant.log('App object', appObject);
36
+ assistant.log('Brand config', brandConfig);
37
37
 
38
38
  // Get settings
39
39
  const settingsArray = powertools.arrayify(Manager.config.ghostii);
@@ -48,22 +48,22 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
48
48
  settings.chance = settings.chance || 1.0;
49
49
  settings.author = settings.author || undefined;
50
50
 
51
- // Resolve app data for this ghostii item
52
- if (settings.app && settings.appUrl) {
53
- // Cross-app: fetch from the other project's /app endpoint
54
- settings.app = await fetchRemoteApp(settings.appUrl).catch((e) => e);
51
+ // Resolve brand data for this ghostii item
52
+ if (settings.brand && settings.brandUrl) {
53
+ // Cross-brand: fetch from the other project's /brand endpoint
54
+ settings.brand = await fetchRemoteBrand(settings.brandUrl).catch((e) => e);
55
55
 
56
- if (settings.app instanceof Error) {
57
- assistant.error('Error fetching remote app data', settings.app);
56
+ if (settings.brand instanceof Error) {
57
+ assistant.error('Error fetching remote brand data', settings.brand);
58
58
  continue;
59
59
  }
60
60
  } else {
61
- // Same-app: use local config
62
- settings.app = appObject;
61
+ // Same-brand: use local config
62
+ settings.brand = brandConfig;
63
63
  }
64
64
 
65
65
  // Log
66
- assistant.log(`Settings (app=${settings.app.brand.id})`, settings);
66
+ assistant.log(`Settings (brand=${settings.brand.brand.id})`, settings);
67
67
 
68
68
  // Quit if articles are disabled
69
69
  if (!settings.articles || !settings.sources.length) {
@@ -90,19 +90,19 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
90
90
  };
91
91
 
92
92
  /**
93
- * Build app object from Manager.config (same shape as /app endpoint response)
93
+ * Build brand config from Manager.config (same shape as /brand endpoint response)
94
94
  */
95
- function buildAppObject(config) {
96
- const { buildPublicConfig } = require(require('path').join(__dirname, '..', '..', 'routes', 'app', 'get.js'));
95
+ function buildBrandConfig(config) {
96
+ const { buildPublicConfig } = require(require('path').join(__dirname, '..', '..', 'routes', 'brand', 'get.js'));
97
97
 
98
98
  return buildPublicConfig(config);
99
99
  }
100
100
 
101
101
  /**
102
- * Fetch app data from a remote BEM project's /app endpoint
102
+ * Fetch brand data from a remote BEM project's /brand endpoint
103
103
  */
104
- function fetchRemoteApp(appUrl) {
105
- return fetch(`${appUrl}/backend-manager/app`, {
104
+ function fetchRemoteBrand(brandUrl) {
105
+ return fetch(`${brandUrl}/backend-manager/brand`, {
106
106
  timeout: 30000,
107
107
  tries: 3,
108
108
  response: 'json',
@@ -113,7 +113,7 @@ async function harvest(assistant, settings) {
113
113
  const date = moment().format('MMMM YYYY');
114
114
 
115
115
  // Log
116
- assistant.log(`harvest(): Starting ${settings.app.brand.id}...`);
116
+ assistant.log(`harvest(): Starting ${settings.brand.brand.id}...`);
117
117
 
118
118
  // Process the number of sources in the settings
119
119
  for (let index = 0; index < settings.articles; index++) {
@@ -209,16 +209,16 @@ function requestGhostii(settings, content) {
209
209
  description: content,
210
210
  insertLinks: true,
211
211
  headerImageUrl: 'unsplash',
212
- url: settings.app.brand.url,
212
+ url: settings.brand.brand.url,
213
213
  sectionQuantity: powertools.random(3, 6, { mode: 'gaussian' }),
214
- feedUrl: `${settings.app.brand.url}/feeds/posts.json`,
214
+ feedUrl: `${settings.brand.brand.url}/feeds/posts.json`,
215
215
  links: settings.links,
216
216
  },
217
217
  });
218
218
  }
219
219
 
220
220
  function uploadPost(assistant, settings, article) {
221
- const apiUrl = `https://api.${(settings.app.brand.url || '').replace(/^https?:\/\//, '')}`;
221
+ const apiUrl = `https://api.${(settings.brand.brand.url || '').replace(/^https?:\/\//, '')}`;
222
222
  return fetch(`${apiUrl}/backend-manager/admin/post`, {
223
223
  method: 'POST',
224
224
  timeout: 90000,
@@ -236,8 +236,8 @@ function uploadPost(assistant, settings, article) {
236
236
  categories: article.categories,
237
237
  tags: article.keywords,
238
238
  path: 'ghostii',
239
- githubUser: settings.app.github.user,
240
- githubRepo: settings.app.github.repo,
239
+ githubUser: settings.brand.github.user,
240
+ githubRepo: settings.brand.github.repo,
241
241
  },
242
242
  });
243
243
  }
@@ -1,6 +1,5 @@
1
1
  const powertools = require('node-powertools');
2
2
  const { REMINDER_DELAYS, COLLECTION } = require('../../libraries/abandoned-cart-config.js');
3
- const { sendOrderEmail } = require('../../events/firestore/payments-webhooks/transitions/send-email.js');
4
3
 
5
4
  /**
6
5
  * Abandoned cart reminder cron job
@@ -26,6 +25,7 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
26
25
 
27
26
  assistant.log(`Processing ${snapshot.size} abandoned cart reminder(s)...`);
28
27
 
28
+ const email = Manager.Email(assistant);
29
29
  let sent = 0;
30
30
  let completed = 0;
31
31
  let skipped = 0;
@@ -68,12 +68,12 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
68
68
  // Send reminder email
69
69
  assistant.log(`Sending abandoned cart reminder #${reminderIndex + 1} to uid=${uid}, product=${data.productId}`);
70
70
 
71
- sendOrderEmail({
71
+ email.send({
72
+ sender: 'marketing',
73
+ to: userDoc,
72
74
  template: 'main/order/abandoned-cart',
73
75
  subject: `Complete your ${brandName} ${productName} checkout`,
74
76
  categories: ['order/abandoned-cart', `order/abandoned-cart/reminder-${reminderIndex + 1}`],
75
- userDoc,
76
- assistant,
77
77
  copy: false,
78
78
  data: {
79
79
  abandonedCart: {
@@ -87,7 +87,9 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
87
87
  checkoutUrl: checkoutUrl,
88
88
  },
89
89
  },
90
- });
90
+ })
91
+ .then(() => assistant.log(`Abandoned cart email sent for uid=${uid}`))
92
+ .catch((e) => assistant.error(`Abandoned cart email failed for uid=${uid}: ${e.message}`));
91
93
 
92
94
  sent++;
93
95
 
@@ -0,0 +1,56 @@
1
+ const moment = require('moment');
2
+
3
+ // Must match the SEND_AT_LIMIT in email.js
4
+ const SEND_AT_LIMIT = 71;
5
+
6
+ /**
7
+ * Email queue processor cron job
8
+ *
9
+ * Picks up emails from the `emails-queue` collection that are now within
10
+ * SendGrid's 71-hour scheduling window and sends them back through the
11
+ * full email.send() pipeline (build, resolve recipients, send, audit trail).
12
+ *
13
+ * Emails land in this queue when their `sendAt` exceeds the 71-hour limit
14
+ * at the time of the original send() call (see email.js → saveToEmailQueue).
15
+ */
16
+ module.exports = async ({ Manager, assistant, context, libraries }) => {
17
+ const { admin } = libraries;
18
+ const cutoff = moment().add(SEND_AT_LIMIT, 'hours').unix();
19
+
20
+ // Query emails that are now within the SendGrid scheduling window
21
+ const snapshot = await admin.firestore()
22
+ .collection('emails-queue')
23
+ .where('sendAt', '<=', cutoff)
24
+ .limit(100)
25
+ .get();
26
+
27
+ if (snapshot.empty) {
28
+ assistant.log('No queued emails ready to send');
29
+ return;
30
+ }
31
+
32
+ assistant.log(`Processing ${snapshot.size} queued email(s)...`);
33
+
34
+ const email = Manager.Email(assistant);
35
+
36
+ const results = await Promise.allSettled(snapshot.docs.map(async (doc) => {
37
+ const { settings } = doc.data();
38
+ const emailId = doc.id;
39
+
40
+ const result = await email.send(settings);
41
+ assistant.log(`Queued email ${emailId} ${result.status}`);
42
+
43
+ await doc.ref.delete();
44
+ }));
45
+
46
+ const sent = results.filter(r => r.status === 'fulfilled').length;
47
+ const failed = results.filter(r => r.status === 'rejected').length;
48
+
49
+ for (const r of results) {
50
+ if (r.status === 'rejected') {
51
+ assistant.error(`Failed to send queued email: ${r.reason?.message}`, r.reason);
52
+ }
53
+ }
54
+
55
+ assistant.log(`Completed! (${sent} sent, ${failed} failed)`);
56
+ };
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * This function fires on every sign-in (including right after account creation).
5
5
  * It updates last activity and sends sign-in analytics.
6
+ *
7
+ * TODO: Add mailer.sync(uid) here with 1x/day rate limit to keep marketing
8
+ * contact data (name, country, subscription fields) fresh between sessions.
6
9
  */
7
10
  module.exports = async ({ Manager, assistant, user, context, libraries }) => {
8
11
  const startTime = Date.now();
@@ -58,6 +58,14 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
58
58
  return;
59
59
  }
60
60
 
61
+ // Remove marketing contact from all providers (non-blocking)
62
+ if (user.email) {
63
+ const email = Manager.Email(assistant);
64
+ email.remove(user.email)
65
+ .then((r) => assistant.log('onDelete: Marketing remove:', r))
66
+ .catch((e) => assistant.error('onDelete: Marketing remove failed:', e));
67
+ }
68
+
61
69
  // Send delete analytics (server-side only event)
62
70
  Manager.Analytics({
63
71
  assistant: assistant,
@@ -334,7 +334,8 @@ function sendDisputeEmail({ alert, match, result, alertId, assistant }) {
334
334
  }
335
335
 
336
336
  email.send({
337
- to: { email: brandEmail },
337
+ sender: 'internal',
338
+ to: brandEmail,
338
339
  subject: subject,
339
340
  template: 'main/basic/card',
340
341
  categories: ['order/dispute-alert'],
@@ -288,6 +288,15 @@ async function processPaymentEvent({ category, library, resource, resourceType,
288
288
  if (isSubscription) {
289
289
  await admin.firestore().doc(`users/${uid}`).set({ subscription: unified }, { merge: true });
290
290
  assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
291
+
292
+ // Sync marketing contact with updated subscription data (non-blocking)
293
+ if (shouldRunHandlers) {
294
+ const email = Manager.Email(assistant);
295
+ const updatedUserDoc = { ...userData, subscription: unified };
296
+ email.sync(updatedUserDoc)
297
+ .then((r) => assistant.log('Marketing sync after payment:', r))
298
+ .catch((e) => assistant.error('Marketing sync after payment failed:', e));
299
+ }
291
300
  }
292
301
 
293
302
  // Write to payments-orders/{orderId}
@@ -12,41 +12,27 @@ const moment = require('moment');
12
12
  * @param {string} options.subject - Email subject line
13
13
  * @param {string[]} options.categories - SendGrid categories for filtering
14
14
  * @param {object} options.data - Template data (passed as-is to the email)
15
- * @param {object} options.userDoc - User document data (already fetched by on-write.js)
15
+ * @param {object} options.userDoc - User document data (passed as `to` — email.js extracts email/name and user template data)
16
16
  * @param {object} options.assistant - Assistant instance
17
17
  */
18
- function sendOrderEmail({ template, subject, categories, data, userDoc, assistant, copy }) {
18
+ function sendOrderEmail({ template, subject, categories, data, userDoc, assistant, copy, sender = 'orders' }) {
19
19
  const email = assistant.Manager.Email(assistant);
20
-
21
- const userEmail = userDoc?.auth?.email;
22
- const userName = userDoc?.personal?.name?.first;
23
20
  const uid = userDoc?.auth?.uid;
24
21
 
25
- if (!userEmail) {
22
+ if (!userDoc?.auth?.email) {
26
23
  assistant.error(`sendOrderEmail(): No email found for uid=${uid}, skipping`);
27
24
  return;
28
25
  }
29
26
 
30
- // Strip sensitive fields before passing to email template
31
- const safeUser = { ...userDoc };
32
- delete safeUser.api;
33
- delete safeUser.oauth2;
34
- delete safeUser.activity;
35
- delete safeUser.affiliate;
36
- delete safeUser.attribution;
37
- delete safeUser.flags;
38
-
39
- const settings = {
40
- to: { email: userEmail, ...(userName && { name: userName }) },
27
+ email.send({
28
+ sender,
29
+ to: userDoc,
41
30
  subject,
42
31
  template,
43
32
  categories,
44
33
  copy: copy !== false,
45
- user: safeUser,
46
34
  data,
47
- };
48
-
49
- email.send(settings)
35
+ })
50
36
  .then((result) => {
51
37
  assistant.log(`sendOrderEmail(): Success template=${template}, uid=${uid}, status=${result.status}`);
52
38
  })
@@ -38,7 +38,7 @@ Module.prototype.main = function () {
38
38
  if (!doc.exists) {
39
39
  await stats.set({
40
40
  users: { total: 0 },
41
- app: Manager.config?.app?.id || null,
41
+ brand: Manager.config?.brand?.id || null,
42
42
  });
43
43
  data = { users: { total: 0 } };
44
44
  }
@@ -102,7 +102,7 @@ Module.prototype.updateStats = function (existingData, update) {
102
102
  // Set defaults
103
103
  let error = null;
104
104
  let newData = {
105
- app: self.Manager.config?.app?.id || null,
105
+ brand: self.Manager.config?.brand?.id || null,
106
106
  };
107
107
 
108
108
  // Log
@@ -131,7 +131,7 @@ Module.prototype.defaultize = function () {
131
131
  email: {},
132
132
  personalization: {},
133
133
  signoff: {},
134
- app: {},
134
+ brand: {},
135
135
  user: {},
136
136
  data: {},
137
137
  },
@@ -181,14 +181,14 @@ Module.prototype.defaultize = function () {
181
181
 
182
182
  email.dynamicTemplateData.user = Manager.User(options.user).properties;
183
183
 
184
- // Get app configuration from Manager.config.brand (backend-manager-config.json)
184
+ // Get brand configuration from Manager.config.brand (backend-manager-config.json)
185
185
  const brand = Manager.config?.brand;
186
186
  if (!brand) {
187
187
  return reject(new Error('Missing brand configuration in backend-manager-config.json'));
188
188
  }
189
189
 
190
- // Build app object from brand config
191
- const app = {
190
+ // Build brand object for email template data
191
+ const brandData = {
192
192
  id: brand.id,
193
193
  name: brand.name,
194
194
  url: brand.url,
@@ -196,11 +196,11 @@ Module.prototype.defaultize = function () {
196
196
  images: brand.images || {},
197
197
  };
198
198
 
199
- if (!app.email) {
199
+ if (!brandData.email) {
200
200
  return reject(new Error('Missing brand.contact.email in backend-manager-config.json'));
201
201
  }
202
202
 
203
- email.dynamicTemplateData.app = app;
203
+ email.dynamicTemplateData.brand = brandData;
204
204
 
205
205
  // Add user to recipients
206
206
  email.to.push({
@@ -211,8 +211,8 @@ Module.prototype.defaultize = function () {
211
211
  // Add carbon copy recipients
212
212
  if (options.copy) {
213
213
  email.cc.push({
214
- email: email.dynamicTemplateData.app.email,
215
- name: email.dynamicTemplateData.app.name,
214
+ email: email.dynamicTemplateData.brand.email,
215
+ name: email.dynamicTemplateData.brand.name,
216
216
  });
217
217
  email.bcc.push(
218
218
  {
@@ -227,17 +227,17 @@ Module.prototype.defaultize = function () {
227
227
  }
228
228
 
229
229
  // Set email properties
230
- email.replyTo = email.replyTo || email.dynamicTemplateData.app.email;
230
+ email.replyTo = email.replyTo || email.dynamicTemplateData.brand.email;
231
231
  email.subject = email.subject || email.dynamicTemplateData.email.subject;
232
232
  email.dynamicTemplateData.email.subject = email.dynamicTemplateData.email.subject || email.subject;
233
233
  email.from = options.from || {
234
- email: email.dynamicTemplateData.app.email,
235
- name: email.dynamicTemplateData.app.name,
234
+ email: email.dynamicTemplateData.brand.email,
235
+ name: email.dynamicTemplateData.brand.name,
236
236
  };
237
237
  email.sendAt = options.sendAt;
238
238
 
239
239
  // Set categories
240
- email.categories = ['transactional', email.dynamicTemplateData.app.id, ...options.categories];
240
+ email.categories = ['transactional', email.dynamicTemplateData.brand.id, ...options.categories];
241
241
 
242
242
  // Remove duplicates from email lists
243
243
  email.to = filter(email.to);
@@ -283,7 +283,7 @@ Module.prototype.defaultize = function () {
283
283
  };
284
284
 
285
285
  // Build unsubscribe URL
286
- email.dynamicTemplateData.email.unsubscribeUrl = `https://itwcreativeworks.com/portal/account/email-preferences?email=${encode(email.to[0].email)}&asmId=${encode(email.asm.groupId)}&templateId=${encode(email.templateId)}&appName=${email.dynamicTemplateData.app.name}&appUrl=${email.dynamicTemplateData.app.url}`;
286
+ email.dynamicTemplateData.email.unsubscribeUrl = `https://itwcreativeworks.com/portal/account/email-preferences?email=${encode(email.to[0].email)}&asmId=${encode(email.asm.groupId)}&templateId=${encode(email.templateId)}&appName=${email.dynamicTemplateData.brand.name}&appUrl=${email.dynamicTemplateData.brand.url}`;
287
287
  email.dynamicTemplateData.email.categories = email.categories;
288
288
  email.dynamicTemplateData.email.carbonCopy = options.copy;
289
289
 
@@ -306,7 +306,7 @@ Module.prototype.defaultize = function () {
306
306
 
307
307
  // Clone and clean data for stringified version
308
308
  const emailClonedData = _.cloneDeep(email.dynamicTemplateData);
309
- emailClonedData.app.sponsorships = {};
309
+ emailClonedData.brand.sponsorships = {};
310
310
  email.dynamicTemplateData._stringified = JSON.stringify(emailClonedData, null, 2);
311
311
 
312
312
  return resolve(email);