backend-manager 5.0.91 → 5.0.93

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 (61) 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 +56 -6
  18. package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +3 -3
  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 +32 -28
  21. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +3 -3
  22. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +3 -3
  23. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +3 -3
  24. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +3 -3
  25. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +3 -3
  26. package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +3 -3
  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/general/emails/general:download-app-link.js +2 -2
  30. package/src/manager/functions/core/actions/api/user/sign-up.js +1 -1
  31. package/src/manager/index.js +12 -0
  32. package/src/manager/libraries/email.js +523 -0
  33. package/src/manager/libraries/infer-contact.js +140 -0
  34. package/src/manager/libraries/prompts/infer-contact.md +43 -0
  35. package/src/manager/routes/admin/backup/post.js +4 -3
  36. package/src/manager/routes/admin/email/post.js +11 -428
  37. package/src/manager/routes/admin/hook/post.js +3 -2
  38. package/src/manager/routes/admin/notification/post.js +14 -12
  39. package/src/manager/routes/admin/post/post.js +5 -6
  40. package/src/manager/routes/admin/post/put.js +3 -2
  41. package/src/manager/routes/admin/stats/get.js +19 -10
  42. package/src/manager/routes/general/email/post.js +8 -21
  43. package/src/manager/routes/general/email/templates/download-app-link.js +2 -2
  44. package/src/manager/routes/marketing/contact/post.js +2 -100
  45. package/src/manager/routes/payments/intent/post.js +0 -2
  46. package/src/manager/routes/payments/intent/processors/test.js +9 -10
  47. package/src/manager/routes/user/oauth2/_helpers.js +3 -2
  48. package/src/manager/routes/user/oauth2/delete.js +3 -3
  49. package/src/manager/routes/user/oauth2/get.js +2 -2
  50. package/src/manager/routes/user/oauth2/post.js +9 -9
  51. package/src/manager/routes/user/sessions/delete.js +4 -3
  52. package/src/manager/routes/user/signup/post.js +254 -54
  53. package/src/manager/schemas/admin/email/post.js +13 -8
  54. package/src/test/run-tests.js +1 -1
  55. package/test/functions/admin/send-email.js +1 -88
  56. package/test/helpers/email.js +421 -0
  57. package/test/helpers/infer-contact.js +299 -0
  58. package/test/routes/admin/email.js +41 -90
  59. package/REFACTOR-BEM-API.md +0 -76
  60. package/REFACTOR-MIDDLEWARE.md +0 -62
  61. package/REFACTOR-PAYMENT.md +0 -66
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Shared email library for building and sending emails via SendGrid
3
+ *
4
+ * Usage:
5
+ * const email = Manager.Email(assistant);
6
+ * const result = await email.send(settings);
7
+ *
8
+ * Used by:
9
+ * - POST /admin/email route
10
+ * - POST /general/email route
11
+ * - Payment transition handlers (send-email.js)
12
+ * - Auth on-create handler (welcome/checkup/feedback emails)
13
+ */
14
+ const _ = require('lodash');
15
+ const moment = require('moment');
16
+
17
+ // SendGrid limit for scheduled emails (72 hours, but use 71 for buffer)
18
+ const SEND_AT_LIMIT = 71;
19
+
20
+ // Template shortcut map — callers use readable paths instead of SendGrid IDs
21
+ // Paths mirror the email website structure: {category}/{subcategory}/{name}
22
+ const TEMPLATES = {
23
+ // v1 templates
24
+ 'main/basic/card': 'd-b7f8da3c98ad49a2ad1e187f3a67b546',
25
+ 'main/engagement/feedback': 'd-c1522214c67b47058669acc5a81ed663',
26
+ 'main/misc/app-download-link': 'd-1d730ac8cc544b7cbccc8fa4a4b3f9ce',
27
+
28
+ // v2 templates
29
+ 'main/order/confirmation': 'd-5371ac2b4e3b490bbce51bfc2922ece8',
30
+ 'main/order/payment-failed': 'd-e56af0ac62364bfb9e50af02854e2cd3',
31
+ 'main/order/payment-recovered': 'd-d6dbd17a260a4755b34a852ba09c2454',
32
+ 'main/order/cancellation-requested': 'd-78074f3e8c844146bf263b86fc8d5ecf',
33
+ 'main/order/cancelled': 'd-39041132e6b24e5ebf0e95bce2d94dba',
34
+ 'main/order/plan-changed': 'd-399086311bbb48b4b77bc90b20fb9d0a',
35
+ 'main/order/trial-ending': 'd-af8ab499cbfb4d56918b4118f44343b0',
36
+ };
37
+
38
+ // "default" resolves to the basic card template
39
+ TEMPLATES['default'] = TEMPLATES['main/basic/card'];
40
+
41
+ // Group shortcut map — SendGrid ASM group IDs
42
+ const GROUPS = {
43
+ 'default': 24077,
44
+ 'marketing': 25927,
45
+ 'account': 25928,
46
+ };
47
+
48
+ function Email(assistant) {
49
+ const self = this;
50
+
51
+ self.assistant = assistant;
52
+ self.Manager = assistant.Manager;
53
+ self.admin = self.Manager.libraries.admin;
54
+
55
+ return self;
56
+ }
57
+
58
+ /**
59
+ * Build a complete SendGrid email object from settings.
60
+ *
61
+ * @param {object} settings - Email settings (to, cc, bcc, subject, template, etc.)
62
+ * @returns {object} SendGrid-ready email object
63
+ * @throws {Error} On validation failure
64
+ */
65
+ Email.prototype.build = async function (settings) {
66
+ const self = this;
67
+ const Manager = self.Manager;
68
+ const admin = self.admin;
69
+ const assistant = self.assistant;
70
+ const powertools = require('node-powertools');
71
+
72
+ // Normalize recipients
73
+ let to = normalizeRecipients(settings.to);
74
+ let cc = normalizeRecipients(settings.cc);
75
+ let bcc = normalizeRecipients(settings.bcc);
76
+
77
+ // Resolve any uid: prefixed recipients from Firestore
78
+ [to, cc, bcc] = await Promise.all([
79
+ resolveRecipients(to, admin, assistant),
80
+ resolveRecipients(cc, admin, assistant),
81
+ resolveRecipients(bcc, admin, assistant),
82
+ ]);
83
+
84
+ // Build user template data from settings.user (for legacy callers that pass user object)
85
+ const userProperties = Manager.User(settings.user || {}).properties;
86
+
87
+ // Get brand config
88
+ const brand = Manager.config?.brand;
89
+
90
+ if (!brand) {
91
+ throw errorWithCode('Missing brand configuration in backend-manager-config.json', 400);
92
+ }
93
+
94
+ const app = {
95
+ id: brand.id,
96
+ name: brand.name,
97
+ url: brand.url,
98
+ email: brand.contact?.email,
99
+ images: sanitizeImagesForEmail(brand.images || {}),
100
+ };
101
+
102
+ if (!app.email) {
103
+ throw errorWithCode('Missing brand.contact.email in backend-manager-config.json', 400);
104
+ }
105
+
106
+ // Add carbon copy recipients
107
+ if (copy) {
108
+ cc.push({
109
+ email: app.email,
110
+ name: app.name,
111
+ });
112
+ bcc.push(
113
+ {
114
+ email: 'support@itwcreativeworks.com',
115
+ name: 'ITW Creative Works',
116
+ },
117
+ {
118
+ email: 'parser+carboncopy@sendgrid-parser.itwcreativeworks.com',
119
+ name: 'ITW Creative Works (Carbon Copy)',
120
+ }
121
+ );
122
+ }
123
+
124
+ // Deduplicate all lists
125
+ ({ to, cc, bcc } = deduplicateRecipients(to, cc, bcc));
126
+
127
+ // Delete empty names
128
+ for (const list of [to, cc, bcc]) {
129
+ for (const entry of list) {
130
+ if (!entry.name) {
131
+ delete entry.name;
132
+ }
133
+ }
134
+ }
135
+
136
+ // Validate
137
+ if (!to.length || !to[0].email) {
138
+ throw errorWithCode('Parameter to is required with at least one email', 400);
139
+ }
140
+
141
+ const subject = settings.subject || settings?.data?.email?.subject || null;
142
+
143
+ if (!subject) {
144
+ throw errorWithCode('Parameter subject is required', 400);
145
+ }
146
+
147
+ const templateId = TEMPLATES[settings.template] || settings.template || TEMPLATES['default'];
148
+ const groupId = GROUPS[settings.group] || settings.group || GROUPS['default'];
149
+ const copy = settings.copy ?? true;
150
+
151
+ // Build categories
152
+ const categories = _.uniq([
153
+ 'transactional',
154
+ app.id,
155
+ ...powertools.arrayify(settings.categories),
156
+ ]);
157
+
158
+ // Normalize sendAt
159
+ const sendAt = normalizeSendAt(settings.sendAt);
160
+
161
+ // Build unsubscribe URL
162
+ const unsubscribeUrl = `${Manager.project.websiteUrl}/portal/account/email-preferences?email=${encode(to[0].email)}&asmId=${encode(groupId)}&templateId=${encode(templateId)}&appName=${app.name}&appUrl=${app.url}`;
163
+
164
+ // Build signoff
165
+ const signoff = settings?.data?.signoff || {};
166
+ signoff.type = signoff.type || 'team';
167
+
168
+ if (signoff.type === 'personal') {
169
+ signoff.image = signoff.image
170
+ || 'https://cdn.itwcreativeworks.com/assets/ian-wiedenman/images/website/ian-wiedenman-headshot-2021-color-1024x1024.jpg';
171
+ signoff.name = signoff.name || 'Ian Wiedenman, CEO';
172
+ signoff.url = signoff.url || 'https://ianwiedenman.com';
173
+ signoff.urlText = signoff.urlText || '@ianwieds';
174
+ }
175
+
176
+ // Build dynamic template data
177
+ const dynamicTemplateData = {
178
+ email: {
179
+ id: Manager.require('uuid').v4(),
180
+ subject: settings?.data?.email?.subject || subject,
181
+ preview: settings?.data?.email?.preview || null,
182
+ body: settings?.data?.email?.body || null,
183
+ unsubscribeUrl,
184
+ categories,
185
+ footer: {
186
+ text: settings?.data?.email?.footer?.text || null,
187
+ },
188
+ carbonCopy: copy,
189
+ },
190
+ personalization: {
191
+ email: to[0].email,
192
+ name: to[0].name,
193
+ ...settings?.data?.personalization,
194
+ },
195
+ signoff,
196
+ app,
197
+ user: userProperties,
198
+ data: settings.data || {},
199
+ };
200
+
201
+ // Build the email object
202
+ const email = {
203
+ to,
204
+ cc,
205
+ bcc,
206
+ from: settings.from || { email: app.email, name: app.name },
207
+ replyTo: settings.replyTo || app.email,
208
+ subject,
209
+ templateId,
210
+ asm: { groupId },
211
+ categories,
212
+ dynamicTemplateData,
213
+ substitutionWrappers: ['{{', '}}'],
214
+ headers: {
215
+ 'List-Unsubscribe': `<${unsubscribeUrl}>`,
216
+ },
217
+ };
218
+
219
+ // Set sendAt
220
+ if (sendAt) {
221
+ email.sendAt = sendAt;
222
+ }
223
+
224
+ // Handle raw HTML override
225
+ if (settings.html) {
226
+ email.content = [{ type: 'text/html', value: settings.html }];
227
+ delete email.templateId;
228
+ }
229
+
230
+ // Build stringified version for template rendering
231
+ const clonedData = _.cloneDeep(dynamicTemplateData);
232
+ clonedData.app.sponsorships = {};
233
+ email.dynamicTemplateData._stringified = JSON.stringify(clonedData, null, 2);
234
+
235
+ return email;
236
+ };
237
+
238
+ /**
239
+ * Build and send an email via SendGrid, or queue it if scheduled beyond the limit.
240
+ * Calls .build() internally — callers only need to pass raw settings.
241
+ *
242
+ * @param {object} settings - Email settings (to, cc, bcc, subject, template, etc.)
243
+ * @returns {{ status: string, options?: object, response?: object }}
244
+ * @throws {Error} With code 400 for validation errors, code 500 for send failures
245
+ */
246
+ Email.prototype.send = async function (settings) {
247
+ const self = this;
248
+ const Manager = self.Manager;
249
+ const admin = self.admin;
250
+ const assistant = self.assistant;
251
+
252
+ assistant.log(`Email.send(): to=${JSON.stringify(settings.to)}, subject=${settings.subject}, template=${settings.template}`);
253
+
254
+ // Build email from settings (throws with code: 400 on validation failure)
255
+ const email = await self.build(settings);
256
+
257
+ // Initialize SendGrid
258
+ const sendgrid = Manager.require('@sendgrid/mail');
259
+ sendgrid.setApiKey(process.env.SENDGRID_API_KEY);
260
+
261
+ // If scheduled beyond the limit, queue it
262
+ if (email.sendAt && email.sendAt >= moment().add(SEND_AT_LIMIT, 'hours').unix()) {
263
+ await saveToEmailQueue(email, admin, assistant);
264
+
265
+ return {
266
+ status: 'queued',
267
+ options: email,
268
+ response: null,
269
+ };
270
+ }
271
+
272
+ // Send via SendGrid
273
+ const send = await sendgrid.send(email).catch(e => e);
274
+
275
+ if (send instanceof Error) {
276
+ const details = send?.response?.body?.errors || send;
277
+ assistant.error('Email send failed:', details);
278
+ throw errorWithCode(`Failed to send email: ${JSON.stringify(details)}`, 500);
279
+ }
280
+
281
+ // Extract message ID
282
+ const messageId = send[0].headers['x-message-id'];
283
+
284
+ assistant.log('Email send succeeded:', messageId, send);
285
+
286
+ // Save audit trail (non-blocking)
287
+ saveAuditTrail(email, messageId, admin, assistant);
288
+
289
+ // Track analytics
290
+ if (assistant.analytics) {
291
+ assistant.analytics.event('admin/email', { status: 'sent' });
292
+ }
293
+
294
+ return {
295
+ status: 'sent',
296
+ options: email,
297
+ response: send,
298
+ };
299
+ };
300
+
301
+ // --- Private helpers ---
302
+
303
+ /**
304
+ * Normalize recipient input into a consistent array of { email, name? } objects.
305
+ * Entries with a `uid:` prefix are marked with `_uid` for later Firestore resolution.
306
+ */
307
+ function normalizeRecipients(input) {
308
+ if (!input) {
309
+ return [];
310
+ }
311
+
312
+ const items = Array.isArray(input) ? input : [input];
313
+ const result = [];
314
+
315
+ for (const item of items) {
316
+ if (!item) {
317
+ continue;
318
+ }
319
+
320
+ if (typeof item === 'string') {
321
+ if (item.startsWith('uid:')) {
322
+ result.push({ _uid: item.slice(4) });
323
+ } else {
324
+ result.push({ email: item });
325
+ }
326
+ } else if (typeof item === 'object' && item.email) {
327
+ result.push({ email: item.email, ...(item.name && { name: item.name }) });
328
+ }
329
+ }
330
+
331
+ return result;
332
+ }
333
+
334
+ /**
335
+ * Resolve any uid-prefixed recipients by fetching user docs from Firestore.
336
+ */
337
+ async function resolveRecipients(recipients, admin, assistant) {
338
+ const uidEntries = recipients.filter(r => r._uid);
339
+ const nonUidEntries = recipients.filter(r => !r._uid);
340
+
341
+ if (uidEntries.length === 0) {
342
+ return nonUidEntries;
343
+ }
344
+
345
+ // Fetch all UIDs in parallel
346
+ const snapshots = await Promise.all(
347
+ uidEntries.map(entry =>
348
+ admin.firestore().doc(`users/${entry._uid}`).get()
349
+ .catch(e => {
350
+ assistant.error(`resolveRecipients(): Failed to fetch user ${entry._uid}`, e);
351
+ return null;
352
+ })
353
+ )
354
+ );
355
+
356
+ const resolved = [];
357
+
358
+ for (let i = 0; i < uidEntries.length; i++) {
359
+ const snap = snapshots[i];
360
+
361
+ if (!snap || !snap.exists) {
362
+ assistant.warn(`resolveRecipients(): User ${uidEntries[i]._uid} not found, skipping`);
363
+ continue;
364
+ }
365
+
366
+ const data = snap.data();
367
+ const email = data?.auth?.email;
368
+
369
+ if (!email) {
370
+ assistant.warn(`resolveRecipients(): User ${uidEntries[i]._uid} has no email, skipping`);
371
+ continue;
372
+ }
373
+
374
+ resolved.push({
375
+ email,
376
+ ...(data?.personal?.name?.first && { name: data.personal.name.first }),
377
+ });
378
+ }
379
+
380
+ return [...nonUidEntries, ...resolved];
381
+ }
382
+
383
+ /**
384
+ * Deduplicate recipients within each list and cross-dedup cc/bcc against to.
385
+ */
386
+ function deduplicateRecipients(to, cc, bcc) {
387
+ const dedup = (arr) => {
388
+ const seen = new Set();
389
+
390
+ return arr.filter(r => {
391
+ if (!r.email || typeof r.email !== 'string') {
392
+ return false;
393
+ }
394
+
395
+ const key = r.email.toLowerCase();
396
+
397
+ if (seen.has(key)) {
398
+ return false;
399
+ }
400
+
401
+ seen.add(key);
402
+ return true;
403
+ });
404
+ };
405
+
406
+ to = dedup(to);
407
+
408
+ const toEmails = new Set(to.map(r => r.email.toLowerCase()));
409
+ cc = dedup(cc).filter(r => !toEmails.has(r.email.toLowerCase()));
410
+
411
+ const toCcEmails = new Set([...toEmails, ...cc.map(r => r.email.toLowerCase())]);
412
+ bcc = dedup(bcc).filter(r => !toCcEmails.has(r.email.toLowerCase()));
413
+
414
+ return { to, cc, bcc };
415
+ }
416
+
417
+ /**
418
+ * Normalize sendAt to a unix timestamp (seconds).
419
+ */
420
+ function normalizeSendAt(sendAt) {
421
+ if (!sendAt && sendAt !== 0) {
422
+ return null;
423
+ }
424
+
425
+ if (typeof sendAt === 'number') {
426
+ // If it looks like milliseconds (> year 2100 in seconds), convert
427
+ if (sendAt > 4102444800) {
428
+ return Math.floor(sendAt / 1000);
429
+ }
430
+ return sendAt;
431
+ }
432
+
433
+ if (typeof sendAt === 'string') {
434
+ const parsed = moment(sendAt);
435
+
436
+ if (parsed.isValid()) {
437
+ return parsed.unix();
438
+ }
439
+ }
440
+
441
+ return null;
442
+ }
443
+
444
+ /**
445
+ * Save email to queue for deferred sending (beyond 71h limit)
446
+ */
447
+ async function saveToEmailQueue(email, admin, assistant) {
448
+ const emailId = email.dynamicTemplateData.email.id;
449
+
450
+ // Clone and clean before storage
451
+ const emailCloned = _.cloneDeepWith(email, (value) => {
452
+ if (typeof value === 'undefined') {
453
+ return null;
454
+ }
455
+ });
456
+ delete emailCloned.dynamicTemplateData._stringified;
457
+
458
+ assistant.log(`saveToEmailQueue(): Saving email ${emailId}`);
459
+
460
+ await admin.firestore().doc(`email-queue/${emailId}`)
461
+ .set(emailCloned)
462
+ .then(() => assistant.log(`saveToEmailQueue(): Success ${emailId}`))
463
+ .catch(e => assistant.error(`saveToEmailQueue(): Failed ${emailId}`, e));
464
+ }
465
+
466
+ /**
467
+ * Save sent email to Firestore for audit trail (non-blocking)
468
+ */
469
+ function saveAuditTrail(email, messageId, admin, assistant) {
470
+ // Clone and clean before storage
471
+ const emailCloned = _.cloneDeepWith(email, (value) => {
472
+ if (typeof value === 'undefined') {
473
+ return null;
474
+ }
475
+ });
476
+ delete emailCloned.dynamicTemplateData._stringified;
477
+
478
+ admin.firestore().doc(`emails/${messageId}`)
479
+ .set({
480
+ id: messageId,
481
+ request: emailCloned,
482
+ body: { html: '', text: '' },
483
+ created: assistant.meta.startTime,
484
+ })
485
+ .then(() => assistant.log(`Audit trail saved: ${messageId}`))
486
+ .catch(e => assistant.error(`Audit trail failed: ${messageId}`, e));
487
+ }
488
+
489
+ /**
490
+ * Create an Error with a code property for distinguishing build (400) vs send (500) failures.
491
+ */
492
+ function errorWithCode(message, code) {
493
+ const err = new Error(message);
494
+ err.code = code;
495
+ return err;
496
+ }
497
+
498
+ /**
499
+ * Convert SVG image URLs to PNG equivalents — email clients don't render SVGs.
500
+ * CDN naming convention: `-x.svg` → `-1024.png`
501
+ */
502
+ function sanitizeImagesForEmail(images) {
503
+ const result = {};
504
+
505
+ for (const [key, value] of Object.entries(images)) {
506
+ if (typeof value === 'string' && value.endsWith('.svg')) {
507
+ result[key] = value.replace(/-x\.svg$/, '-1024.png');
508
+ } else {
509
+ result[key] = value;
510
+ }
511
+ }
512
+
513
+ return result;
514
+ }
515
+
516
+ /**
517
+ * URL-encode a value as base64
518
+ */
519
+ function encode(s) {
520
+ return encodeURIComponent(Buffer.from(String(s)).toString('base64'));
521
+ }
522
+
523
+ module.exports = Email;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Shared contact inference library
3
+ *
4
+ * Infers first/last name and company from an email address.
5
+ * Tries AI first (if OPENAI_API_KEY is set), falls back to regex parsing.
6
+ *
7
+ * Usage:
8
+ * const { inferContact } = require('./libraries/infer-contact.js');
9
+ * const result = await inferContact(email, assistant);
10
+ * // { firstName, lastName, company, confidence, method }
11
+ */
12
+ const path = require('path');
13
+
14
+ const PROMPT_PATH = path.join(__dirname, 'prompts', 'infer-contact.md');
15
+
16
+ // Common email providers (don't infer company from these domains)
17
+ const GENERIC_DOMAINS = new Set([
18
+ 'gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'aol.com',
19
+ 'icloud.com', 'mail.com', 'protonmail.com', 'proton.me', 'zoho.com',
20
+ 'yandex.com', 'gmx.com', 'live.com', 'msn.com', 'me.com',
21
+ ]);
22
+
23
+ /**
24
+ * Infer contact info from email address
25
+ * Tries AI first (if OPENAI_API_KEY is set), falls back to regex
26
+ *
27
+ * @param {string} email - Email address
28
+ * @param {object} assistant - Assistant instance (for AI access)
29
+ * @returns {{ firstName: string, lastName: string, company: string, confidence: number, method: string }}
30
+ */
31
+ async function inferContact(email, assistant) {
32
+ if (process.env.OPENAI_API_KEY) {
33
+ const aiResult = await inferContactWithAI(email, assistant);
34
+ if (aiResult && (aiResult.firstName || aiResult.lastName)) {
35
+ return aiResult;
36
+ }
37
+ }
38
+
39
+ return inferContactFromEmail(email);
40
+ }
41
+
42
+ /**
43
+ * Use AI to infer contact info from email
44
+ *
45
+ * @param {string} email - Email address
46
+ * @param {object} assistant - Assistant instance
47
+ * @returns {object|null} Inferred contact or null on failure
48
+ */
49
+ async function inferContactWithAI(email, assistant) {
50
+ try {
51
+ const ai = assistant.Manager.AI(assistant);
52
+ const result = await ai.request({
53
+ model: 'gpt-5-mini',
54
+ timeout: 30000,
55
+ maxTokens: 1024,
56
+ moderate: false,
57
+ response: 'json',
58
+ prompt: {
59
+ path: PROMPT_PATH,
60
+ },
61
+ message: {
62
+ content: `Email: ${email}`,
63
+ },
64
+ });
65
+
66
+ if (result?.firstName !== undefined) {
67
+ return {
68
+ firstName: capitalize(result.firstName || ''),
69
+ lastName: capitalize(result.lastName || ''),
70
+ company: capitalize(result.company || ''),
71
+ confidence: typeof result.confidence === 'number' ? result.confidence : 0.5,
72
+ method: 'ai',
73
+ };
74
+ }
75
+ } catch (e) {
76
+ if (assistant) {
77
+ assistant.error('inferContactWithAI: Failed:', e);
78
+ }
79
+ }
80
+
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * Regex-based contact inference from email
86
+ * Extracts name from local part and company from domain
87
+ *
88
+ * @param {string} email - Email address
89
+ * @returns {{ firstName: string, lastName: string, company: string, confidence: number, method: string }}
90
+ */
91
+ function inferContactFromEmail(email) {
92
+ const [local, domain] = email.split('@');
93
+
94
+ // Infer company from domain (skip generic providers)
95
+ let company = '';
96
+ if (domain && !GENERIC_DOMAINS.has(domain.toLowerCase())) {
97
+ const domainName = domain.split('.')[0];
98
+ company = capitalize(domainName.replace(/[-_]/g, ' '));
99
+ }
100
+
101
+ // Infer name from local part
102
+ const cleaned = local.replace(/[0-9]+$/, '');
103
+ const parts = cleaned.split(/[._-]/);
104
+
105
+ if (parts.length >= 2) {
106
+ return {
107
+ firstName: capitalize(parts[0]),
108
+ lastName: capitalize(parts.slice(1).join(' ')),
109
+ company,
110
+ confidence: 0.5,
111
+ method: 'regex',
112
+ };
113
+ }
114
+
115
+ return {
116
+ firstName: capitalize(cleaned),
117
+ lastName: '',
118
+ company,
119
+ confidence: 0.25,
120
+ method: 'regex',
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Capitalize first letter of each word
126
+ *
127
+ * @param {string} str - String to capitalize
128
+ * @returns {string} Capitalized string
129
+ */
130
+ function capitalize(str) {
131
+ if (!str) {
132
+ return '';
133
+ }
134
+ return str
135
+ .split(' ')
136
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
137
+ .join(' ');
138
+ }
139
+
140
+ module.exports = { inferContact, inferContactFromEmail, capitalize };
@@ -0,0 +1,43 @@
1
+ <identity>
2
+ You extract names and company from email addresses.
3
+ </identity>
4
+
5
+ <format>
6
+ Return ONLY valid JSON like so:
7
+ {
8
+ "firstName": "...",
9
+ "lastName": "...",
10
+ "company": "...",
11
+ "confidence": "..."
12
+ }
13
+
14
+ - firstName: First name (string), capitalized
15
+ - lastName: Last name (string), capitalized
16
+ - company: Company name (string), capitalized
17
+ - confidence: Confidence level (number), 0-1 scale
18
+
19
+ If you cannot determine a name, use empty strings.
20
+ </format>
21
+
22
+ <examples>
23
+ <example>
24
+ <input>john.smith@acme.com</input>
25
+ <output>{"firstName": "John", "lastName": "Smith", "company": "Acme", "confidence": 0.9}</output>
26
+ </example>
27
+ <example>
28
+ <input>jsmith123@gmail.com</input>
29
+ <output>{"firstName": "J", "lastName": "Smith", "company": "", "confidence": 0.4}</output>
30
+ </example>
31
+ <example>
32
+ <input>support@bigcorp.io</input>
33
+ <output>{"firstName": "", "lastName": "", "company": "Bigcorp", "confidence": 0.7}</output>
34
+ </example>
35
+ <example>
36
+ <input>mary_jane_watson@stark-industries.com</input>
37
+ <output>{"firstName": "Mary", "lastName": "Watson", "company": "Stark Industries", "confidence": 0.85}</output>
38
+ </example>
39
+ <example>
40
+ <input>info@company.org</input>
41
+ <output>{"firstName": "", "lastName": "", "company": "Company", "confidence": 0.6}</output>
42
+ </example>
43
+ </examples>