@aglyn/plugins-marketing 1.0.0-beta.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +56 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/announcement-bar-card.component.d.ts +20 -0
  8. package/src/lib/components/announcement-bar-card.component.js +292 -0
  9. package/src/lib/components/announcement-bar-card.component.js.map +1 -0
  10. package/src/lib/components/campaign-composer.d.ts +84 -0
  11. package/src/lib/components/campaign-composer.js +1643 -0
  12. package/src/lib/components/campaign-composer.js.map +1 -0
  13. package/src/lib/components/campaign-conversions-card.d.ts +22 -0
  14. package/src/lib/components/campaign-conversions-card.js +642 -0
  15. package/src/lib/components/campaign-conversions-card.js.map +1 -0
  16. package/src/lib/components/campaign-detail-card.d.ts +55 -0
  17. package/src/lib/components/campaign-detail-card.js +771 -0
  18. package/src/lib/components/campaign-detail-card.js.map +1 -0
  19. package/src/lib/components/campaign-edit-drawer.d.ts +60 -0
  20. package/src/lib/components/campaign-edit-drawer.js +252 -0
  21. package/src/lib/components/campaign-edit-drawer.js.map +1 -0
  22. package/src/lib/components/campaign-email-zones.d.ts +111 -0
  23. package/src/lib/components/campaign-email-zones.js +54 -0
  24. package/src/lib/components/campaign-email-zones.js.map +1 -0
  25. package/src/lib/components/campaign-glance-card.component.d.ts +18 -0
  26. package/src/lib/components/campaign-glance-card.component.js +151 -0
  27. package/src/lib/components/campaign-glance-card.component.js.map +1 -0
  28. package/src/lib/components/campaign-members-section.d.ts +87 -0
  29. package/src/lib/components/campaign-members-section.js +499 -0
  30. package/src/lib/components/campaign-members-section.js.map +1 -0
  31. package/src/lib/components/campaign-reach-sections.d.ts +137 -0
  32. package/src/lib/components/campaign-reach-sections.js +694 -0
  33. package/src/lib/components/campaign-reach-sections.js.map +1 -0
  34. package/src/lib/components/campaign-report-card.d.ts +91 -0
  35. package/src/lib/components/campaign-report-card.js +667 -0
  36. package/src/lib/components/campaign-report-card.js.map +1 -0
  37. package/src/lib/components/campaign-test-send-drawer.d.ts +45 -0
  38. package/src/lib/components/campaign-test-send-drawer.js +238 -0
  39. package/src/lib/components/campaign-test-send-drawer.js.map +1 -0
  40. package/src/lib/components/campaigns-card.d.ts +31 -0
  41. package/src/lib/components/campaigns-card.js +667 -0
  42. package/src/lib/components/campaigns-card.js.map +1 -0
  43. package/src/lib/components/conversion-attribution.component.d.ts +62 -0
  44. package/src/lib/components/conversion-attribution.component.js +127 -0
  45. package/src/lib/components/conversion-attribution.component.js.map +1 -0
  46. package/src/lib/components/email-compose-card.d.ts +12 -0
  47. package/src/lib/components/email-compose-card.js +157 -0
  48. package/src/lib/components/email-compose-card.js.map +1 -0
  49. package/src/lib/components/email-detail.d.ts +40 -0
  50. package/src/lib/components/email-detail.js +1180 -0
  51. package/src/lib/components/email-detail.js.map +1 -0
  52. package/src/lib/components/email-edit-drawer.d.ts +54 -0
  53. package/src/lib/components/email-edit-drawer.js +172 -0
  54. package/src/lib/components/email-edit-drawer.js.map +1 -0
  55. package/src/lib/components/email-messages-widget.d.ts +22 -0
  56. package/src/lib/components/email-messages-widget.js +49 -0
  57. package/src/lib/components/email-messages-widget.js.map +1 -0
  58. package/src/lib/components/email-recipients-card.d.ts +50 -0
  59. package/src/lib/components/email-recipients-card.js +338 -0
  60. package/src/lib/components/email-recipients-card.js.map +1 -0
  61. package/src/lib/components/emails-list-card.d.ts +36 -0
  62. package/src/lib/components/emails-list-card.js +544 -0
  63. package/src/lib/components/emails-list-card.js.map +1 -0
  64. package/src/lib/components/experiment-zones.d.ts +78 -0
  65. package/src/lib/components/experiment-zones.js +65 -0
  66. package/src/lib/components/experiment-zones.js.map +1 -0
  67. package/src/lib/components/host-experiments-card.component.d.ts +17 -0
  68. package/src/lib/components/host-experiments-card.component.js +887 -0
  69. package/src/lib/components/host-experiments-card.component.js.map +1 -0
  70. package/src/lib/components/host-marketing-summary-card.component.d.ts +15 -0
  71. package/src/lib/components/host-marketing-summary-card.component.js +161 -0
  72. package/src/lib/components/host-marketing-summary-card.component.js.map +1 -0
  73. package/src/lib/components/host-overlays-card.component.d.ts +19 -0
  74. package/src/lib/components/host-overlays-card.component.js +783 -0
  75. package/src/lib/components/host-overlays-card.component.js.map +1 -0
  76. package/src/lib/components/marketing-console-page.d.ts +28 -0
  77. package/src/lib/components/marketing-console-page.js +166 -0
  78. package/src/lib/components/marketing-console-page.js.map +1 -0
  79. package/src/lib/components/marketing-console-sections.d.ts +34 -0
  80. package/src/lib/components/marketing-console-sections.js +77 -0
  81. package/src/lib/components/marketing-console-sections.js.map +1 -0
  82. package/src/lib/components/overlay-stats-row.component.d.ts +15 -0
  83. package/src/lib/components/overlay-stats-row.component.js +78 -0
  84. package/src/lib/components/overlay-stats-row.component.js.map +1 -0
  85. package/src/lib/components/popup-card.component.d.ts +18 -0
  86. package/src/lib/components/popup-card.component.js +462 -0
  87. package/src/lib/components/popup-card.component.js.map +1 -0
  88. package/src/lib/components/record-attribution-widget.d.ts +35 -0
  89. package/src/lib/components/record-attribution-widget.js +33 -0
  90. package/src/lib/components/record-attribution-widget.js.map +1 -0
  91. package/src/lib/components/site-runtime.d.ts +9 -0
  92. package/src/lib/components/site-runtime.js +1081 -0
  93. package/src/lib/components/site-runtime.js.map +1 -0
  94. package/src/lib/components/use-campaign-send-api.d.ts +35 -0
  95. package/src/lib/components/use-campaign-send-api.js +99 -0
  96. package/src/lib/components/use-campaign-send-api.js.map +1 -0
  97. package/src/lib/components/use-campaign-topic-options.d.ts +20 -0
  98. package/src/lib/components/use-campaign-topic-options.js +44 -0
  99. package/src/lib/components/use-campaign-topic-options.js.map +1 -0
  100. package/src/lib/components/use-emails-hub-path.d.ts +39 -0
  101. package/src/lib/components/use-emails-hub-path.js +70 -0
  102. package/src/lib/components/use-emails-hub-path.js.map +1 -0
  103. package/src/lib/components/use-marketing-hub-path.d.ts +21 -0
  104. package/src/lib/components/use-marketing-hub-path.js +50 -0
  105. package/src/lib/components/use-marketing-hub-path.js.map +1 -0
  106. package/src/lib/components/use-overlay-copy-editor.d.ts +79 -0
  107. package/src/lib/components/use-overlay-copy-editor.js +110 -0
  108. package/src/lib/components/use-overlay-copy-editor.js.map +1 -0
  109. package/src/lib/constants/bundle-common.d.ts +8 -0
  110. package/src/lib/constants/bundle-common.js +9 -0
  111. package/src/lib/constants/bundle-common.js.map +1 -0
  112. package/src/lib/model/campaign-membership-figures.d.ts +109 -0
  113. package/src/lib/model/campaign-membership-figures.js +113 -0
  114. package/src/lib/model/campaign-membership-figures.js.map +1 -0
  115. package/src/lib/model/compile-client-automations.d.ts +65 -0
  116. package/src/lib/model/compile-client-automations.js +95 -0
  117. package/src/lib/model/compile-client-automations.js.map +1 -0
  118. package/src/lib/model/experiments.d.ts +130 -0
  119. package/src/lib/model/experiments.js +186 -0
  120. package/src/lib/model/experiments.js.map +1 -0
  121. package/src/lib/model/index.d.ts +26 -0
  122. package/src/lib/model/index.js +26 -0
  123. package/src/lib/model/index.js.map +1 -0
  124. package/src/lib/model/marketing-record-routes.d.ts +6 -0
  125. package/src/lib/model/marketing-record-routes.js +46 -0
  126. package/src/lib/model/marketing-record-routes.js.map +1 -0
  127. package/src/lib/model/overlay-copy.d.ts +62 -0
  128. package/src/lib/model/overlay-copy.js +78 -0
  129. package/src/lib/model/overlay-copy.js.map +1 -0
  130. package/src/lib/model/overlays.d.ts +113 -0
  131. package/src/lib/model/overlays.js +93 -0
  132. package/src/lib/model/overlays.js.map +1 -0
  133. package/src/lib/model/site-contract.d.ts +100 -0
  134. package/src/lib/model/site-contract.js +23 -0
  135. package/src/lib/model/site-contract.js.map +1 -0
  136. package/src/lib/plugin.d.ts +27 -0
  137. package/src/lib/plugin.js +168 -0
  138. package/src/lib/plugin.js.map +1 -0
  139. package/src/lib/preview-props.d.ts +31 -0
  140. package/src/lib/preview-props.js +54 -0
  141. package/src/lib/preview-props.js.map +1 -0
  142. package/src/lib/server/campaign-manage.d.ts +62 -0
  143. package/src/lib/server/campaign-manage.js +829 -0
  144. package/src/lib/server/campaign-manage.js.map +1 -0
  145. package/src/lib/server/campaign-process-scheduled.d.ts +26 -0
  146. package/src/lib/server/campaign-process-scheduled.js +198 -0
  147. package/src/lib/server/campaign-process-scheduled.js.map +1 -0
  148. package/src/lib/server/campaign-recipients.d.ts +55 -0
  149. package/src/lib/server/campaign-recipients.js +219 -0
  150. package/src/lib/server/campaign-recipients.js.map +1 -0
  151. package/src/lib/server/campaign-send.d.ts +487 -0
  152. package/src/lib/server/campaign-send.js +3157 -0
  153. package/src/lib/server/campaign-send.js.map +1 -0
  154. package/src/lib/server/email-campaign-reach.d.ts +174 -0
  155. package/src/lib/server/email-campaign-reach.js +254 -0
  156. package/src/lib/server/email-campaign-reach.js.map +1 -0
  157. package/src/lib/server/email-events.d.ts +27 -0
  158. package/src/lib/server/email-events.js +714 -0
  159. package/src/lib/server/email-events.js.map +1 -0
  160. package/src/lib/server/get-client-automations.d.ts +60 -0
  161. package/src/lib/server/get-client-automations.js +87 -0
  162. package/src/lib/server/get-client-automations.js.map +1 -0
  163. package/src/lib/server/get-overlays.d.ts +28 -0
  164. package/src/lib/server/get-overlays.js +65 -0
  165. package/src/lib/server/get-overlays.js.map +1 -0
  166. package/src/lib/server/get-screen-experiments.d.ts +42 -0
  167. package/src/lib/server/get-screen-experiments.js +82 -0
  168. package/src/lib/server/get-screen-experiments.js.map +1 -0
  169. package/src/lib/server/lists-materialize.d.ts +18 -0
  170. package/src/lib/server/lists-materialize.js +130 -0
  171. package/src/lib/server/lists-materialize.js.map +1 -0
  172. package/src/lib/server/marketing-figures.d.ts +37 -0
  173. package/src/lib/server/marketing-figures.js +248 -0
  174. package/src/lib/server/marketing-figures.js.map +1 -0
  175. package/src/lib/server/site-page-enricher.d.ts +26 -0
  176. package/src/lib/server/site-page-enricher.js +210 -0
  177. package/src/lib/server/site-page-enricher.js.map +1 -0
  178. package/src/lib/server.d.ts +20 -0
  179. package/src/lib/server.js +152 -0
  180. package/src/lib/server.js.map +1 -0
  181. package/src/lib/site.d.ts +22 -0
  182. package/src/lib/site.js +48 -0
  183. package/src/lib/site.js.map +1 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * Where the record lives: beside the link rollup, under the send it belongs
19
+ * to.
20
+ *
21
+ * A subcollection document rather than a field on the send, for the reason
22
+ * the link rollup is one — the send document is read by the emails list, the
23
+ * glance widget and the send path, and a set that grows with the audience on
24
+ * it would make every one of those reads larger.
25
+ */
26
+ export declare const CAMPAIGN_REACH_DOC = "reached";
27
+ /** The subcollection the link rollup already uses. */
28
+ export declare const CAMPAIGN_REACH_SUBCOLLECTION = "reports";
29
+ /**
30
+ * The most people one email may reach across all of its sends.
31
+ *
32
+ * The sender's audience read budget, taken from the one place it is stated
33
+ * rather than restated here. See the header for why the two are necessarily
34
+ * the same number: an email cannot reach more people than one resolution of
35
+ * its audience can hold.
36
+ */
37
+ export declare const CAMPAIGN_REACH_CEILING = 5000;
38
+ /** The stored document. */
39
+ export interface CampaignReachRecord {
40
+ /** `sha256` of each normalized address this email has reached. */
41
+ keys: string[];
42
+ /** `keys.length`, so a reader can size the record without loading it. */
43
+ count: number;
44
+ /**
45
+ * `sha256` of each address a send CONSIDERED and did not mail — suppressed,
46
+ * or gone from the topic the email opens on.
47
+ *
48
+ * See {@link readCampaignSettled} for why a batched send needs this and a
49
+ * merchant's follow-up must not read it.
50
+ */
51
+ skipped?: string[];
52
+ /** `skipped.length`, alongside `count` for the same reason. */
53
+ skippedCount?: number;
54
+ }
55
+ /**
56
+ * Everything one send of this email has DECIDED about, either way.
57
+ *
58
+ * `reached` is who got it. `skipped` is who was addressed by a batch, refused
59
+ * by a suppression list or a topic opt-out, and must not consume a slot in
60
+ * every later batch of the same email.
61
+ */
62
+ export interface CampaignSettledRecord {
63
+ reached: Set<string>;
64
+ skipped: Set<string>;
65
+ }
66
+ /**
67
+ * The keys of everyone this email has reached.
68
+ *
69
+ * FAILS CLOSED — it throws rather than answering "nobody". The asymmetry with
70
+ * {@link readMarketingFrequency}, which fails open, is the same one
71
+ * `filterSendableForHost` draws: a frequency window that cannot be read
72
+ * refuses a message nobody objected to, where a reach record that cannot be
73
+ * read would mail somebody a second copy of a message they already have. A
74
+ * follow-up is a discretionary act a merchant can repeat in a minute; a
75
+ * duplicate in a stranger's inbox is not retractable.
76
+ *
77
+ * @throws when the record cannot be read.
78
+ */
79
+ export declare function readCampaignReach(hostId: string, sendId: string, firestore?: any): Promise<Set<string>>;
80
+ /**
81
+ * Everything one email has decided about, in ONE read.
82
+ *
83
+ * ## Why a batch subtracts more than a follow-up does
84
+ *
85
+ * A merchant's follow-up subtracts {@link readCampaignReach} alone, and that
86
+ * is right: somebody who was suppressed when the email first went out, and
87
+ * has since been released, has never had it and should get it.
88
+ *
89
+ * A BATCH of the same email cannot use that rule, and the reason is the same
90
+ * one that puts the reach subtraction above the per-send cap rather than
91
+ * below it. The cap takes the first N of a stable order. An address the last
92
+ * batch addressed and could not mail is still at the head of that order, so
93
+ * it is selected again, and again, and it consumes a slot every time. A
94
+ * hundred suppressed addresses at the head of a list cost a hundred of every
95
+ * batch's five hundred; five hundred of them stop the campaign dead, having
96
+ * addressed nobody, forever.
97
+ *
98
+ * So a batch subtracts everything the email has SETTLED — mailed or refused —
99
+ * and the frontier advances by the whole cap every time. The distinction is
100
+ * kept in two fields rather than one because the two questions are different
101
+ * and only one of them is "who has this email".
102
+ *
103
+ * FAILS CLOSED, exactly as {@link readCampaignReach} does and for the same
104
+ * reason: a batch that cannot say who it has already mailed must not run.
105
+ *
106
+ * @throws when the record cannot be read.
107
+ */
108
+ export declare function readCampaignSettled(hostId: string, sendId: string, firestore?: any): Promise<CampaignSettledRecord>;
109
+ /**
110
+ * How many addresses this email's record accounts for, either way.
111
+ *
112
+ * The figure {@link CAMPAIGN_REACH_CEILING} bounds. Both halves count,
113
+ * because both halves are stored on the one document Firestore's size limit
114
+ * applies to.
115
+ */
116
+ export declare function campaignSettledSize(settled: CampaignSettledRecord): number;
117
+ /**
118
+ * Splits an audience into the people this email has not reached and the count
119
+ * of those it has.
120
+ *
121
+ * An address this cannot key counts as ALREADY REACHED and is dropped. The
122
+ * same posture the read takes: an address whose identity we cannot establish
123
+ * is one we cannot prove we have not already mailed, and both suppression
124
+ * lists refuse it for the same reason.
125
+ */
126
+ export declare function partitionByCampaignReach(emails: readonly string[], reached: ReadonlySet<string>): {
127
+ unreached: string[];
128
+ alreadyReached: number;
129
+ };
130
+ /**
131
+ * Adds the addresses a send delivered to, to the email's reach record.
132
+ *
133
+ * `arrayUnion` rather than a read-modify-write: it is atomic, it is
134
+ * idempotent, and two sends of the same email contending would otherwise lose
135
+ * one of their sets — which is a set of people a later follow-up would then
136
+ * mail twice.
137
+ *
138
+ * Returns how many keys were offered, NOT how many were new; the union
139
+ * decides that and does not report it. The caller records the count so a
140
+ * follow-up can check the record covers the send.
141
+ *
142
+ * @throws nothing on a Firestore failure — a send that has already delivered
143
+ * must not be turned into an error by its own bookkeeping. The COVERAGE check
144
+ * in {@link campaignReachCovers} is what catches a lost write, by refusing
145
+ * the follow-up rather than by pretending the record is whole.
146
+ */
147
+ export declare function recordCampaignReach(hostId: string, sendId: string, emails: readonly string[], firestore?: any): Promise<number>;
148
+ /**
149
+ * Adds the addresses a send CONSIDERED and did not mail.
150
+ *
151
+ * The same document, the same `arrayUnion` and the same never-throws posture
152
+ * as {@link recordCampaignReach}, under a field of its own so that a
153
+ * merchant's follow-up and an automatic batch can read different questions
154
+ * off one record. A failed write costs a later batch a repeated suppression
155
+ * lookup and nothing else — nobody is mailed twice by it, because the
156
+ * addresses in here are the ones nothing may mail at all.
157
+ */
158
+ export declare function recordCampaignSkipped(hostId: string, sendId: string, emails: readonly string[], firestore?: any): Promise<number>;
159
+ /**
160
+ * Whether the reach record accounts for every message this email has sent.
161
+ *
162
+ * The one guard standing between a lost bookkeeping write and a duplicate in
163
+ * somebody's inbox. `sent` is the send document's own delivered total, and
164
+ * the reach record holds one key per delivered message — a recipient cannot
165
+ * be addressed twice by one send, because the audience is deduplicated, and
166
+ * cannot be addressed twice ACROSS sends, because that is the property this
167
+ * record exists to hold. So the two numbers agree exactly, and a record that
168
+ * is short is a record that is missing somebody.
169
+ *
170
+ * It also answers correctly for every email sent before this record existed:
171
+ * no document, `0` keys, a non-zero `sent`, and the follow-up is refused —
172
+ * which is right, because nobody can say who that send reached.
173
+ */
174
+ export declare function campaignReachCovers(reached: ReadonlySet<string>, sent: number): boolean;
@@ -0,0 +1,254 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * WHO ONE EMAIL HAS ALREADY REACHED.
18
+ *
19
+ * An email may be sent more than once — a merchant adds the people who joined
20
+ * the list since it went out, or the ones a bounce kept it from. The single
21
+ * property that makes that safe is that a later send must address nobody an
22
+ * earlier one already reached, and nothing in the send path could answer that
23
+ * question: the delivered addresses were collected for the frequency window
24
+ * and then dropped.
25
+ *
26
+ * ## Why a record of its own, rather than the delivery log
27
+ *
28
+ * `emailDeliveries` looks like the answer and is not. It is populated by the
29
+ * provider WEBHOOK, so it lags the send by seconds to minutes and is missing
30
+ * entirely for a message the provider accepted but has not reported on. A
31
+ * follow-up computed against it would re-mail exactly the people whose events
32
+ * had not landed yet — the one outcome this whole feature must not produce.
33
+ *
34
+ * This record is written by the SENDER, from the addresses it saw
35
+ * `sendEmail` succeed for. It is the send's own account of itself.
36
+ *
37
+ * ## Why keys and not addresses
38
+ *
39
+ * The stored value is {@link emailSuppressionKey} — `sha256` of the
40
+ * normalized address, the same derivation both suppression lists and the
41
+ * frequency window key on. Reusing it rather than adding a second one is the
42
+ * point: a second hash of the same address is how this area came to have two
43
+ * derivations that agreed only by accident.
44
+ *
45
+ * It also means the document is a membership test and not an address list.
46
+ * The send's subcollection is readable by a site member, and a mailing list
47
+ * in the clear under a document the console lists is a thing to not create.
48
+ *
49
+ * ## Why one document, and what bounds it
50
+ *
51
+ * One `arrayUnion` read and one write per send, against a document nothing on
52
+ * the read path opens — as against a document per recipient, which would be
53
+ * 500 writes per send and up to {@link CAMPAIGN_REACH_CEILING} reads per
54
+ * follow-up.
55
+ *
56
+ * The ceiling is the audience scan ceiling the send path already has, and
57
+ * that is not a coincidence: a send cannot resolve more people than that in
58
+ * the first place, so an email whose reach exceeds it has been re-sent enough
59
+ * times to have addressed more people than any one of its audiences can hold.
60
+ * Refusing there keeps the document inside Firestore's 1 MB limit — 5,000
61
+ * 64-character keys is about 330 KB — with the refusal stated rather than a
62
+ * write that starts failing.
63
+ */ import { EMAIL_MAX_AUDIENCE_PER_SEND } from "@aglyn/shared-util-email";
64
+ import firebaseAdmin from "@aglyn/tenant-data-admin/server/firebase-admin";
65
+ import { emailSuppressionKey } from "@aglyn/tenant-data-admin/server/email-suppression";
66
+ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
67
+ /**
68
+ * Where the record lives: beside the link rollup, under the send it belongs
69
+ * to.
70
+ *
71
+ * A subcollection document rather than a field on the send, for the reason
72
+ * the link rollup is one — the send document is read by the emails list, the
73
+ * glance widget and the send path, and a set that grows with the audience on
74
+ * it would make every one of those reads larger.
75
+ */ export const CAMPAIGN_REACH_DOC = 'reached';
76
+ /** The subcollection the link rollup already uses. */ export const CAMPAIGN_REACH_SUBCOLLECTION = 'reports';
77
+ /**
78
+ * The most people one email may reach across all of its sends.
79
+ *
80
+ * The sender's audience read budget, taken from the one place it is stated
81
+ * rather than restated here. See the header for why the two are necessarily
82
+ * the same number: an email cannot reach more people than one resolution of
83
+ * its audience can hold.
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);
87
+ }
88
+ /**
89
+ * The keys of everyone this email has reached.
90
+ *
91
+ * FAILS CLOSED — it throws rather than answering "nobody". The asymmetry with
92
+ * {@link readMarketingFrequency}, which fails open, is the same one
93
+ * `filterSendableForHost` draws: a frequency window that cannot be read
94
+ * refuses a message nobody objected to, where a reach record that cannot be
95
+ * read would mail somebody a second copy of a message they already have. A
96
+ * follow-up is a discretionary act a merchant can repeat in a minute; a
97
+ * duplicate in a stranger's inbox is not retractable.
98
+ *
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();
102
+ const stored = snapshot.exists ? snapshot.get('keys') : null;
103
+ if (!Array.isArray(stored)) return new Set();
104
+ return new Set(stored.map((key)=>String(key)));
105
+ }
106
+ /**
107
+ * Everything one email has decided about, in ONE read.
108
+ *
109
+ * ## Why a batch subtracts more than a follow-up does
110
+ *
111
+ * A merchant's follow-up subtracts {@link readCampaignReach} alone, and that
112
+ * is right: somebody who was suppressed when the email first went out, and
113
+ * has since been released, has never had it and should get it.
114
+ *
115
+ * A BATCH of the same email cannot use that rule, and the reason is the same
116
+ * one that puts the reach subtraction above the per-send cap rather than
117
+ * below it. The cap takes the first N of a stable order. An address the last
118
+ * batch addressed and could not mail is still at the head of that order, so
119
+ * it is selected again, and again, and it consumes a slot every time. A
120
+ * hundred suppressed addresses at the head of a list cost a hundred of every
121
+ * batch's five hundred; five hundred of them stop the campaign dead, having
122
+ * addressed nobody, forever.
123
+ *
124
+ * So a batch subtracts everything the email has SETTLED — mailed or refused —
125
+ * and the frontier advances by the whole cap every time. The distinction is
126
+ * kept in two fields rather than one because the two questions are different
127
+ * and only one of them is "who has this email".
128
+ *
129
+ * FAILS CLOSED, exactly as {@link readCampaignReach} does and for the same
130
+ * reason: a batch that cannot say who it has already mailed must not run.
131
+ *
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();
135
+ const keys = snapshot.exists ? snapshot.get('keys') : null;
136
+ const skipped = snapshot.exists ? snapshot.get('skipped') : null;
137
+ const toSet = (stored)=>Array.isArray(stored) ? new Set(stored.map((key)=>String(key))) : new Set();
138
+ return {
139
+ reached: toSet(keys),
140
+ skipped: toSet(skipped)
141
+ };
142
+ }
143
+ /**
144
+ * How many addresses this email's record accounts for, either way.
145
+ *
146
+ * The figure {@link CAMPAIGN_REACH_CEILING} bounds. Both halves count,
147
+ * because both halves are stored on the one document Firestore's size limit
148
+ * applies to.
149
+ */ export function campaignSettledSize(settled) {
150
+ return settled.reached.size + settled.skipped.size;
151
+ }
152
+ /**
153
+ * Splits an audience into the people this email has not reached and the count
154
+ * of those it has.
155
+ *
156
+ * An address this cannot key counts as ALREADY REACHED and is dropped. The
157
+ * same posture the read takes: an address whose identity we cannot establish
158
+ * is one we cannot prove we have not already mailed, and both suppression
159
+ * lists refuse it for the same reason.
160
+ */ export function partitionByCampaignReach(emails, reached) {
161
+ const unreached = [];
162
+ let alreadyReached = 0;
163
+ for (const email of emails){
164
+ const key = emailSuppressionKey(email);
165
+ if (key && !reached.has(key)) unreached.push(email);
166
+ else alreadyReached += 1;
167
+ }
168
+ return {
169
+ unreached,
170
+ alreadyReached
171
+ };
172
+ }
173
+ /**
174
+ * Adds the addresses a send delivered to, to the email's reach record.
175
+ *
176
+ * `arrayUnion` rather than a read-modify-write: it is atomic, it is
177
+ * idempotent, and two sends of the same email contending would otherwise lose
178
+ * one of their sets — which is a set of people a later follow-up would then
179
+ * mail twice.
180
+ *
181
+ * Returns how many keys were offered, NOT how many were new; the union
182
+ * decides that and does not report it. The caller records the count so a
183
+ * follow-up can check the record covers the send.
184
+ *
185
+ * @throws nothing on a Firestore failure — a send that has already delivered
186
+ * must not be turned into an error by its own bookkeeping. The COVERAGE check
187
+ * in {@link campaignReachCovers} is what catches a lost write, by refusing
188
+ * the follow-up rather than by pretending the record is whole.
189
+ */ export async function recordCampaignReach(hostId, sendId, emails, firestore) {
190
+ const keys = [
191
+ ...new Set(emails.map((email)=>emailSuppressionKey(email)).filter((key)=>Boolean(key)))
192
+ ];
193
+ if (!keys.length || !hostId || !sendId) return 0;
194
+ try {
195
+ await reachDoc(hostId, sendId, firestore).set({
196
+ keys: firebaseAdmin.firestore.FieldValue.arrayUnion(...keys),
197
+ count: firebaseAdmin.firestore.FieldValue.increment(keys.length)
198
+ }, {
199
+ merge: true
200
+ });
201
+ return keys.length;
202
+ } catch (error) {
203
+ console.error('[email-campaign-reach] reach record failed', error);
204
+ return 0;
205
+ }
206
+ }
207
+ /**
208
+ * Adds the addresses a send CONSIDERED and did not mail.
209
+ *
210
+ * The same document, the same `arrayUnion` and the same never-throws posture
211
+ * as {@link recordCampaignReach}, under a field of its own so that a
212
+ * merchant's follow-up and an automatic batch can read different questions
213
+ * off one record. A failed write costs a later batch a repeated suppression
214
+ * lookup and nothing else — nobody is mailed twice by it, because the
215
+ * addresses in here are the ones nothing may mail at all.
216
+ */ export async function recordCampaignSkipped(hostId, sendId, emails, firestore) {
217
+ const keys = [
218
+ ...new Set(emails.map((email)=>emailSuppressionKey(email)).filter((key)=>Boolean(key)))
219
+ ];
220
+ if (!keys.length || !hostId || !sendId) return 0;
221
+ try {
222
+ await reachDoc(hostId, sendId, firestore).set({
223
+ skipped: firebaseAdmin.firestore.FieldValue.arrayUnion(...keys),
224
+ skippedCount: firebaseAdmin.firestore.FieldValue.increment(keys.length)
225
+ }, {
226
+ merge: true
227
+ });
228
+ return keys.length;
229
+ } catch (error) {
230
+ console.error('[email-campaign-reach] skipped record failed', error);
231
+ return 0;
232
+ }
233
+ }
234
+ /**
235
+ * Whether the reach record accounts for every message this email has sent.
236
+ *
237
+ * The one guard standing between a lost bookkeeping write and a duplicate in
238
+ * somebody's inbox. `sent` is the send document's own delivered total, and
239
+ * the reach record holds one key per delivered message — a recipient cannot
240
+ * be addressed twice by one send, because the audience is deduplicated, and
241
+ * cannot be addressed twice ACROSS sends, because that is the property this
242
+ * record exists to hold. So the two numbers agree exactly, and a record that
243
+ * is short is a record that is missing somebody.
244
+ *
245
+ * It also answers correctly for every email sent before this record existed:
246
+ * no document, `0` keys, a non-zero `sent`, and the follow-up is refused —
247
+ * which is right, because nobody can say who that send reached.
248
+ */ export function campaignReachCovers(reached, sent) {
249
+ const delivered = Number(sent);
250
+ if (!Number.isFinite(delivered) || delivered <= 0) return true;
251
+ return reached.size >= delivered;
252
+ }
253
+
254
+ //# sourceMappingURL=email-campaign-reach.js.map
@@ -0,0 +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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"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginApiHandler } from '@aglyn/aglyn/server';
18
+ /**
19
+ * Resend event ingestion (AGL-268), relocated from the console app route
20
+ * into its owning plugin (AGL-418) — the URL `/api/email/events` is
21
+ * preserved through the plugin API dispatcher. Opened/clicked events
22
+ * increment the tagged campaign's stats; clicks on experiment sends also
23
+ * count as the recipient's variant conversion — the variant re-derives
24
+ * deterministically from the address, so nothing per-send is stored.
25
+ * Svix signs the RAW body: `req.rawBody` carries the exact request text.
26
+ */
27
+ export declare const emailEventsHandler: PluginApiHandler;