@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.
- package/LICENSE +201 -0
- package/README.md +73 -0
- package/package.json +39 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +23 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/campaign-picker.component.d.ts +54 -0
- package/src/lib/components/campaign-picker.component.js +121 -0
- package/src/lib/components/campaign-picker.component.js.map +1 -0
- package/src/lib/components/report-figures.d.ts +51 -0
- package/src/lib/components/report-figures.js +120 -0
- package/src/lib/components/report-figures.js.map +1 -0
- package/src/lib/model/campaign-container.d.ts +359 -0
- package/src/lib/model/campaign-container.js +355 -0
- package/src/lib/model/campaign-container.js.map +1 -0
- package/src/lib/model/campaign-conversions.d.ts +286 -0
- package/src/lib/model/campaign-conversions.js +249 -0
- package/src/lib/model/campaign-conversions.js.map +1 -0
- package/src/lib/model/campaign-report.d.ts +304 -0
- package/src/lib/model/campaign-report.js +326 -0
- package/src/lib/model/campaign-report.js.map +1 -0
- package/src/lib/model/campaign-revenue.d.ts +327 -0
- package/src/lib/model/campaign-revenue.js +332 -0
- package/src/lib/model/campaign-revenue.js.map +1 -0
- package/src/lib/model/campaign-send-time.d.ts +74 -0
- package/src/lib/model/campaign-send-time.js +120 -0
- package/src/lib/model/campaign-send-time.js.map +1 -0
- package/src/lib/model/email-record.d.ts +175 -0
- package/src/lib/model/email-record.js +198 -0
- package/src/lib/model/email-record.js.map +1 -0
- package/src/lib/model/index.d.ts +53 -0
- package/src/lib/model/index.js +48 -0
- 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
|