@aglyn/plugins-marketing 1.0.0-beta.143

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 (183) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +56 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/announcement-bar-card.component.d.ts +20 -0
  8. package/src/lib/components/announcement-bar-card.component.js +292 -0
  9. package/src/lib/components/announcement-bar-card.component.js.map +1 -0
  10. package/src/lib/components/campaign-composer.d.ts +84 -0
  11. package/src/lib/components/campaign-composer.js +1643 -0
  12. package/src/lib/components/campaign-composer.js.map +1 -0
  13. package/src/lib/components/campaign-conversions-card.d.ts +22 -0
  14. package/src/lib/components/campaign-conversions-card.js +642 -0
  15. package/src/lib/components/campaign-conversions-card.js.map +1 -0
  16. package/src/lib/components/campaign-detail-card.d.ts +55 -0
  17. package/src/lib/components/campaign-detail-card.js +771 -0
  18. package/src/lib/components/campaign-detail-card.js.map +1 -0
  19. package/src/lib/components/campaign-edit-drawer.d.ts +60 -0
  20. package/src/lib/components/campaign-edit-drawer.js +252 -0
  21. package/src/lib/components/campaign-edit-drawer.js.map +1 -0
  22. package/src/lib/components/campaign-email-zones.d.ts +111 -0
  23. package/src/lib/components/campaign-email-zones.js +54 -0
  24. package/src/lib/components/campaign-email-zones.js.map +1 -0
  25. package/src/lib/components/campaign-glance-card.component.d.ts +18 -0
  26. package/src/lib/components/campaign-glance-card.component.js +151 -0
  27. package/src/lib/components/campaign-glance-card.component.js.map +1 -0
  28. package/src/lib/components/campaign-members-section.d.ts +87 -0
  29. package/src/lib/components/campaign-members-section.js +499 -0
  30. package/src/lib/components/campaign-members-section.js.map +1 -0
  31. package/src/lib/components/campaign-reach-sections.d.ts +137 -0
  32. package/src/lib/components/campaign-reach-sections.js +694 -0
  33. package/src/lib/components/campaign-reach-sections.js.map +1 -0
  34. package/src/lib/components/campaign-report-card.d.ts +91 -0
  35. package/src/lib/components/campaign-report-card.js +667 -0
  36. package/src/lib/components/campaign-report-card.js.map +1 -0
  37. package/src/lib/components/campaign-test-send-drawer.d.ts +45 -0
  38. package/src/lib/components/campaign-test-send-drawer.js +238 -0
  39. package/src/lib/components/campaign-test-send-drawer.js.map +1 -0
  40. package/src/lib/components/campaigns-card.d.ts +31 -0
  41. package/src/lib/components/campaigns-card.js +667 -0
  42. package/src/lib/components/campaigns-card.js.map +1 -0
  43. package/src/lib/components/conversion-attribution.component.d.ts +62 -0
  44. package/src/lib/components/conversion-attribution.component.js +127 -0
  45. package/src/lib/components/conversion-attribution.component.js.map +1 -0
  46. package/src/lib/components/email-compose-card.d.ts +12 -0
  47. package/src/lib/components/email-compose-card.js +157 -0
  48. package/src/lib/components/email-compose-card.js.map +1 -0
  49. package/src/lib/components/email-detail.d.ts +40 -0
  50. package/src/lib/components/email-detail.js +1180 -0
  51. package/src/lib/components/email-detail.js.map +1 -0
  52. package/src/lib/components/email-edit-drawer.d.ts +54 -0
  53. package/src/lib/components/email-edit-drawer.js +172 -0
  54. package/src/lib/components/email-edit-drawer.js.map +1 -0
  55. package/src/lib/components/email-messages-widget.d.ts +22 -0
  56. package/src/lib/components/email-messages-widget.js +49 -0
  57. package/src/lib/components/email-messages-widget.js.map +1 -0
  58. package/src/lib/components/email-recipients-card.d.ts +50 -0
  59. package/src/lib/components/email-recipients-card.js +338 -0
  60. package/src/lib/components/email-recipients-card.js.map +1 -0
  61. package/src/lib/components/emails-list-card.d.ts +36 -0
  62. package/src/lib/components/emails-list-card.js +544 -0
  63. package/src/lib/components/emails-list-card.js.map +1 -0
  64. package/src/lib/components/experiment-zones.d.ts +78 -0
  65. package/src/lib/components/experiment-zones.js +65 -0
  66. package/src/lib/components/experiment-zones.js.map +1 -0
  67. package/src/lib/components/host-experiments-card.component.d.ts +17 -0
  68. package/src/lib/components/host-experiments-card.component.js +887 -0
  69. package/src/lib/components/host-experiments-card.component.js.map +1 -0
  70. package/src/lib/components/host-marketing-summary-card.component.d.ts +15 -0
  71. package/src/lib/components/host-marketing-summary-card.component.js +161 -0
  72. package/src/lib/components/host-marketing-summary-card.component.js.map +1 -0
  73. package/src/lib/components/host-overlays-card.component.d.ts +19 -0
  74. package/src/lib/components/host-overlays-card.component.js +783 -0
  75. package/src/lib/components/host-overlays-card.component.js.map +1 -0
  76. package/src/lib/components/marketing-console-page.d.ts +28 -0
  77. package/src/lib/components/marketing-console-page.js +166 -0
  78. package/src/lib/components/marketing-console-page.js.map +1 -0
  79. package/src/lib/components/marketing-console-sections.d.ts +34 -0
  80. package/src/lib/components/marketing-console-sections.js +77 -0
  81. package/src/lib/components/marketing-console-sections.js.map +1 -0
  82. package/src/lib/components/overlay-stats-row.component.d.ts +15 -0
  83. package/src/lib/components/overlay-stats-row.component.js +78 -0
  84. package/src/lib/components/overlay-stats-row.component.js.map +1 -0
  85. package/src/lib/components/popup-card.component.d.ts +18 -0
  86. package/src/lib/components/popup-card.component.js +462 -0
  87. package/src/lib/components/popup-card.component.js.map +1 -0
  88. package/src/lib/components/record-attribution-widget.d.ts +35 -0
  89. package/src/lib/components/record-attribution-widget.js +33 -0
  90. package/src/lib/components/record-attribution-widget.js.map +1 -0
  91. package/src/lib/components/site-runtime.d.ts +9 -0
  92. package/src/lib/components/site-runtime.js +1081 -0
  93. package/src/lib/components/site-runtime.js.map +1 -0
  94. package/src/lib/components/use-campaign-send-api.d.ts +35 -0
  95. package/src/lib/components/use-campaign-send-api.js +99 -0
  96. package/src/lib/components/use-campaign-send-api.js.map +1 -0
  97. package/src/lib/components/use-campaign-topic-options.d.ts +20 -0
  98. package/src/lib/components/use-campaign-topic-options.js +44 -0
  99. package/src/lib/components/use-campaign-topic-options.js.map +1 -0
  100. package/src/lib/components/use-emails-hub-path.d.ts +39 -0
  101. package/src/lib/components/use-emails-hub-path.js +70 -0
  102. package/src/lib/components/use-emails-hub-path.js.map +1 -0
  103. package/src/lib/components/use-marketing-hub-path.d.ts +21 -0
  104. package/src/lib/components/use-marketing-hub-path.js +50 -0
  105. package/src/lib/components/use-marketing-hub-path.js.map +1 -0
  106. package/src/lib/components/use-overlay-copy-editor.d.ts +79 -0
  107. package/src/lib/components/use-overlay-copy-editor.js +110 -0
  108. package/src/lib/components/use-overlay-copy-editor.js.map +1 -0
  109. package/src/lib/constants/bundle-common.d.ts +8 -0
  110. package/src/lib/constants/bundle-common.js +9 -0
  111. package/src/lib/constants/bundle-common.js.map +1 -0
  112. package/src/lib/model/campaign-membership-figures.d.ts +109 -0
  113. package/src/lib/model/campaign-membership-figures.js +113 -0
  114. package/src/lib/model/campaign-membership-figures.js.map +1 -0
  115. package/src/lib/model/compile-client-automations.d.ts +65 -0
  116. package/src/lib/model/compile-client-automations.js +95 -0
  117. package/src/lib/model/compile-client-automations.js.map +1 -0
  118. package/src/lib/model/experiments.d.ts +130 -0
  119. package/src/lib/model/experiments.js +186 -0
  120. package/src/lib/model/experiments.js.map +1 -0
  121. package/src/lib/model/index.d.ts +26 -0
  122. package/src/lib/model/index.js +26 -0
  123. package/src/lib/model/index.js.map +1 -0
  124. package/src/lib/model/marketing-record-routes.d.ts +6 -0
  125. package/src/lib/model/marketing-record-routes.js +46 -0
  126. package/src/lib/model/marketing-record-routes.js.map +1 -0
  127. package/src/lib/model/overlay-copy.d.ts +62 -0
  128. package/src/lib/model/overlay-copy.js +78 -0
  129. package/src/lib/model/overlay-copy.js.map +1 -0
  130. package/src/lib/model/overlays.d.ts +113 -0
  131. package/src/lib/model/overlays.js +93 -0
  132. package/src/lib/model/overlays.js.map +1 -0
  133. package/src/lib/model/site-contract.d.ts +100 -0
  134. package/src/lib/model/site-contract.js +23 -0
  135. package/src/lib/model/site-contract.js.map +1 -0
  136. package/src/lib/plugin.d.ts +27 -0
  137. package/src/lib/plugin.js +168 -0
  138. package/src/lib/plugin.js.map +1 -0
  139. package/src/lib/preview-props.d.ts +31 -0
  140. package/src/lib/preview-props.js +54 -0
  141. package/src/lib/preview-props.js.map +1 -0
  142. package/src/lib/server/campaign-manage.d.ts +62 -0
  143. package/src/lib/server/campaign-manage.js +829 -0
  144. package/src/lib/server/campaign-manage.js.map +1 -0
  145. package/src/lib/server/campaign-process-scheduled.d.ts +26 -0
  146. package/src/lib/server/campaign-process-scheduled.js +198 -0
  147. package/src/lib/server/campaign-process-scheduled.js.map +1 -0
  148. package/src/lib/server/campaign-recipients.d.ts +55 -0
  149. package/src/lib/server/campaign-recipients.js +219 -0
  150. package/src/lib/server/campaign-recipients.js.map +1 -0
  151. package/src/lib/server/campaign-send.d.ts +487 -0
  152. package/src/lib/server/campaign-send.js +3157 -0
  153. package/src/lib/server/campaign-send.js.map +1 -0
  154. package/src/lib/server/email-campaign-reach.d.ts +174 -0
  155. package/src/lib/server/email-campaign-reach.js +254 -0
  156. package/src/lib/server/email-campaign-reach.js.map +1 -0
  157. package/src/lib/server/email-events.d.ts +27 -0
  158. package/src/lib/server/email-events.js +714 -0
  159. package/src/lib/server/email-events.js.map +1 -0
  160. package/src/lib/server/get-client-automations.d.ts +60 -0
  161. package/src/lib/server/get-client-automations.js +87 -0
  162. package/src/lib/server/get-client-automations.js.map +1 -0
  163. package/src/lib/server/get-overlays.d.ts +28 -0
  164. package/src/lib/server/get-overlays.js +65 -0
  165. package/src/lib/server/get-overlays.js.map +1 -0
  166. package/src/lib/server/get-screen-experiments.d.ts +42 -0
  167. package/src/lib/server/get-screen-experiments.js +82 -0
  168. package/src/lib/server/get-screen-experiments.js.map +1 -0
  169. package/src/lib/server/lists-materialize.d.ts +18 -0
  170. package/src/lib/server/lists-materialize.js +130 -0
  171. package/src/lib/server/lists-materialize.js.map +1 -0
  172. package/src/lib/server/marketing-figures.d.ts +37 -0
  173. package/src/lib/server/marketing-figures.js +248 -0
  174. package/src/lib/server/marketing-figures.js.map +1 -0
  175. package/src/lib/server/site-page-enricher.d.ts +26 -0
  176. package/src/lib/server/site-page-enricher.js +210 -0
  177. package/src/lib/server/site-page-enricher.js.map +1 -0
  178. package/src/lib/server.d.ts +20 -0
  179. package/src/lib/server.js +152 -0
  180. package/src/lib/server.js.map +1 -0
  181. package/src/lib/site.d.ts +22 -0
  182. package/src/lib/site.js +48 -0
  183. package/src/lib/site.js.map +1 -0
@@ -0,0 +1,714 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { claimAttempt, CRM_EMAIL_ACTIVITY_TAG, CRM_EMAIL_ORG_TAG } from "@aglyn/aglyn/server";
18
+ import { normalizeResendDeliveryEvents } from "@aglyn/shared-util-email";
19
+ // By its own path, not the barrel: the check holds a `crypto` HMAC, and the
20
+ // barrel is reached from the browser through the campaign model (AGL-2657).
21
+ import { verifySvixSignature } from "@aglyn/shared-util-email/svix-signature";
22
+ // AGL-1771 lifted `isDocumentId` here from the local copy AGL-1768 wrote. The
23
+ // copy's stated reason was wrong: `@nx/enforce-module-boundaries` does NOT
24
+ // refuse an edge between two feature plugins by that tag — every plugin
25
+ // carries `aglyn:addons`, whose rule permits `aglyn:addons` as a target (it is
26
+ // `scope:plugin` that refuses another plugin, and `check:lib-boundaries` that
27
+ // holds it). It now lives beside `updateExisting` in the library where Firestore paths are
28
+ // built, which was always the better home and is now the reachable one.
29
+ import { firebaseAdmin, updateExisting } from "@aglyn/tenant-data-admin";
30
+ // From the LEAF, not the barrel, for the same reason `isDocumentId` is
31
+ // (AGL-1771): a spec that mocks `@aglyn/tenant-data-admin` — which it must,
32
+ // because that graph reaches the admin SDK — would otherwise replace the real
33
+ // suppression writer with whatever the factory happened to list. A stub there
34
+ // is a false green on the one behaviour AGL-2407 is about.
35
+ import { suppressEmail } from "@aglyn/tenant-data-admin/server/email-suppression";
36
+ // Same leaf-import reasoning again: the per-recipient delivery log is the only
37
+ // record staff have of what we sent someone, and a mocked-away writer is a
38
+ // green test over an empty log.
39
+ import { recordEmailCampaignTouch, recordEmailDeliveryEvents, recordPersonEngagement } from "@aglyn/tenant-data-admin/server/email-delivery-log";
40
+ // The leaf again: the contact's own stamp is what the record page, the list
41
+ // and the re-engagement audience read, and a wholesale mock would green a
42
+ // webhook that stamped nobody.
43
+ import { recordContactEmailEngagement } from "@aglyn/tenant-data-admin/server/contact-email-engagement";
44
+ import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
45
+ import { crmEmailDeliveryStateForEvent, recordCrmEmailDelivery } from "@aglyn/tenant-data-admin/server/crm-email-activity";
46
+ import { getOrgForHost } from "@aglyn/tenant-data-admin/server/organizations";
47
+ import { recordEmailReputationFailure } from "@aglyn/tenant-data-admin/server/email-sender-reputation";
48
+ // The link rollup's key derivation and its cap live beside the READER that
49
+ // renders them (`@aglyn/shared-ui-email-campaigns/model`) rather than here, so
50
+ // the shape the webhook writes and the shape the report reads cannot drift
51
+ // into two definitions of what a "link" is.
52
+ import { CAMPAIGN_LINK_ROLLUP_MAX, campaignLinkKey } from "@aglyn/shared-ui-email-campaigns/model";
53
+ import { createHash } from "crypto";
54
+ import { FieldValue } from "firebase-admin/firestore";
55
+ import { assignExperimentVariant } from "../model/experiments.js";
56
+ // The one list `campaign-send` reads, keyed the one way it keys it.
57
+ import { suppressionId } from "./campaign-send.js";
58
+ /**
59
+ * Svix signature check (Resend webhooks): the shared implementation in
60
+ * `@aglyn/shared-util-email`, which the CRM's capture webhook (AGL-2657)
61
+ * verifies with too, so the constant-time comparison lives once.
62
+ */ const verifySvix = verifySvixSignature;
63
+ /** Tags arrive as an array of {name, value} or a plain map — accept both. */ function tagMap(raw) {
64
+ if (Array.isArray(raw)) {
65
+ const map = {};
66
+ for (const tag of raw){
67
+ var _tag_value;
68
+ if (tag == null ? void 0 : tag.name) map[String(tag.name)] = String((_tag_value = tag.value) != null ? _tag_value : '');
69
+ }
70
+ return map;
71
+ }
72
+ if (raw && typeof raw === 'object') {
73
+ return Object.fromEntries(Object.entries(raw).map(([key, value])=>[
74
+ key,
75
+ String(value != null ? value : '')
76
+ ]));
77
+ }
78
+ return {};
79
+ }
80
+ /**
81
+ * A bounce or a complaint suppresses the address (AGL-1918).
82
+ *
83
+ * Until now this webhook answered `email.bounced` and `email.complained` with
84
+ * `200 {ignored:true}` — acknowledged, and dropped. The consequences were not
85
+ * cosmetic, because `campaign-send.ts` filters its audience against exactly
86
+ * one list and nothing but an unsubscribe click ever wrote to it: a hard-bounced
87
+ * address was re-sent on every subsequent campaign forever, and a spam
88
+ * complaint had no effect on whether that complainant was mailed again. Both
89
+ * are the behaviours mailbox providers measure a sending domain on, and
90
+ * `aglyn.com` carries the password resets and receipts on the same key and the
91
+ * same From address as the campaigns.
92
+ *
93
+ * WHICH failures suppress:
94
+ *
95
+ * - **Every complaint.** Someone pressed "report spam". There is no reading of
96
+ * that which permits mailing them again.
97
+ * - **Permanent bounces only.** Resend reports `data.bounce.type` as
98
+ * `Permanent` or `Transient`. A transient bounce is a full mailbox or a
99
+ * greylisting server — suppressing on one would unsubscribe a real
100
+ * subscriber over a temporary condition at their provider, which is a
101
+ * customer's list being quietly destroyed by our error handling.
102
+ * - An **unrecognised or absent** bounce type does NOT suppress. Guessing in
103
+ * the suppressing direction is the destructive guess, and the spec asserts
104
+ * the shape so a payload change fails as itself rather than by silently
105
+ * ceasing to suppress anything.
106
+ *
107
+ * The entry is keyed and shaped exactly as the unsubscribe handler's
108
+ * (`suppressionId(email)` → `{ email, createdAt }`), because the reader does
109
+ * not care how an address got there — it is one list, and a second shape would
110
+ * be a second list that `campaign-send` reads half of. `createdAt` is written
111
+ * only when the document is new, so a bounce arriving after an unsubscribe
112
+ * does not restamp the date the person actually unsubscribed.
113
+ *
114
+ * WHERE it lands, since AGL-2407: BOTH lists, and the per-host one is now the
115
+ * optional half.
116
+ *
117
+ * - The PLATFORM list (`emailSuppressions`) is always written. That is the
118
+ * half that did not exist: a bounce on an invite, a password reset, a
119
+ * receipt or a usage summary carries no `hostId` — only `campaign-send`
120
+ * ever stamped one — so this webhook had nowhere to file it and answered
121
+ * `200 {ignored:true}`, exactly as if the address were fine. A dead mailbox
122
+ * was re-mailed on every subsequent send, forever.
123
+ * - The PER-HOST list is written when the send named a site, because that is
124
+ * the list `campaign-send` filters its audience against and a merchant's
125
+ * own list is theirs to see and undo (AGL-2410).
126
+ *
127
+ * A hard bounce is address-level truth and belongs on the platform list even
128
+ * when a site was named — the mailbox does not exist for anyone. A COMPLAINT
129
+ * is a judgement about one sender's mail, and it goes on the platform list too
130
+ * for a narrower reason: the platform list is consulted only by bulk mail, and
131
+ * someone who pressed "report spam" on anything from `noreply@aglyn.com` must
132
+ * not receive more bulk mail from `noreply@aglyn.com`. Neither list is
133
+ * consulted by transactional mail; see `email-suppression.ts` for why.
134
+ */ async function recordDeliveryFailure(args) {
135
+ var _ref;
136
+ const { firestore, hostRef, type, recipient, bounceType, context, res } = args;
137
+ if (!recipient) return res.status(200).json({
138
+ ignored: true
139
+ });
140
+ const complaint = type === 'email.complained';
141
+ const permanent = bounceType.trim().toLowerCase() === 'permanent';
142
+ if (!complaint && !permanent) {
143
+ // A transient bounce is real information we deliberately do not act on.
144
+ return res.status(200).json({
145
+ ok: true,
146
+ suppressed: false
147
+ });
148
+ }
149
+ const reason = complaint ? 'complaint' : 'bounce';
150
+ /*==========================================
151
+ * THE SAME EVENT, COUNTED AGAINST THE TENANT.
152
+ *
153
+ * The campaign counter the caller writes answers "how did this mailing do".
154
+ * This one answers the question the shared sending domain actually depends
155
+ * on: how is THIS WORKSPACE doing, across every campaign it has sent.
156
+ * Nothing computed a rate at any scope before it, so one merchant's bad
157
+ * list could push the domain every other merchant's receipts leave on
158
+ * toward a block, and the first anybody would hear of it is a rejection.
159
+ *
160
+ * ## Why HERE, below the permanence filter
161
+ *
162
+ * The two decisions in front of it are the same two the suppression needs,
163
+ * and a rate that included them would be measuring something else. A
164
+ * TRANSIENT bounce is a full mailbox or a greylisting server — it says
165
+ * nothing about list quality, it does not suppress, and counting it would
166
+ * trip a merchant's breaker on their subscribers' holiday auto-replies.
167
+ *
168
+ * ## Why only a send that named a site
169
+ *
170
+ * `hostId` is the only tenant identity a delivery event carries, and only
171
+ * `campaign-send` stamps one. A bounce on a password reset or an invite
172
+ * therefore reaches the suppression lists — address-level truth belongs on
173
+ * them — and deliberately not this counter: the breaker it feeds may only
174
+ * ever refuse a CAMPAIGN, so mail it could never act on must neither
175
+ * inflate the rate nor dilute it.
176
+ *
177
+ * Swallowed, and taken before the suppression writes rather than after, so
178
+ * a counter that fails cannot cost an address its place on either list.
179
+ *=========================================*/ if (hostRef) {
180
+ await getOrgForHost(hostRef.id).then((org)=>(org == null ? void 0 : org.orgId) ? recordEmailReputationFailure(org.orgId, reason) : undefined).catch(()=>undefined);
181
+ }
182
+ // The platform list FIRST, and unconditionally. Ordered ahead of the
183
+ // per-host write because it is the one that must happen for every failure:
184
+ // if the per-host write throws, the outer handler answers 200 and the
185
+ // address is still off the bulk senders' lists.
186
+ await suppressEmail({
187
+ email: recipient,
188
+ reason,
189
+ context: context || null,
190
+ hostId: (_ref = hostRef == null ? void 0 : hostRef.id) != null ? _ref : null,
191
+ firestore
192
+ });
193
+ if (hostRef) {
194
+ const ref = hostRef.collection('suppressions').doc(suppressionId(recipient));
195
+ await firestore.runTransaction(async (transaction)=>{
196
+ const existing = await transaction.get(ref);
197
+ transaction.set(ref, _extends({
198
+ email: recipient,
199
+ reason,
200
+ suppressedAt: FieldValue.serverTimestamp()
201
+ }, existing.exists ? {} : {
202
+ createdAt: FieldValue.serverTimestamp()
203
+ }), {
204
+ merge: true
205
+ });
206
+ });
207
+ }
208
+ return res.status(200).json({
209
+ ok: true,
210
+ suppressed: true,
211
+ scope: hostRef ? 'host' : 'platform'
212
+ });
213
+ }
214
+ /**
215
+ * Counts one click against its destination in the campaign's link rollup.
216
+ *
217
+ * A transaction because the CAP has to be enforced against the map as it
218
+ * stands: a merge-set cannot ask "is this key already here, and how many keys
219
+ * are there" and would grow the document without limit. Firestore's 1 MiB
220
+ * document ceiling is the hard reason, and a rollup that silently stopped
221
+ * being writable at that ceiling would take the campaign's other counters
222
+ * down with it if they shared the document — which is why this is its own.
223
+ *
224
+ * Nothing is DROPPED at the cap. A click on a destination past it lands in
225
+ * `overflowClicks`, and a click that arrived with no destination at all lands
226
+ * in `unattributedClicks`, so the table's own total plus the two excluded
227
+ * figures reconcile with `stats.clicks` and the screen can say where the
228
+ * difference went. Dropping either would leave a link table whose sum quietly
229
+ * disagreed with the click count printed above it.
230
+ *
231
+ * The rollup does NOT create the campaign. Same reasoning as `updateExisting`
232
+ * on the counters: a click arriving days after a merchant deleted a campaign
233
+ * must not resurrect it, here as a document holding one map of URLs.
234
+ */ async function recordCampaignLinkClick(args) {
235
+ const { firestore, campaignRef, link } = args;
236
+ const ref = campaignRef.collection('reports').doc('links');
237
+ await firestore.runTransaction(async (transaction)=>{
238
+ var _ref, _stored_links;
239
+ const campaign = await transaction.get(campaignRef);
240
+ if (!campaign.exists) return;
241
+ const snapshot = await transaction.get(ref);
242
+ const stored = (_ref = snapshot.exists ? snapshot.data() : null) != null ? _ref : {};
243
+ const links = (_stored_links = stored.links) != null ? _stored_links : {};
244
+ if (!link) {
245
+ transaction.set(ref, {
246
+ unattributedClicks: FieldValue.increment(1)
247
+ }, {
248
+ merge: true
249
+ });
250
+ return;
251
+ }
252
+ // The map key is a hash, not the URL: a Firestore field name may not
253
+ // contain `.`, `/` or `~`, and every URL contains at least two of them.
254
+ // The URL itself rides in the value, so nothing has to be un-hashed.
255
+ const key = createHash('sha256').update(link).digest('hex').slice(0, 32);
256
+ if (links[key] === undefined && Object.keys(links).length >= CAMPAIGN_LINK_ROLLUP_MAX) {
257
+ transaction.set(ref, {
258
+ overflowClicks: FieldValue.increment(1)
259
+ }, {
260
+ merge: true
261
+ });
262
+ return;
263
+ }
264
+ transaction.set(ref, {
265
+ // A nested map under a merge-set, which merges at depth — the dotted
266
+ // form would write a field whose NAME contains dots and leave `links`
267
+ // empty, the exact fault `email-delivery-log.ts` records against
268
+ // `timestamps`.
269
+ links: {
270
+ [key]: {
271
+ url: link,
272
+ clicks: FieldValue.increment(1)
273
+ }
274
+ },
275
+ updatedAt: FieldValue.serverTimestamp()
276
+ }, {
277
+ merge: true
278
+ });
279
+ });
280
+ }
281
+ /**
282
+ * Resend event ingestion (AGL-268), relocated from the console app route
283
+ * into its owning plugin (AGL-418) — the URL `/api/email/events` is
284
+ * preserved through the plugin API dispatcher. Opened/clicked events
285
+ * increment the tagged campaign's stats; clicks on experiment sends also
286
+ * count as the recipient's variant conversion — the variant re-derives
287
+ * deterministically from the address, so nothing per-send is stored.
288
+ * Svix signs the RAW body: `req.rawBody` carries the exact request text.
289
+ */ export const emailEventsHandler = async (req, res)=>{
290
+ var _req_rawBody, _headers_svixid, _headers_svixtimestamp, _headers_svixsignature;
291
+ if (req.method !== 'POST') {
292
+ return res.status(405).json({
293
+ error: 'Method not allowed'
294
+ });
295
+ }
296
+ const secret = process.env.RESEND_WEBHOOK_SECRET;
297
+ if (!secret) {
298
+ return res.status(501).json({
299
+ error: 'Webhook is not configured'
300
+ });
301
+ }
302
+ const payload = Buffer.from((_req_rawBody = req.rawBody) != null ? _req_rawBody : '', 'utf8');
303
+ const headers = req.headers;
304
+ const svixId = String((_headers_svixid = headers['svix-id']) != null ? _headers_svixid : '');
305
+ const svixTimestamp = String((_headers_svixtimestamp = headers['svix-timestamp']) != null ? _headers_svixtimestamp : '');
306
+ const svixSignature = String((_headers_svixsignature = headers['svix-signature']) != null ? _headers_svixsignature : '');
307
+ if (!svixId || !svixTimestamp || !verifySvix(secret, svixId, svixTimestamp, payload, svixSignature)) {
308
+ return res.status(401).json({
309
+ error: 'Bad signature'
310
+ });
311
+ }
312
+ try {
313
+ var _ref, _ref1, _data_to_, _ref2;
314
+ var _deliveryEvents_;
315
+ const event = JSON.parse(payload.toString('utf8'));
316
+ const type = String((_ref = event == null ? void 0 : event.type) != null ? _ref : '');
317
+ /*==========================================
318
+ * THE PER-RECIPIENT DELIVERY LOG.
319
+ *
320
+ * FIRST, and for every event type rather than the four below, because the
321
+ * log is the staff answer to "did this person get their invite" and that
322
+ * question is mostly asked about `sent`, `delivered` and `bounced` —
323
+ * none of which the campaign statistics below have any use for.
324
+ *
325
+ * `normalizeResendDeliveryEvents` is the one place in the tree that reads
326
+ * Resend's wire format; everything downstream stores and renders our own
327
+ * vocabulary, so changing sender is a new adapter and nothing else.
328
+ *
329
+ * Best-effort and awaited but never fatal: a log write that fails must
330
+ * not turn into a non-2xx, which the provider would answer by retrying
331
+ * the same event forever.
332
+ *=========================================*/ const deliveryEvents = normalizeResendDeliveryEvents(event, Date.now());
333
+ const outcomes = await recordEmailDeliveryEvents(deliveryEvents).catch(()=>[]);
334
+ /*
335
+ * DISTINCT RECIPIENTS this event is the first of its kind for.
336
+ *
337
+ * The log's transaction already held each message's prior state, so this
338
+ * is free — and it is the only honest way to count distinct openers
339
+ * without a second document per recipient. It is also idempotent for the
340
+ * same reason the replay guard below exists: a redelivered or replayed
341
+ * event finds the state already recorded and contributes zero.
342
+ *
343
+ * Zero when the log write failed, which loses the count rather than
344
+ * inventing one. That is the correct direction: a lost increment
345
+ * understates engagement, and a guessed one is a number nobody can
346
+ * defend.
347
+ */ const firstSeen = outcomes.filter((one)=>one.firstOfType).length;
348
+ /*==========================================
349
+ * THE PER-PERSON ENGAGEMENT ROLLUP.
350
+ *
351
+ * Opens and clicks were recorded per message and per campaign and rolled
352
+ * onto NOBODY, so "has this person engaged lately" could only be answered
353
+ * by walking every message subcollection. Two shipped things needed that
354
+ * answer and could not have it: an audience rule that says "opened in the
355
+ * last 30 days", and a sunset that stops mailing an address which has
356
+ * gone quiet.
357
+ *
358
+ * HERE, above the type gate and above the campaign gates, on purpose.
359
+ * Engagement is a fact about the PERSON, and the message they engaged
360
+ * with does not have to be a campaign for it to be one — somebody who
361
+ * clicks a receipt is reading our mail. Placing it below the
362
+ * `hostId`/`campaignId` gate would record engagement for campaign mail
363
+ * only and then let a sunset refuse people on the strength of it, which
364
+ * is a control drawing conclusions from a fraction of the evidence.
365
+ *
366
+ * Driven by the same `firstOfType` outcomes `firstSeen` is counted from,
367
+ * so a replay contributes nothing here for the same reason it contributes
368
+ * nothing to `stats.uniqueOpens` — and the rollup needs no claim of its
369
+ * own. Best-effort: a person's stamp is worth less than the campaign
370
+ * counters below it and much less than a suppression.
371
+ *=========================================*/ await recordPersonEngagement(outcomes).catch(()=>0);
372
+ if (type !== 'email.opened' && type !== 'email.clicked' && type !== 'email.bounced' && type !== 'email.complained' && type !== 'email.delivered') {
373
+ return res.status(200).json({
374
+ ignored: true
375
+ });
376
+ }
377
+ const data = (_ref1 = event == null ? void 0 : event.data) != null ? _ref1 : {};
378
+ const tags = tagMap(data == null ? void 0 : data.tags);
379
+ const hostId = tags['hostId'];
380
+ const campaignId = tags['campaignId'];
381
+ const recipient = String(Array.isArray(data == null ? void 0 : data.to) ? (_data_to_ = data.to[0]) != null ? _data_to_ : '' : (_ref2 = data == null ? void 0 : data.to) != null ? _ref2 : '').trim().toLowerCase();
382
+ // A path component, so "non-empty" was never the whole question.
383
+ const firestore = firebaseAdmin.app().firestore();
384
+ const hostRef = isDocumentId(hostId) ? firestore.collection('hosts').doc(hostId) : null;
385
+ /** The campaign this event belongs to, or null when it names none. */ const campaignRef = hostRef && isDocumentId(campaignId) ? hostRef.collection('campaigns').doc(campaignId) : null;
386
+ /*==========================================
387
+ * THE CONTACT'S OWN STAMP (AGL-2616).
388
+ *
389
+ * The person rollup above is about the ADDRESS and moves on any sender's
390
+ * mail; this one is about THIS SITE's relationship with the person and
391
+ * moves only on its own campaigns — which is why it sits below the
392
+ * `hostId` gate the rollup deliberately sits above. Same `firstOfType`
393
+ * outcomes, so a replay contributes nothing here either, and the leaf
394
+ * decides which types count. Best-effort: a stamp is worth less than
395
+ * the campaign counters below it.
396
+ *=========================================*/ if (hostRef && (type === 'email.opened' || type === 'email.clicked')) {
397
+ await recordContactEmailEngagement({
398
+ hostId: hostRef.id,
399
+ outcomes
400
+ }).catch(()=>0);
401
+ }
402
+ /*==========================================
403
+ * THE ONE-TO-ONE EMAIL'S TIMELINE ENTRY (AGL-2615).
404
+ *
405
+ * A message sent from a CRM record carries the org and the activity row
406
+ * it was logged as, and the five events below are the row's delivery
407
+ * state — the chip a rep reads beside "Email" on the timeline. Placed
408
+ * above the campaign gates because a one-to-one email is not a campaign
409
+ * and names none; placed below the log and the engagement rollup
410
+ * because those are facts about the message and the person that this
411
+ * row merely restates.
412
+ *
413
+ * The state comes from the NORMALIZED event, not the wire string: the
414
+ * adapter above is the one reader of the provider's vocabulary. Both ids
415
+ * are path components, so both are checked the way `hostId` is. Never
416
+ * fatal — the writer reports rather than throws — and never behind the
417
+ * replay claim: the write is monotonic, so a replay finds the row
418
+ * already there and changes nothing.
419
+ *=========================================*/ const crmState = crmEmailDeliveryStateForEvent((_deliveryEvents_ = deliveryEvents[0]) == null ? void 0 : _deliveryEvents_.type);
420
+ const crmActivityId = tags[CRM_EMAIL_ACTIVITY_TAG];
421
+ const crmOrgId = tags[CRM_EMAIL_ORG_TAG];
422
+ if (crmState && isDocumentId(crmActivityId) && isDocumentId(crmOrgId)) {
423
+ var _ref3;
424
+ var _deliveryEvents_1;
425
+ await recordCrmEmailDelivery(firestore, {
426
+ orgId: crmOrgId,
427
+ activityId: crmActivityId,
428
+ state: crmState,
429
+ atMs: (_ref3 = (_deliveryEvents_1 = deliveryEvents[0]) == null ? void 0 : _deliveryEvents_1.at) != null ? _ref3 : Date.now()
430
+ });
431
+ }
432
+ /*==========================================
433
+ * THE DELIVERY DENOMINATOR.
434
+ *
435
+ * `email.delivered` used to be answered `200 {ignored:true}`, which is
436
+ * why every campaign rate had to be taken over `sent`. Sent is what the
437
+ * PROVIDER accepted; delivered is what the receiving server accepted, and
438
+ * the gap between them is the bounces. An open rate over `sent` therefore
439
+ * reads lower than the same campaign measured anywhere else, and a rate
440
+ * that only we compute differently is a rate a merchant cannot check.
441
+ *
442
+ * NO REPLAY CLAIM ON THIS ONE, and that is deliberate rather than an
443
+ * omission: `firstSeen` is derived from whether the delivery log had
444
+ * already recorded a `delivered` for this MESSAGE, so a retry, a replay
445
+ * and a duplicate webhook all contribute zero without a claim document
446
+ * being minted per delivered event. The counters below that DO carry a
447
+ * claim are the ones counting events rather than messages.
448
+ *=========================================*/ if (type === 'email.delivered') {
449
+ if (!campaignRef || !firstSeen) {
450
+ return res.status(200).json({
451
+ ignored: true
452
+ });
453
+ }
454
+ // `updateExisting` for the AGL-1768 reason the open counter carries:
455
+ // a merge-set against a deleted campaign RE-CREATES it as a document
456
+ // holding nothing but a `stats` map.
457
+ await updateExisting(campaignRef, {
458
+ 'stats.delivered': FieldValue.increment(firstSeen)
459
+ });
460
+ return res.status(200).json({
461
+ ok: true,
462
+ counted: true
463
+ });
464
+ }
465
+ if (type === 'email.bounced' || type === 'email.complained') {
466
+ var _ref4, _tags_context;
467
+ var _data_bounce;
468
+ /*
469
+ * The campaign counter FIRST, and its failure swallowed.
470
+ *
471
+ * Ordered ahead of the suppression because the suppression is the write
472
+ * that must happen — it is what stops us mailing a dead or hostile
473
+ * address again — and `recordDeliveryFailure` owns the response. A
474
+ * statistic must never be able to cost a suppression, so this is
475
+ * wrapped rather than awaited into the same failure path.
476
+ *
477
+ * Same `firstSeen` idempotency as `delivered` above: one bounce per
478
+ * message, however many times the provider tells us about it.
479
+ */ if (campaignRef && firstSeen) {
480
+ await updateExisting(campaignRef, {
481
+ [type === 'email.bounced' ? 'stats.bounced' : 'stats.complained']: FieldValue.increment(firstSeen)
482
+ }).catch(()=>undefined);
483
+ }
484
+ // NO `hostId` gate here since AGL-2407. It used to sit above this
485
+ // branch, which is what made every transactional bounce a no-op: only
486
+ // `campaign-send` stamps a `hostId` tag, so a bounce on an invite or a
487
+ // password reset failed the gate and was dropped with `ignored: true`.
488
+ // The failure path needs no site at all — `recordDeliveryFailure` files
489
+ // the platform record either way and adds the per-host one when there
490
+ // is a host to add it to.
491
+ return await recordDeliveryFailure({
492
+ firestore,
493
+ hostRef,
494
+ type,
495
+ recipient,
496
+ bounceType: String((_ref4 = data == null ? void 0 : (_data_bounce = data.bounce) == null ? void 0 : _data_bounce.type) != null ? _ref4 : ''),
497
+ context: (_tags_context = tags['context']) != null ? _tags_context : '',
498
+ res
499
+ });
500
+ }
501
+ // Opens and clicks ARE per-campaign, so this pair still needs both ids —
502
+ // and therefore still needs the host, which the failure path above no
503
+ // longer does.
504
+ if (!hostRef || !isDocumentId(campaignId)) {
505
+ return res.status(200).json({
506
+ ignored: true
507
+ });
508
+ }
509
+ /*==========================================
510
+ * THE REPLAY GUARD, around the counters and nothing else.
511
+ *
512
+ * Delivery is AT LEAST ONCE and the two writes below are
513
+ * `FieldValue.increment(1)`, which is the combination that inflates a
514
+ * statistic. Three things deliver the same event twice: a provider retry
515
+ * after our function wrote and then timed out before answering, a
516
+ * retry after any non-2xx, and a human pressing **Replay** in the
517
+ * provider's dashboard — which is not a hypothetical, since replay is how
518
+ * events that failed while the signing secret was unset get recovered.
519
+ * Every one of those turns one open into two.
520
+ *
521
+ * The claim is keyed on the Svix message id, which is stable across all
522
+ * three: a retry and a replay of one event carry the id the first
523
+ * delivery carried. `kind` and `scopeId` go into the digest with it, so
524
+ * one site's event cannot collide with another's.
525
+ *
526
+ * ## Why it wraps the counters rather than the whole handler
527
+ *
528
+ * Everything above this point is already idempotent and worth re-running.
529
+ * The per-recipient delivery log keys by the provider's message id and
530
+ * merges, so a replay refreshes a row rather than adding one — and a row
531
+ * that failed to write the first time SHOULD get another chance.
532
+ * Suppression is a set: suppressing an address twice is suppressing it
533
+ * once. Only the increments cannot survive being repeated, so only they
534
+ * are behind the claim.
535
+ *
536
+ * ⚠️ `claimAttempt` treats an EMPTY key as "no claim" and returns a
537
+ * no-op, which would silently reopen this hole. It cannot happen here —
538
+ * the signature check above refuses a request with no `svix-id` before
539
+ * reaching this line — and there is a test that fails if that stops
540
+ * being true.
541
+ *=========================================*/ const counted = await claimAttempt(firestore, {
542
+ kind: 'resend-email-event',
543
+ scopeId: hostId,
544
+ orgId: '',
545
+ key: svixId,
546
+ busyMessage: 'This delivery event is already being counted.'
547
+ });
548
+ if ('replay' in counted) {
549
+ return res.status(200).json({
550
+ ok: true,
551
+ counted: false
552
+ });
553
+ }
554
+ // Plain refusal (AGL-1768). A merge-set against a missing path CREATES it,
555
+ // so an open re-created a campaign the merchant had deleted — a document
556
+ // holding a `stats` map and nothing else: no subject, no body, no
557
+ // audience, no status. Opens trail sends by days, so deleting it again did
558
+ // not help. `updateExisting` rejects only that case (gRPC NOT_FOUND) and
559
+ // rethrows everything else, so a Firestore outage stays distinguishable
560
+ // from an open against a deleted campaign instead of being swallowed by a
561
+ // `.catch(() => undefined)`. AGL-1760's test — does refusing discard money
562
+ // or work that already happened? — is passed: the open count for a
563
+ // campaign that no longer exists has no reader.
564
+ //
565
+ // DOTTED FIELD PATH, not a nested map. `update({ stats: { opens: … } })`
566
+ // REPLACES the whole `stats` map, so every open would clobber `clicks`;
567
+ // only `set({ merge: true })` merges maps at depth.
568
+ try {
569
+ /*
570
+ * TWO COUNTERS PER EVENT, because they answer two questions and the
571
+ * report has to be able to name which it is showing.
572
+ *
573
+ * `stats.opens` counts EVENTS: one reader opening four times is four.
574
+ * That is the number this handler has always kept, it is the honest
575
+ * count of what happened, and it is useless as a rate numerator — an
576
+ * open rate built on it exceeds 100% the moment anyone reads an email
577
+ * twice, and a percentage above 100 teaches a reader that the label is
578
+ * lying.
579
+ *
580
+ * `stats.uniqueOpens` counts MESSAGES that had never been opened
581
+ * before, which is distinct readers, which is what every open rate in
582
+ * the industry divides. `firstSeen` comes from the delivery log's own
583
+ * transaction, so it costs no read here.
584
+ */ const totals = {
585
+ [type === 'email.opened' ? 'stats.opens' : 'stats.clicks']: FieldValue.increment(1)
586
+ };
587
+ if (firstSeen) {
588
+ totals[type === 'email.opened' ? 'stats.uniqueOpens' : 'stats.uniqueClicks'] = FieldValue.increment(firstSeen);
589
+ }
590
+ await updateExisting(hostRef.collection('campaigns').doc(campaignId), totals);
591
+ /*==========================================
592
+ * LINK-LEVEL CLICKS — the aggregate `data.click.link` never had.
593
+ *
594
+ * The field IS present on Resend's `email.clicked` payload and has been
595
+ * read for a while: `normalizeResendDeliveryEvents` puts it on the
596
+ * event and the per-recipient delivery log stores it. What did not
597
+ * exist was a per-campaign rollup, and it could not be produced at read
598
+ * time — that would mean querying every recipient's delivery row for
599
+ * the campaign, which is the scan a campaign report must not do.
600
+ *
601
+ * ONE DOCUMENT, not a document per URL. The report then reads the whole
602
+ * table with a single `getDoc`, and the map cannot grow without bound
603
+ * because the transaction refuses a new key past the cap and counts the
604
+ * click as overflow instead. `campaignLinkKey` drops the query string,
605
+ * so a link personalised per recipient cannot mint a row per recipient
606
+ * — see that function for why that is a correctness requirement and not
607
+ * only a size one.
608
+ *
609
+ * Inside the claim, so a replayed click cannot inflate a link row any
610
+ * more than it can inflate `stats.clicks`. Best-effort: a rollup write
611
+ * that fails must not cost the click count above it, which is the
612
+ * number the whole report leans on.
613
+ *=========================================*/ if (type === 'email.clicked') {
614
+ var _ref5;
615
+ var _data_click, _outcomes_find;
616
+ await recordCampaignLinkClick({
617
+ firestore,
618
+ campaignRef: hostRef.collection('campaigns').doc(campaignId),
619
+ link: campaignLinkKey(data == null ? void 0 : (_data_click = data.click) == null ? void 0 : _data_click.link)
620
+ }).catch(()=>undefined);
621
+ /*==========================================
622
+ * THE TOUCH REVENUE ATTRIBUTION IS TAKEN OVER.
623
+ *
624
+ * "Which campaign brought this buyer here" cannot be answered from
625
+ * anything above: the delivery log holds a row per message, and
626
+ * finding a person's most recent click would mean reading every one
627
+ * of them. So the click writes the answer down — one field on the
628
+ * person's own document, per site — and an order reads it with a
629
+ * single keyed lookup.
630
+ *
631
+ * ONLY a click. An open would be the weaker evidence and, since Mail
632
+ * Privacy Protection, frequently not a human at all; crediting money
633
+ * to one would hand a campaign the orders of people who never read
634
+ * it. `email-revenue-attribution.ts` records the full reasoning.
635
+ *
636
+ * The instant is the PROVIDER'S, taken from the outcome the delivery
637
+ * log already wrote, so a delayed webhook credits the click at the
638
+ * time it happened rather than at the time we heard about it — which
639
+ * is the difference between inside and outside the window for a click
640
+ * near its edge. `Date.now()` is the fallback for a click whose log
641
+ * write failed, and it is the later of the two, so it can only narrow
642
+ * the window rather than widen it.
643
+ *
644
+ * Best-effort, like the link rollup above it and for the same reason:
645
+ * a touch that failed to write costs one order's attribution, and it
646
+ * must not cost the click count the whole report leans on.
647
+ *=========================================*/ await recordEmailCampaignTouch({
648
+ email: recipient,
649
+ hostId,
650
+ campaignId,
651
+ atMs: (_ref5 = (_outcomes_find = outcomes.find((one)=>one.type === 'clicked')) == null ? void 0 : _outcomes_find.at) != null ? _ref5 : Date.now()
652
+ }).catch(()=>false);
653
+ }
654
+ // Experiment conversion (AGL-268): clicks are the signal.
655
+ const experimentId = tags['experimentId'];
656
+ if (type === 'email.clicked' && isDocumentId(experimentId) && recipient) {
657
+ const experimentSnapshot = await hostRef.collection('experiments').doc(experimentId).get();
658
+ const experiment = experimentSnapshot.data();
659
+ if (experimentSnapshot.exists && experiment) {
660
+ const variant = assignExperimentVariant(experiment, experimentId, recipient);
661
+ // `variant.id` is merchant-authored — `validateExperiment` checks the
662
+ // ids are unique and nothing about their SHAPE — and it is a path
663
+ // component here too.
664
+ if (variant && isDocumentId(variant.id)) {
665
+ // Still a merge-set, and deliberately so: this one CREATES. The
666
+ // first conversion for a variant has no stats document yet, the
667
+ // experiment it hangs off was just proven to exist, and the click
668
+ // is work that really happened — refusing would discard it. The
669
+ // `.catch(() => undefined)` is gone for the same reason as above: a
670
+ // swallowed failure here is a conversion lost silently.
671
+ await experimentSnapshot.ref.collection('stats').doc(variant.id).set({
672
+ conversions: FieldValue.increment(1),
673
+ updatedAt: FieldValue.serverTimestamp()
674
+ }, {
675
+ merge: true
676
+ });
677
+ }
678
+ }
679
+ }
680
+ } catch (error) {
681
+ /*
682
+ * Give the key back, so the count this attempt did not make is still
683
+ * makeable. Without it a failure here is permanent in a way the failure
684
+ * itself is not: the outer handler answers 200 whatever happens (see
685
+ * below), so the provider never retries, and a settled claim would then
686
+ * refuse the manual replay that is the only remaining way to recover
687
+ * the event.
688
+ *
689
+ * Releasing cannot itself be fatal — the original error is what is
690
+ * worth reporting, and losing it to a secondary failure while cleaning
691
+ * up would hide the real cause.
692
+ */ await counted.claim.release().catch(()=>undefined);
693
+ throw error;
694
+ }
695
+ /*
696
+ * Settled only after both writes landed, which is what makes the claim
697
+ * mean "this event has been counted" rather than "this event was seen".
698
+ */ await counted.claim.record(200, {
699
+ ok: true,
700
+ counted: true
701
+ });
702
+ return res.status(200).json({
703
+ ok: true
704
+ });
705
+ } catch (error) {
706
+ console.error(error);
707
+ // Never make Resend retry-storm.
708
+ return res.status(200).json({
709
+ ok: true
710
+ });
711
+ }
712
+ };
713
+
714
+ //# sourceMappingURL=email-events.js.map