@aglyn/shared-ui-email-campaigns 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 (33) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +73 -0
  3. package/package.json +39 -0
  4. package/src/index.d.ts +17 -0
  5. package/src/index.js +23 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/campaign-picker.component.d.ts +54 -0
  8. package/src/lib/components/campaign-picker.component.js +121 -0
  9. package/src/lib/components/campaign-picker.component.js.map +1 -0
  10. package/src/lib/components/report-figures.d.ts +51 -0
  11. package/src/lib/components/report-figures.js +120 -0
  12. package/src/lib/components/report-figures.js.map +1 -0
  13. package/src/lib/model/campaign-container.d.ts +359 -0
  14. package/src/lib/model/campaign-container.js +355 -0
  15. package/src/lib/model/campaign-container.js.map +1 -0
  16. package/src/lib/model/campaign-conversions.d.ts +286 -0
  17. package/src/lib/model/campaign-conversions.js +249 -0
  18. package/src/lib/model/campaign-conversions.js.map +1 -0
  19. package/src/lib/model/campaign-report.d.ts +304 -0
  20. package/src/lib/model/campaign-report.js +326 -0
  21. package/src/lib/model/campaign-report.js.map +1 -0
  22. package/src/lib/model/campaign-revenue.d.ts +327 -0
  23. package/src/lib/model/campaign-revenue.js +332 -0
  24. package/src/lib/model/campaign-revenue.js.map +1 -0
  25. package/src/lib/model/campaign-send-time.d.ts +74 -0
  26. package/src/lib/model/campaign-send-time.js +120 -0
  27. package/src/lib/model/campaign-send-time.js.map +1 -0
  28. package/src/lib/model/email-record.d.ts +175 -0
  29. package/src/lib/model/email-record.js +198 -0
  30. package/src/lib/model/email-record.js.map +1 -0
  31. package/src/lib/model/index.d.ts +53 -0
  32. package/src/lib/model/index.js +48 -0
  33. package/src/lib/model/index.js.map +1 -0
@@ -0,0 +1,304 @@
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
+ * CAMPAIGN REPORTING MATH — the only place a rate is computed.
19
+ *
20
+ * ## Why a pure module and not a component
21
+ *
22
+ * Every number on the report screen is a division, and a division is where
23
+ * email reporting goes wrong. Putting the arithmetic in JSX means the
24
+ * denominator is chosen by whoever writes the next card, in a file nobody
25
+ * tests for arithmetic; putting it here means each rate is named once,
26
+ * carries its own denominator as data, and is provable.
27
+ *
28
+ * ## The rule this module exists to enforce
29
+ *
30
+ * **A rate is a triple — numerator, denominator, and the NAME of the
31
+ * denominator — or it is not reported.** An open rate over `sent` and an open
32
+ * rate over `delivered` are different numbers with the same label, and the
33
+ * gap between them is exactly the mail that bounced. The industry convention
34
+ * is over `delivered`, and a report that quietly used `sent` would read
35
+ * higher than the same campaign measured anywhere else.
36
+ *
37
+ * So {@link CampaignRate} carries `denominatorLabel`, and the screen is
38
+ * required to render it. There is no overload that omits it.
39
+ *
40
+ * ## Why some rates are deliberately absent
41
+ *
42
+ * {@link campaignRate} answers `null`, not zero, when it cannot divide:
43
+ *
44
+ * - **A zero denominator.** 0 opens out of 0 delivered is not a 0% open
45
+ * rate, it is no open rate. Rendering 0% invites the reader to compare it
46
+ * with a campaign that really did fail.
47
+ * - **An UNKNOWN denominator.** `delivered` is counted by the delivery
48
+ * webhook, which was connected after some campaigns were sent. A campaign
49
+ * with 400 sends and no delivery events has an unknown denominator, not a
50
+ * denominator of zero — and dividing by `sent` instead is precisely the
51
+ * flattering substitution above.
52
+ *
53
+ * ## The structural-zero window
54
+ *
55
+ * Click tracking rewrites links in the HTML part. Sends that carried no HTML
56
+ * part were therefore untrackable, and every one of those campaigns reports 0
57
+ * clicks whatever the recipients actually did — a real 0 and a structural 0
58
+ * rendered identically. `send-email.ts` now synthesises an HTML part for a
59
+ * text-only send, so every send after that carries one, and
60
+ * `campaign-send.ts` records {@link CampaignStats.clickTracked} to say so.
61
+ *
62
+ * A campaign with no such marker predates the record. Its click COUNT is
63
+ * still shown — it is a real count of real events — but no click RATE is
64
+ * computed from it, because a rate presents the number as a measurement of
65
+ * the audience and for those campaigns it is a measurement of the sender.
66
+ */
67
+ /**
68
+ * The `stats` map on `hosts/{hostId}/campaigns/{campaignId}`.
69
+ *
70
+ * Every field is optional and every reader defaults it, because these are
71
+ * written by three different writers at three different times — the send, the
72
+ * delivery webhook, the unsubscribe handler — and a campaign is a legitimate,
73
+ * readable document from the instant the first of them lands.
74
+ */
75
+ export interface CampaignStats {
76
+ /** The whole audience the send was taken from, before the per-send cap. */
77
+ audienceSize?: number;
78
+ /** `audienceSize` is a FLOOR: audience resolution hit its read ceiling. */
79
+ audienceSizeTruncated?: boolean;
80
+ /** Addresses this send ADDRESSED — the audience after the per-send cap. */
81
+ recipients?: number;
82
+ /** Messages the provider accepted. The `sent` in "sent/recipients". */
83
+ sent?: number;
84
+ /** Of the audience, how many carry a recorded marketing consent basis. */
85
+ consented?: number;
86
+ /** Of `consented`, how many hold a basis an operator asserted for them. */
87
+ consentedByOperator?: number;
88
+ /** Of the audience, how many are reachable only because enforcement is
89
+ * not retroactive — the population a strict consent policy removes. */
90
+ grandfathered?: number;
91
+ /** Of the audience, how many the consent rule refused to mail. */
92
+ consentWithheld?: number;
93
+ /** Of `recipients`, how many were already suppressed (unsubscribed,
94
+ * bounced or complained on an earlier send). */
95
+ suppressed?: number;
96
+ /** Of `recipients`, how many asked this site for mail less often than this
97
+ * send would have arrived. Still subscribed; reached by a later campaign. */
98
+ cadenceHeld?: number;
99
+ /** Recipients the hourly send governor refused mid-batch. */
100
+ deferred?: number;
101
+ /**
102
+ * This send carried an HTML part, so its links were trackable.
103
+ *
104
+ * Absent on every campaign sent before the field existed — see the
105
+ * structural-zero note in this module's header. Absent is NOT false; it is
106
+ * "not recorded", and the report says so rather than guessing.
107
+ */
108
+ clickTracked?: boolean;
109
+ /** Per-variant send counts for an A/B campaign. */
110
+ variantSends?: Record<string, number>;
111
+ /** Messages the receiving server accepted. The rate DENOMINATOR. */
112
+ delivered?: number;
113
+ /** Open EVENTS. One reader opening four times counts four. */
114
+ opens?: number;
115
+ /** Click EVENTS. One reader clicking three links counts three. */
116
+ clicks?: number;
117
+ /** Messages whose FIRST open was seen — distinct readers who opened. */
118
+ uniqueOpens?: number;
119
+ /** Messages whose FIRST click was seen — distinct readers who clicked. */
120
+ uniqueClicks?: number;
121
+ /** Messages that bounced, permanent and transient together. */
122
+ bounced?: number;
123
+ /** Recipients who pressed "report spam". */
124
+ complained?: number;
125
+ /** Recipients who unsubscribed through THIS campaign's link. */
126
+ unsubscribes?: number;
127
+ }
128
+ /**
129
+ * One rate, with the denominator it was taken over named as data.
130
+ *
131
+ * `denominatorLabel` is not a display nicety. It is the field that makes two
132
+ * numbers called "open rate" distinguishable, and the screen renders it
133
+ * beside the percentage for that reason.
134
+ */
135
+ export interface CampaignRate {
136
+ /** 0–1. Multiply for display; the model never formats. */
137
+ value: number;
138
+ numerator: number;
139
+ denominator: number;
140
+ /** Reader-facing name of the denominator, e.g. `'delivered'`. */
141
+ denominatorLabel: string;
142
+ }
143
+ /**
144
+ * A rate, or `null` when one cannot honestly be taken.
145
+ *
146
+ * `null` on a zero or unknown denominator — see the module header for why
147
+ * that is not the same as 0%.
148
+ */
149
+ export declare function campaignRate(numerator: number | undefined, denominator: number | undefined, denominatorLabel: string): CampaignRate | null;
150
+ /** Why a number the report would otherwise show is being withheld. */
151
+ export interface CampaignCaveat {
152
+ /** Stable id, so a spec can assert on the caveat rather than its prose. */
153
+ id: 'delivery-unrecorded' | 'click-tracking-unrecorded' | 'audience-truncated' | 'send-deferred' | 'revenue-denominator-unrecorded' | 'revenue-multi-currency' | 'revenue-mid-flight' | 'revenue-mixed-model' | 'conversions-kinds-overlap' | 'conversions-web-not-rolled-up' | 'conversions-unattributed-is-a-ceiling' | 'conversions-total-crosses-hosts';
154
+ message: string;
155
+ }
156
+ /** One population the send measured, for the audience breakdown. */
157
+ export interface CampaignPopulation {
158
+ id: string;
159
+ label: string;
160
+ count: number;
161
+ /** What this count is a part OF, named. */
162
+ ofLabel: string;
163
+ of: number;
164
+ }
165
+ /** Everything the report screen renders, decided here rather than in JSX. */
166
+ export interface CampaignReport {
167
+ sent: number;
168
+ recipients: number;
169
+ delivered: number | null;
170
+ opens: number;
171
+ clicks: number;
172
+ uniqueOpens: number | null;
173
+ uniqueClicks: number | null;
174
+ bounced: number;
175
+ complained: number;
176
+ unsubscribes: number;
177
+ /** Rates, each `null` when its denominator is zero or unrecorded. */
178
+ rates: {
179
+ /** Accepted by the receiving server, over what the provider accepted. */
180
+ delivery: CampaignRate | null;
181
+ /** Distinct readers who opened, over delivered. */
182
+ open: CampaignRate | null;
183
+ /** Distinct readers who clicked, over delivered. */
184
+ click: CampaignRate | null;
185
+ /**
186
+ * Distinct clickers over distinct OPENERS — a different question from
187
+ * `click`, and the one the two get confused for. It answers "of the
188
+ * people who read it, how many acted", not "of the people who received
189
+ * it". Reported separately and labelled separately, never as "click
190
+ * rate".
191
+ */
192
+ clickToOpen: CampaignRate | null;
193
+ /** Bounced over what the provider accepted. */
194
+ bounce: CampaignRate | null;
195
+ /** Complaints over delivered — the number mailbox providers judge on. */
196
+ complaint: CampaignRate | null;
197
+ /** Unsubscribes through this campaign's link, over delivered. */
198
+ unsubscribe: CampaignRate | null;
199
+ };
200
+ populations: CampaignPopulation[];
201
+ caveats: CampaignCaveat[];
202
+ }
203
+ /**
204
+ * Turns a stored `stats` map into the report.
205
+ *
206
+ * ## The denominator decisions, in one place
207
+ *
208
+ * - **`delivered`** carries the engagement rates — open, click, complaint,
209
+ * unsubscribe. Mail that bounced was never in front of a human, so
210
+ * including it in the denominator of an open rate depresses a number that
211
+ * describes the audience with a fact about the address list. This is also
212
+ * the convention every other tool reports, which matters: a merchant
213
+ * comparing our figure with their previous ESP's must be comparing the same
214
+ * quantity.
215
+ * - **`sent`** carries the delivery and bounce rates, because those describe
216
+ * what happened to what we handed the provider, and `delivered` is the
217
+ * numerator of one of them — a rate cannot be over itself.
218
+ * - **`uniqueOpens`** is the open-rate numerator, not `opens`. Open EVENTS
219
+ * over recipients can exceed 100% the moment one person opens twice, and a
220
+ * percentage above 100 is how a reader learns the number means something
221
+ * other than what it says. Both are shown; only the distinct count is
222
+ * divided.
223
+ *
224
+ * ## `delivered` unknown vs. zero
225
+ *
226
+ * A campaign predating the delivery webhook records no `delivered` at all.
227
+ * That is reported as `null` and every rate over it is withheld, with a
228
+ * caveat naming the reason — rather than substituting `sent`, which would
229
+ * silently publish the flattered number this module exists to refuse.
230
+ */
231
+ export declare function campaignReport(stats: CampaignStats | undefined): CampaignReport;
232
+ /**
233
+ * How many distinct destinations one campaign's rollup keeps.
234
+ *
235
+ * A CAP rather than a page size, and it exists because the map lives in a
236
+ * single document with a 1 MiB ceiling. Clicks past the cap are counted in
237
+ * {@link CampaignLinkRollup.overflowClicks} rather than dropped, so the
238
+ * table's total still reconciles with `stats.clicks`.
239
+ */
240
+ export declare const CAMPAIGN_LINK_ROLLUP_MAX = 50;
241
+ /**
242
+ * Reduces a clicked URL to the key the rollup counts under.
243
+ *
244
+ * ## Why the query string is dropped
245
+ *
246
+ * Two reasons, and the second is the one that forces it:
247
+ *
248
+ * 1. **A campaign body goes through `resolveMergeTags` per recipient**, so a
249
+ * link may carry a personalised query. Keying on the full URL would then
250
+ * mint one rollup row per RECIPIENT — the aggregate degenerates into the
251
+ * per-recipient log it exists to summarise, and it blows the cap on the
252
+ * first campaign that does it.
253
+ * 2. **A personalised query can carry the recipient's own address.** The
254
+ * rollup is an aggregate read by everyone on the site's team; it must not
255
+ * become a list of who clicked, and dropping the query is what guarantees
256
+ * it cannot.
257
+ *
258
+ * ⚠️ The cost is real and is stated on the screen: two links to the same page
259
+ * distinguished only by their UTM parameters count as ONE row. That is a
260
+ * known limitation, not an oversight — see the note in the report card.
261
+ *
262
+ * @returns the normalized URL, or `null` for anything unparseable or not
263
+ * http(s). A rollup key must be a URL a merchant recognises.
264
+ */
265
+ export declare function campaignLinkKey(link: string | null | undefined): string | null;
266
+ /** One destination in the rollup. */
267
+ export interface CampaignLinkRow {
268
+ url: string;
269
+ /** Click EVENTS on this destination. One reader clicking twice counts two. */
270
+ clicks: number;
271
+ /** Share of the campaign's counted link clicks. */
272
+ share: CampaignRate | null;
273
+ }
274
+ /** The stored shape of `campaigns/{campaignId}/reports/links`. */
275
+ export interface CampaignLinkRollup {
276
+ links?: Record<string, {
277
+ url?: string;
278
+ clicks?: number;
279
+ }>;
280
+ /** Clicks on destinations past {@link CAMPAIGN_LINK_ROLLUP_MAX}. */
281
+ overflowClicks?: number;
282
+ /** Click events that arrived carrying no destination at all. */
283
+ unattributedClicks?: number;
284
+ }
285
+ /** What the link table renders. */
286
+ export interface CampaignLinkReport {
287
+ rows: CampaignLinkRow[];
288
+ /** Clicks counted against a named destination — the table's total. */
289
+ attributedClicks: number;
290
+ overflowClicks: number;
291
+ unattributedClicks: number;
292
+ /** True once the cap bit, so the table says it is not the whole list. */
293
+ truncated: boolean;
294
+ }
295
+ /**
296
+ * The link table, sorted by clicks descending.
297
+ *
298
+ * `share` is over ATTRIBUTED clicks — the clicks this table accounts for —
299
+ * and not over `stats.clicks`. The two differ by the overflow and the
300
+ * unattributed, and a share column that did not sum to 100% because of rows
301
+ * that are not on screen is the kind of arithmetic a reader cannot check.
302
+ * Both excluded figures are returned so the screen can state them.
303
+ */
304
+ export declare function campaignLinkReport(rollup: CampaignLinkRollup | undefined): CampaignLinkReport;
@@ -0,0 +1,326 @@
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
+ * CAMPAIGN REPORTING MATH — the only place a rate is computed.
18
+ *
19
+ * ## Why a pure module and not a component
20
+ *
21
+ * Every number on the report screen is a division, and a division is where
22
+ * email reporting goes wrong. Putting the arithmetic in JSX means the
23
+ * denominator is chosen by whoever writes the next card, in a file nobody
24
+ * tests for arithmetic; putting it here means each rate is named once,
25
+ * carries its own denominator as data, and is provable.
26
+ *
27
+ * ## The rule this module exists to enforce
28
+ *
29
+ * **A rate is a triple — numerator, denominator, and the NAME of the
30
+ * denominator — or it is not reported.** An open rate over `sent` and an open
31
+ * rate over `delivered` are different numbers with the same label, and the
32
+ * gap between them is exactly the mail that bounced. The industry convention
33
+ * is over `delivered`, and a report that quietly used `sent` would read
34
+ * higher than the same campaign measured anywhere else.
35
+ *
36
+ * So {@link CampaignRate} carries `denominatorLabel`, and the screen is
37
+ * required to render it. There is no overload that omits it.
38
+ *
39
+ * ## Why some rates are deliberately absent
40
+ *
41
+ * {@link campaignRate} answers `null`, not zero, when it cannot divide:
42
+ *
43
+ * - **A zero denominator.** 0 opens out of 0 delivered is not a 0% open
44
+ * rate, it is no open rate. Rendering 0% invites the reader to compare it
45
+ * with a campaign that really did fail.
46
+ * - **An UNKNOWN denominator.** `delivered` is counted by the delivery
47
+ * webhook, which was connected after some campaigns were sent. A campaign
48
+ * with 400 sends and no delivery events has an unknown denominator, not a
49
+ * denominator of zero — and dividing by `sent` instead is precisely the
50
+ * flattering substitution above.
51
+ *
52
+ * ## The structural-zero window
53
+ *
54
+ * Click tracking rewrites links in the HTML part. Sends that carried no HTML
55
+ * part were therefore untrackable, and every one of those campaigns reports 0
56
+ * clicks whatever the recipients actually did — a real 0 and a structural 0
57
+ * rendered identically. `send-email.ts` now synthesises an HTML part for a
58
+ * text-only send, so every send after that carries one, and
59
+ * `campaign-send.ts` records {@link CampaignStats.clickTracked} to say so.
60
+ *
61
+ * A campaign with no such marker predates the record. Its click COUNT is
62
+ * still shown — it is a real count of real events — but no click RATE is
63
+ * computed from it, because a rate presents the number as a measurement of
64
+ * the audience and for those campaigns it is a measurement of the sender.
65
+ */ /**
66
+ * The `stats` map on `hosts/{hostId}/campaigns/{campaignId}`.
67
+ *
68
+ * Every field is optional and every reader defaults it, because these are
69
+ * written by three different writers at three different times — the send, the
70
+ * delivery webhook, the unsubscribe handler — and a campaign is a legitimate,
71
+ * readable document from the instant the first of them lands.
72
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
73
+ /**
74
+ * A rate, or `null` when one cannot honestly be taken.
75
+ *
76
+ * `null` on a zero or unknown denominator — see the module header for why
77
+ * that is not the same as 0%.
78
+ */ export function campaignRate(numerator, denominator, denominatorLabel) {
79
+ const top = Number(numerator != null ? numerator : 0);
80
+ const bottom = Number(denominator != null ? denominator : 0);
81
+ if (!Number.isFinite(top) || !Number.isFinite(bottom)) return null;
82
+ if (bottom <= 0) return null;
83
+ return {
84
+ value: top / bottom,
85
+ numerator: top,
86
+ denominator: bottom,
87
+ denominatorLabel
88
+ };
89
+ }
90
+ /**
91
+ * Turns a stored `stats` map into the report.
92
+ *
93
+ * ## The denominator decisions, in one place
94
+ *
95
+ * - **`delivered`** carries the engagement rates — open, click, complaint,
96
+ * unsubscribe. Mail that bounced was never in front of a human, so
97
+ * including it in the denominator of an open rate depresses a number that
98
+ * describes the audience with a fact about the address list. This is also
99
+ * the convention every other tool reports, which matters: a merchant
100
+ * comparing our figure with their previous ESP's must be comparing the same
101
+ * quantity.
102
+ * - **`sent`** carries the delivery and bounce rates, because those describe
103
+ * what happened to what we handed the provider, and `delivered` is the
104
+ * numerator of one of them — a rate cannot be over itself.
105
+ * - **`uniqueOpens`** is the open-rate numerator, not `opens`. Open EVENTS
106
+ * over recipients can exceed 100% the moment one person opens twice, and a
107
+ * percentage above 100 is how a reader learns the number means something
108
+ * other than what it says. Both are shown; only the distinct count is
109
+ * divided.
110
+ *
111
+ * ## `delivered` unknown vs. zero
112
+ *
113
+ * A campaign predating the delivery webhook records no `delivered` at all.
114
+ * That is reported as `null` and every rate over it is withheld, with a
115
+ * caveat naming the reason — rather than substituting `sent`, which would
116
+ * silently publish the flattered number this module exists to refuse.
117
+ */ export function campaignReport(stats) {
118
+ var _source_sent, _source_recipients, _source_opens, _source_clicks, _source_bounced, _source_complained, _source_unsubscribes, _source_audienceSize, _source_deferred;
119
+ const source = stats != null ? stats : {};
120
+ const sent = Number((_source_sent = source.sent) != null ? _source_sent : 0);
121
+ const recipients = Number((_source_recipients = source.recipients) != null ? _source_recipients : 0);
122
+ const opens = Number((_source_opens = source.opens) != null ? _source_opens : 0);
123
+ const clicks = Number((_source_clicks = source.clicks) != null ? _source_clicks : 0);
124
+ const bounced = Number((_source_bounced = source.bounced) != null ? _source_bounced : 0);
125
+ const complained = Number((_source_complained = source.complained) != null ? _source_complained : 0);
126
+ const unsubscribes = Number((_source_unsubscribes = source.unsubscribes) != null ? _source_unsubscribes : 0);
127
+ /*
128
+ * ABSENT, not zero. `stats.delivered` is written only by the delivery
129
+ * webhook, so `undefined` means "no delivery event has ever been recorded
130
+ * for this campaign" — which for an old campaign means the webhook was not
131
+ * connected, and for a campaign sent thirty seconds ago means the events
132
+ * are still in flight. Neither is "nothing was delivered", and both are
133
+ * ruined by `?? 0`, which would turn the unknown into a hard zero and make
134
+ * every rate over it `null` for the RIGHT answer by the WRONG reasoning —
135
+ * and would render "0 delivered" on screen beside "500 sent".
136
+ */ const delivered = source.delivered === undefined ? null : Number(source.delivered);
137
+ const uniqueOpens = source.uniqueOpens === undefined ? null : Number(source.uniqueOpens);
138
+ const uniqueClicks = source.uniqueClicks === undefined ? null : Number(source.uniqueClicks);
139
+ /*
140
+ * The click rate is withheld for a campaign that never recorded carrying an
141
+ * HTML part, even when clicks are non-zero and `delivered` is known. See
142
+ * the structural-zero note in the module header: for those sends 0 is the
143
+ * only value the number could ever have taken, so a rate computed from it
144
+ * measures our sending code rather than the recipients.
145
+ */ const clickTrackable = source.clickTracked === true;
146
+ const rates = {
147
+ /*
148
+ * `null` when `delivered` is UNRECORDED, and this is the one rate where
149
+ * the numerator can be unknown rather than zero.
150
+ *
151
+ * Everywhere else an absent numerator is a genuine nought — a campaign
152
+ * with delivery events and no opens really does have a 0% open rate, and
153
+ * that is worth showing. Here the numerator IS the unrecorded quantity,
154
+ * so `campaignRate(undefined, 1000, 'sent')` would divide a missing
155
+ * measurement by a real one and publish "0.0% delivery rate — 0 of 1,000
156
+ * sent" for a campaign whose delivery events were merely never recorded.
157
+ * That is the flattering-substitution failure this module exists to
158
+ * refuse, running in the other direction: not a rate that reads too high,
159
+ * but a campaign that reads as a total delivery failure.
160
+ */ delivery: delivered === null ? null : campaignRate(delivered, sent, 'sent'),
161
+ open: campaignRate(uniqueOpens != null ? uniqueOpens : undefined, delivered != null ? delivered : undefined, 'delivered'),
162
+ click: clickTrackable ? campaignRate(uniqueClicks != null ? uniqueClicks : undefined, delivered != null ? delivered : undefined, 'delivered') : null,
163
+ clickToOpen: clickTrackable ? campaignRate(uniqueClicks != null ? uniqueClicks : undefined, uniqueOpens != null ? uniqueOpens : undefined, 'unique openers') : null,
164
+ bounce: campaignRate(bounced, sent, 'sent'),
165
+ complaint: campaignRate(complained, delivered != null ? delivered : undefined, 'delivered'),
166
+ unsubscribe: campaignRate(unsubscribes, delivered != null ? delivered : undefined, 'delivered')
167
+ };
168
+ /*
169
+ * The populations the SEND measured, reported as parts of a named whole
170
+ * rather than as bare counts. "412 withheld" invites the question "out of
171
+ * what"; the answer is the audience, and it is a different whole from the
172
+ * one `suppressed` is measured against — consent is decided over the whole
173
+ * audience and suppression over the capped recipient list, because that is
174
+ * where each check runs. Netting them into one column would present two
175
+ * different denominators as one.
176
+ */ const audienceSize = Number((_source_audienceSize = source.audienceSize) != null ? _source_audienceSize : 0);
177
+ const populations = [];
178
+ const addPopulation = (id, label, count, ofLabel, of)=>{
179
+ if (count === undefined) return;
180
+ populations.push({
181
+ id,
182
+ label,
183
+ count: Number(count),
184
+ ofLabel,
185
+ of
186
+ });
187
+ };
188
+ addPopulation('consented', 'Had a consent basis', source.consented, 'audience', audienceSize);
189
+ addPopulation('consentedByOperator', 'Consent asserted by an operator', source.consentedByOperator, 'audience', audienceSize);
190
+ addPopulation('grandfathered', 'Reachable only because consent is not enforced retroactively', source.grandfathered, 'audience', audienceSize);
191
+ addPopulation('consentWithheld', 'Withheld by the consent rule', source.consentWithheld, 'audience', audienceSize);
192
+ addPopulation('suppressed', 'Already suppressed', source.suppressed, 'addressed', recipients);
193
+ addPopulation('cadenceHeld', 'Asked for mail less often than this', source.cadenceHeld, 'addressed', recipients);
194
+ const caveats = [];
195
+ if (delivered === null) {
196
+ caveats.push({
197
+ id: 'delivery-unrecorded',
198
+ message: 'No delivery events have been recorded for this campaign, so open, ' + 'click, complaint and unsubscribe rates cannot be computed — every ' + 'one of them is taken over delivered. Counts below are still real.'
199
+ });
200
+ }
201
+ if (!clickTrackable) {
202
+ caveats.push({
203
+ id: 'click-tracking-unrecorded',
204
+ message: 'This send did not record carrying an HTML part. Click tracking ' + 'rewrites links in the HTML, so a send without one reports zero ' + 'clicks whatever recipients did. The click count is shown; no click ' + 'rate is computed from it.'
205
+ });
206
+ }
207
+ if (source.audienceSizeTruncated) {
208
+ caveats.push({
209
+ id: 'audience-truncated',
210
+ message: 'Audience resolution stopped at its read ceiling, so the audience ' + 'size is a floor — the real audience is at least this large, and ' + 'every share taken over it is at most the figure shown.'
211
+ });
212
+ }
213
+ if (Number((_source_deferred = source.deferred) != null ? _source_deferred : 0) > 0) {
214
+ caveats.push({
215
+ id: 'send-deferred',
216
+ message: `${Number(source.deferred)} recipients were held back by the hourly ` + 'send governor and never received this campaign. They are counted in ' + 'addressed, not in sent.'
217
+ });
218
+ }
219
+ return {
220
+ sent,
221
+ recipients,
222
+ delivered,
223
+ opens,
224
+ clicks,
225
+ uniqueOpens,
226
+ uniqueClicks,
227
+ bounced,
228
+ complained,
229
+ unsubscribes,
230
+ rates,
231
+ populations,
232
+ caveats
233
+ };
234
+ }
235
+ /*==========================================
236
+ * LINK-LEVEL CLICKS.
237
+ *
238
+ * Resend's `email.clicked` payload carries `data.click.link`, the destination
239
+ * the recipient followed; `normalizeResendDeliveryEvents` already reads it
240
+ * into `EmailDeliveryEvent.link`, and the per-recipient delivery log already
241
+ * stores it. What did not exist was an aggregate — which is what "link
242
+ * clicks" means, and it cannot be produced from the delivery log without
243
+ * reading every recipient row for the campaign.
244
+ *
245
+ * So it is a WRITE-TIME rollup: one document per campaign,
246
+ * `campaigns/{campaignId}/reports/links`, holding a bounded map. The report
247
+ * reads exactly one document for the whole table.
248
+ *=========================================*/ /**
249
+ * How many distinct destinations one campaign's rollup keeps.
250
+ *
251
+ * A CAP rather than a page size, and it exists because the map lives in a
252
+ * single document with a 1 MiB ceiling. Clicks past the cap are counted in
253
+ * {@link CampaignLinkRollup.overflowClicks} rather than dropped, so the
254
+ * table's total still reconciles with `stats.clicks`.
255
+ */ export const CAMPAIGN_LINK_ROLLUP_MAX = 50;
256
+ /**
257
+ * Reduces a clicked URL to the key the rollup counts under.
258
+ *
259
+ * ## Why the query string is dropped
260
+ *
261
+ * Two reasons, and the second is the one that forces it:
262
+ *
263
+ * 1. **A campaign body goes through `resolveMergeTags` per recipient**, so a
264
+ * link may carry a personalised query. Keying on the full URL would then
265
+ * mint one rollup row per RECIPIENT — the aggregate degenerates into the
266
+ * per-recipient log it exists to summarise, and it blows the cap on the
267
+ * first campaign that does it.
268
+ * 2. **A personalised query can carry the recipient's own address.** The
269
+ * rollup is an aggregate read by everyone on the site's team; it must not
270
+ * become a list of who clicked, and dropping the query is what guarantees
271
+ * it cannot.
272
+ *
273
+ * ⚠️ The cost is real and is stated on the screen: two links to the same page
274
+ * distinguished only by their UTM parameters count as ONE row. That is a
275
+ * known limitation, not an oversight — see the note in the report card.
276
+ *
277
+ * @returns the normalized URL, or `null` for anything unparseable or not
278
+ * http(s). A rollup key must be a URL a merchant recognises.
279
+ */ export function campaignLinkKey(link) {
280
+ const raw = String(link != null ? link : '').trim();
281
+ if (!raw) return null;
282
+ try {
283
+ const url = new URL(raw);
284
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
285
+ // Trailing slash normalised away so `/pricing` and `/pricing/` are one
286
+ // row; the bare origin keeps its slash so the key is still a valid URL.
287
+ const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, '') : url.pathname;
288
+ return `${url.origin}${path}`;
289
+ } catch (unused) {
290
+ return null;
291
+ }
292
+ }
293
+ /**
294
+ * The link table, sorted by clicks descending.
295
+ *
296
+ * `share` is over ATTRIBUTED clicks — the clicks this table accounts for —
297
+ * and not over `stats.clicks`. The two differ by the overflow and the
298
+ * unattributed, and a share column that did not sum to 100% because of rows
299
+ * that are not on screen is the kind of arithmetic a reader cannot check.
300
+ * Both excluded figures are returned so the screen can state them.
301
+ */ export function campaignLinkReport(rollup) {
302
+ var _ref, _ref1, _ref2;
303
+ const entries = Object.values((_ref = rollup == null ? void 0 : rollup.links) != null ? _ref : {}).map((entry)=>{
304
+ var _ref, _ref1;
305
+ return {
306
+ url: String((_ref = entry == null ? void 0 : entry.url) != null ? _ref : ''),
307
+ clicks: Number((_ref1 = entry == null ? void 0 : entry.clicks) != null ? _ref1 : 0)
308
+ };
309
+ }).filter((entry)=>entry.url && Number.isFinite(entry.clicks));
310
+ const attributedClicks = entries.reduce((total, one)=>total + one.clicks, 0);
311
+ const rows = [
312
+ ...entries
313
+ ].sort((a, b)=>b.clicks - a.clicks || a.url.localeCompare(b.url)).map((entry)=>_extends({}, entry, {
314
+ share: campaignRate(entry.clicks, attributedClicks, 'link clicks counted')
315
+ }));
316
+ const overflowClicks = Number((_ref1 = rollup == null ? void 0 : rollup.overflowClicks) != null ? _ref1 : 0);
317
+ return {
318
+ rows,
319
+ attributedClicks,
320
+ overflowClicks,
321
+ unattributedClicks: Number((_ref2 = rollup == null ? void 0 : rollup.unattributedClicks) != null ? _ref2 : 0),
322
+ truncated: entries.length >= CAMPAIGN_LINK_ROLLUP_MAX || overflowClicks > 0
323
+ };
324
+ }
325
+
326
+ //# sourceMappingURL=campaign-report.js.map