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
@@ -0,0 +1,429 @@
1
+ /**
2
+ * SendGrid provider — shared API helpers for contacts and Single Sends
3
+ *
4
+ * Used by: marketing/index.js (sync, remove, campaigns)
5
+ */
6
+ const fetch = require('wonderful-fetch');
7
+ const Manager = require('../../../index.js');
8
+ const { resolveFieldValues } = require('../constants.js');
9
+
10
+ const BASE_URL = 'https://api.sendgrid.com/v3';
11
+
12
+ // --- Internal helpers ---
13
+
14
+ function headers() {
15
+ return {
16
+ 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
17
+ };
18
+ }
19
+
20
+ // Cached field name → SendGrid ID map (e.g., { brand_id: 'e1_T', user_auth_uid: 'e2_T' })
21
+ let _fieldIdCache = null;
22
+
23
+ /**
24
+ * Fetch custom field definitions from SendGrid and build a name → id map.
25
+ * Cached in memory for the lifetime of the process.
26
+ *
27
+ * @returns {object} Map of field name → SendGrid field ID
28
+ */
29
+ async function resolveFieldIds() {
30
+ if (_fieldIdCache) {
31
+ return _fieldIdCache;
32
+ }
33
+
34
+ try {
35
+ const data = await fetch(`${BASE_URL}/marketing/field_definitions`, {
36
+ response: 'json',
37
+ headers: headers(),
38
+ timeout: 10000,
39
+ });
40
+
41
+ _fieldIdCache = {};
42
+
43
+ for (const field of (data.custom_fields || [])) {
44
+ _fieldIdCache[field.name] = field.id;
45
+ }
46
+
47
+ return _fieldIdCache;
48
+ } catch (e) {
49
+ console.error('SendGrid resolveFieldIds error:', e);
50
+ return {};
51
+ }
52
+ }
53
+
54
+ // --- Contact Management ---
55
+
56
+ /**
57
+ * Upsert contacts to SendGrid Marketing Contacts.
58
+ * Creates if new, merges/overwrites fields if existing.
59
+ *
60
+ * @param {object} options
61
+ * @param {Array<object>} options.contacts - Array of contact objects ({ email, first_name, last_name, custom_fields })
62
+ * @param {Array<string>} [options.listIds] - List IDs to add contacts to
63
+ * @returns {{ success: boolean, jobId?: string, error?: string }}
64
+ */
65
+ async function upsertContacts({ contacts, listIds }) {
66
+ try {
67
+ const body = { contacts };
68
+
69
+ if (listIds && listIds.length) {
70
+ body.list_ids = listIds;
71
+ }
72
+
73
+ const data = await fetch(`${BASE_URL}/marketing/contacts`, {
74
+ method: 'put',
75
+ response: 'json',
76
+ headers: headers(),
77
+ timeout: 15000,
78
+ body,
79
+ });
80
+
81
+ if (data.job_id) {
82
+ return { success: true, jobId: data.job_id };
83
+ }
84
+
85
+ return { success: false, error: data.errors?.[0]?.message || 'Unknown error' };
86
+ } catch (e) {
87
+ console.error('SendGrid upsertContacts error:', e);
88
+ return { success: false, error: e.message };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Remove a contact from SendGrid by email address.
94
+ *
95
+ * @param {string} email
96
+ * @returns {{ success: boolean, jobId?: string, skipped?: boolean, error?: string }}
97
+ */
98
+ async function removeContact(email) {
99
+ try {
100
+ // Step 1: Get contact ID by email
101
+ const searchData = await fetch(`${BASE_URL}/marketing/contacts/search/emails`, {
102
+ method: 'post',
103
+ response: 'json',
104
+ headers: headers(),
105
+ timeout: 10000,
106
+ body: { emails: [email] },
107
+ });
108
+
109
+ if (!searchData.result?.[email]?.contact?.id) {
110
+ return { success: true, skipped: true, reason: 'Contact not found' };
111
+ }
112
+
113
+ const contactId = searchData.result[email].contact.id;
114
+
115
+ // Step 2: Delete contact by ID
116
+ const deleteData = await fetch(`${BASE_URL}/marketing/contacts?ids=${contactId}`, {
117
+ method: 'delete',
118
+ response: 'json',
119
+ headers: headers(),
120
+ timeout: 10000,
121
+ });
122
+
123
+ if (deleteData.job_id) {
124
+ return { success: true, jobId: deleteData.job_id };
125
+ }
126
+
127
+ return { success: false, error: deleteData.errors?.[0]?.message || 'Delete failed' };
128
+ } catch (e) {
129
+ console.error('SendGrid removeContact error:', e);
130
+ return { success: false, error: e.message };
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Get a SendGrid list ID by brand name (fuzzy match).
136
+ *
137
+ * @param {string} brandName
138
+ * @returns {string|null} List ID or null
139
+ */
140
+ async function getListId() {
141
+ const brandName = Manager.config.brand?.name;
142
+ const brandNameLower = (brandName || '').toLowerCase();
143
+ const allLists = [];
144
+ let pageToken = '';
145
+ const pageSize = 1000;
146
+
147
+ try {
148
+ while (true) {
149
+ const url = `${BASE_URL}/marketing/lists?page_size=${pageSize}${pageToken ? `&page_token=${pageToken}` : ''}`;
150
+ const data = await fetch(url, {
151
+ response: 'json',
152
+ headers: headers(),
153
+ timeout: 10000,
154
+ });
155
+
156
+ if (!data.result || data.result.length === 0) {
157
+ break;
158
+ }
159
+
160
+ const matchedList = data.result.find(list =>
161
+ list.name.toLowerCase() === brandNameLower
162
+ || list.name.toLowerCase().includes(brandNameLower)
163
+ || brandNameLower.includes(list.name.toLowerCase())
164
+ );
165
+
166
+ if (matchedList) {
167
+ return matchedList.id;
168
+ }
169
+
170
+ allLists.push(...data.result);
171
+
172
+ if (!data._metadata?.next) {
173
+ break;
174
+ }
175
+
176
+ const nextUrl = new URL(data._metadata.next);
177
+ pageToken = nextUrl.searchParams.get('page_token');
178
+
179
+ if (!pageToken) {
180
+ break;
181
+ }
182
+ }
183
+
184
+ if (allLists.length === 1) {
185
+ return allLists[0].id;
186
+ }
187
+
188
+ if (allLists.length > 0) {
189
+ console.error(`SendGrid: No list matched brand "${brandName}". Available: ${allLists.map(l => l.name).join(', ')}`);
190
+ }
191
+ } catch (e) {
192
+ console.error('SendGrid list lookup error:', e);
193
+ }
194
+
195
+ return null;
196
+ }
197
+
198
+ // --- Single Sends (Campaigns) ---
199
+
200
+ /**
201
+ * Create a Single Send (marketing campaign).
202
+ *
203
+ * @param {object} options
204
+ * @param {string} options.name - Campaign name
205
+ * @param {string} options.subject - Email subject
206
+ * @param {string} options.templateId - SendGrid template ID
207
+ * @param {object} options.from - { email, name }
208
+ * @param {object} options.sendTo - { list_ids?, segment_ids?, all? }
209
+ * @param {number} [options.asmGroupId] - Unsubscribe group ID
210
+ * @param {Array<string>} [options.categories] - Email categories
211
+ * @param {object} [options.dynamicTemplateData] - Template variables
212
+ * @returns {{ success: boolean, id?: string, error?: string }}
213
+ */
214
+ async function createSingleSend({ name, subject, templateId, from, sendTo, asmGroupId, categories, dynamicTemplateData }) {
215
+ try {
216
+ const body = {
217
+ name,
218
+ send_to: sendTo,
219
+ email_config: {
220
+ subject,
221
+ sender_id: null,
222
+ custom_unsubscribe_url: null,
223
+ generate_plain_content: true,
224
+ },
225
+ };
226
+
227
+ // Use design_editor with template
228
+ if (templateId) {
229
+ body.email_config.editor = 'design';
230
+ body.email_config.template_id = templateId;
231
+ }
232
+
233
+ if (from) {
234
+ body.email_config.sender_id = null;
235
+ // SendGrid Single Sends use sender_id OR from, depending on account setup.
236
+ // We'll set the from fields directly if supported, otherwise the sender_id
237
+ // must be pre-configured in SendGrid.
238
+ }
239
+
240
+ if (asmGroupId) {
241
+ body.email_config.suppression_group_id = asmGroupId;
242
+ }
243
+
244
+ if (categories && categories.length) {
245
+ body.email_config.categories = categories;
246
+ }
247
+
248
+ const data = await fetch(`${BASE_URL}/marketing/singlesends`, {
249
+ method: 'post',
250
+ response: 'json',
251
+ headers: headers(),
252
+ timeout: 15000,
253
+ body,
254
+ });
255
+
256
+ if (data.id) {
257
+ return { success: true, id: data.id };
258
+ }
259
+
260
+ return { success: false, error: data.errors?.[0]?.message || 'Unknown error' };
261
+ } catch (e) {
262
+ console.error('SendGrid createSingleSend error:', e);
263
+ return { success: false, error: e.message };
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Schedule a Single Send for delivery.
269
+ *
270
+ * @param {string} singleSendId - The Single Send ID
271
+ * @param {string} sendAt - ISO 8601 datetime string (e.g., '2026-04-01T14:00:00Z'), or 'now'
272
+ * @returns {{ success: boolean, error?: string }}
273
+ */
274
+ async function scheduleSingleSend(singleSendId, sendAt) {
275
+ try {
276
+ const data = await fetch(`${BASE_URL}/marketing/singlesends/${singleSendId}/schedule`, {
277
+ method: 'put',
278
+ response: 'json',
279
+ headers: headers(),
280
+ timeout: 15000,
281
+ body: { send_at: sendAt },
282
+ });
283
+
284
+ if (data.send_at || data.status === 'scheduled') {
285
+ return { success: true };
286
+ }
287
+
288
+ return { success: false, error: data.errors?.[0]?.message || 'Schedule failed' };
289
+ } catch (e) {
290
+ console.error('SendGrid scheduleSingleSend error:', e);
291
+ return { success: false, error: e.message };
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Cancel a scheduled Single Send.
297
+ *
298
+ * @param {string} singleSendId
299
+ * @returns {{ success: boolean, error?: string }}
300
+ */
301
+ async function cancelSingleSend(singleSendId) {
302
+ try {
303
+ const data = await fetch(`${BASE_URL}/marketing/singlesends/${singleSendId}`, {
304
+ method: 'delete',
305
+ response: 'json',
306
+ headers: headers(),
307
+ timeout: 10000,
308
+ });
309
+
310
+ return { success: true };
311
+ } catch (e) {
312
+ console.error('SendGrid cancelSingleSend error:', e);
313
+ return { success: false, error: e.message };
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Get a Single Send by ID.
319
+ *
320
+ * @param {string} singleSendId
321
+ * @returns {object|null}
322
+ */
323
+ async function getSingleSend(singleSendId) {
324
+ try {
325
+ const data = await fetch(`${BASE_URL}/marketing/singlesends/${singleSendId}`, {
326
+ response: 'json',
327
+ headers: headers(),
328
+ timeout: 10000,
329
+ });
330
+
331
+ return data.id ? data : null;
332
+ } catch (e) {
333
+ console.error('SendGrid getSingleSend error:', e);
334
+ return null;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * List Single Sends with optional status filter.
340
+ *
341
+ * @param {object} [options]
342
+ * @param {string} [options.status] - Filter by status: draft, scheduled, triggered
343
+ * @returns {Array<object>}
344
+ */
345
+ async function listSingleSends(options) {
346
+ const { status } = options || {};
347
+
348
+ try {
349
+ const url = `${BASE_URL}/marketing/singlesends${status ? `?status=${status}` : ''}`;
350
+ const data = await fetch(url, {
351
+ response: 'json',
352
+ headers: headers(),
353
+ timeout: 10000,
354
+ });
355
+
356
+ return data.result || [];
357
+ } catch (e) {
358
+ console.error('SendGrid listSingleSends error:', e);
359
+ return [];
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Add a contact to SendGrid — resolves list, upserts with optional custom fields.
365
+ *
366
+ * @param {object} options
367
+ * @param {string} options.email
368
+ * @param {string} [options.firstName]
369
+ * @param {string} [options.lastName]
370
+ * @param {object} [options.customFields] - Pre-built custom_fields object (keyed by SendGrid field IDs)
371
+ * @returns {{ success: boolean, jobId?: string, listId?: string, error?: string }}
372
+ */
373
+ async function addContact({ email, firstName, lastName, customFields }) {
374
+ const contact = {
375
+ email: email.toLowerCase(),
376
+ first_name: firstName || undefined,
377
+ last_name: lastName || undefined,
378
+ custom_fields: customFields || {},
379
+ };
380
+
381
+ const listId = await getListId();
382
+ const result = await upsertContacts({
383
+ contacts: [contact],
384
+ listIds: listId ? [listId] : [],
385
+ });
386
+
387
+ if (result.success && listId) {
388
+ result.listId = listId;
389
+ }
390
+
391
+ return result;
392
+ }
393
+
394
+ /**
395
+ * Build SendGrid custom_fields object from a user doc.
396
+ * Resolves all field values, then maps field names to SendGrid IDs via runtime lookup.
397
+ *
398
+ * @param {object} userDoc - User document from Firestore
399
+ * @returns {object} Custom fields keyed by SendGrid field ID (e.g., { e1_T: 'basic' })
400
+ */
401
+ async function buildFields(userDoc) {
402
+ const values = resolveFieldValues(userDoc, Manager.config);
403
+ const idMap = await resolveFieldIds();
404
+ const fields = {};
405
+
406
+ for (const [name, value] of Object.entries(values)) {
407
+ const sgId = idMap[name];
408
+
409
+ if (sgId) {
410
+ fields[sgId] = value;
411
+ }
412
+ }
413
+
414
+ return fields;
415
+ }
416
+
417
+ module.exports = {
418
+ // Contacts
419
+ addContact,
420
+ removeContact,
421
+ buildFields,
422
+
423
+ // Campaigns (Single Sends)
424
+ createSingleSend,
425
+ scheduleSingleSend,
426
+ cancelSingleSend,
427
+ getSingleSend,
428
+ listSingleSends,
429
+ };