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
@@ -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,19 @@ 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
+ assistant.log(`User doc for ${uid}: exists=${userDoc.exists}, email=${userData?.auth?.email || 'null'}, name=${userData?.personal?.name?.first || 'null'}, subscription=${userData?.subscription?.product?.id || 'null'}`);
144
+
145
+ // Auto-fill user name from payment processor if not already set
146
+ if (!userData?.personal?.name?.first) {
147
+ const customerName = extractCustomerName(resource, resourceType);
148
+ if (customerName?.first) {
149
+ await admin.firestore().doc(`users/${uid}`).set({
150
+ personal: { name: customerName },
151
+ }, { merge: true });
152
+ assistant.log(`Auto-filled user name from ${resourceType}: ${customerName.first} ${customerName.last || ''}`);
153
+ }
154
+ }
155
+
140
156
  // Transform raw resource → unified object
141
157
  const transformOptions = { config: Manager.config, eventName: eventType, eventId: eventId };
142
158
  const unified = isSubscription
@@ -180,7 +196,7 @@ async function processPaymentEvent({ category, library, resource, uid, processor
180
196
 
181
197
  if (shouldRunHandlers) {
182
198
  transitions.dispatch(transitionName, category, {
183
- before, after: unified, order, uid, userDoc: userData, admin, assistant, Manager, eventType, eventId,
199
+ before, after: unified, order, uid, userDoc: userData, assistant,
184
200
  });
185
201
  } else {
186
202
  assistant.log(`Transition handler skipped (testing mode): ${category}/${transitionName}`);
@@ -189,7 +205,7 @@ async function processPaymentEvent({ category, library, resource, uid, processor
189
205
 
190
206
  // Track payment analytics (non-blocking)
191
207
  if (transitionName && shouldRunHandlers) {
192
- trackPayment({ category, transitionName, unified, uid, processor, assistant, Manager });
208
+ trackPayment({ category, transitionName, unified, uid, processor, assistant });
193
209
  }
194
210
 
195
211
  // Write unified subscription to user doc (subscriptions only)
@@ -206,3 +222,37 @@ async function processPaymentEvent({ category, library, resource, uid, processor
206
222
 
207
223
  return transitionName;
208
224
  }
225
+
226
+ /**
227
+ * Extract customer name from a raw payment processor resource
228
+ *
229
+ * @param {object} resource - Raw processor resource (Stripe subscription, session, invoice)
230
+ * @param {string} resourceType - 'subscription' | 'session' | 'invoice'
231
+ * @returns {{ first: string, last: string }|null}
232
+ */
233
+ function extractCustomerName(resource, resourceType) {
234
+ let fullName = null;
235
+
236
+ // Checkout sessions have customer_details.name
237
+ if (resourceType === 'session') {
238
+ fullName = resource.customer_details?.name;
239
+ }
240
+
241
+ // Invoices have customer_name
242
+ if (resourceType === 'invoice') {
243
+ fullName = resource.customer_name;
244
+ }
245
+
246
+ // Subscriptions only have customer ID, no name
247
+
248
+ if (!fullName) {
249
+ return null;
250
+ }
251
+
252
+ const { capitalize } = require('../../../libraries/infer-contact.js');
253
+ const parts = fullName.trim().split(/\s+/);
254
+ return {
255
+ first: capitalize(parts[0]) || null,
256
+ last: capitalize(parts.slice(1).join(' ')) || null,
257
+ };
258
+ }
@@ -4,16 +4,16 @@
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, userDoc, assistant }) {
8
8
  assistant.log(`Transition [one-time/purchase-completed]: uid=${uid}, resourceId=${after.payment.resourceId}`);
9
9
 
10
10
  sendOrderEmail({
11
- template: 'd-5371ac2b4e3b490bbce51bfc2922ece8',
11
+ template: 'main/order/confirmation',
12
12
  subject: 'Your order is confirmed!',
13
13
  categories: ['order/confirmation'],
14
14
  uid,
15
+ userDoc,
15
16
  assistant,
16
- Manager,
17
17
  data: {
18
18
  order: {
19
19
  ...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, userDoc, 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,49 @@
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
16
+ * @param {object} options.userDoc - User document data (used to get email directly, avoids race conditions)
16
17
  * @param {object} options.assistant - Assistant instance
17
- * @param {object} options.Manager - Manager instance
18
18
  */
19
- function sendOrderEmail({ template, subject, categories, data, uid, assistant, Manager }) {
20
- const fetch = require('wonderful-fetch');
19
+ function sendOrderEmail({ template, subject, categories, data, uid, userDoc, assistant }) {
20
+ const email = assistant.Manager.Email(assistant);
21
21
 
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
- });
22
+ // Use email directly from userDoc (already fetched by on-write.js, avoids redundant Firestore lookup)
23
+ const userEmail = userDoc?.auth?.email;
24
+ const userName = userDoc?.personal?.name?.first;
25
+
26
+ if (!userEmail) {
27
+ assistant.error(`sendOrderEmail(): No email found for uid=${uid}, skipping`);
28
+ return;
29
+ }
30
+
31
+ const settings = {
32
+ to: { email: userEmail, ...(userName && { name: userName }) },
33
+ subject,
34
+ template,
35
+ categories,
36
+ copy: false,
37
+ data,
38
+ };
39
+
40
+ email.send(settings)
41
+ .then((result) => {
42
+ assistant.log(`sendOrderEmail(): Success template=${template}, uid=${uid}, status=${result.status}`);
43
+ })
44
+ .catch((e) => {
45
+ assistant.error(`sendOrderEmail(): Failed template=${template}, uid=${uid}: ${e.message}`);
46
+ });
43
47
  }
44
48
 
45
49
  /**
@@ -4,16 +4,16 @@
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, userDoc, 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({
11
- template: 'd-78074f3e8c844146bf263b86fc8d5ecf',
11
+ template: 'main/order/cancellation-requested',
12
12
  subject: 'Your subscription cancellation is confirmed',
13
13
  categories: ['order/cancellation-requested'],
14
14
  uid,
15
+ userDoc,
15
16
  assistant,
16
- Manager,
17
17
  data: {
18
18
  order: {
19
19
  ...order,
@@ -5,18 +5,18 @@
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, userDoc, 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}`);
12
12
 
13
13
  sendOrderEmail({
14
- template: 'd-5371ac2b4e3b490bbce51bfc2922ece8',
14
+ template: 'main/order/confirmation',
15
15
  subject: isTrial ? 'Your free trial has started!' : 'Your subscription is confirmed!',
16
16
  categories: ['order/confirmation'],
17
17
  uid,
18
+ userDoc,
18
19
  assistant,
19
- Manager,
20
20
  data: {
21
21
  order: {
22
22
  ...order,
@@ -4,16 +4,16 @@
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, userDoc, assistant }) {
8
8
  assistant.log(`Transition [subscription/payment-failed]: uid=${uid}, product=${after.product.id}, previousStatus=${before?.status}`);
9
9
 
10
10
  sendOrderEmail({
11
- template: 'd-e56af0ac62364bfb9e50af02854e2cd3',
11
+ template: 'main/order/payment-failed',
12
12
  subject: 'Your payment failed',
13
13
  categories: ['order/payment-failed'],
14
14
  uid,
15
+ userDoc,
15
16
  assistant,
16
- Manager,
17
17
  data: {
18
18
  order: {
19
19
  ...order,
@@ -4,16 +4,16 @@
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, userDoc, assistant }) {
8
8
  assistant.log(`Transition [subscription/payment-recovered]: uid=${uid}, product=${after.product.id}`);
9
9
 
10
10
  sendOrderEmail({
11
- template: 'd-d6dbd17a260a4755b34a852ba09c2454',
11
+ template: 'main/order/payment-recovered',
12
12
  subject: 'Your payment was successful',
13
13
  categories: ['order/payment-recovered'],
14
14
  uid,
15
+ userDoc,
15
16
  assistant,
16
- Manager,
17
17
  data: {
18
18
  order: {
19
19
  ...order,
@@ -4,17 +4,17 @@
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, userDoc, 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
 
11
11
  sendOrderEmail({
12
- template: 'd-399086311bbb48b4b77bc90b20fb9d0a',
12
+ template: 'main/order/plan-changed',
13
13
  subject: 'Your subscription plan has been updated',
14
14
  categories: ['order/plan-changed'],
15
15
  uid,
16
+ userDoc,
16
17
  assistant,
17
- Manager,
18
18
  data: {
19
19
  order: {
20
20
  ...order,
@@ -4,19 +4,19 @@
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, userDoc, 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)
11
11
  const hasFutureExpiry = after.expires?.timestamp && new Date(after.expires.timestamp) > new Date();
12
12
 
13
13
  sendOrderEmail({
14
- template: 'd-39041132e6b24e5ebf0e95bce2d94dba',
14
+ template: 'main/order/cancelled',
15
15
  subject: 'Your subscription has been cancelled',
16
16
  categories: ['order/cancelled'],
17
17
  uid,
18
+ userDoc,
18
19
  assistant,
19
- Manager,
20
20
  data: {
21
21
  order: {
22
22
  ...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'));