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.
- package/CHANGELOG.md +2 -2
- package/CLAUDE.md +14 -6
- package/README.md +6 -6
- package/TODO-MARKETING.md +3 -0
- package/TODO-PAYMENT-v2.md +71 -0
- package/TODO.md +7 -0
- package/package.json +3 -3
- package/src/cli/commands/{emulators.js → emulator.js} +15 -15
- package/src/cli/commands/index.js +1 -1
- package/src/cli/commands/setup-tests/{emulators-config.js → emulator-config.js} +4 -4
- package/src/cli/commands/setup-tests/index.js +2 -2
- package/src/cli/commands/setup-tests/project-id-consistency.js +1 -1
- package/src/cli/commands/test.js +16 -16
- package/src/cli/index.js +4 -4
- package/src/manager/events/auth/on-create.js +5 -158
- package/src/manager/events/firestore/payments-webhooks/analytics.js +4 -3
- package/src/manager/events/firestore/payments-webhooks/on-write.js +56 -6
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +1 -1
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +32 -28
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +3 -3
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +3 -3
- package/src/manager/functions/core/actions/api/admin/send-email.js +0 -131
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +2 -137
- package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +2 -2
- package/src/manager/functions/core/actions/api/user/sign-up.js +1 -1
- package/src/manager/index.js +12 -0
- package/src/manager/libraries/email.js +523 -0
- package/src/manager/libraries/infer-contact.js +140 -0
- package/src/manager/libraries/prompts/infer-contact.md +43 -0
- package/src/manager/routes/admin/backup/post.js +4 -3
- package/src/manager/routes/admin/email/post.js +11 -428
- package/src/manager/routes/admin/hook/post.js +3 -2
- package/src/manager/routes/admin/notification/post.js +14 -12
- package/src/manager/routes/admin/post/post.js +5 -6
- package/src/manager/routes/admin/post/put.js +3 -2
- package/src/manager/routes/admin/stats/get.js +19 -10
- package/src/manager/routes/general/email/post.js +8 -21
- package/src/manager/routes/general/email/templates/download-app-link.js +2 -2
- package/src/manager/routes/marketing/contact/post.js +2 -100
- package/src/manager/routes/payments/intent/post.js +0 -2
- package/src/manager/routes/payments/intent/processors/test.js +9 -10
- package/src/manager/routes/user/oauth2/_helpers.js +3 -2
- package/src/manager/routes/user/oauth2/delete.js +3 -3
- package/src/manager/routes/user/oauth2/get.js +2 -2
- package/src/manager/routes/user/oauth2/post.js +9 -9
- package/src/manager/routes/user/sessions/delete.js +4 -3
- package/src/manager/routes/user/signup/post.js +254 -54
- package/src/manager/schemas/admin/email/post.js +13 -8
- package/src/test/run-tests.js +1 -1
- package/test/functions/admin/send-email.js +1 -88
- package/test/helpers/email.js +421 -0
- package/test/helpers/infer-contact.js +299 -0
- package/test/routes/admin/email.js +41 -90
- package/REFACTOR-BEM-API.md +0 -76
- package/REFACTOR-MIDDLEWARE.md +0 -62
- 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>
|