@aglyn/plugins-marketing 1.0.0-beta.173 → 1.0.0-beta.174

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 (79) hide show
  1. package/README.md +2 -1
  2. package/package.json +15 -15
  3. package/src/lib/components/campaign-detail-card.d.ts +12 -1
  4. package/src/lib/components/campaign-detail-card.js +146 -35
  5. package/src/lib/components/campaign-detail-card.js.map +1 -1
  6. package/src/lib/components/campaign-edit-drawer.d.ts +13 -0
  7. package/src/lib/components/campaign-edit-drawer.js +56 -4
  8. package/src/lib/components/campaign-edit-drawer.js.map +1 -1
  9. package/src/lib/components/campaign-glance-card.component.js +10 -2
  10. package/src/lib/components/campaign-glance-card.component.js.map +1 -1
  11. package/src/lib/components/campaign-members-section.d.ts +6 -0
  12. package/src/lib/components/campaign-members-section.js +2 -2
  13. package/src/lib/components/campaign-members-section.js.map +1 -1
  14. package/src/lib/components/campaign-queries.d.ts +58 -0
  15. package/src/lib/components/campaign-queries.js +72 -0
  16. package/src/lib/components/campaign-queries.js.map +1 -0
  17. package/src/lib/components/campaign-reach-sections.d.ts +21 -7
  18. package/src/lib/components/campaign-reach-sections.js +20 -17
  19. package/src/lib/components/campaign-reach-sections.js.map +1 -1
  20. package/src/lib/components/campaign-report-card.d.ts +8 -1
  21. package/src/lib/components/campaign-report-card.js +33 -16
  22. package/src/lib/components/campaign-report-card.js.map +1 -1
  23. package/src/lib/components/campaigns-card.d.ts +10 -1
  24. package/src/lib/components/campaigns-card.js +102 -19
  25. package/src/lib/components/campaigns-card.js.map +1 -1
  26. package/src/lib/components/email-compose-card.d.ts +3 -2
  27. package/src/lib/components/email-compose-card.js +18 -7
  28. package/src/lib/components/email-compose-card.js.map +1 -1
  29. package/src/lib/components/email-detail.d.ts +11 -2
  30. package/src/lib/components/email-detail.js +50 -23
  31. package/src/lib/components/email-detail.js.map +1 -1
  32. package/src/lib/components/emails-list-card.d.ts +10 -1
  33. package/src/lib/components/emails-list-card.js +129 -23
  34. package/src/lib/components/emails-list-card.js.map +1 -1
  35. package/src/lib/components/host-marketing-summary-card.component.js +9 -1
  36. package/src/lib/components/host-marketing-summary-card.component.js.map +1 -1
  37. package/src/lib/components/marketing-console-page.js +81 -4
  38. package/src/lib/components/marketing-console-page.js.map +1 -1
  39. package/src/lib/components/marketing-console-sections.d.ts +16 -1
  40. package/src/lib/components/marketing-console-sections.js +25 -2
  41. package/src/lib/components/marketing-console-sections.js.map +1 -1
  42. package/src/lib/components/marketing-org-mount.d.ts +71 -0
  43. package/src/lib/components/marketing-org-mount.js +84 -0
  44. package/src/lib/components/marketing-org-mount.js.map +1 -0
  45. package/src/lib/components/use-campaign-send-api.d.ts +2 -2
  46. package/src/lib/components/use-campaign-send-api.js +22 -6
  47. package/src/lib/components/use-campaign-send-api.js.map +1 -1
  48. package/src/lib/components/use-emails-hub-path.d.ts +13 -3
  49. package/src/lib/components/use-emails-hub-path.js +25 -4
  50. package/src/lib/components/use-emails-hub-path.js.map +1 -1
  51. package/src/lib/components/use-marketing-hub-path.d.ts +19 -16
  52. package/src/lib/components/use-marketing-hub-path.js +6 -32
  53. package/src/lib/components/use-marketing-hub-path.js.map +1 -1
  54. package/src/lib/model/marketing-record-routes.js +11 -4
  55. package/src/lib/model/marketing-record-routes.js.map +1 -1
  56. package/src/lib/plugin.js +31 -1
  57. package/src/lib/plugin.js.map +1 -1
  58. package/src/lib/server/campaign-manage.d.ts +31 -2
  59. package/src/lib/server/campaign-manage.js +365 -137
  60. package/src/lib/server/campaign-manage.js.map +1 -1
  61. package/src/lib/server/campaign-org-refs.d.ts +45 -0
  62. package/src/lib/server/campaign-org-refs.js +83 -0
  63. package/src/lib/server/campaign-org-refs.js.map +1 -0
  64. package/src/lib/server/campaign-process-scheduled.d.ts +32 -2
  65. package/src/lib/server/campaign-process-scheduled.js +79 -9
  66. package/src/lib/server/campaign-process-scheduled.js.map +1 -1
  67. package/src/lib/server/campaign-recipients.js +18 -4
  68. package/src/lib/server/campaign-recipients.js.map +1 -1
  69. package/src/lib/server/campaign-send.js +139 -55
  70. package/src/lib/server/campaign-send.js.map +1 -1
  71. package/src/lib/server/email-campaign-reach.d.ts +6 -5
  72. package/src/lib/server/email-campaign-reach.js +14 -14
  73. package/src/lib/server/email-campaign-reach.js.map +1 -1
  74. package/src/lib/server/email-events.js +48 -17
  75. package/src/lib/server/email-events.js.map +1 -1
  76. package/src/lib/server/marketing-figures.js +12 -1
  77. package/src/lib/server/marketing-figures.js.map +1 -1
  78. package/src/lib/server.js +7 -3
  79. package/src/lib/server.js.map +1 -1
@@ -16,7 +16,8 @@
16
16
  */
17
17
  /**
18
18
  * Where the record lives: beside the link rollup, under the send it belongs
19
- * to.
19
+ * to — whichever document the caller holds for that send, so the record
20
+ * always travels with it.
20
21
  *
21
22
  * A subcollection document rather than a field on the send, for the reason
22
23
  * the link rollup is one — the send document is read by the emails list, the
@@ -76,7 +77,7 @@ export interface CampaignSettledRecord {
76
77
  *
77
78
  * @throws when the record cannot be read.
78
79
  */
79
- export declare function readCampaignReach(hostId: string, sendId: string, firestore?: any): Promise<Set<string>>;
80
+ export declare function readCampaignReach(sendRef: FirebaseFirestore.DocumentReference): Promise<Set<string>>;
80
81
  /**
81
82
  * Everything one email has decided about, in ONE read.
82
83
  *
@@ -105,7 +106,7 @@ export declare function readCampaignReach(hostId: string, sendId: string, firest
105
106
  *
106
107
  * @throws when the record cannot be read.
107
108
  */
108
- export declare function readCampaignSettled(hostId: string, sendId: string, firestore?: any): Promise<CampaignSettledRecord>;
109
+ export declare function readCampaignSettled(sendRef: FirebaseFirestore.DocumentReference): Promise<CampaignSettledRecord>;
109
110
  /**
110
111
  * How many addresses this email's record accounts for, either way.
111
112
  *
@@ -144,7 +145,7 @@ export declare function partitionByCampaignReach(emails: readonly string[], reac
144
145
  * in {@link campaignReachCovers} is what catches a lost write, by refusing
145
146
  * the follow-up rather than by pretending the record is whole.
146
147
  */
147
- export declare function recordCampaignReach(hostId: string, sendId: string, emails: readonly string[], firestore?: any): Promise<number>;
148
+ export declare function recordCampaignReach(sendRef: FirebaseFirestore.DocumentReference, emails: readonly string[]): Promise<number>;
148
149
  /**
149
150
  * Adds the addresses a send CONSIDERED and did not mail.
150
151
  *
@@ -155,7 +156,7 @@ export declare function recordCampaignReach(hostId: string, sendId: string, emai
155
156
  * lookup and nothing else — nobody is mailed twice by it, because the
156
157
  * addresses in here are the ones nothing may mail at all.
157
158
  */
158
- export declare function recordCampaignSkipped(hostId: string, sendId: string, emails: readonly string[], firestore?: any): Promise<number>;
159
+ export declare function recordCampaignSkipped(sendRef: FirebaseFirestore.DocumentReference, emails: readonly string[]): Promise<number>;
159
160
  /**
160
161
  * Whether the reach record accounts for every message this email has sent.
161
162
  *
@@ -63,10 +63,10 @@
63
63
  */ import { EMAIL_MAX_AUDIENCE_PER_SEND } from "@aglyn/shared-util-email";
64
64
  import firebaseAdmin from "@aglyn/tenant-data-admin/server/firebase-admin";
65
65
  import { emailSuppressionKey } from "@aglyn/tenant-data-admin/server/email-suppression";
66
- const defaultFirestore = ()=>firebaseAdmin.app().firestore();
67
66
  /**
68
67
  * Where the record lives: beside the link rollup, under the send it belongs
69
- * to.
68
+ * to — whichever document the caller holds for that send, so the record
69
+ * always travels with it.
70
70
  *
71
71
  * A subcollection document rather than a field on the send, for the reason
72
72
  * the link rollup is one — the send document is read by the emails list, the
@@ -82,8 +82,8 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
82
82
  * the same number: an email cannot reach more people than one resolution of
83
83
  * its audience can hold.
84
84
  */ export const CAMPAIGN_REACH_CEILING = EMAIL_MAX_AUDIENCE_PER_SEND;
85
- function reachDoc(hostId, sendId, firestore) {
86
- return (firestore != null ? firestore : defaultFirestore()).collection('hosts').doc(hostId).collection('campaigns').doc(sendId).collection(CAMPAIGN_REACH_SUBCOLLECTION).doc(CAMPAIGN_REACH_DOC);
85
+ function reachDoc(sendRef) {
86
+ return sendRef.collection(CAMPAIGN_REACH_SUBCOLLECTION).doc(CAMPAIGN_REACH_DOC);
87
87
  }
88
88
  /**
89
89
  * The keys of everyone this email has reached.
@@ -97,8 +97,8 @@ function reachDoc(hostId, sendId, firestore) {
97
97
  * duplicate in a stranger's inbox is not retractable.
98
98
  *
99
99
  * @throws when the record cannot be read.
100
- */ export async function readCampaignReach(hostId, sendId, firestore) {
101
- const snapshot = await reachDoc(hostId, sendId, firestore).get();
100
+ */ export async function readCampaignReach(sendRef) {
101
+ const snapshot = await reachDoc(sendRef).get();
102
102
  const stored = snapshot.exists ? snapshot.get('keys') : null;
103
103
  if (!Array.isArray(stored)) return new Set();
104
104
  return new Set(stored.map((key)=>String(key)));
@@ -130,8 +130,8 @@ function reachDoc(hostId, sendId, firestore) {
130
130
  * reason: a batch that cannot say who it has already mailed must not run.
131
131
  *
132
132
  * @throws when the record cannot be read.
133
- */ export async function readCampaignSettled(hostId, sendId, firestore) {
134
- const snapshot = await reachDoc(hostId, sendId, firestore).get();
133
+ */ export async function readCampaignSettled(sendRef) {
134
+ const snapshot = await reachDoc(sendRef).get();
135
135
  const keys = snapshot.exists ? snapshot.get('keys') : null;
136
136
  const skipped = snapshot.exists ? snapshot.get('skipped') : null;
137
137
  const toSet = (stored)=>Array.isArray(stored) ? new Set(stored.map((key)=>String(key))) : new Set();
@@ -186,13 +186,13 @@ function reachDoc(hostId, sendId, firestore) {
186
186
  * must not be turned into an error by its own bookkeeping. The COVERAGE check
187
187
  * in {@link campaignReachCovers} is what catches a lost write, by refusing
188
188
  * the follow-up rather than by pretending the record is whole.
189
- */ export async function recordCampaignReach(hostId, sendId, emails, firestore) {
189
+ */ export async function recordCampaignReach(sendRef, emails) {
190
190
  const keys = [
191
191
  ...new Set(emails.map((email)=>emailSuppressionKey(email)).filter((key)=>Boolean(key)))
192
192
  ];
193
- if (!keys.length || !hostId || !sendId) return 0;
193
+ if (!keys.length || !sendRef) return 0;
194
194
  try {
195
- await reachDoc(hostId, sendId, firestore).set({
195
+ await reachDoc(sendRef).set({
196
196
  keys: firebaseAdmin.firestore.FieldValue.arrayUnion(...keys),
197
197
  count: firebaseAdmin.firestore.FieldValue.increment(keys.length)
198
198
  }, {
@@ -213,13 +213,13 @@ function reachDoc(hostId, sendId, firestore) {
213
213
  * off one record. A failed write costs a later batch a repeated suppression
214
214
  * lookup and nothing else — nobody is mailed twice by it, because the
215
215
  * addresses in here are the ones nothing may mail at all.
216
- */ export async function recordCampaignSkipped(hostId, sendId, emails, firestore) {
216
+ */ export async function recordCampaignSkipped(sendRef, emails) {
217
217
  const keys = [
218
218
  ...new Set(emails.map((email)=>emailSuppressionKey(email)).filter((key)=>Boolean(key)))
219
219
  ];
220
- if (!keys.length || !hostId || !sendId) return 0;
220
+ if (!keys.length || !sendRef) return 0;
221
221
  try {
222
- await reachDoc(hostId, sendId, firestore).set({
222
+ await reachDoc(sendRef).set({
223
223
  skipped: firebaseAdmin.firestore.FieldValue.arrayUnion(...keys),
224
224
  skippedCount: firebaseAdmin.firestore.FieldValue.increment(keys.length)
225
225
  }, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/email-campaign-reach.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHO ONE EMAIL HAS ALREADY REACHED.\n *\n * An email may be sent more than once — a merchant adds the people who joined\n * the list since it went out, or the ones a bounce kept it from. The single\n * property that makes that safe is that a later send must address nobody an\n * earlier one already reached, and nothing in the send path could answer that\n * question: the delivered addresses were collected for the frequency window\n * and then dropped.\n *\n * ## Why a record of its own, rather than the delivery log\n *\n * `emailDeliveries` looks like the answer and is not. It is populated by the\n * provider WEBHOOK, so it lags the send by seconds to minutes and is missing\n * entirely for a message the provider accepted but has not reported on. A\n * follow-up computed against it would re-mail exactly the people whose events\n * had not landed yet — the one outcome this whole feature must not produce.\n *\n * This record is written by the SENDER, from the addresses it saw\n * `sendEmail` succeed for. It is the send's own account of itself.\n *\n * ## Why keys and not addresses\n *\n * The stored value is {@link emailSuppressionKey} — `sha256` of the\n * normalized address, the same derivation both suppression lists and the\n * frequency window key on. Reusing it rather than adding a second one is the\n * point: a second hash of the same address is how this area came to have two\n * derivations that agreed only by accident.\n *\n * It also means the document is a membership test and not an address list.\n * The send's subcollection is readable by a site member, and a mailing list\n * in the clear under a document the console lists is a thing to not create.\n *\n * ## Why one document, and what bounds it\n *\n * One `arrayUnion` read and one write per send, against a document nothing on\n * the read path opens — as against a document per recipient, which would be\n * 500 writes per send and up to {@link CAMPAIGN_REACH_CEILING} reads per\n * follow-up.\n *\n * The ceiling is the audience scan ceiling the send path already has, and\n * that is not a coincidence: a send cannot resolve more people than that in\n * the first place, so an email whose reach exceeds it has been re-sent enough\n * times to have addressed more people than any one of its audiences can hold.\n * Refusing there keeps the document inside Firestore's 1 MB limit — 5,000\n * 64-character keys is about 330 KB — with the refusal stated rather than a\n * write that starts failing.\n */\n\nimport { EMAIL_MAX_AUDIENCE_PER_SEND } from '@aglyn/shared-util-email'\nimport firebaseAdmin from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { emailSuppressionKey } from '@aglyn/tenant-data-admin/server/email-suppression'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * Where the record lives: beside the link rollup, under the send it belongs\n * to.\n *\n * A subcollection document rather than a field on the send, for the reason\n * the link rollup is one — the send document is read by the emails list, the\n * glance widget and the send path, and a set that grows with the audience on\n * it would make every one of those reads larger.\n */\nexport const CAMPAIGN_REACH_DOC = 'reached'\n\n/** The subcollection the link rollup already uses. */\nexport const CAMPAIGN_REACH_SUBCOLLECTION = 'reports'\n\n/**\n * The most people one email may reach across all of its sends.\n *\n * The sender's audience read budget, taken from the one place it is stated\n * rather than restated here. See the header for why the two are necessarily\n * the same number: an email cannot reach more people than one resolution of\n * its audience can hold.\n */\nexport const CAMPAIGN_REACH_CEILING = EMAIL_MAX_AUDIENCE_PER_SEND\n\nfunction reachDoc(\n hostId: string,\n sendId: string,\n firestore?: any,\n): FirebaseFirestore.DocumentReference {\n return (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .collection('campaigns')\n .doc(sendId)\n .collection(CAMPAIGN_REACH_SUBCOLLECTION)\n .doc(CAMPAIGN_REACH_DOC)\n}\n\n/** The stored document. */\nexport interface CampaignReachRecord {\n /** `sha256` of each normalized address this email has reached. */\n keys: string[]\n /** `keys.length`, so a reader can size the record without loading it. */\n count: number\n /**\n * `sha256` of each address a send CONSIDERED and did not mail — suppressed,\n * or gone from the topic the email opens on.\n *\n * See {@link readCampaignSettled} for why a batched send needs this and a\n * merchant's follow-up must not read it.\n */\n skipped?: string[]\n /** `skipped.length`, alongside `count` for the same reason. */\n skippedCount?: number\n}\n\n/**\n * Everything one send of this email has DECIDED about, either way.\n *\n * `reached` is who got it. `skipped` is who was addressed by a batch, refused\n * by a suppression list or a topic opt-out, and must not consume a slot in\n * every later batch of the same email.\n */\nexport interface CampaignSettledRecord {\n reached: Set<string>\n skipped: Set<string>\n}\n\n/**\n * The keys of everyone this email has reached.\n *\n * FAILS CLOSED — it throws rather than answering \"nobody\". The asymmetry with\n * {@link readMarketingFrequency}, which fails open, is the same one\n * `filterSendableForHost` draws: a frequency window that cannot be read\n * refuses a message nobody objected to, where a reach record that cannot be\n * read would mail somebody a second copy of a message they already have. A\n * follow-up is a discretionary act a merchant can repeat in a minute; a\n * duplicate in a stranger's inbox is not retractable.\n *\n * @throws when the record cannot be read.\n */\nexport async function readCampaignReach(\n hostId: string,\n sendId: string,\n firestore?: any,\n): Promise<Set<string>> {\n const snapshot = await reachDoc(hostId, sendId, firestore).get()\n const stored = snapshot.exists ? snapshot.get('keys') : null\n if (!Array.isArray(stored)) return new Set<string>()\n return new Set<string>(stored.map((key: unknown) => String(key)))\n}\n\n/**\n * Everything one email has decided about, in ONE read.\n *\n * ## Why a batch subtracts more than a follow-up does\n *\n * A merchant's follow-up subtracts {@link readCampaignReach} alone, and that\n * is right: somebody who was suppressed when the email first went out, and\n * has since been released, has never had it and should get it.\n *\n * A BATCH of the same email cannot use that rule, and the reason is the same\n * one that puts the reach subtraction above the per-send cap rather than\n * below it. The cap takes the first N of a stable order. An address the last\n * batch addressed and could not mail is still at the head of that order, so\n * it is selected again, and again, and it consumes a slot every time. A\n * hundred suppressed addresses at the head of a list cost a hundred of every\n * batch's five hundred; five hundred of them stop the campaign dead, having\n * addressed nobody, forever.\n *\n * So a batch subtracts everything the email has SETTLED — mailed or refused —\n * and the frontier advances by the whole cap every time. The distinction is\n * kept in two fields rather than one because the two questions are different\n * and only one of them is \"who has this email\".\n *\n * FAILS CLOSED, exactly as {@link readCampaignReach} does and for the same\n * reason: a batch that cannot say who it has already mailed must not run.\n *\n * @throws when the record cannot be read.\n */\nexport async function readCampaignSettled(\n hostId: string,\n sendId: string,\n firestore?: any,\n): Promise<CampaignSettledRecord> {\n const snapshot = await reachDoc(hostId, sendId, firestore).get()\n const keys = snapshot.exists ? snapshot.get('keys') : null\n const skipped = snapshot.exists ? snapshot.get('skipped') : null\n const toSet = (stored: unknown) =>\n Array.isArray(stored)\n ? new Set<string>(stored.map((key: unknown) => String(key)))\n : new Set<string>()\n return { reached: toSet(keys), skipped: toSet(skipped) }\n}\n\n/**\n * How many addresses this email's record accounts for, either way.\n *\n * The figure {@link CAMPAIGN_REACH_CEILING} bounds. Both halves count,\n * because both halves are stored on the one document Firestore's size limit\n * applies to.\n */\nexport function campaignSettledSize(settled: CampaignSettledRecord): number {\n return settled.reached.size + settled.skipped.size\n}\n\n/**\n * Splits an audience into the people this email has not reached and the count\n * of those it has.\n *\n * An address this cannot key counts as ALREADY REACHED and is dropped. The\n * same posture the read takes: an address whose identity we cannot establish\n * is one we cannot prove we have not already mailed, and both suppression\n * lists refuse it for the same reason.\n */\nexport function partitionByCampaignReach(\n emails: readonly string[],\n reached: ReadonlySet<string>,\n): { unreached: string[]; alreadyReached: number } {\n const unreached: string[] = []\n let alreadyReached = 0\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key && !reached.has(key)) unreached.push(email)\n else alreadyReached += 1\n }\n return { unreached, alreadyReached }\n}\n\n/**\n * Adds the addresses a send delivered to, to the email's reach record.\n *\n * `arrayUnion` rather than a read-modify-write: it is atomic, it is\n * idempotent, and two sends of the same email contending would otherwise lose\n * one of their sets — which is a set of people a later follow-up would then\n * mail twice.\n *\n * Returns how many keys were offered, NOT how many were new; the union\n * decides that and does not report it. The caller records the count so a\n * follow-up can check the record covers the send.\n *\n * @throws nothing on a Firestore failure — a send that has already delivered\n * must not be turned into an error by its own bookkeeping. The COVERAGE check\n * in {@link campaignReachCovers} is what catches a lost write, by refusing\n * the follow-up rather than by pretending the record is whole.\n */\nexport async function recordCampaignReach(\n hostId: string,\n sendId: string,\n emails: readonly string[],\n firestore?: any,\n): Promise<number> {\n const keys = [\n ...new Set(\n emails\n .map((email) => emailSuppressionKey(email))\n .filter((key): key is string => Boolean(key)),\n ),\n ]\n if (!keys.length || !hostId || !sendId) return 0\n try {\n await reachDoc(hostId, sendId, firestore).set(\n {\n keys: firebaseAdmin.firestore.FieldValue.arrayUnion(...keys),\n count: firebaseAdmin.firestore.FieldValue.increment(keys.length),\n },\n { merge: true },\n )\n return keys.length\n } catch (error) {\n console.error('[email-campaign-reach] reach record failed', error)\n return 0\n }\n}\n\n/**\n * Adds the addresses a send CONSIDERED and did not mail.\n *\n * The same document, the same `arrayUnion` and the same never-throws posture\n * as {@link recordCampaignReach}, under a field of its own so that a\n * merchant's follow-up and an automatic batch can read different questions\n * off one record. A failed write costs a later batch a repeated suppression\n * lookup and nothing else — nobody is mailed twice by it, because the\n * addresses in here are the ones nothing may mail at all.\n */\nexport async function recordCampaignSkipped(\n hostId: string,\n sendId: string,\n emails: readonly string[],\n firestore?: any,\n): Promise<number> {\n const keys = [\n ...new Set(\n emails\n .map((email) => emailSuppressionKey(email))\n .filter((key): key is string => Boolean(key)),\n ),\n ]\n if (!keys.length || !hostId || !sendId) return 0\n try {\n await reachDoc(hostId, sendId, firestore).set(\n {\n skipped: firebaseAdmin.firestore.FieldValue.arrayUnion(...keys),\n skippedCount: firebaseAdmin.firestore.FieldValue.increment(keys.length),\n },\n { merge: true },\n )\n return keys.length\n } catch (error) {\n console.error('[email-campaign-reach] skipped record failed', error)\n return 0\n }\n}\n\n/**\n * Whether the reach record accounts for every message this email has sent.\n *\n * The one guard standing between a lost bookkeeping write and a duplicate in\n * somebody's inbox. `sent` is the send document's own delivered total, and\n * the reach record holds one key per delivered message — a recipient cannot\n * be addressed twice by one send, because the audience is deduplicated, and\n * cannot be addressed twice ACROSS sends, because that is the property this\n * record exists to hold. So the two numbers agree exactly, and a record that\n * is short is a record that is missing somebody.\n *\n * It also answers correctly for every email sent before this record existed:\n * no document, `0` keys, a non-zero `sent`, and the follow-up is refused —\n * which is right, because nobody can say who that send reached.\n */\nexport function campaignReachCovers(\n reached: ReadonlySet<string>,\n sent: number,\n): boolean {\n const delivered = Number(sent)\n if (!Number.isFinite(delivered) || delivered <= 0) return true\n return reached.size >= delivered\n}\n"],"names":["EMAIL_MAX_AUDIENCE_PER_SEND","firebaseAdmin","emailSuppressionKey","defaultFirestore","app","firestore","CAMPAIGN_REACH_DOC","CAMPAIGN_REACH_SUBCOLLECTION","CAMPAIGN_REACH_CEILING","reachDoc","hostId","sendId","collection","doc","readCampaignReach","snapshot","get","stored","exists","Array","isArray","Set","map","key","String","readCampaignSettled","keys","skipped","toSet","reached","campaignSettledSize","settled","size","partitionByCampaignReach","emails","unreached","alreadyReached","email","has","push","recordCampaignReach","filter","Boolean","length","set","FieldValue","arrayUnion","count","increment","merge","error","console","recordCampaignSkipped","skippedCount","campaignReachCovers","sent","delivered","Number","isFinite"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/email-campaign-reach.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHO ONE EMAIL HAS ALREADY REACHED.\n *\n * An email may be sent more than once — a merchant adds the people who joined\n * the list since it went out, or the ones a bounce kept it from. The single\n * property that makes that safe is that a later send must address nobody an\n * earlier one already reached, and nothing in the send path could answer that\n * question: the delivered addresses were collected for the frequency window\n * and then dropped.\n *\n * ## Why a record of its own, rather than the delivery log\n *\n * `emailDeliveries` looks like the answer and is not. It is populated by the\n * provider WEBHOOK, so it lags the send by seconds to minutes and is missing\n * entirely for a message the provider accepted but has not reported on. A\n * follow-up computed against it would re-mail exactly the people whose events\n * had not landed yet — the one outcome this whole feature must not produce.\n *\n * This record is written by the SENDER, from the addresses it saw\n * `sendEmail` succeed for. It is the send's own account of itself.\n *\n * ## Why keys and not addresses\n *\n * The stored value is {@link emailSuppressionKey} — `sha256` of the\n * normalized address, the same derivation both suppression lists and the\n * frequency window key on. Reusing it rather than adding a second one is the\n * point: a second hash of the same address is how this area came to have two\n * derivations that agreed only by accident.\n *\n * It also means the document is a membership test and not an address list.\n * The send's subcollection is readable by a site member, and a mailing list\n * in the clear under a document the console lists is a thing to not create.\n *\n * ## Why one document, and what bounds it\n *\n * One `arrayUnion` read and one write per send, against a document nothing on\n * the read path opens — as against a document per recipient, which would be\n * 500 writes per send and up to {@link CAMPAIGN_REACH_CEILING} reads per\n * follow-up.\n *\n * The ceiling is the audience scan ceiling the send path already has, and\n * that is not a coincidence: a send cannot resolve more people than that in\n * the first place, so an email whose reach exceeds it has been re-sent enough\n * times to have addressed more people than any one of its audiences can hold.\n * Refusing there keeps the document inside Firestore's 1 MB limit — 5,000\n * 64-character keys is about 330 KB — with the refusal stated rather than a\n * write that starts failing.\n */\n\nimport { EMAIL_MAX_AUDIENCE_PER_SEND } from '@aglyn/shared-util-email'\nimport firebaseAdmin from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { emailSuppressionKey } from '@aglyn/tenant-data-admin/server/email-suppression'\n\n/**\n * Where the record lives: beside the link rollup, under the send it belongs\n * to — whichever document the caller holds for that send, so the record\n * always travels with it.\n *\n * A subcollection document rather than a field on the send, for the reason\n * the link rollup is one — the send document is read by the emails list, the\n * glance widget and the send path, and a set that grows with the audience on\n * it would make every one of those reads larger.\n */\nexport const CAMPAIGN_REACH_DOC = 'reached'\n\n/** The subcollection the link rollup already uses. */\nexport const CAMPAIGN_REACH_SUBCOLLECTION = 'reports'\n\n/**\n * The most people one email may reach across all of its sends.\n *\n * The sender's audience read budget, taken from the one place it is stated\n * rather than restated here. See the header for why the two are necessarily\n * the same number: an email cannot reach more people than one resolution of\n * its audience can hold.\n */\nexport const CAMPAIGN_REACH_CEILING = EMAIL_MAX_AUDIENCE_PER_SEND\n\nfunction reachDoc(\n sendRef: FirebaseFirestore.DocumentReference,\n): FirebaseFirestore.DocumentReference {\n return sendRef\n .collection(CAMPAIGN_REACH_SUBCOLLECTION)\n .doc(CAMPAIGN_REACH_DOC)\n}\n\n/** The stored document. */\nexport interface CampaignReachRecord {\n /** `sha256` of each normalized address this email has reached. */\n keys: string[]\n /** `keys.length`, so a reader can size the record without loading it. */\n count: number\n /**\n * `sha256` of each address a send CONSIDERED and did not mail — suppressed,\n * or gone from the topic the email opens on.\n *\n * See {@link readCampaignSettled} for why a batched send needs this and a\n * merchant's follow-up must not read it.\n */\n skipped?: string[]\n /** `skipped.length`, alongside `count` for the same reason. */\n skippedCount?: number\n}\n\n/**\n * Everything one send of this email has DECIDED about, either way.\n *\n * `reached` is who got it. `skipped` is who was addressed by a batch, refused\n * by a suppression list or a topic opt-out, and must not consume a slot in\n * every later batch of the same email.\n */\nexport interface CampaignSettledRecord {\n reached: Set<string>\n skipped: Set<string>\n}\n\n/**\n * The keys of everyone this email has reached.\n *\n * FAILS CLOSED — it throws rather than answering \"nobody\". The asymmetry with\n * {@link readMarketingFrequency}, which fails open, is the same one\n * `filterSendableForHost` draws: a frequency window that cannot be read\n * refuses a message nobody objected to, where a reach record that cannot be\n * read would mail somebody a second copy of a message they already have. A\n * follow-up is a discretionary act a merchant can repeat in a minute; a\n * duplicate in a stranger's inbox is not retractable.\n *\n * @throws when the record cannot be read.\n */\nexport async function readCampaignReach(\n sendRef: FirebaseFirestore.DocumentReference,\n): Promise<Set<string>> {\n const snapshot = await reachDoc(sendRef).get()\n const stored = snapshot.exists ? snapshot.get('keys') : null\n if (!Array.isArray(stored)) return new Set<string>()\n return new Set<string>(stored.map((key: unknown) => String(key)))\n}\n\n/**\n * Everything one email has decided about, in ONE read.\n *\n * ## Why a batch subtracts more than a follow-up does\n *\n * A merchant's follow-up subtracts {@link readCampaignReach} alone, and that\n * is right: somebody who was suppressed when the email first went out, and\n * has since been released, has never had it and should get it.\n *\n * A BATCH of the same email cannot use that rule, and the reason is the same\n * one that puts the reach subtraction above the per-send cap rather than\n * below it. The cap takes the first N of a stable order. An address the last\n * batch addressed and could not mail is still at the head of that order, so\n * it is selected again, and again, and it consumes a slot every time. A\n * hundred suppressed addresses at the head of a list cost a hundred of every\n * batch's five hundred; five hundred of them stop the campaign dead, having\n * addressed nobody, forever.\n *\n * So a batch subtracts everything the email has SETTLED — mailed or refused —\n * and the frontier advances by the whole cap every time. The distinction is\n * kept in two fields rather than one because the two questions are different\n * and only one of them is \"who has this email\".\n *\n * FAILS CLOSED, exactly as {@link readCampaignReach} does and for the same\n * reason: a batch that cannot say who it has already mailed must not run.\n *\n * @throws when the record cannot be read.\n */\nexport async function readCampaignSettled(\n sendRef: FirebaseFirestore.DocumentReference,\n): Promise<CampaignSettledRecord> {\n const snapshot = await reachDoc(sendRef).get()\n const keys = snapshot.exists ? snapshot.get('keys') : null\n const skipped = snapshot.exists ? snapshot.get('skipped') : null\n const toSet = (stored: unknown) =>\n Array.isArray(stored)\n ? new Set<string>(stored.map((key: unknown) => String(key)))\n : new Set<string>()\n return { reached: toSet(keys), skipped: toSet(skipped) }\n}\n\n/**\n * How many addresses this email's record accounts for, either way.\n *\n * The figure {@link CAMPAIGN_REACH_CEILING} bounds. Both halves count,\n * because both halves are stored on the one document Firestore's size limit\n * applies to.\n */\nexport function campaignSettledSize(settled: CampaignSettledRecord): number {\n return settled.reached.size + settled.skipped.size\n}\n\n/**\n * Splits an audience into the people this email has not reached and the count\n * of those it has.\n *\n * An address this cannot key counts as ALREADY REACHED and is dropped. The\n * same posture the read takes: an address whose identity we cannot establish\n * is one we cannot prove we have not already mailed, and both suppression\n * lists refuse it for the same reason.\n */\nexport function partitionByCampaignReach(\n emails: readonly string[],\n reached: ReadonlySet<string>,\n): { unreached: string[]; alreadyReached: number } {\n const unreached: string[] = []\n let alreadyReached = 0\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key && !reached.has(key)) unreached.push(email)\n else alreadyReached += 1\n }\n return { unreached, alreadyReached }\n}\n\n/**\n * Adds the addresses a send delivered to, to the email's reach record.\n *\n * `arrayUnion` rather than a read-modify-write: it is atomic, it is\n * idempotent, and two sends of the same email contending would otherwise lose\n * one of their sets — which is a set of people a later follow-up would then\n * mail twice.\n *\n * Returns how many keys were offered, NOT how many were new; the union\n * decides that and does not report it. The caller records the count so a\n * follow-up can check the record covers the send.\n *\n * @throws nothing on a Firestore failure — a send that has already delivered\n * must not be turned into an error by its own bookkeeping. The COVERAGE check\n * in {@link campaignReachCovers} is what catches a lost write, by refusing\n * the follow-up rather than by pretending the record is whole.\n */\nexport async function recordCampaignReach(\n sendRef: FirebaseFirestore.DocumentReference,\n emails: readonly string[],\n): Promise<number> {\n const keys = [\n ...new Set(\n emails\n .map((email) => emailSuppressionKey(email))\n .filter((key): key is string => Boolean(key)),\n ),\n ]\n if (!keys.length || !sendRef) return 0\n try {\n await reachDoc(sendRef).set(\n {\n keys: firebaseAdmin.firestore.FieldValue.arrayUnion(...keys),\n count: firebaseAdmin.firestore.FieldValue.increment(keys.length),\n },\n { merge: true },\n )\n return keys.length\n } catch (error) {\n console.error('[email-campaign-reach] reach record failed', error)\n return 0\n }\n}\n\n/**\n * Adds the addresses a send CONSIDERED and did not mail.\n *\n * The same document, the same `arrayUnion` and the same never-throws posture\n * as {@link recordCampaignReach}, under a field of its own so that a\n * merchant's follow-up and an automatic batch can read different questions\n * off one record. A failed write costs a later batch a repeated suppression\n * lookup and nothing else — nobody is mailed twice by it, because the\n * addresses in here are the ones nothing may mail at all.\n */\nexport async function recordCampaignSkipped(\n sendRef: FirebaseFirestore.DocumentReference,\n emails: readonly string[],\n): Promise<number> {\n const keys = [\n ...new Set(\n emails\n .map((email) => emailSuppressionKey(email))\n .filter((key): key is string => Boolean(key)),\n ),\n ]\n if (!keys.length || !sendRef) return 0\n try {\n await reachDoc(sendRef).set(\n {\n skipped: firebaseAdmin.firestore.FieldValue.arrayUnion(...keys),\n skippedCount: firebaseAdmin.firestore.FieldValue.increment(keys.length),\n },\n { merge: true },\n )\n return keys.length\n } catch (error) {\n console.error('[email-campaign-reach] skipped record failed', error)\n return 0\n }\n}\n\n/**\n * Whether the reach record accounts for every message this email has sent.\n *\n * The one guard standing between a lost bookkeeping write and a duplicate in\n * somebody's inbox. `sent` is the send document's own delivered total, and\n * the reach record holds one key per delivered message — a recipient cannot\n * be addressed twice by one send, because the audience is deduplicated, and\n * cannot be addressed twice ACROSS sends, because that is the property this\n * record exists to hold. So the two numbers agree exactly, and a record that\n * is short is a record that is missing somebody.\n *\n * It also answers correctly for every email sent before this record existed:\n * no document, `0` keys, a non-zero `sent`, and the follow-up is refused —\n * which is right, because nobody can say who that send reached.\n */\nexport function campaignReachCovers(\n reached: ReadonlySet<string>,\n sent: number,\n): boolean {\n const delivered = Number(sent)\n if (!Number.isFinite(delivered) || delivered <= 0) return true\n return reached.size >= delivered\n}\n"],"names":["EMAIL_MAX_AUDIENCE_PER_SEND","firebaseAdmin","emailSuppressionKey","CAMPAIGN_REACH_DOC","CAMPAIGN_REACH_SUBCOLLECTION","CAMPAIGN_REACH_CEILING","reachDoc","sendRef","collection","doc","readCampaignReach","snapshot","get","stored","exists","Array","isArray","Set","map","key","String","readCampaignSettled","keys","skipped","toSet","reached","campaignSettledSize","settled","size","partitionByCampaignReach","emails","unreached","alreadyReached","email","has","push","recordCampaignReach","filter","Boolean","length","set","firestore","FieldValue","arrayUnion","count","increment","merge","error","console","recordCampaignSkipped","skippedCount","campaignReachCovers","sent","delivered","Number","isFinite"],"mappings":"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"}
@@ -44,6 +44,10 @@ import { recordContactEmailEngagement } from "@aglyn/tenant-data-admin/server/co
44
44
  import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
45
45
  import { crmEmailDeliveryStateForEvent, recordCrmEmailDelivery } from "@aglyn/tenant-data-admin/server/crm-email-activity";
46
46
  import { getOrgForHost } from "@aglyn/tenant-data-admin/server/organizations";
47
+ // The leaf again: which document a delivery event counts against is the
48
+ // question every counter below depends on, and a stub would answer it for
49
+ // them.
50
+ import { resolveCampaignSendRef } from "@aglyn/tenant-data-admin/server/campaign-conversion-attribution";
47
51
  import { recordEmailReputationFailure } from "@aglyn/tenant-data-admin/server/email-sender-reputation";
48
52
  // The link rollup's key derivation and its cap live beside the READER that
49
53
  // renders them (`@aglyn/shared-ui-email-campaigns/model`) rather than here, so
@@ -310,8 +314,8 @@ import { suppressionId } from "./campaign-send.js";
310
314
  });
311
315
  }
312
316
  try {
313
- var _ref, _ref1, _data_to_, _ref2;
314
- var _deliveryEvents_;
317
+ var _ref, _ref1, _data_to_, _ref2, _ref3;
318
+ var _this, _deliveryEvents_;
315
319
  const event = JSON.parse(payload.toString('utf8'));
316
320
  const type = String((_ref = event == null ? void 0 : event.type) != null ? _ref : '');
317
321
  /*==========================================
@@ -382,7 +386,29 @@ import { suppressionId } from "./campaign-send.js";
382
386
  // A path component, so "non-empty" was never the whole question.
383
387
  const firestore = firebaseAdmin.app().firestore();
384
388
  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;
389
+ /**
390
+ * The send this event belongs to, or null when it names none or the send
391
+ * no longer exists.
392
+ *
393
+ * Found rather than built. A send is recorded under the organization, but
394
+ * the event carries what was tagged on the message when it went out —
395
+ * the site and the send id always, the org only on mail sent since sends
396
+ * moved up — and a message sent before that is still delivering, opening
397
+ * and bouncing. `resolveCampaignSendRef` asks the org and then the site,
398
+ * so both are counted against the document that exists. Null skips every
399
+ * counter below, which is what a deleted send's events should do: an
400
+ * open never re-creates the email it belonged to.
401
+ *
402
+ * A failed lookup is null too. The counters are worth less than the
403
+ * suppression the bounce path owes, and that must not be lost to a read
404
+ * that failed while finding a counter to increment.
405
+ */ const orgTag = tags['orgId'];
406
+ const campaignRef = hostRef && isDocumentId(campaignId) ? await resolveCampaignSendRef({
407
+ hostId,
408
+ sendId: campaignId,
409
+ orgId: isDocumentId(orgTag) ? orgTag : (_ref3 = (_this = await getOrgForHost(hostId).catch(()=>null)) == null ? void 0 : _this.orgId) != null ? _ref3 : null,
410
+ firestore
411
+ }).catch(()=>null) : null;
386
412
  /*==========================================
387
413
  * THE CONTACT'S OWN STAMP (AGL-2616).
388
414
  *
@@ -420,13 +446,13 @@ import { suppressionId } from "./campaign-send.js";
420
446
  const crmActivityId = tags[CRM_EMAIL_ACTIVITY_TAG];
421
447
  const crmOrgId = tags[CRM_EMAIL_ORG_TAG];
422
448
  if (crmState && isDocumentId(crmActivityId) && isDocumentId(crmOrgId)) {
423
- var _ref3;
449
+ var _ref4;
424
450
  var _deliveryEvents_1;
425
451
  await recordCrmEmailDelivery(firestore, {
426
452
  orgId: crmOrgId,
427
453
  activityId: crmActivityId,
428
454
  state: crmState,
429
- atMs: (_ref3 = (_deliveryEvents_1 = deliveryEvents[0]) == null ? void 0 : _deliveryEvents_1.at) != null ? _ref3 : Date.now()
455
+ atMs: (_ref4 = (_deliveryEvents_1 = deliveryEvents[0]) == null ? void 0 : _deliveryEvents_1.at) != null ? _ref4 : Date.now()
430
456
  });
431
457
  }
432
458
  /*==========================================
@@ -463,7 +489,7 @@ import { suppressionId } from "./campaign-send.js";
463
489
  });
464
490
  }
465
491
  if (type === 'email.bounced' || type === 'email.complained') {
466
- var _ref4, _tags_context;
492
+ var _ref5, _tags_context;
467
493
  var _data_bounce;
468
494
  /*
469
495
  * The campaign counter FIRST, and its failure swallowed.
@@ -493,14 +519,16 @@ import { suppressionId } from "./campaign-send.js";
493
519
  hostRef,
494
520
  type,
495
521
  recipient,
496
- bounceType: String((_ref4 = data == null ? void 0 : (_data_bounce = data.bounce) == null ? void 0 : _data_bounce.type) != null ? _ref4 : ''),
522
+ bounceType: String((_ref5 = data == null ? void 0 : (_data_bounce = data.bounce) == null ? void 0 : _data_bounce.type) != null ? _ref5 : ''),
497
523
  context: (_tags_context = tags['context']) != null ? _tags_context : '',
498
524
  res
499
525
  });
500
526
  }
501
527
  // Opens and clicks ARE per-campaign, so this pair still needs both ids —
502
528
  // and therefore still needs the host, which the failure path above no
503
- // longer does.
529
+ // longer does. A send that no longer exists still gets past here: its
530
+ // counters are skipped below, but the click it carried is still a
531
+ // conversion for its experiment and a touch for the person.
504
532
  if (!hostRef || !isDocumentId(campaignId)) {
505
533
  return res.status(200).json({
506
534
  ignored: true
@@ -587,7 +615,7 @@ import { suppressionId } from "./campaign-send.js";
587
615
  if (firstSeen) {
588
616
  totals[type === 'email.opened' ? 'stats.uniqueOpens' : 'stats.uniqueClicks'] = FieldValue.increment(firstSeen);
589
617
  }
590
- await updateExisting(hostRef.collection('campaigns').doc(campaignId), totals);
618
+ if (campaignRef) await updateExisting(campaignRef, totals);
591
619
  /*==========================================
592
620
  * LINK-LEVEL CLICKS — the aggregate `data.click.link` never had.
593
621
  *
@@ -611,13 +639,16 @@ import { suppressionId } from "./campaign-send.js";
611
639
  * that fails must not cost the click count above it, which is the
612
640
  * number the whole report leans on.
613
641
  *=========================================*/ 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);
642
+ var _ref6;
643
+ var _outcomes_find;
644
+ if (campaignRef) {
645
+ var _data_click;
646
+ await recordCampaignLinkClick({
647
+ firestore,
648
+ campaignRef,
649
+ link: campaignLinkKey(data == null ? void 0 : (_data_click = data.click) == null ? void 0 : _data_click.link)
650
+ }).catch(()=>undefined);
651
+ }
621
652
  /*==========================================
622
653
  * THE TOUCH REVENUE ATTRIBUTION IS TAKEN OVER.
623
654
  *
@@ -648,7 +679,7 @@ import { suppressionId } from "./campaign-send.js";
648
679
  email: recipient,
649
680
  hostId,
650
681
  campaignId,
651
- atMs: (_ref5 = (_outcomes_find = outcomes.find((one)=>one.type === 'clicked')) == null ? void 0 : _outcomes_find.at) != null ? _ref5 : Date.now()
682
+ atMs: (_ref6 = (_outcomes_find = outcomes.find((one)=>one.type === 'clicked')) == null ? void 0 : _outcomes_find.at) != null ? _ref6 : Date.now()
652
683
  }).catch(()=>false);
653
684
  }
654
685
  // Experiment conversion (AGL-268): clicks are the signal.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/email-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n claimAttempt,\n CRM_EMAIL_ACTIVITY_TAG,\n CRM_EMAIL_ORG_TAG,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { normalizeResendDeliveryEvents } from '@aglyn/shared-util-email'\n// By its own path, not the barrel: the check holds a `crypto` HMAC, and the\n// barrel is reached from the browser through the campaign model (AGL-2657).\nimport { verifySvixSignature } from '@aglyn/shared-util-email/svix-signature'\n// AGL-1771 lifted `isDocumentId` here from the local copy AGL-1768 wrote. The\n// copy's stated reason was wrong: `@nx/enforce-module-boundaries` does NOT\n// refuse an edge between two feature plugins by that tag — every plugin\n// carries `aglyn:addons`, whose rule permits `aglyn:addons` as a target (it is\n// `scope:plugin` that refuses another plugin, and `check:lib-boundaries` that\n// holds it). It now lives beside `updateExisting` in the library where Firestore paths are\n// built, which was always the better home and is now the reachable one.\nimport { firebaseAdmin, updateExisting } from '@aglyn/tenant-data-admin'\n// From the LEAF, not the barrel, for the same reason `isDocumentId` is\n// (AGL-1771): a spec that mocks `@aglyn/tenant-data-admin` — which it must,\n// because that graph reaches the admin SDK — would otherwise replace the real\n// suppression writer with whatever the factory happened to list. A stub there\n// is a false green on the one behaviour AGL-2407 is about.\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\n// Same leaf-import reasoning again: the per-recipient delivery log is the only\n// record staff have of what we sent someone, and a mocked-away writer is a\n// green test over an empty log.\nimport {\n recordEmailCampaignTouch,\n recordEmailDeliveryEvents,\n recordPersonEngagement,\n} from '@aglyn/tenant-data-admin/server/email-delivery-log'\n// The leaf again: the contact's own stamp is what the record page, the list\n// and the re-engagement audience read, and a wholesale mock would green a\n// webhook that stamped nobody.\nimport { recordContactEmailEngagement } from '@aglyn/tenant-data-admin/server/contact-email-engagement'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport {\n crmEmailDeliveryStateForEvent,\n recordCrmEmailDelivery,\n} from '@aglyn/tenant-data-admin/server/crm-email-activity'\nimport { getOrgForHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport { recordEmailReputationFailure } from '@aglyn/tenant-data-admin/server/email-sender-reputation'\n// The link rollup's key derivation and its cap live beside the READER that\n// renders them (`@aglyn/shared-ui-email-campaigns/model`) rather than here, so\n// the shape the webhook writes and the shape the report reads cannot drift\n// into two definitions of what a \"link\" is.\nimport {\n CAMPAIGN_LINK_ROLLUP_MAX,\n campaignLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { assignExperimentVariant, type HostExperiment } from '../model/experiments'\n// The one list `campaign-send` reads, keyed the one way it keys it.\nimport { suppressionId } from './campaign-send'\n\n/**\n * Svix signature check (Resend webhooks): the shared implementation in\n * `@aglyn/shared-util-email`, which the CRM's capture webhook (AGL-2657)\n * verifies with too, so the constant-time comparison lives once.\n */\nconst verifySvix = verifySvixSignature\n\n/** Tags arrive as an array of {name, value} or a plain map — accept both. */\nfunction tagMap(raw: unknown): Record<string, string> {\n if (Array.isArray(raw)) {\n const map: Record<string, string> = {}\n for (const tag of raw) {\n if (tag?.name) map[String(tag.name)] = String(tag.value ?? '')\n }\n return map\n }\n if (raw && typeof raw === 'object') {\n return Object.fromEntries(\n Object.entries(raw as Record<string, unknown>).map(([key, value]) => [\n key,\n String(value ?? ''),\n ]),\n )\n }\n return {}\n}\n\n/**\n * A bounce or a complaint suppresses the address (AGL-1918).\n *\n * Until now this webhook answered `email.bounced` and `email.complained` with\n * `200 {ignored:true}` — acknowledged, and dropped. The consequences were not\n * cosmetic, because `campaign-send.ts` filters its audience against exactly\n * one list and nothing but an unsubscribe click ever wrote to it: a hard-bounced\n * address was re-sent on every subsequent campaign forever, and a spam\n * complaint had no effect on whether that complainant was mailed again. Both\n * are the behaviours mailbox providers measure a sending domain on, and\n * `aglyn.com` carries the password resets and receipts on the same key and the\n * same From address as the campaigns.\n *\n * WHICH failures suppress:\n *\n * - **Every complaint.** Someone pressed \"report spam\". There is no reading of\n * that which permits mailing them again.\n * - **Permanent bounces only.** Resend reports `data.bounce.type` as\n * `Permanent` or `Transient`. A transient bounce is a full mailbox or a\n * greylisting server — suppressing on one would unsubscribe a real\n * subscriber over a temporary condition at their provider, which is a\n * customer's list being quietly destroyed by our error handling.\n * - An **unrecognised or absent** bounce type does NOT suppress. Guessing in\n * the suppressing direction is the destructive guess, and the spec asserts\n * the shape so a payload change fails as itself rather than by silently\n * ceasing to suppress anything.\n *\n * The entry is keyed and shaped exactly as the unsubscribe handler's\n * (`suppressionId(email)` → `{ email, createdAt }`), because the reader does\n * not care how an address got there — it is one list, and a second shape would\n * be a second list that `campaign-send` reads half of. `createdAt` is written\n * only when the document is new, so a bounce arriving after an unsubscribe\n * does not restamp the date the person actually unsubscribed.\n *\n * WHERE it lands, since AGL-2407: BOTH lists, and the per-host one is now the\n * optional half.\n *\n * - The PLATFORM list (`emailSuppressions`) is always written. That is the\n * half that did not exist: a bounce on an invite, a password reset, a\n * receipt or a usage summary carries no `hostId` — only `campaign-send`\n * ever stamped one — so this webhook had nowhere to file it and answered\n * `200 {ignored:true}`, exactly as if the address were fine. A dead mailbox\n * was re-mailed on every subsequent send, forever.\n * - The PER-HOST list is written when the send named a site, because that is\n * the list `campaign-send` filters its audience against and a merchant's\n * own list is theirs to see and undo (AGL-2410).\n *\n * A hard bounce is address-level truth and belongs on the platform list even\n * when a site was named — the mailbox does not exist for anyone. A COMPLAINT\n * is a judgement about one sender's mail, and it goes on the platform list too\n * for a narrower reason: the platform list is consulted only by bulk mail, and\n * someone who pressed \"report spam\" on anything from `noreply@aglyn.com` must\n * not receive more bulk mail from `noreply@aglyn.com`. Neither list is\n * consulted by transactional mail; see `email-suppression.ts` for why.\n */\nasync function recordDeliveryFailure(args: {\n firestore: FirebaseFirestore.Firestore\n /** Null when the send named no site — the case AGL-2407 exists for. */\n hostRef: FirebaseFirestore.DocumentReference | null\n type: 'email.bounced' | 'email.complained'\n recipient: string\n bounceType: string\n /** The `context` tag `sendEmail` stamps, naming which sender produced it. */\n context: string\n res: Parameters<PluginApiHandler>[1]\n}) {\n const { firestore, hostRef, type, recipient, bounceType, context, res } = args\n if (!recipient) return res.status(200).json({ ignored: true })\n\n const complaint = type === 'email.complained'\n const permanent = bounceType.trim().toLowerCase() === 'permanent'\n if (!complaint && !permanent) {\n // A transient bounce is real information we deliberately do not act on.\n return res.status(200).json({ ok: true, suppressed: false })\n }\n const reason = complaint ? 'complaint' : 'bounce'\n\n /*==========================================\n * THE SAME EVENT, COUNTED AGAINST THE TENANT.\n *\n * The campaign counter the caller writes answers \"how did this mailing do\".\n * This one answers the question the shared sending domain actually depends\n * on: how is THIS WORKSPACE doing, across every campaign it has sent.\n * Nothing computed a rate at any scope before it, so one merchant's bad\n * list could push the domain every other merchant's receipts leave on\n * toward a block, and the first anybody would hear of it is a rejection.\n *\n * ## Why HERE, below the permanence filter\n *\n * The two decisions in front of it are the same two the suppression needs,\n * and a rate that included them would be measuring something else. A\n * TRANSIENT bounce is a full mailbox or a greylisting server — it says\n * nothing about list quality, it does not suppress, and counting it would\n * trip a merchant's breaker on their subscribers' holiday auto-replies.\n *\n * ## Why only a send that named a site\n *\n * `hostId` is the only tenant identity a delivery event carries, and only\n * `campaign-send` stamps one. A bounce on a password reset or an invite\n * therefore reaches the suppression lists — address-level truth belongs on\n * them — and deliberately not this counter: the breaker it feeds may only\n * ever refuse a CAMPAIGN, so mail it could never act on must neither\n * inflate the rate nor dilute it.\n *\n * Swallowed, and taken before the suppression writes rather than after, so\n * a counter that fails cannot cost an address its place on either list.\n *=========================================*/\n if (hostRef) {\n await getOrgForHost(hostRef.id)\n .then((org) =>\n org?.orgId\n ? recordEmailReputationFailure(org.orgId, reason)\n : undefined,\n )\n .catch(() => undefined)\n }\n\n // The platform list FIRST, and unconditionally. Ordered ahead of the\n // per-host write because it is the one that must happen for every failure:\n // if the per-host write throws, the outer handler answers 200 and the\n // address is still off the bulk senders' lists.\n await suppressEmail({\n email: recipient,\n reason,\n context: context || null,\n hostId: hostRef?.id ?? null,\n firestore,\n })\n\n if (hostRef) {\n const ref = hostRef.collection('suppressions').doc(suppressionId(recipient))\n await firestore.runTransaction(async (transaction) => {\n const existing = await transaction.get(ref)\n transaction.set(\n ref,\n {\n email: recipient,\n reason,\n suppressedAt: FieldValue.serverTimestamp(),\n ...(existing.exists\n ? {}\n : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n })\n }\n return res\n .status(200)\n .json({ ok: true, suppressed: true, scope: hostRef ? 'host' : 'platform' })\n}\n\n/**\n * Counts one click against its destination in the campaign's link rollup.\n *\n * A transaction because the CAP has to be enforced against the map as it\n * stands: a merge-set cannot ask \"is this key already here, and how many keys\n * are there\" and would grow the document without limit. Firestore's 1 MiB\n * document ceiling is the hard reason, and a rollup that silently stopped\n * being writable at that ceiling would take the campaign's other counters\n * down with it if they shared the document — which is why this is its own.\n *\n * Nothing is DROPPED at the cap. A click on a destination past it lands in\n * `overflowClicks`, and a click that arrived with no destination at all lands\n * in `unattributedClicks`, so the table's own total plus the two excluded\n * figures reconcile with `stats.clicks` and the screen can say where the\n * difference went. Dropping either would leave a link table whose sum quietly\n * disagreed with the click count printed above it.\n *\n * The rollup does NOT create the campaign. Same reasoning as `updateExisting`\n * on the counters: a click arriving days after a merchant deleted a campaign\n * must not resurrect it, here as a document holding one map of URLs.\n */\nasync function recordCampaignLinkClick(args: {\n firestore: FirebaseFirestore.Firestore\n campaignRef: FirebaseFirestore.DocumentReference\n link: string | null\n}): Promise<void> {\n const { firestore, campaignRef, link } = args\n const ref = campaignRef.collection('reports').doc('links')\n await firestore.runTransaction(async (transaction) => {\n const campaign = await transaction.get(campaignRef)\n if (!campaign.exists) return\n const snapshot = await transaction.get(ref)\n const stored = (snapshot.exists ? snapshot.data() : null) ?? {}\n const links = (stored.links ?? {}) as Record<string, unknown>\n\n if (!link) {\n transaction.set(\n ref,\n { unattributedClicks: FieldValue.increment(1) },\n { merge: true },\n )\n return\n }\n // The map key is a hash, not the URL: a Firestore field name may not\n // contain `.`, `/` or `~`, and every URL contains at least two of them.\n // The URL itself rides in the value, so nothing has to be un-hashed.\n const key = createHash('sha256').update(link).digest('hex').slice(0, 32)\n if (links[key] === undefined && Object.keys(links).length >= CAMPAIGN_LINK_ROLLUP_MAX) {\n transaction.set(\n ref,\n { overflowClicks: FieldValue.increment(1) },\n { merge: true },\n )\n return\n }\n transaction.set(\n ref,\n {\n // A nested map under a merge-set, which merges at depth — the dotted\n // form would write a field whose NAME contains dots and leave `links`\n // empty, the exact fault `email-delivery-log.ts` records against\n // `timestamps`.\n links: { [key]: { url: link, clicks: FieldValue.increment(1) } },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n}\n\n/**\n * Resend event ingestion (AGL-268), relocated from the console app route\n * into its owning plugin (AGL-418) — the URL `/api/email/events` is\n * preserved through the plugin API dispatcher. Opened/clicked events\n * increment the tagged campaign's stats; clicks on experiment sends also\n * count as the recipient's variant conversion — the variant re-derives\n * deterministically from the address, so nothing per-send is stored.\n * Svix signs the RAW body: `req.rawBody` carries the exact request text.\n */\nexport const emailEventsHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const secret = process.env.RESEND_WEBHOOK_SECRET\n if (!secret) {\n return res.status(501).json({ error: 'Webhook is not configured' })\n }\n const payload = Buffer.from(req.rawBody ?? '', 'utf8')\n const headers = req.headers as Partial<Record<string, string>>\n const svixId = String(headers['svix-id'] ?? '')\n const svixTimestamp = String(headers['svix-timestamp'] ?? '')\n const svixSignature = String(headers['svix-signature'] ?? '')\n if (\n !svixId ||\n !svixTimestamp ||\n !verifySvix(secret, svixId, svixTimestamp, payload, svixSignature)\n ) {\n return res.status(401).json({ error: 'Bad signature' })\n }\n\n try {\n const event = JSON.parse(payload.toString('utf8'))\n const type = String(event?.type ?? '')\n\n /*==========================================\n * THE PER-RECIPIENT DELIVERY LOG.\n *\n * FIRST, and for every event type rather than the four below, because the\n * log is the staff answer to \"did this person get their invite\" and that\n * question is mostly asked about `sent`, `delivered` and `bounced` —\n * none of which the campaign statistics below have any use for.\n *\n * `normalizeResendDeliveryEvents` is the one place in the tree that reads\n * Resend's wire format; everything downstream stores and renders our own\n * vocabulary, so changing sender is a new adapter and nothing else.\n *\n * Best-effort and awaited but never fatal: a log write that fails must\n * not turn into a non-2xx, which the provider would answer by retrying\n * the same event forever.\n *=========================================*/\n const deliveryEvents = normalizeResendDeliveryEvents(event, Date.now())\n const outcomes = await recordEmailDeliveryEvents(deliveryEvents).catch(\n () => [],\n )\n\n /*\n * DISTINCT RECIPIENTS this event is the first of its kind for.\n *\n * The log's transaction already held each message's prior state, so this\n * is free — and it is the only honest way to count distinct openers\n * without a second document per recipient. It is also idempotent for the\n * same reason the replay guard below exists: a redelivered or replayed\n * event finds the state already recorded and contributes zero.\n *\n * Zero when the log write failed, which loses the count rather than\n * inventing one. That is the correct direction: a lost increment\n * understates engagement, and a guessed one is a number nobody can\n * defend.\n */\n const firstSeen = outcomes.filter((one) => one.firstOfType).length\n\n /*==========================================\n * THE PER-PERSON ENGAGEMENT ROLLUP.\n *\n * Opens and clicks were recorded per message and per campaign and rolled\n * onto NOBODY, so \"has this person engaged lately\" could only be answered\n * by walking every message subcollection. Two shipped things needed that\n * answer and could not have it: an audience rule that says \"opened in the\n * last 30 days\", and a sunset that stops mailing an address which has\n * gone quiet.\n *\n * HERE, above the type gate and above the campaign gates, on purpose.\n * Engagement is a fact about the PERSON, and the message they engaged\n * with does not have to be a campaign for it to be one — somebody who\n * clicks a receipt is reading our mail. Placing it below the\n * `hostId`/`campaignId` gate would record engagement for campaign mail\n * only and then let a sunset refuse people on the strength of it, which\n * is a control drawing conclusions from a fraction of the evidence.\n *\n * Driven by the same `firstOfType` outcomes `firstSeen` is counted from,\n * so a replay contributes nothing here for the same reason it contributes\n * nothing to `stats.uniqueOpens` — and the rollup needs no claim of its\n * own. Best-effort: a person's stamp is worth less than the campaign\n * counters below it and much less than a suppression.\n *=========================================*/\n await recordPersonEngagement(outcomes).catch(() => 0)\n\n if (\n type !== 'email.opened' &&\n type !== 'email.clicked' &&\n type !== 'email.bounced' &&\n type !== 'email.complained' &&\n type !== 'email.delivered'\n ) {\n return res.status(200).json({ ignored: true })\n }\n const data = event?.data ?? {}\n const tags = tagMap(data?.tags)\n const hostId = tags['hostId']\n const campaignId = tags['campaignId']\n const recipient = String(\n Array.isArray(data?.to) ? (data.to[0] ?? '') : (data?.to ?? ''),\n )\n .trim()\n .toLowerCase()\n // A path component, so \"non-empty\" was never the whole question.\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = isDocumentId(hostId)\n ? firestore.collection('hosts').doc(hostId)\n : null\n /** The campaign this event belongs to, or null when it names none. */\n const campaignRef =\n hostRef && isDocumentId(campaignId)\n ? hostRef.collection('campaigns').doc(campaignId)\n : null\n\n /*==========================================\n * THE CONTACT'S OWN STAMP (AGL-2616).\n *\n * The person rollup above is about the ADDRESS and moves on any sender's\n * mail; this one is about THIS SITE's relationship with the person and\n * moves only on its own campaigns — which is why it sits below the\n * `hostId` gate the rollup deliberately sits above. Same `firstOfType`\n * outcomes, so a replay contributes nothing here either, and the leaf\n * decides which types count. Best-effort: a stamp is worth less than\n * the campaign counters below it.\n *=========================================*/\n if (hostRef && (type === 'email.opened' || type === 'email.clicked')) {\n await recordContactEmailEngagement({ hostId: hostRef.id, outcomes }).catch(\n () => 0,\n )\n }\n\n /*==========================================\n * THE ONE-TO-ONE EMAIL'S TIMELINE ENTRY (AGL-2615).\n *\n * A message sent from a CRM record carries the org and the activity row\n * it was logged as, and the five events below are the row's delivery\n * state — the chip a rep reads beside \"Email\" on the timeline. Placed\n * above the campaign gates because a one-to-one email is not a campaign\n * and names none; placed below the log and the engagement rollup\n * because those are facts about the message and the person that this\n * row merely restates.\n *\n * The state comes from the NORMALIZED event, not the wire string: the\n * adapter above is the one reader of the provider's vocabulary. Both ids\n * are path components, so both are checked the way `hostId` is. Never\n * fatal — the writer reports rather than throws — and never behind the\n * replay claim: the write is monotonic, so a replay finds the row\n * already there and changes nothing.\n *=========================================*/\n const crmState = crmEmailDeliveryStateForEvent(deliveryEvents[0]?.type)\n const crmActivityId = tags[CRM_EMAIL_ACTIVITY_TAG]\n const crmOrgId = tags[CRM_EMAIL_ORG_TAG]\n if (crmState && isDocumentId(crmActivityId) && isDocumentId(crmOrgId)) {\n await recordCrmEmailDelivery(firestore, {\n orgId: crmOrgId,\n activityId: crmActivityId,\n state: crmState,\n atMs: deliveryEvents[0]?.at ?? Date.now(),\n })\n }\n\n /*==========================================\n * THE DELIVERY DENOMINATOR.\n *\n * `email.delivered` used to be answered `200 {ignored:true}`, which is\n * why every campaign rate had to be taken over `sent`. Sent is what the\n * PROVIDER accepted; delivered is what the receiving server accepted, and\n * the gap between them is the bounces. An open rate over `sent` therefore\n * reads lower than the same campaign measured anywhere else, and a rate\n * that only we compute differently is a rate a merchant cannot check.\n *\n * NO REPLAY CLAIM ON THIS ONE, and that is deliberate rather than an\n * omission: `firstSeen` is derived from whether the delivery log had\n * already recorded a `delivered` for this MESSAGE, so a retry, a replay\n * and a duplicate webhook all contribute zero without a claim document\n * being minted per delivered event. The counters below that DO carry a\n * claim are the ones counting events rather than messages.\n *=========================================*/\n if (type === 'email.delivered') {\n if (!campaignRef || !firstSeen) {\n return res.status(200).json({ ignored: true })\n }\n // `updateExisting` for the AGL-1768 reason the open counter carries:\n // a merge-set against a deleted campaign RE-CREATES it as a document\n // holding nothing but a `stats` map.\n await updateExisting(campaignRef, {\n 'stats.delivered': FieldValue.increment(firstSeen),\n })\n return res.status(200).json({ ok: true, counted: true })\n }\n\n if (type === 'email.bounced' || type === 'email.complained') {\n /*\n * The campaign counter FIRST, and its failure swallowed.\n *\n * Ordered ahead of the suppression because the suppression is the write\n * that must happen — it is what stops us mailing a dead or hostile\n * address again — and `recordDeliveryFailure` owns the response. A\n * statistic must never be able to cost a suppression, so this is\n * wrapped rather than awaited into the same failure path.\n *\n * Same `firstSeen` idempotency as `delivered` above: one bounce per\n * message, however many times the provider tells us about it.\n */\n if (campaignRef && firstSeen) {\n await updateExisting(campaignRef, {\n [type === 'email.bounced' ? 'stats.bounced' : 'stats.complained']:\n FieldValue.increment(firstSeen),\n }).catch(() => undefined)\n }\n // NO `hostId` gate here since AGL-2407. It used to sit above this\n // branch, which is what made every transactional bounce a no-op: only\n // `campaign-send` stamps a `hostId` tag, so a bounce on an invite or a\n // password reset failed the gate and was dropped with `ignored: true`.\n // The failure path needs no site at all — `recordDeliveryFailure` files\n // the platform record either way and adds the per-host one when there\n // is a host to add it to.\n return await recordDeliveryFailure({\n firestore,\n hostRef,\n type,\n recipient,\n bounceType: String(data?.bounce?.type ?? ''),\n context: tags['context'] ?? '',\n res,\n })\n }\n\n // Opens and clicks ARE per-campaign, so this pair still needs both ids —\n // and therefore still needs the host, which the failure path above no\n // longer does.\n if (!hostRef || !isDocumentId(campaignId)) {\n return res.status(200).json({ ignored: true })\n }\n\n /*==========================================\n * THE REPLAY GUARD, around the counters and nothing else.\n *\n * Delivery is AT LEAST ONCE and the two writes below are\n * `FieldValue.increment(1)`, which is the combination that inflates a\n * statistic. Three things deliver the same event twice: a provider retry\n * after our function wrote and then timed out before answering, a\n * retry after any non-2xx, and a human pressing **Replay** in the\n * provider's dashboard — which is not a hypothetical, since replay is how\n * events that failed while the signing secret was unset get recovered.\n * Every one of those turns one open into two.\n *\n * The claim is keyed on the Svix message id, which is stable across all\n * three: a retry and a replay of one event carry the id the first\n * delivery carried. `kind` and `scopeId` go into the digest with it, so\n * one site's event cannot collide with another's.\n *\n * ## Why it wraps the counters rather than the whole handler\n *\n * Everything above this point is already idempotent and worth re-running.\n * The per-recipient delivery log keys by the provider's message id and\n * merges, so a replay refreshes a row rather than adding one — and a row\n * that failed to write the first time SHOULD get another chance.\n * Suppression is a set: suppressing an address twice is suppressing it\n * once. Only the increments cannot survive being repeated, so only they\n * are behind the claim.\n *\n * ⚠️ `claimAttempt` treats an EMPTY key as \"no claim\" and returns a\n * no-op, which would silently reopen this hole. It cannot happen here —\n * the signature check above refuses a request with no `svix-id` before\n * reaching this line — and there is a test that fails if that stops\n * being true.\n *=========================================*/\n const counted = await claimAttempt(firestore, {\n kind: 'resend-email-event',\n scopeId: hostId,\n orgId: '',\n key: svixId,\n busyMessage: 'This delivery event is already being counted.',\n })\n if ('replay' in counted) {\n return res.status(200).json({ ok: true, counted: false })\n }\n // Plain refusal (AGL-1768). A merge-set against a missing path CREATES it,\n // so an open re-created a campaign the merchant had deleted — a document\n // holding a `stats` map and nothing else: no subject, no body, no\n // audience, no status. Opens trail sends by days, so deleting it again did\n // not help. `updateExisting` rejects only that case (gRPC NOT_FOUND) and\n // rethrows everything else, so a Firestore outage stays distinguishable\n // from an open against a deleted campaign instead of being swallowed by a\n // `.catch(() => undefined)`. AGL-1760's test — does refusing discard money\n // or work that already happened? — is passed: the open count for a\n // campaign that no longer exists has no reader.\n //\n // DOTTED FIELD PATH, not a nested map. `update({ stats: { opens: … } })`\n // REPLACES the whole `stats` map, so every open would clobber `clicks`;\n // only `set({ merge: true })` merges maps at depth.\n try {\n /*\n * TWO COUNTERS PER EVENT, because they answer two questions and the\n * report has to be able to name which it is showing.\n *\n * `stats.opens` counts EVENTS: one reader opening four times is four.\n * That is the number this handler has always kept, it is the honest\n * count of what happened, and it is useless as a rate numerator — an\n * open rate built on it exceeds 100% the moment anyone reads an email\n * twice, and a percentage above 100 teaches a reader that the label is\n * lying.\n *\n * `stats.uniqueOpens` counts MESSAGES that had never been opened\n * before, which is distinct readers, which is what every open rate in\n * the industry divides. `firstSeen` comes from the delivery log's own\n * transaction, so it costs no read here.\n */\n const totals: Record<string, unknown> = {\n [type === 'email.opened' ? 'stats.opens' : 'stats.clicks']:\n FieldValue.increment(1),\n }\n if (firstSeen) {\n totals[\n type === 'email.opened' ? 'stats.uniqueOpens' : 'stats.uniqueClicks'\n ] = FieldValue.increment(firstSeen)\n }\n await updateExisting(hostRef.collection('campaigns').doc(campaignId), totals)\n\n /*==========================================\n * LINK-LEVEL CLICKS — the aggregate `data.click.link` never had.\n *\n * The field IS present on Resend's `email.clicked` payload and has been\n * read for a while: `normalizeResendDeliveryEvents` puts it on the\n * event and the per-recipient delivery log stores it. What did not\n * exist was a per-campaign rollup, and it could not be produced at read\n * time — that would mean querying every recipient's delivery row for\n * the campaign, which is the scan a campaign report must not do.\n *\n * ONE DOCUMENT, not a document per URL. The report then reads the whole\n * table with a single `getDoc`, and the map cannot grow without bound\n * because the transaction refuses a new key past the cap and counts the\n * click as overflow instead. `campaignLinkKey` drops the query string,\n * so a link personalised per recipient cannot mint a row per recipient\n * — see that function for why that is a correctness requirement and not\n * only a size one.\n *\n * Inside the claim, so a replayed click cannot inflate a link row any\n * more than it can inflate `stats.clicks`. Best-effort: a rollup write\n * that fails must not cost the click count above it, which is the\n * number the whole report leans on.\n *=========================================*/\n if (type === 'email.clicked') {\n await recordCampaignLinkClick({\n firestore,\n campaignRef: hostRef.collection('campaigns').doc(campaignId),\n link: campaignLinkKey(data?.click?.link),\n }).catch(() => undefined)\n\n /*==========================================\n * THE TOUCH REVENUE ATTRIBUTION IS TAKEN OVER.\n *\n * \"Which campaign brought this buyer here\" cannot be answered from\n * anything above: the delivery log holds a row per message, and\n * finding a person's most recent click would mean reading every one\n * of them. So the click writes the answer down — one field on the\n * person's own document, per site — and an order reads it with a\n * single keyed lookup.\n *\n * ONLY a click. An open would be the weaker evidence and, since Mail\n * Privacy Protection, frequently not a human at all; crediting money\n * to one would hand a campaign the orders of people who never read\n * it. `email-revenue-attribution.ts` records the full reasoning.\n *\n * The instant is the PROVIDER'S, taken from the outcome the delivery\n * log already wrote, so a delayed webhook credits the click at the\n * time it happened rather than at the time we heard about it — which\n * is the difference between inside and outside the window for a click\n * near its edge. `Date.now()` is the fallback for a click whose log\n * write failed, and it is the later of the two, so it can only narrow\n * the window rather than widen it.\n *\n * Best-effort, like the link rollup above it and for the same reason:\n * a touch that failed to write costs one order's attribution, and it\n * must not cost the click count the whole report leans on.\n *=========================================*/\n await recordEmailCampaignTouch({\n email: recipient,\n hostId,\n campaignId,\n atMs:\n outcomes.find((one) => one.type === 'clicked')?.at ?? Date.now(),\n }).catch(() => false)\n }\n\n // Experiment conversion (AGL-268): clicks are the signal.\n const experimentId = tags['experimentId']\n if (type === 'email.clicked' && isDocumentId(experimentId) && recipient) {\n const experimentSnapshot = await hostRef\n .collection('experiments')\n .doc(experimentId)\n .get()\n const experiment = experimentSnapshot.data() as\n | HostExperiment\n | undefined\n if (experimentSnapshot.exists && experiment) {\n const variant = assignExperimentVariant(\n experiment,\n experimentId,\n recipient,\n )\n // `variant.id` is merchant-authored — `validateExperiment` checks the\n // ids are unique and nothing about their SHAPE — and it is a path\n // component here too.\n if (variant && isDocumentId(variant.id)) {\n // Still a merge-set, and deliberately so: this one CREATES. The\n // first conversion for a variant has no stats document yet, the\n // experiment it hangs off was just proven to exist, and the click\n // is work that really happened — refusing would discard it. The\n // `.catch(() => undefined)` is gone for the same reason as above: a\n // swallowed failure here is a conversion lost silently.\n await experimentSnapshot.ref\n .collection('stats')\n .doc(variant.id)\n .set(\n {\n conversions: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n }\n }\n }\n } catch (error) {\n /*\n * Give the key back, so the count this attempt did not make is still\n * makeable. Without it a failure here is permanent in a way the failure\n * itself is not: the outer handler answers 200 whatever happens (see\n * below), so the provider never retries, and a settled claim would then\n * refuse the manual replay that is the only remaining way to recover\n * the event.\n *\n * Releasing cannot itself be fatal — the original error is what is\n * worth reporting, and losing it to a secondary failure while cleaning\n * up would hide the real cause.\n */\n await counted.claim.release().catch(() => undefined)\n throw error\n }\n /*\n * Settled only after both writes landed, which is what makes the claim\n * mean \"this event has been counted\" rather than \"this event was seen\".\n */\n await counted.claim.record(200, { ok: true, counted: true })\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n // Never make Resend retry-storm.\n return res.status(200).json({ ok: true })\n }\n}\n"],"names":["claimAttempt","CRM_EMAIL_ACTIVITY_TAG","CRM_EMAIL_ORG_TAG","normalizeResendDeliveryEvents","verifySvixSignature","firebaseAdmin","updateExisting","suppressEmail","recordEmailCampaignTouch","recordEmailDeliveryEvents","recordPersonEngagement","recordContactEmailEngagement","isDocumentId","crmEmailDeliveryStateForEvent","recordCrmEmailDelivery","getOrgForHost","recordEmailReputationFailure","CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","createHash","FieldValue","assignExperimentVariant","suppressionId","verifySvix","tagMap","raw","Array","isArray","map","tag","name","String","value","Object","fromEntries","entries","key","recordDeliveryFailure","args","firestore","hostRef","type","recipient","bounceType","context","res","status","json","ignored","complaint","permanent","trim","toLowerCase","ok","suppressed","reason","id","then","org","orgId","undefined","catch","email","hostId","ref","collection","doc","runTransaction","transaction","existing","get","set","suppressedAt","serverTimestamp","exists","createdAt","merge","scope","recordCampaignLinkClick","campaignRef","link","snapshot","stored","campaign","data","links","unattributedClicks","increment","update","digest","slice","keys","length","overflowClicks","url","clicks","updatedAt","emailEventsHandler","req","headers","method","error","secret","process","env","RESEND_WEBHOOK_SECRET","payload","Buffer","from","rawBody","svixId","svixTimestamp","svixSignature","deliveryEvents","event","JSON","parse","toString","Date","now","outcomes","firstSeen","filter","one","firstOfType","tags","campaignId","to","app","crmState","crmActivityId","crmOrgId","activityId","state","atMs","at","counted","bounce","kind","scopeId","busyMessage","totals","click","find","experimentId","experimentSnapshot","experiment","variant","conversions","claim","release","record","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/email-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n claimAttempt,\n CRM_EMAIL_ACTIVITY_TAG,\n CRM_EMAIL_ORG_TAG,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { normalizeResendDeliveryEvents } from '@aglyn/shared-util-email'\n// By its own path, not the barrel: the check holds a `crypto` HMAC, and the\n// barrel is reached from the browser through the campaign model (AGL-2657).\nimport { verifySvixSignature } from '@aglyn/shared-util-email/svix-signature'\n// AGL-1771 lifted `isDocumentId` here from the local copy AGL-1768 wrote. The\n// copy's stated reason was wrong: `@nx/enforce-module-boundaries` does NOT\n// refuse an edge between two feature plugins by that tag — every plugin\n// carries `aglyn:addons`, whose rule permits `aglyn:addons` as a target (it is\n// `scope:plugin` that refuses another plugin, and `check:lib-boundaries` that\n// holds it). It now lives beside `updateExisting` in the library where Firestore paths are\n// built, which was always the better home and is now the reachable one.\nimport { firebaseAdmin, updateExisting } from '@aglyn/tenant-data-admin'\n// From the LEAF, not the barrel, for the same reason `isDocumentId` is\n// (AGL-1771): a spec that mocks `@aglyn/tenant-data-admin` — which it must,\n// because that graph reaches the admin SDK — would otherwise replace the real\n// suppression writer with whatever the factory happened to list. A stub there\n// is a false green on the one behaviour AGL-2407 is about.\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\n// Same leaf-import reasoning again: the per-recipient delivery log is the only\n// record staff have of what we sent someone, and a mocked-away writer is a\n// green test over an empty log.\nimport {\n recordEmailCampaignTouch,\n recordEmailDeliveryEvents,\n recordPersonEngagement,\n} from '@aglyn/tenant-data-admin/server/email-delivery-log'\n// The leaf again: the contact's own stamp is what the record page, the list\n// and the re-engagement audience read, and a wholesale mock would green a\n// webhook that stamped nobody.\nimport { recordContactEmailEngagement } from '@aglyn/tenant-data-admin/server/contact-email-engagement'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport {\n crmEmailDeliveryStateForEvent,\n recordCrmEmailDelivery,\n} from '@aglyn/tenant-data-admin/server/crm-email-activity'\nimport { getOrgForHost } from '@aglyn/tenant-data-admin/server/organizations'\n// The leaf again: which document a delivery event counts against is the\n// question every counter below depends on, and a stub would answer it for\n// them.\nimport { resolveCampaignSendRef } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { recordEmailReputationFailure } from '@aglyn/tenant-data-admin/server/email-sender-reputation'\n// The link rollup's key derivation and its cap live beside the READER that\n// renders them (`@aglyn/shared-ui-email-campaigns/model`) rather than here, so\n// the shape the webhook writes and the shape the report reads cannot drift\n// into two definitions of what a \"link\" is.\nimport {\n CAMPAIGN_LINK_ROLLUP_MAX,\n campaignLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { assignExperimentVariant, type HostExperiment } from '../model/experiments'\n// The one list `campaign-send` reads, keyed the one way it keys it.\nimport { suppressionId } from './campaign-send'\n\n/**\n * Svix signature check (Resend webhooks): the shared implementation in\n * `@aglyn/shared-util-email`, which the CRM's capture webhook (AGL-2657)\n * verifies with too, so the constant-time comparison lives once.\n */\nconst verifySvix = verifySvixSignature\n\n/** Tags arrive as an array of {name, value} or a plain map — accept both. */\nfunction tagMap(raw: unknown): Record<string, string> {\n if (Array.isArray(raw)) {\n const map: Record<string, string> = {}\n for (const tag of raw) {\n if (tag?.name) map[String(tag.name)] = String(tag.value ?? '')\n }\n return map\n }\n if (raw && typeof raw === 'object') {\n return Object.fromEntries(\n Object.entries(raw as Record<string, unknown>).map(([key, value]) => [\n key,\n String(value ?? ''),\n ]),\n )\n }\n return {}\n}\n\n/**\n * A bounce or a complaint suppresses the address (AGL-1918).\n *\n * Until now this webhook answered `email.bounced` and `email.complained` with\n * `200 {ignored:true}` — acknowledged, and dropped. The consequences were not\n * cosmetic, because `campaign-send.ts` filters its audience against exactly\n * one list and nothing but an unsubscribe click ever wrote to it: a hard-bounced\n * address was re-sent on every subsequent campaign forever, and a spam\n * complaint had no effect on whether that complainant was mailed again. Both\n * are the behaviours mailbox providers measure a sending domain on, and\n * `aglyn.com` carries the password resets and receipts on the same key and the\n * same From address as the campaigns.\n *\n * WHICH failures suppress:\n *\n * - **Every complaint.** Someone pressed \"report spam\". There is no reading of\n * that which permits mailing them again.\n * - **Permanent bounces only.** Resend reports `data.bounce.type` as\n * `Permanent` or `Transient`. A transient bounce is a full mailbox or a\n * greylisting server — suppressing on one would unsubscribe a real\n * subscriber over a temporary condition at their provider, which is a\n * customer's list being quietly destroyed by our error handling.\n * - An **unrecognised or absent** bounce type does NOT suppress. Guessing in\n * the suppressing direction is the destructive guess, and the spec asserts\n * the shape so a payload change fails as itself rather than by silently\n * ceasing to suppress anything.\n *\n * The entry is keyed and shaped exactly as the unsubscribe handler's\n * (`suppressionId(email)` → `{ email, createdAt }`), because the reader does\n * not care how an address got there — it is one list, and a second shape would\n * be a second list that `campaign-send` reads half of. `createdAt` is written\n * only when the document is new, so a bounce arriving after an unsubscribe\n * does not restamp the date the person actually unsubscribed.\n *\n * WHERE it lands, since AGL-2407: BOTH lists, and the per-host one is now the\n * optional half.\n *\n * - The PLATFORM list (`emailSuppressions`) is always written. That is the\n * half that did not exist: a bounce on an invite, a password reset, a\n * receipt or a usage summary carries no `hostId` — only `campaign-send`\n * ever stamped one — so this webhook had nowhere to file it and answered\n * `200 {ignored:true}`, exactly as if the address were fine. A dead mailbox\n * was re-mailed on every subsequent send, forever.\n * - The PER-HOST list is written when the send named a site, because that is\n * the list `campaign-send` filters its audience against and a merchant's\n * own list is theirs to see and undo (AGL-2410).\n *\n * A hard bounce is address-level truth and belongs on the platform list even\n * when a site was named — the mailbox does not exist for anyone. A COMPLAINT\n * is a judgement about one sender's mail, and it goes on the platform list too\n * for a narrower reason: the platform list is consulted only by bulk mail, and\n * someone who pressed \"report spam\" on anything from `noreply@aglyn.com` must\n * not receive more bulk mail from `noreply@aglyn.com`. Neither list is\n * consulted by transactional mail; see `email-suppression.ts` for why.\n */\nasync function recordDeliveryFailure(args: {\n firestore: FirebaseFirestore.Firestore\n /** Null when the send named no site — the case AGL-2407 exists for. */\n hostRef: FirebaseFirestore.DocumentReference | null\n type: 'email.bounced' | 'email.complained'\n recipient: string\n bounceType: string\n /** The `context` tag `sendEmail` stamps, naming which sender produced it. */\n context: string\n res: Parameters<PluginApiHandler>[1]\n}) {\n const { firestore, hostRef, type, recipient, bounceType, context, res } = args\n if (!recipient) return res.status(200).json({ ignored: true })\n\n const complaint = type === 'email.complained'\n const permanent = bounceType.trim().toLowerCase() === 'permanent'\n if (!complaint && !permanent) {\n // A transient bounce is real information we deliberately do not act on.\n return res.status(200).json({ ok: true, suppressed: false })\n }\n const reason = complaint ? 'complaint' : 'bounce'\n\n /*==========================================\n * THE SAME EVENT, COUNTED AGAINST THE TENANT.\n *\n * The campaign counter the caller writes answers \"how did this mailing do\".\n * This one answers the question the shared sending domain actually depends\n * on: how is THIS WORKSPACE doing, across every campaign it has sent.\n * Nothing computed a rate at any scope before it, so one merchant's bad\n * list could push the domain every other merchant's receipts leave on\n * toward a block, and the first anybody would hear of it is a rejection.\n *\n * ## Why HERE, below the permanence filter\n *\n * The two decisions in front of it are the same two the suppression needs,\n * and a rate that included them would be measuring something else. A\n * TRANSIENT bounce is a full mailbox or a greylisting server — it says\n * nothing about list quality, it does not suppress, and counting it would\n * trip a merchant's breaker on their subscribers' holiday auto-replies.\n *\n * ## Why only a send that named a site\n *\n * `hostId` is the only tenant identity a delivery event carries, and only\n * `campaign-send` stamps one. A bounce on a password reset or an invite\n * therefore reaches the suppression lists — address-level truth belongs on\n * them — and deliberately not this counter: the breaker it feeds may only\n * ever refuse a CAMPAIGN, so mail it could never act on must neither\n * inflate the rate nor dilute it.\n *\n * Swallowed, and taken before the suppression writes rather than after, so\n * a counter that fails cannot cost an address its place on either list.\n *=========================================*/\n if (hostRef) {\n await getOrgForHost(hostRef.id)\n .then((org) =>\n org?.orgId\n ? recordEmailReputationFailure(org.orgId, reason)\n : undefined,\n )\n .catch(() => undefined)\n }\n\n // The platform list FIRST, and unconditionally. Ordered ahead of the\n // per-host write because it is the one that must happen for every failure:\n // if the per-host write throws, the outer handler answers 200 and the\n // address is still off the bulk senders' lists.\n await suppressEmail({\n email: recipient,\n reason,\n context: context || null,\n hostId: hostRef?.id ?? null,\n firestore,\n })\n\n if (hostRef) {\n const ref = hostRef.collection('suppressions').doc(suppressionId(recipient))\n await firestore.runTransaction(async (transaction) => {\n const existing = await transaction.get(ref)\n transaction.set(\n ref,\n {\n email: recipient,\n reason,\n suppressedAt: FieldValue.serverTimestamp(),\n ...(existing.exists\n ? {}\n : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n })\n }\n return res\n .status(200)\n .json({ ok: true, suppressed: true, scope: hostRef ? 'host' : 'platform' })\n}\n\n/**\n * Counts one click against its destination in the campaign's link rollup.\n *\n * A transaction because the CAP has to be enforced against the map as it\n * stands: a merge-set cannot ask \"is this key already here, and how many keys\n * are there\" and would grow the document without limit. Firestore's 1 MiB\n * document ceiling is the hard reason, and a rollup that silently stopped\n * being writable at that ceiling would take the campaign's other counters\n * down with it if they shared the document — which is why this is its own.\n *\n * Nothing is DROPPED at the cap. A click on a destination past it lands in\n * `overflowClicks`, and a click that arrived with no destination at all lands\n * in `unattributedClicks`, so the table's own total plus the two excluded\n * figures reconcile with `stats.clicks` and the screen can say where the\n * difference went. Dropping either would leave a link table whose sum quietly\n * disagreed with the click count printed above it.\n *\n * The rollup does NOT create the campaign. Same reasoning as `updateExisting`\n * on the counters: a click arriving days after a merchant deleted a campaign\n * must not resurrect it, here as a document holding one map of URLs.\n */\nasync function recordCampaignLinkClick(args: {\n firestore: FirebaseFirestore.Firestore\n campaignRef: FirebaseFirestore.DocumentReference\n link: string | null\n}): Promise<void> {\n const { firestore, campaignRef, link } = args\n const ref = campaignRef.collection('reports').doc('links')\n await firestore.runTransaction(async (transaction) => {\n const campaign = await transaction.get(campaignRef)\n if (!campaign.exists) return\n const snapshot = await transaction.get(ref)\n const stored = (snapshot.exists ? snapshot.data() : null) ?? {}\n const links = (stored.links ?? {}) as Record<string, unknown>\n\n if (!link) {\n transaction.set(\n ref,\n { unattributedClicks: FieldValue.increment(1) },\n { merge: true },\n )\n return\n }\n // The map key is a hash, not the URL: a Firestore field name may not\n // contain `.`, `/` or `~`, and every URL contains at least two of them.\n // The URL itself rides in the value, so nothing has to be un-hashed.\n const key = createHash('sha256').update(link).digest('hex').slice(0, 32)\n if (links[key] === undefined && Object.keys(links).length >= CAMPAIGN_LINK_ROLLUP_MAX) {\n transaction.set(\n ref,\n { overflowClicks: FieldValue.increment(1) },\n { merge: true },\n )\n return\n }\n transaction.set(\n ref,\n {\n // A nested map under a merge-set, which merges at depth — the dotted\n // form would write a field whose NAME contains dots and leave `links`\n // empty, the exact fault `email-delivery-log.ts` records against\n // `timestamps`.\n links: { [key]: { url: link, clicks: FieldValue.increment(1) } },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n}\n\n/**\n * Resend event ingestion (AGL-268), relocated from the console app route\n * into its owning plugin (AGL-418) — the URL `/api/email/events` is\n * preserved through the plugin API dispatcher. Opened/clicked events\n * increment the tagged campaign's stats; clicks on experiment sends also\n * count as the recipient's variant conversion — the variant re-derives\n * deterministically from the address, so nothing per-send is stored.\n * Svix signs the RAW body: `req.rawBody` carries the exact request text.\n */\nexport const emailEventsHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const secret = process.env.RESEND_WEBHOOK_SECRET\n if (!secret) {\n return res.status(501).json({ error: 'Webhook is not configured' })\n }\n const payload = Buffer.from(req.rawBody ?? '', 'utf8')\n const headers = req.headers as Partial<Record<string, string>>\n const svixId = String(headers['svix-id'] ?? '')\n const svixTimestamp = String(headers['svix-timestamp'] ?? '')\n const svixSignature = String(headers['svix-signature'] ?? '')\n if (\n !svixId ||\n !svixTimestamp ||\n !verifySvix(secret, svixId, svixTimestamp, payload, svixSignature)\n ) {\n return res.status(401).json({ error: 'Bad signature' })\n }\n\n try {\n const event = JSON.parse(payload.toString('utf8'))\n const type = String(event?.type ?? '')\n\n /*==========================================\n * THE PER-RECIPIENT DELIVERY LOG.\n *\n * FIRST, and for every event type rather than the four below, because the\n * log is the staff answer to \"did this person get their invite\" and that\n * question is mostly asked about `sent`, `delivered` and `bounced` —\n * none of which the campaign statistics below have any use for.\n *\n * `normalizeResendDeliveryEvents` is the one place in the tree that reads\n * Resend's wire format; everything downstream stores and renders our own\n * vocabulary, so changing sender is a new adapter and nothing else.\n *\n * Best-effort and awaited but never fatal: a log write that fails must\n * not turn into a non-2xx, which the provider would answer by retrying\n * the same event forever.\n *=========================================*/\n const deliveryEvents = normalizeResendDeliveryEvents(event, Date.now())\n const outcomes = await recordEmailDeliveryEvents(deliveryEvents).catch(\n () => [],\n )\n\n /*\n * DISTINCT RECIPIENTS this event is the first of its kind for.\n *\n * The log's transaction already held each message's prior state, so this\n * is free — and it is the only honest way to count distinct openers\n * without a second document per recipient. It is also idempotent for the\n * same reason the replay guard below exists: a redelivered or replayed\n * event finds the state already recorded and contributes zero.\n *\n * Zero when the log write failed, which loses the count rather than\n * inventing one. That is the correct direction: a lost increment\n * understates engagement, and a guessed one is a number nobody can\n * defend.\n */\n const firstSeen = outcomes.filter((one) => one.firstOfType).length\n\n /*==========================================\n * THE PER-PERSON ENGAGEMENT ROLLUP.\n *\n * Opens and clicks were recorded per message and per campaign and rolled\n * onto NOBODY, so \"has this person engaged lately\" could only be answered\n * by walking every message subcollection. Two shipped things needed that\n * answer and could not have it: an audience rule that says \"opened in the\n * last 30 days\", and a sunset that stops mailing an address which has\n * gone quiet.\n *\n * HERE, above the type gate and above the campaign gates, on purpose.\n * Engagement is a fact about the PERSON, and the message they engaged\n * with does not have to be a campaign for it to be one — somebody who\n * clicks a receipt is reading our mail. Placing it below the\n * `hostId`/`campaignId` gate would record engagement for campaign mail\n * only and then let a sunset refuse people on the strength of it, which\n * is a control drawing conclusions from a fraction of the evidence.\n *\n * Driven by the same `firstOfType` outcomes `firstSeen` is counted from,\n * so a replay contributes nothing here for the same reason it contributes\n * nothing to `stats.uniqueOpens` — and the rollup needs no claim of its\n * own. Best-effort: a person's stamp is worth less than the campaign\n * counters below it and much less than a suppression.\n *=========================================*/\n await recordPersonEngagement(outcomes).catch(() => 0)\n\n if (\n type !== 'email.opened' &&\n type !== 'email.clicked' &&\n type !== 'email.bounced' &&\n type !== 'email.complained' &&\n type !== 'email.delivered'\n ) {\n return res.status(200).json({ ignored: true })\n }\n const data = event?.data ?? {}\n const tags = tagMap(data?.tags)\n const hostId = tags['hostId']\n const campaignId = tags['campaignId']\n const recipient = String(\n Array.isArray(data?.to) ? (data.to[0] ?? '') : (data?.to ?? ''),\n )\n .trim()\n .toLowerCase()\n // A path component, so \"non-empty\" was never the whole question.\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = isDocumentId(hostId)\n ? firestore.collection('hosts').doc(hostId)\n : null\n /**\n * The send this event belongs to, or null when it names none or the send\n * no longer exists.\n *\n * Found rather than built. A send is recorded under the organization, but\n * the event carries what was tagged on the message when it went out —\n * the site and the send id always, the org only on mail sent since sends\n * moved up — and a message sent before that is still delivering, opening\n * and bouncing. `resolveCampaignSendRef` asks the org and then the site,\n * so both are counted against the document that exists. Null skips every\n * counter below, which is what a deleted send's events should do: an\n * open never re-creates the email it belonged to.\n *\n * A failed lookup is null too. The counters are worth less than the\n * suppression the bounce path owes, and that must not be lost to a read\n * that failed while finding a counter to increment.\n */\n const orgTag = tags['orgId']\n const campaignRef =\n hostRef && isDocumentId(campaignId)\n ? await resolveCampaignSendRef({\n hostId,\n sendId: campaignId,\n orgId: isDocumentId(orgTag)\n ? orgTag\n : ((await getOrgForHost(hostId).catch(() => null))?.orgId ?? null),\n firestore,\n }).catch(() => null)\n : null\n\n /*==========================================\n * THE CONTACT'S OWN STAMP (AGL-2616).\n *\n * The person rollup above is about the ADDRESS and moves on any sender's\n * mail; this one is about THIS SITE's relationship with the person and\n * moves only on its own campaigns — which is why it sits below the\n * `hostId` gate the rollup deliberately sits above. Same `firstOfType`\n * outcomes, so a replay contributes nothing here either, and the leaf\n * decides which types count. Best-effort: a stamp is worth less than\n * the campaign counters below it.\n *=========================================*/\n if (hostRef && (type === 'email.opened' || type === 'email.clicked')) {\n await recordContactEmailEngagement({ hostId: hostRef.id, outcomes }).catch(\n () => 0,\n )\n }\n\n /*==========================================\n * THE ONE-TO-ONE EMAIL'S TIMELINE ENTRY (AGL-2615).\n *\n * A message sent from a CRM record carries the org and the activity row\n * it was logged as, and the five events below are the row's delivery\n * state — the chip a rep reads beside \"Email\" on the timeline. Placed\n * above the campaign gates because a one-to-one email is not a campaign\n * and names none; placed below the log and the engagement rollup\n * because those are facts about the message and the person that this\n * row merely restates.\n *\n * The state comes from the NORMALIZED event, not the wire string: the\n * adapter above is the one reader of the provider's vocabulary. Both ids\n * are path components, so both are checked the way `hostId` is. Never\n * fatal — the writer reports rather than throws — and never behind the\n * replay claim: the write is monotonic, so a replay finds the row\n * already there and changes nothing.\n *=========================================*/\n const crmState = crmEmailDeliveryStateForEvent(deliveryEvents[0]?.type)\n const crmActivityId = tags[CRM_EMAIL_ACTIVITY_TAG]\n const crmOrgId = tags[CRM_EMAIL_ORG_TAG]\n if (crmState && isDocumentId(crmActivityId) && isDocumentId(crmOrgId)) {\n await recordCrmEmailDelivery(firestore, {\n orgId: crmOrgId,\n activityId: crmActivityId,\n state: crmState,\n atMs: deliveryEvents[0]?.at ?? Date.now(),\n })\n }\n\n /*==========================================\n * THE DELIVERY DENOMINATOR.\n *\n * `email.delivered` used to be answered `200 {ignored:true}`, which is\n * why every campaign rate had to be taken over `sent`. Sent is what the\n * PROVIDER accepted; delivered is what the receiving server accepted, and\n * the gap between them is the bounces. An open rate over `sent` therefore\n * reads lower than the same campaign measured anywhere else, and a rate\n * that only we compute differently is a rate a merchant cannot check.\n *\n * NO REPLAY CLAIM ON THIS ONE, and that is deliberate rather than an\n * omission: `firstSeen` is derived from whether the delivery log had\n * already recorded a `delivered` for this MESSAGE, so a retry, a replay\n * and a duplicate webhook all contribute zero without a claim document\n * being minted per delivered event. The counters below that DO carry a\n * claim are the ones counting events rather than messages.\n *=========================================*/\n if (type === 'email.delivered') {\n if (!campaignRef || !firstSeen) {\n return res.status(200).json({ ignored: true })\n }\n // `updateExisting` for the AGL-1768 reason the open counter carries:\n // a merge-set against a deleted campaign RE-CREATES it as a document\n // holding nothing but a `stats` map.\n await updateExisting(campaignRef, {\n 'stats.delivered': FieldValue.increment(firstSeen),\n })\n return res.status(200).json({ ok: true, counted: true })\n }\n\n if (type === 'email.bounced' || type === 'email.complained') {\n /*\n * The campaign counter FIRST, and its failure swallowed.\n *\n * Ordered ahead of the suppression because the suppression is the write\n * that must happen — it is what stops us mailing a dead or hostile\n * address again — and `recordDeliveryFailure` owns the response. A\n * statistic must never be able to cost a suppression, so this is\n * wrapped rather than awaited into the same failure path.\n *\n * Same `firstSeen` idempotency as `delivered` above: one bounce per\n * message, however many times the provider tells us about it.\n */\n if (campaignRef && firstSeen) {\n await updateExisting(campaignRef, {\n [type === 'email.bounced' ? 'stats.bounced' : 'stats.complained']:\n FieldValue.increment(firstSeen),\n }).catch(() => undefined)\n }\n // NO `hostId` gate here since AGL-2407. It used to sit above this\n // branch, which is what made every transactional bounce a no-op: only\n // `campaign-send` stamps a `hostId` tag, so a bounce on an invite or a\n // password reset failed the gate and was dropped with `ignored: true`.\n // The failure path needs no site at all — `recordDeliveryFailure` files\n // the platform record either way and adds the per-host one when there\n // is a host to add it to.\n return await recordDeliveryFailure({\n firestore,\n hostRef,\n type,\n recipient,\n bounceType: String(data?.bounce?.type ?? ''),\n context: tags['context'] ?? '',\n res,\n })\n }\n\n // Opens and clicks ARE per-campaign, so this pair still needs both ids —\n // and therefore still needs the host, which the failure path above no\n // longer does. A send that no longer exists still gets past here: its\n // counters are skipped below, but the click it carried is still a\n // conversion for its experiment and a touch for the person.\n if (!hostRef || !isDocumentId(campaignId)) {\n return res.status(200).json({ ignored: true })\n }\n\n /*==========================================\n * THE REPLAY GUARD, around the counters and nothing else.\n *\n * Delivery is AT LEAST ONCE and the two writes below are\n * `FieldValue.increment(1)`, which is the combination that inflates a\n * statistic. Three things deliver the same event twice: a provider retry\n * after our function wrote and then timed out before answering, a\n * retry after any non-2xx, and a human pressing **Replay** in the\n * provider's dashboard — which is not a hypothetical, since replay is how\n * events that failed while the signing secret was unset get recovered.\n * Every one of those turns one open into two.\n *\n * The claim is keyed on the Svix message id, which is stable across all\n * three: a retry and a replay of one event carry the id the first\n * delivery carried. `kind` and `scopeId` go into the digest with it, so\n * one site's event cannot collide with another's.\n *\n * ## Why it wraps the counters rather than the whole handler\n *\n * Everything above this point is already idempotent and worth re-running.\n * The per-recipient delivery log keys by the provider's message id and\n * merges, so a replay refreshes a row rather than adding one — and a row\n * that failed to write the first time SHOULD get another chance.\n * Suppression is a set: suppressing an address twice is suppressing it\n * once. Only the increments cannot survive being repeated, so only they\n * are behind the claim.\n *\n * ⚠️ `claimAttempt` treats an EMPTY key as \"no claim\" and returns a\n * no-op, which would silently reopen this hole. It cannot happen here —\n * the signature check above refuses a request with no `svix-id` before\n * reaching this line — and there is a test that fails if that stops\n * being true.\n *=========================================*/\n const counted = await claimAttempt(firestore, {\n kind: 'resend-email-event',\n scopeId: hostId,\n orgId: '',\n key: svixId,\n busyMessage: 'This delivery event is already being counted.',\n })\n if ('replay' in counted) {\n return res.status(200).json({ ok: true, counted: false })\n }\n // Plain refusal (AGL-1768). A merge-set against a missing path CREATES it,\n // so an open re-created a campaign the merchant had deleted — a document\n // holding a `stats` map and nothing else: no subject, no body, no\n // audience, no status. Opens trail sends by days, so deleting it again did\n // not help. `updateExisting` rejects only that case (gRPC NOT_FOUND) and\n // rethrows everything else, so a Firestore outage stays distinguishable\n // from an open against a deleted campaign instead of being swallowed by a\n // `.catch(() => undefined)`. AGL-1760's test — does refusing discard money\n // or work that already happened? — is passed: the open count for a\n // campaign that no longer exists has no reader.\n //\n // DOTTED FIELD PATH, not a nested map. `update({ stats: { opens: … } })`\n // REPLACES the whole `stats` map, so every open would clobber `clicks`;\n // only `set({ merge: true })` merges maps at depth.\n try {\n /*\n * TWO COUNTERS PER EVENT, because they answer two questions and the\n * report has to be able to name which it is showing.\n *\n * `stats.opens` counts EVENTS: one reader opening four times is four.\n * That is the number this handler has always kept, it is the honest\n * count of what happened, and it is useless as a rate numerator — an\n * open rate built on it exceeds 100% the moment anyone reads an email\n * twice, and a percentage above 100 teaches a reader that the label is\n * lying.\n *\n * `stats.uniqueOpens` counts MESSAGES that had never been opened\n * before, which is distinct readers, which is what every open rate in\n * the industry divides. `firstSeen` comes from the delivery log's own\n * transaction, so it costs no read here.\n */\n const totals: Record<string, unknown> = {\n [type === 'email.opened' ? 'stats.opens' : 'stats.clicks']:\n FieldValue.increment(1),\n }\n if (firstSeen) {\n totals[\n type === 'email.opened' ? 'stats.uniqueOpens' : 'stats.uniqueClicks'\n ] = FieldValue.increment(firstSeen)\n }\n if (campaignRef) await updateExisting(campaignRef, totals)\n\n /*==========================================\n * LINK-LEVEL CLICKS — the aggregate `data.click.link` never had.\n *\n * The field IS present on Resend's `email.clicked` payload and has been\n * read for a while: `normalizeResendDeliveryEvents` puts it on the\n * event and the per-recipient delivery log stores it. What did not\n * exist was a per-campaign rollup, and it could not be produced at read\n * time — that would mean querying every recipient's delivery row for\n * the campaign, which is the scan a campaign report must not do.\n *\n * ONE DOCUMENT, not a document per URL. The report then reads the whole\n * table with a single `getDoc`, and the map cannot grow without bound\n * because the transaction refuses a new key past the cap and counts the\n * click as overflow instead. `campaignLinkKey` drops the query string,\n * so a link personalised per recipient cannot mint a row per recipient\n * — see that function for why that is a correctness requirement and not\n * only a size one.\n *\n * Inside the claim, so a replayed click cannot inflate a link row any\n * more than it can inflate `stats.clicks`. Best-effort: a rollup write\n * that fails must not cost the click count above it, which is the\n * number the whole report leans on.\n *=========================================*/\n if (type === 'email.clicked') {\n if (campaignRef) {\n await recordCampaignLinkClick({\n firestore,\n campaignRef,\n link: campaignLinkKey(data?.click?.link),\n }).catch(() => undefined)\n }\n\n /*==========================================\n * THE TOUCH REVENUE ATTRIBUTION IS TAKEN OVER.\n *\n * \"Which campaign brought this buyer here\" cannot be answered from\n * anything above: the delivery log holds a row per message, and\n * finding a person's most recent click would mean reading every one\n * of them. So the click writes the answer down — one field on the\n * person's own document, per site — and an order reads it with a\n * single keyed lookup.\n *\n * ONLY a click. An open would be the weaker evidence and, since Mail\n * Privacy Protection, frequently not a human at all; crediting money\n * to one would hand a campaign the orders of people who never read\n * it. `email-revenue-attribution.ts` records the full reasoning.\n *\n * The instant is the PROVIDER'S, taken from the outcome the delivery\n * log already wrote, so a delayed webhook credits the click at the\n * time it happened rather than at the time we heard about it — which\n * is the difference between inside and outside the window for a click\n * near its edge. `Date.now()` is the fallback for a click whose log\n * write failed, and it is the later of the two, so it can only narrow\n * the window rather than widen it.\n *\n * Best-effort, like the link rollup above it and for the same reason:\n * a touch that failed to write costs one order's attribution, and it\n * must not cost the click count the whole report leans on.\n *=========================================*/\n await recordEmailCampaignTouch({\n email: recipient,\n hostId,\n campaignId,\n atMs:\n outcomes.find((one) => one.type === 'clicked')?.at ?? Date.now(),\n }).catch(() => false)\n }\n\n // Experiment conversion (AGL-268): clicks are the signal.\n const experimentId = tags['experimentId']\n if (type === 'email.clicked' && isDocumentId(experimentId) && recipient) {\n const experimentSnapshot = await hostRef\n .collection('experiments')\n .doc(experimentId)\n .get()\n const experiment = experimentSnapshot.data() as\n | HostExperiment\n | undefined\n if (experimentSnapshot.exists && experiment) {\n const variant = assignExperimentVariant(\n experiment,\n experimentId,\n recipient,\n )\n // `variant.id` is merchant-authored — `validateExperiment` checks the\n // ids are unique and nothing about their SHAPE — and it is a path\n // component here too.\n if (variant && isDocumentId(variant.id)) {\n // Still a merge-set, and deliberately so: this one CREATES. The\n // first conversion for a variant has no stats document yet, the\n // experiment it hangs off was just proven to exist, and the click\n // is work that really happened — refusing would discard it. The\n // `.catch(() => undefined)` is gone for the same reason as above: a\n // swallowed failure here is a conversion lost silently.\n await experimentSnapshot.ref\n .collection('stats')\n .doc(variant.id)\n .set(\n {\n conversions: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n }\n }\n }\n } catch (error) {\n /*\n * Give the key back, so the count this attempt did not make is still\n * makeable. Without it a failure here is permanent in a way the failure\n * itself is not: the outer handler answers 200 whatever happens (see\n * below), so the provider never retries, and a settled claim would then\n * refuse the manual replay that is the only remaining way to recover\n * the event.\n *\n * Releasing cannot itself be fatal — the original error is what is\n * worth reporting, and losing it to a secondary failure while cleaning\n * up would hide the real cause.\n */\n await counted.claim.release().catch(() => undefined)\n throw error\n }\n /*\n * Settled only after both writes landed, which is what makes the claim\n * mean \"this event has been counted\" rather than \"this event was seen\".\n */\n await counted.claim.record(200, { ok: true, counted: true })\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n // Never make Resend retry-storm.\n return res.status(200).json({ ok: true })\n }\n}\n"],"names":["claimAttempt","CRM_EMAIL_ACTIVITY_TAG","CRM_EMAIL_ORG_TAG","normalizeResendDeliveryEvents","verifySvixSignature","firebaseAdmin","updateExisting","suppressEmail","recordEmailCampaignTouch","recordEmailDeliveryEvents","recordPersonEngagement","recordContactEmailEngagement","isDocumentId","crmEmailDeliveryStateForEvent","recordCrmEmailDelivery","getOrgForHost","resolveCampaignSendRef","recordEmailReputationFailure","CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","createHash","FieldValue","assignExperimentVariant","suppressionId","verifySvix","tagMap","raw","Array","isArray","map","tag","name","String","value","Object","fromEntries","entries","key","recordDeliveryFailure","args","firestore","hostRef","type","recipient","bounceType","context","res","status","json","ignored","complaint","permanent","trim","toLowerCase","ok","suppressed","reason","id","then","org","orgId","undefined","catch","email","hostId","ref","collection","doc","runTransaction","transaction","existing","get","set","suppressedAt","serverTimestamp","exists","createdAt","merge","scope","recordCampaignLinkClick","campaignRef","link","snapshot","stored","campaign","data","links","unattributedClicks","increment","update","digest","slice","keys","length","overflowClicks","url","clicks","updatedAt","emailEventsHandler","req","headers","method","error","secret","process","env","RESEND_WEBHOOK_SECRET","payload","Buffer","from","rawBody","svixId","svixTimestamp","svixSignature","deliveryEvents","event","JSON","parse","toString","Date","now","outcomes","firstSeen","filter","one","firstOfType","tags","campaignId","to","app","orgTag","sendId","crmState","crmActivityId","crmOrgId","activityId","state","atMs","at","counted","bounce","kind","scopeId","busyMessage","totals","click","find","experimentId","experimentSnapshot","experiment","variant","conversions","claim","release","record","console"],"mappings":"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"}