backend-manager 5.0.91 → 5.0.92

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 (59) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/CLAUDE.md +14 -6
  3. package/README.md +6 -6
  4. package/TODO-MARKETING.md +3 -0
  5. package/TODO-PAYMENT-v2.md +71 -0
  6. package/TODO.md +7 -0
  7. package/package.json +3 -3
  8. package/src/cli/commands/{emulators.js → emulator.js} +15 -15
  9. package/src/cli/commands/index.js +1 -1
  10. package/src/cli/commands/setup-tests/{emulators-config.js → emulator-config.js} +4 -4
  11. package/src/cli/commands/setup-tests/index.js +2 -2
  12. package/src/cli/commands/setup-tests/project-id-consistency.js +1 -1
  13. package/src/cli/commands/test.js +16 -16
  14. package/src/cli/index.js +4 -4
  15. package/src/manager/events/auth/on-create.js +5 -158
  16. package/src/manager/events/firestore/payments-webhooks/analytics.js +4 -3
  17. package/src/manager/events/firestore/payments-webhooks/on-write.js +54 -6
  18. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +1 -2
  19. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +1 -1
  20. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +22 -28
  21. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +1 -2
  22. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +1 -2
  23. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +1 -2
  24. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +1 -2
  25. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +1 -2
  26. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +1 -2
  27. package/src/manager/functions/core/actions/api/admin/send-email.js +0 -131
  28. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +2 -137
  29. package/src/manager/functions/core/actions/api/user/sign-up.js +1 -1
  30. package/src/manager/index.js +12 -0
  31. package/src/manager/libraries/email.js +483 -0
  32. package/src/manager/libraries/infer-contact.js +140 -0
  33. package/src/manager/libraries/prompts/infer-contact.md +43 -0
  34. package/src/manager/routes/admin/backup/post.js +4 -3
  35. package/src/manager/routes/admin/email/post.js +11 -428
  36. package/src/manager/routes/admin/hook/post.js +3 -2
  37. package/src/manager/routes/admin/notification/post.js +14 -12
  38. package/src/manager/routes/admin/post/post.js +5 -6
  39. package/src/manager/routes/admin/post/put.js +3 -2
  40. package/src/manager/routes/admin/stats/get.js +19 -10
  41. package/src/manager/routes/general/email/post.js +8 -21
  42. package/src/manager/routes/marketing/contact/post.js +2 -100
  43. package/src/manager/routes/payments/intent/post.js +0 -2
  44. package/src/manager/routes/payments/intent/processors/test.js +9 -10
  45. package/src/manager/routes/user/oauth2/_helpers.js +3 -2
  46. package/src/manager/routes/user/oauth2/delete.js +3 -3
  47. package/src/manager/routes/user/oauth2/get.js +2 -2
  48. package/src/manager/routes/user/oauth2/post.js +9 -9
  49. package/src/manager/routes/user/sessions/delete.js +4 -3
  50. package/src/manager/routes/user/signup/post.js +254 -54
  51. package/src/manager/schemas/admin/email/post.js +10 -5
  52. package/src/test/run-tests.js +1 -1
  53. package/test/functions/admin/send-email.js +1 -88
  54. package/test/helpers/email.js +381 -0
  55. package/test/helpers/infer-contact.js +299 -0
  56. package/test/routes/admin/email.js +41 -90
  57. package/REFACTOR-BEM-API.md +0 -76
  58. package/REFACTOR-MIDDLEWARE.md +0 -62
  59. package/REFACTOR-PAYMENT.md +0 -66
@@ -18,10 +18,11 @@
18
18
  * @param {object} options.unified - Unified subscription or one-time object
19
19
  * @param {string} options.uid - User ID
20
20
  * @param {string} options.processor - Payment processor (e.g., 'stripe', 'paypal')
21
- * @param {object} options.assistant - Assistant instance
22
- * @param {object} options.Manager - Manager instance
21
+ * @param {object} options.assistant - Assistant instance (Manager derived via assistant.Manager)
23
22
  */
24
- function trackPayment({ category, transitionName, unified, uid, processor, assistant, Manager }) {
23
+ function trackPayment({ category, transitionName, unified, uid, processor, assistant }) {
24
+ const Manager = assistant.Manager;
25
+
25
26
  try {
26
27
  // Resolve the analytics event to fire based on transition
27
28
  const event = resolvePaymentEvent(category, transitionName, unified, Manager.config);
@@ -14,8 +14,9 @@ const { trackPayment } = require('./analytics.js');
14
14
  * 4. Detects state transitions and dispatches handler files (non-blocking)
15
15
  * 5. Marks the webhook as completed
16
16
  */
17
- module.exports = async ({ Manager, assistant, change, context, libraries }) => {
18
- const { admin } = libraries;
17
+ module.exports = async ({ assistant, change, context }) => {
18
+ const Manager = assistant.Manager;
19
+ const admin = Manager.libraries.admin;
19
20
 
20
21
  const dataAfter = change.after.data();
21
22
 
@@ -79,7 +80,7 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
79
80
  throw new Error(`Unknown event category: ${category}`);
80
81
  }
81
82
 
82
- const transitionName = await processPaymentEvent({ category, library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager });
83
+ const transitionName = await processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant });
83
84
 
84
85
  // Mark webhook as completed (include transition name for auditing/testing)
85
86
  await webhookRef.set({
@@ -117,7 +118,9 @@ module.exports = async ({ Manager, assistant, change, context, libraries }) => {
117
118
  * 6. Track analytics (non-blocking)
118
119
  * 7. Write to Firestore (user doc for subscriptions + payments-orders)
119
120
  */
120
- async function processPaymentEvent({ category, library, resource, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, admin, assistant, Manager }) {
121
+ async function processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant }) {
122
+ const Manager = assistant.Manager;
123
+ const admin = Manager.libraries.admin;
121
124
  const isSubscription = category === 'subscription';
122
125
 
123
126
  // Staleness check: skip if a newer webhook already wrote to this order
@@ -137,6 +140,17 @@ async function processPaymentEvent({ category, library, resource, uid, processor
137
140
  const userData = userDoc.exists ? userDoc.data() : {};
138
141
  const before = isSubscription ? (userData.subscription || null) : null;
139
142
 
143
+ // Auto-fill user name from payment processor if not already set
144
+ if (!userData?.personal?.name?.first) {
145
+ const customerName = extractCustomerName(resource, resourceType);
146
+ if (customerName?.first) {
147
+ await admin.firestore().doc(`users/${uid}`).set({
148
+ personal: { name: customerName },
149
+ }, { merge: true });
150
+ assistant.log(`Auto-filled user name from ${resourceType}: ${customerName.first} ${customerName.last || ''}`);
151
+ }
152
+ }
153
+
140
154
  // Transform raw resource → unified object
141
155
  const transformOptions = { config: Manager.config, eventName: eventType, eventId: eventId };
142
156
  const unified = isSubscription
@@ -180,7 +194,7 @@ async function processPaymentEvent({ category, library, resource, uid, processor
180
194
 
181
195
  if (shouldRunHandlers) {
182
196
  transitions.dispatch(transitionName, category, {
183
- before, after: unified, order, uid, userDoc: userData, admin, assistant, Manager, eventType, eventId,
197
+ before, after: unified, order, uid, assistant,
184
198
  });
185
199
  } else {
186
200
  assistant.log(`Transition handler skipped (testing mode): ${category}/${transitionName}`);
@@ -189,7 +203,7 @@ async function processPaymentEvent({ category, library, resource, uid, processor
189
203
 
190
204
  // Track payment analytics (non-blocking)
191
205
  if (transitionName && shouldRunHandlers) {
192
- trackPayment({ category, transitionName, unified, uid, processor, assistant, Manager });
206
+ trackPayment({ category, transitionName, unified, uid, processor, assistant });
193
207
  }
194
208
 
195
209
  // Write unified subscription to user doc (subscriptions only)
@@ -206,3 +220,37 @@ async function processPaymentEvent({ category, library, resource, uid, processor
206
220
 
207
221
  return transitionName;
208
222
  }
223
+
224
+ /**
225
+ * Extract customer name from a raw payment processor resource
226
+ *
227
+ * @param {object} resource - Raw processor resource (Stripe subscription, session, invoice)
228
+ * @param {string} resourceType - 'subscription' | 'session' | 'invoice'
229
+ * @returns {{ first: string, last: string }|null}
230
+ */
231
+ function extractCustomerName(resource, resourceType) {
232
+ let fullName = null;
233
+
234
+ // Checkout sessions have customer_details.name
235
+ if (resourceType === 'session') {
236
+ fullName = resource.customer_details?.name;
237
+ }
238
+
239
+ // Invoices have customer_name
240
+ if (resourceType === 'invoice') {
241
+ fullName = resource.customer_name;
242
+ }
243
+
244
+ // Subscriptions only have customer ID, no name
245
+
246
+ if (!fullName) {
247
+ return null;
248
+ }
249
+
250
+ const { capitalize } = require('../../../libraries/infer-contact.js');
251
+ const parts = fullName.trim().split(/\s+/);
252
+ return {
253
+ first: capitalize(parts[0]) || null,
254
+ last: capitalize(parts.slice(1).join(' ')) || null,
255
+ };
256
+ }
@@ -4,7 +4,7 @@
4
4
  */
5
5
  const { sendOrderEmail, formatDate } = require('../send-email.js');
6
6
 
7
- module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
7
+ module.exports = async function ({ before, after, order, uid, assistant }) {
8
8
  assistant.log(`Transition [one-time/purchase-completed]: uid=${uid}, resourceId=${after.payment.resourceId}`);
9
9
 
10
10
  sendOrderEmail({
@@ -13,7 +13,6 @@ module.exports = async function ({ before, after, order, uid, assistant, Manager
13
13
  categories: ['order/confirmation'],
14
14
  uid,
15
15
  assistant,
16
- Manager,
17
16
  data: {
18
17
  order: {
19
18
  ...order,
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * NOTE: No email template exists for this transition yet. Keeping as stub.
6
6
  */
7
- module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
7
+ module.exports = async function ({ before, after, order, uid, assistant }) {
8
8
  assistant.log(`Transition [one-time/purchase-failed]: uid=${uid}, orderId=${order?.id}`);
9
9
 
10
10
  // TODO: Send payment failure email once template is created
@@ -1,45 +1,39 @@
1
1
  /**
2
2
  * Shared email helper for payment transition handlers
3
- * Sends transactional order emails via POST /admin/email
3
+ * Sends transactional order emails directly via the shared email library (no HTTP round-trip)
4
4
  */
5
5
  const moment = require('moment');
6
6
 
7
7
  /**
8
- * Send an order email via the admin/email endpoint (fire-and-forget)
8
+ * Send an order email directly using the shared email library (fire-and-forget)
9
9
  *
10
10
  * @param {object} options
11
11
  * @param {string} options.template - SendGrid dynamic template ID
12
12
  * @param {string} options.subject - Email subject line
13
13
  * @param {string[]} options.categories - SendGrid categories for filtering
14
- * @param {object} options.data - Template data (passed as-is to the email API)
15
- * @param {string} options.uid - User UID (endpoint resolves email from Firestore)
14
+ * @param {object} options.data - Template data (passed as-is to the email)
15
+ * @param {string} options.uid - User UID (resolved from Firestore for email + name)
16
16
  * @param {object} options.assistant - Assistant instance
17
- * @param {object} options.Manager - Manager instance
18
17
  */
19
- function sendOrderEmail({ template, subject, categories, data, uid, assistant, Manager }) {
20
- const fetch = require('wonderful-fetch');
18
+ function sendOrderEmail({ template, subject, categories, data, uid, assistant }) {
19
+ const email = assistant.Manager.Email(assistant);
21
20
 
22
- fetch(`${Manager.project.apiUrl}/backend-manager/admin/email`, {
23
- method: 'POST',
24
- response: 'json',
25
- timeout: 30000,
26
- body: {
27
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
28
- user: { auth: { uid } },
29
- subject: subject,
30
- template: template,
31
- categories: categories,
32
- copy: false,
33
- ensureUnique: true,
34
- data: data,
35
- },
36
- })
37
- .then((json) => {
38
- assistant.log(`sendOrderEmail(): Success template=${template}, uid=${uid}, status=${json?.data?.status || 'unknown'}`);
39
- })
40
- .catch((e) => {
41
- assistant.error(`sendOrderEmail(): Failed template=${template}, uid=${uid}: ${e.message}`);
42
- });
21
+ const settings = {
22
+ to: `uid:${uid}`,
23
+ subject,
24
+ template,
25
+ categories,
26
+ copy: false,
27
+ data,
28
+ };
29
+
30
+ email.send(settings)
31
+ .then((result) => {
32
+ assistant.log(`sendOrderEmail(): Success template=${template}, uid=${uid}, status=${result.status}`);
33
+ })
34
+ .catch((e) => {
35
+ assistant.error(`sendOrderEmail(): Failed template=${template}, uid=${uid}: ${e.message}`);
36
+ });
43
37
  }
44
38
 
45
39
  /**
@@ -4,7 +4,7 @@
4
4
  */
5
5
  const { sendOrderEmail, formatDate } = require('../send-email.js');
6
6
 
7
- module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
7
+ module.exports = async function ({ before, after, order, uid, assistant }) {
8
8
  assistant.log(`Transition [subscription/cancellation-requested]: uid=${uid}, product=${after.product.id}, cancelDate=${after.cancellation.date?.timestamp}`);
9
9
 
10
10
  sendOrderEmail({
@@ -13,7 +13,6 @@ module.exports = async function ({ before, after, order, uid, assistant, Manager
13
13
  categories: ['order/cancellation-requested'],
14
14
  uid,
15
15
  assistant,
16
- Manager,
17
16
  data: {
18
17
  order: {
19
18
  ...order,
@@ -5,7 +5,7 @@
5
5
  */
6
6
  const { sendOrderEmail, formatDate } = require('../send-email.js');
7
7
 
8
- module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
8
+ module.exports = async function ({ before, after, order, uid, assistant }) {
9
9
  const isTrial = after.trial?.claimed === true;
10
10
 
11
11
  assistant.log(`Transition [subscription/new-subscription]: uid=${uid}, product=${after.product.id}, frequency=${after.payment.frequency}, trial=${isTrial}`);
@@ -16,7 +16,6 @@ module.exports = async function ({ before, after, order, uid, assistant, Manager
16
16
  categories: ['order/confirmation'],
17
17
  uid,
18
18
  assistant,
19
- Manager,
20
19
  data: {
21
20
  order: {
22
21
  ...order,
@@ -4,7 +4,7 @@
4
4
  */
5
5
  const { sendOrderEmail, formatDate } = require('../send-email.js');
6
6
 
7
- module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
7
+ module.exports = async function ({ before, after, order, uid, assistant }) {
8
8
  assistant.log(`Transition [subscription/payment-failed]: uid=${uid}, product=${after.product.id}, previousStatus=${before?.status}`);
9
9
 
10
10
  sendOrderEmail({
@@ -13,7 +13,6 @@ module.exports = async function ({ before, after, order, uid, assistant, Manager
13
13
  categories: ['order/payment-failed'],
14
14
  uid,
15
15
  assistant,
16
- Manager,
17
16
  data: {
18
17
  order: {
19
18
  ...order,
@@ -4,7 +4,7 @@
4
4
  */
5
5
  const { sendOrderEmail, formatDate } = require('../send-email.js');
6
6
 
7
- module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
7
+ module.exports = async function ({ before, after, order, uid, assistant }) {
8
8
  assistant.log(`Transition [subscription/payment-recovered]: uid=${uid}, product=${after.product.id}`);
9
9
 
10
10
  sendOrderEmail({
@@ -13,7 +13,6 @@ module.exports = async function ({ before, after, order, uid, assistant, Manager
13
13
  categories: ['order/payment-recovered'],
14
14
  uid,
15
15
  assistant,
16
- Manager,
17
16
  data: {
18
17
  order: {
19
18
  ...order,
@@ -4,7 +4,7 @@
4
4
  */
5
5
  const { sendOrderEmail, formatDate } = require('../send-email.js');
6
6
 
7
- module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
7
+ module.exports = async function ({ before, after, order, uid, assistant }) {
8
8
  const direction = after.product.id > before.product.id ? 'upgrade' : 'downgrade';
9
9
  assistant.log(`Transition [subscription/plan-changed]: uid=${uid}, ${before.product.id} → ${after.product.id} (${direction})`);
10
10
 
@@ -14,7 +14,6 @@ module.exports = async function ({ before, after, order, uid, assistant, Manager
14
14
  categories: ['order/plan-changed'],
15
15
  uid,
16
16
  assistant,
17
- Manager,
18
17
  data: {
19
18
  order: {
20
19
  ...order,
@@ -4,7 +4,7 @@
4
4
  */
5
5
  const { sendOrderEmail, formatDate } = require('../send-email.js');
6
6
 
7
- module.exports = async function ({ before, after, order, uid, assistant, Manager }) {
7
+ module.exports = async function ({ before, after, order, uid, assistant }) {
8
8
  assistant.log(`Transition [subscription/subscription-cancelled]: uid=${uid}, previousProduct=${before?.product?.id}, previousStatus=${before?.status}`);
9
9
 
10
10
  // Check if subscription has a future expiry (e.g., cancelled at period end)
@@ -16,7 +16,6 @@ module.exports = async function ({ before, after, order, uid, assistant, Manager
16
16
  categories: ['order/cancelled'],
17
17
  uid,
18
18
  assistant,
19
- Manager,
20
19
  data: {
21
20
  order: {
22
21
  ...order,
@@ -51,18 +51,6 @@ Module.prototype.main = function () {
51
51
  return reject(assistant.errorify(email.message, { code: 400 }));
52
52
  }
53
53
 
54
- // Check for duplicate emails being sent
55
- const uniqueResult = await self.ensureFirstInstance(email);
56
-
57
- // If not unique, return early
58
- if (!uniqueResult) {
59
- return resolve({
60
- data: {
61
- status: 'non-unique',
62
- },
63
- });
64
- }
65
-
66
54
  // If scheduled beyond SendGrid's limit, queue it
67
55
  if (email.sendAt && email.sendAt >= moment().add(SEND_AT_LIMIT, 'hours').unix()) {
68
56
  await self.saveToEmailQueue(email).catch(e => e);
@@ -151,7 +139,6 @@ Module.prototype.defaultize = function () {
151
139
 
152
140
  // Set defaults
153
141
  options.copy = typeof options.copy === 'undefined' ? true : options.copy;
154
- options.ensureUnique = typeof options.ensureUnique === 'undefined' ? true : options.ensureUnique;
155
142
  options.categories = powertools.arrayify(options.categories || []);
156
143
 
157
144
  email.to = powertools.arrayify(options.to || []);
@@ -262,38 +249,6 @@ Module.prototype.defaultize = function () {
262
249
  email.cc = email.cc.filter(obj => !email.to.some(obj2 => obj.email === obj2.email));
263
250
  email.bcc = email.bcc.filter(obj => !email.to.some(obj2 => obj.email === obj2.email));
264
251
 
265
- // Try to get contact name from SendGrid
266
- await fetch(`https://api.sendgrid.com/v3/marketing/contacts/search/emails`, {
267
- method: 'post',
268
- response: 'json',
269
- timeout: 60000,
270
- headers: {
271
- 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
272
- 'Content-Type': 'application/json',
273
- },
274
- body: {
275
- emails: email.to.map(obj => obj.email),
276
- },
277
- })
278
- .then((json) => {
279
- assistant.log('Got contact names', json);
280
-
281
- // Update names from contacts
282
- email.to.forEach((to) => {
283
- const match = json.result[to.email];
284
- if (match) {
285
- email.to[0].name = match.contact.first_name || email.dynamicTemplateData.user.personal.name.first;
286
- }
287
- });
288
- })
289
- .catch((e) => {
290
- if (e.status === 404) {
291
- assistant.log('Contact does not exist in database');
292
- } else {
293
- assistant.error('Failed to get contact names', e);
294
- }
295
- });
296
-
297
252
  // Log resolved email
298
253
  assistant.log('Resolved email.to', email.to);
299
254
 
@@ -331,7 +286,6 @@ Module.prototype.defaultize = function () {
331
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}`;
332
287
  email.dynamicTemplateData.email.categories = email.categories;
333
288
  email.dynamicTemplateData.email.carbonCopy = options.copy;
334
- email.dynamicTemplateData.email.ensureUnique = options.ensureUnique;
335
289
 
336
290
  // Handle raw HTML content (overrides template)
337
291
  if (options.html) {
@@ -350,16 +304,6 @@ Module.prototype.defaultize = function () {
350
304
  'List-Unsubscribe': `<${email.dynamicTemplateData.email.unsubscribeUrl}>`,
351
305
  };
352
306
 
353
- // Generate email hash for deduplication
354
- email.hash = crypto.createHash('sha256');
355
- email.hash.update(
356
- email.to.map(obj => obj.email).join(',')
357
- + email.from.email
358
- + email.subject
359
- + options.categories.join(',')
360
- );
361
- email.hash = email.hash.digest('hex');
362
-
363
307
  // Clone and clean data for stringified version
364
308
  const emailClonedData = _.cloneDeep(email.dynamicTemplateData);
365
309
  emailClonedData.app.sponsorships = {};
@@ -400,81 +344,6 @@ Module.prototype.saveToEmailQueue = function (email) {
400
344
  });
401
345
  };
402
346
 
403
- Module.prototype.ensureFirstInstance = function (email) {
404
- const self = this;
405
- const Manager = self.Manager;
406
- const assistant = self.assistant;
407
- const payload = self.payload;
408
-
409
- return new Promise(async function(resolve, reject) {
410
- const timeout = assistant.isDevelopment() ? 3000 : 45000;
411
- const { admin } = self.libraries;
412
-
413
- const hash = email.hash;
414
- const id = email.dynamicTemplateData.email.id;
415
- const options = payload.data.payload;
416
-
417
- assistant.log(`ensureFirstInstance(): Checking for unique email hash=${hash}, id=${id}`);
418
-
419
- // Skip uniqueness check if disabled
420
- if (!options.ensureUnique) {
421
- assistant.log(`ensureFirstInstance(): Skipping unique email check`);
422
- return resolve(true);
423
- }
424
-
425
- // Save email to temporary storage
426
- await admin.firestore().doc(`temporary/email-queue`).set({
427
- [hash]: {
428
- [id]: assistant.meta.startTime.timestampUNIX,
429
- },
430
- }, { merge: true })
431
- .then((doc) => {
432
- assistant.log(`ensureFirstInstance(): Saved email to temporary storage`, hash);
433
- })
434
- .catch((e) => {
435
- assistant.error(`ensureFirstInstance(): Failed to save email to temporary storage`, hash, e);
436
- });
437
-
438
- // Wait for timeout to allow duplicates to register
439
- assistant.log(`ensureFirstInstance(): Waiting for ${timeout / 1000} sec`);
440
- await powertools.poll(async (index) => {
441
- return false;
442
- }, { interval: 1000, timeout: timeout })
443
- .catch((e) => {
444
- assistant.log(`ensureFirstInstance(): Timeout reached`);
445
- });
446
-
447
- // Check if this is the first instance
448
- const result = await admin.firestore().doc(`temporary/email-queue`).get()
449
- .then((doc) => doc.data()?.[hash] || {})
450
- .catch((e) => ({}));
451
-
452
- const length = Object.keys(result).length;
453
- const isFirstInstance = length === 1 || result[id] === Math.min(...Object.values(result));
454
-
455
- assistant.log(`ensureFirstInstance(): Result`, result);
456
- assistant.log(`ensureFirstInstance(): Result isFirstInstance`, length, isFirstInstance);
457
-
458
- if (isFirstInstance) {
459
- // Delete email from temporary storage
460
- await admin.firestore().doc(`temporary/email-queue`).set({
461
- [hash]: FieldValue.delete(),
462
- }, { merge: true })
463
- .then((doc) => {
464
- assistant.log(`ensureFirstInstance(): Deleted email from temporary storage`, hash);
465
- })
466
- .catch((e) => {
467
- assistant.error(`ensureFirstInstance(): Failed to delete email from temporary storage`, hash, e);
468
- });
469
-
470
- return resolve(true);
471
- } else {
472
- assistant.warn(`ensureFirstInstance(): Email is not unique`, hash, length, result);
473
- return resolve(false);
474
- }
475
- });
476
- };
477
-
478
347
  // Helper to URL-encode base64
479
348
  function encode(s) {
480
349
  return encodeURIComponent(Buffer.from(String(s)).toString('base64'));
@@ -1,11 +1,11 @@
1
1
  const fetch = require('wonderful-fetch');
2
2
  const path = require('path');
3
3
  const dns = require('dns').promises;
4
- const OpenAI = require(path.join(__dirname, '..', '..', '..', '..', '..', 'libraries', 'openai'));
5
4
 
6
5
  // Load disposable domains list
7
6
  const DISPOSABLE_DOMAINS = require(path.join(__dirname, '..', '..', '..', '..', '..', 'libraries', 'disposable-domains.json'));
8
7
  const DISPOSABLE_SET = new Set(DISPOSABLE_DOMAINS.map(d => d.toLowerCase()));
8
+ const { inferContact } = require(path.join(__dirname, '..', '..', '..', '..', '..', 'libraries', 'infer-contact.js'));
9
9
 
10
10
  function Module() {}
11
11
 
@@ -108,7 +108,7 @@ Module.prototype.main = function () {
108
108
  // Infer name if not provided
109
109
  let nameInferred = null;
110
110
  if (!firstName && !lastName) {
111
- nameInferred = await inferName(email, assistant);
111
+ nameInferred = await inferContact(email, assistant);
112
112
  firstName = nameInferred.firstName;
113
113
  lastName = nameInferred.lastName;
114
114
  }
@@ -248,141 +248,6 @@ async function checkMxRecord(domain) {
248
248
  }
249
249
  }
250
250
 
251
- /**
252
- * Infer first/last name from email address
253
- * Uses ChatGPT if OPENAI_API_KEY exists, otherwise regex parsing
254
- */
255
- async function inferName(email, assistant) {
256
- // Try ChatGPT first if available
257
- if (process.env.OPENAI_API_KEY) {
258
- const aiResult = await inferNameWithAI(email, assistant);
259
- if (aiResult && (aiResult.firstName || aiResult.lastName)) {
260
- return aiResult;
261
- }
262
- }
263
-
264
- // Fallback to regex parsing
265
- return inferNameFromEmail(email);
266
- }
267
-
268
- /**
269
- * Use ChatGPT to infer name from email
270
- */
271
- async function inferNameWithAI(email, assistant) {
272
- try {
273
- const ai = new OpenAI(assistant);
274
- const result = await ai.request({
275
- // model: 'gpt-4.1-nano',
276
- model: 'gpt-5-mini',
277
- timeout: 30000,
278
- maxTokens: 1024,
279
- moderate: false,
280
- response: 'json',
281
- prompt: {
282
- content: `
283
- <identity>
284
- You extract names and company from email addresses.
285
- </identity>
286
-
287
- <format>
288
- Return ONLY valid JSON like so:
289
- {
290
- "firstName": "...", // First name <string>, capitalized
291
- "lastName": "...", // Last name <string>, capitalized
292
- "company": "...", // Company name <string>, capitalized
293
- "confidence": "..." // Confidence level <number>, 0-1 scale
294
- }
295
-
296
- If you cannot determine a name, use empty strings.
297
- </format>
298
-
299
- <examples>
300
- <example>
301
- <input>john.smith@acme.com</input>
302
- <output>{"firstName": "John", "lastName": "Smith", "company": "Acme", "confidence": 0.9}</output>
303
- </example>
304
- <example>
305
- <input>jsmith123@gmail.com</input>
306
- <output>{"firstName": "J", "lastName": "Smith", "company": "", "confidence": 0.4}</output>
307
- </example>
308
- <example>
309
- <input>support@bigcorp.io</input>
310
- <output>{"firstName": "", "lastName": "", "company": "Bigcorp", "confidence": 0.7}</output>
311
- </example>
312
- <example>
313
- <input>mary_jane_watson@stark-industries.com</input>
314
- <output>{"firstName": "Mary", "lastName": "Watson", "company": "Stark Industries", "confidence": 0.85}</output>
315
- </example>
316
- <example>
317
- <input>info@company.org</input>
318
- <output>{"firstName": "", "lastName": "", "company": "Company", "confidence": 0.6}</output>
319
- </example>
320
- </examples>
321
- `,
322
- },
323
- message: {
324
- content: `Email: ${email}`,
325
- },
326
- });
327
-
328
- if (result?.firstName !== undefined) {
329
- return {
330
- firstName: capitalize(result.firstName || ''),
331
- lastName: capitalize(result.lastName || ''),
332
- company: capitalize(result.company || ''),
333
- confidence: typeof result.confidence === 'number' ? result.confidence : 0.5,
334
- method: 'ai',
335
- };
336
- }
337
- } catch (e) {
338
- console.error('AI name inference error:', e);
339
- }
340
-
341
- return null;
342
- }
343
-
344
- /**
345
- * Regex-based name inference from email
346
- */
347
- function inferNameFromEmail(email) {
348
- const local = email.split('@')[0];
349
-
350
- // Remove trailing numbers
351
- const cleaned = local.replace(/[0-9]+$/, '');
352
-
353
- // Split on common separators
354
- const parts = cleaned.split(/[._-]/);
355
-
356
- if (parts.length >= 2) {
357
- return {
358
- firstName: capitalize(parts[0]),
359
- lastName: capitalize(parts.slice(1).join(' ')),
360
- confidence: 0.5,
361
- method: 'regex',
362
- };
363
- }
364
-
365
- return {
366
- firstName: capitalize(cleaned),
367
- lastName: '',
368
- confidence: 0.25,
369
- method: 'regex',
370
- };
371
- }
372
-
373
- /**
374
- * Capitalize first letter of each word
375
- */
376
- function capitalize(str) {
377
- if (!str) {
378
- return '';
379
- }
380
- return str
381
- .split(' ')
382
- .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
383
- .join(' ');
384
- }
385
-
386
251
  /**
387
252
  * Add contact to SendGrid Marketing Contacts
388
253
  */
@@ -112,7 +112,7 @@ Module.prototype.main = function () {
112
112
  });
113
113
  }
114
114
 
115
- // Note: SendGrid list and welcome emails are now handled by auth:on-create
115
+ // Note: SendGrid list, welcome emails, and name inference are handled by user/signup route
116
116
 
117
117
  return resolve({
118
118
  data: {