@aglyn/plugins-marketing 1.0.0-beta.221 → 1.0.0-beta.222

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 (106) hide show
  1. package/README.md +4 -1
  2. package/package.json +15 -15
  3. package/src/lib/components/campaign-conversions-card.js +53 -64
  4. package/src/lib/components/campaign-conversions-card.js.map +1 -1
  5. package/src/lib/components/campaign-detail-card.js +2 -1
  6. package/src/lib/components/campaign-detail-card.js.map +1 -1
  7. package/src/lib/components/campaign-edit-drawer.js +1 -1
  8. package/src/lib/components/campaign-edit-drawer.js.map +1 -1
  9. package/src/lib/components/campaign-queries.js +1 -1
  10. package/src/lib/components/campaign-queries.js.map +1 -1
  11. package/src/lib/components/campaign-reach-sections.js +1 -1
  12. package/src/lib/components/campaign-reach-sections.js.map +1 -1
  13. package/src/lib/components/campaign-report-card.js +5 -4
  14. package/src/lib/components/campaign-report-card.js.map +1 -1
  15. package/src/lib/components/campaigns-card.js +1 -1
  16. package/src/lib/components/campaigns-card.js.map +1 -1
  17. package/src/lib/components/email-compose-card.js +1 -1
  18. package/src/lib/components/email-compose-card.js.map +1 -1
  19. package/src/lib/components/email-detail.js +5 -4
  20. package/src/lib/components/email-detail.js.map +1 -1
  21. package/src/lib/components/email-template-report-card.d.ts +53 -0
  22. package/src/lib/components/email-template-report-card.js +469 -0
  23. package/src/lib/components/email-template-report-card.js.map +1 -0
  24. package/src/lib/components/emails-list-card.js +2 -2
  25. package/src/lib/components/emails-list-card.js.map +1 -1
  26. package/src/lib/components/host-experiments-card.component.js +3 -2
  27. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  28. package/src/lib/components/site-runtime.js +3 -1
  29. package/src/lib/components/site-runtime.js.map +1 -1
  30. package/src/lib/declarations.server.d.ts +9 -0
  31. package/src/lib/declarations.server.js +62 -0
  32. package/src/lib/declarations.server.js.map +1 -1
  33. package/src/lib/model/campaign-container.d.ts +503 -0
  34. package/src/lib/model/campaign-container.js +498 -0
  35. package/src/lib/model/campaign-container.js.map +1 -0
  36. package/src/lib/model/campaign-conversions.d.ts +8 -1
  37. package/src/lib/model/campaign-conversions.js +11 -0
  38. package/src/lib/model/campaign-conversions.js.map +1 -1
  39. package/src/lib/model/campaign-report.d.ts +158 -0
  40. package/src/lib/model/campaign-report.js +268 -0
  41. package/src/lib/model/campaign-report.js.map +1 -0
  42. package/src/lib/model/campaign-revenue.d.ts +3 -3
  43. package/src/lib/model/campaign-revenue.js +3 -3
  44. package/src/lib/model/campaign-revenue.js.map +1 -1
  45. package/src/lib/model/campaign-send-time.js +2 -2
  46. package/src/lib/model/campaign-send-time.js.map +1 -1
  47. package/src/lib/model/compile-client-automations.d.ts +2 -2
  48. package/src/lib/model/compile-client-automations.js +3 -3
  49. package/src/lib/model/compile-client-automations.js.map +1 -1
  50. package/src/lib/model/email-record.d.ts +175 -0
  51. package/src/lib/model/email-record.js +206 -0
  52. package/src/lib/model/email-record.js.map +1 -0
  53. package/src/lib/model/site-contract.d.ts +2 -2
  54. package/src/lib/model/site-contract.js.map +1 -1
  55. package/src/lib/model/template-report.d.ts +158 -0
  56. package/src/lib/model/template-report.js +249 -0
  57. package/src/lib/model/template-report.js.map +1 -0
  58. package/src/lib/plugin.js +16 -0
  59. package/src/lib/plugin.js.map +1 -1
  60. package/src/lib/preview-props.js.map +1 -1
  61. package/src/lib/server/campaign-attribution-store.d.ts +55 -0
  62. package/src/lib/server/campaign-attribution-store.js +98 -0
  63. package/src/lib/server/campaign-attribution-store.js.map +1 -0
  64. package/src/lib/server/campaign-conversion-attribution.d.ts +283 -0
  65. package/src/lib/server/campaign-conversion-attribution.js +447 -0
  66. package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
  67. package/src/lib/server/campaign-manage.js +1 -1
  68. package/src/lib/server/campaign-manage.js.map +1 -1
  69. package/src/lib/server/campaign-org-refs.js +1 -1
  70. package/src/lib/server/campaign-org-refs.js.map +1 -1
  71. package/src/lib/server/campaign-recipients.js +7 -7
  72. package/src/lib/server/campaign-recipients.js.map +1 -1
  73. package/src/lib/server/campaign-send.js +25 -19
  74. package/src/lib/server/campaign-send.js.map +1 -1
  75. package/src/lib/server/conversion-creditor.d.ts +18 -0
  76. package/src/lib/server/conversion-creditor.js +131 -0
  77. package/src/lib/server/conversion-creditor.js.map +1 -0
  78. package/src/lib/server/email-campaign-touch.d.ts +81 -0
  79. package/src/lib/server/email-campaign-touch.js +227 -0
  80. package/src/lib/server/email-campaign-touch.js.map +1 -0
  81. package/src/lib/server/email-events.js +8 -6
  82. package/src/lib/server/email-events.js.map +1 -1
  83. package/src/lib/server/email-revenue-attribution.d.ts +186 -0
  84. package/src/lib/server/email-revenue-attribution.js +322 -0
  85. package/src/lib/server/email-revenue-attribution.js.map +1 -0
  86. package/src/lib/server/email-send-record-index.d.ts +34 -0
  87. package/src/lib/server/email-send-record-index.js +90 -0
  88. package/src/lib/server/email-send-record-index.js.map +1 -0
  89. package/src/lib/server/get-client-automations.js.map +1 -1
  90. package/src/lib/server/marketing-figures.js +3 -3
  91. package/src/lib/server/marketing-figures.js.map +1 -1
  92. package/src/lib/server/send-tally.d.ts +32 -0
  93. package/src/lib/server/send-tally.js +54 -0
  94. package/src/lib/server/send-tally.js.map +1 -0
  95. package/src/lib/testing/stand-in-person-records.d.ts +35 -0
  96. package/src/lib/testing/stand-in-person-records.js +67 -0
  97. package/src/lib/testing/stand-in-person-records.js.map +1 -0
  98. package/src/lib/testing/stand-in-record-counts.d.ts +26 -0
  99. package/src/lib/testing/stand-in-record-counts.js +70 -0
  100. package/src/lib/testing/stand-in-record-counts.js.map +1 -0
  101. package/src/lib/transfer/campaigns-package.d.ts +72 -0
  102. package/src/lib/transfer/campaigns-package.js +145 -0
  103. package/src/lib/transfer/campaigns-package.js.map +1 -0
  104. package/src/lib/transfer/campaigns-package.server.d.ts +33 -0
  105. package/src/lib/transfer/campaigns-package.server.js +305 -0
  106. package/src/lib/transfer/campaigns-package.server.js.map +1 -0
@@ -0,0 +1,503 @@
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
+ * A CAMPAIGN IS A CONTAINER; A SEND IS ONE MESSAGE INSIDE IT.
19
+ *
20
+ * ## Both belong to the ORGANIZATION (AGL-3273)
21
+ *
22
+ * `orgs/{orgId}/emailCampaigns/{campaignId}` holds a CONTAINER: a name, a
23
+ * window, the lists it aims at. `orgs/{orgId}/campaigns/{sendId}` holds a
24
+ * SEND: one subject, one body, one audience, one set of counters.
25
+ *
26
+ * They lived under one site (`hosts/{hostId}/…`) until the audience side —
27
+ * contacts, lists, topics, sending domains — had long since become the
28
+ * org's. An organization's outbound sequences then had to be filed under a
29
+ * campaign that belonged to its marketing site, and the org-level Leads page
30
+ * could not name a campaign at all. Moving both up is what lets one campaign
31
+ * coordinate a push across every site, a sequence and the leads it produced.
32
+ *
33
+ * ## What stays a SITE fact, and where it is recorded
34
+ *
35
+ * A campaign's reach is {@link EmailCampaign.visibleTo}, the same scope
36
+ * tokens every org-owned resource carries: `['org']` is every site, and
37
+ * `['host:{id}', …]` is the sites it is placed on. It decides which sites'
38
+ * pickers offer the campaign and which scoped collaborators can read it —
39
+ * nothing else, because membership stays a field on the member.
40
+ *
41
+ * A send, by contrast, is always sent AS one site: that site's sender, the
42
+ * consent the person gave that site, the site's unsubscribe signature and
43
+ * its designs. {@link CAMPAIGN_SEND_HOST_FIELD} records which, and the
44
+ * send's `visibleTo` is that one site's token.
45
+ *
46
+ * ## Ids did not change, and that is load-bearing
47
+ *
48
+ * Every unsubscribe link that has ever gone out carries `hostId` and
49
+ * `cid={sendId}`, both inside the link's HMAC, and those emails sit in
50
+ * inboxes forever. So the move copied every document under its existing id,
51
+ * and every reader that starts from a link resolves the send through the
52
+ * site's org (`resolveCampaignSendRef` in the Marketing plugin), with the
53
+ * old site path as the fallback for a document the migration has not reached.
54
+ * A member's `campaignIds` names container ids, which likewise never moved.
55
+ *
56
+ * A send written before containers existed carries no
57
+ * {@link CAMPAIGN_SEND_CONTAINER_FIELD}, and {@link campaignListRows}
58
+ * presents it as a container of one rather than hiding it.
59
+ *
60
+ * ## Why the arithmetic is here
61
+ *
62
+ * The same reason `campaign-report.ts` gives for the per-send rates: a
63
+ * denominator chosen in JSX is a denominator nobody tests. Aggregating across
64
+ * sends adds one problem the single-send report does not have — some sends
65
+ * recorded a field and others never did — and summing those into one number
66
+ * silently reports a partial total as a complete one. Every aggregate here
67
+ * therefore reports how many sends it could measure.
68
+ */
69
+ import { type ListUnsubscribeForcedReason } from '@aglyn/shared-util-email/list-unsubscribe';
70
+ import { type SendRate, type SendStats } from '@aglyn/shared-ui-email-campaigns/model/send-report';
71
+ /** The org collection holding campaign CONTAINERS. */
72
+ export declare const EMAIL_CAMPAIGNS_COLLECTION = "emailCampaigns";
73
+ /** The org collection holding SENDS — one document per email. */
74
+ export declare const CAMPAIGN_SENDS_COLLECTION = "campaigns";
75
+ /** `orgs/{orgId}/emailCampaigns`, as path segments. */
76
+ export declare function orgEmailCampaignsPath(orgId: string): [string, string, string];
77
+ /** `orgs/{orgId}/campaigns`, as path segments. */
78
+ export declare function orgCampaignSendsPath(orgId: string): [string, string, string];
79
+ /** `orgs/{orgId}/campaignSequenceReports`, as path segments. */
80
+ export declare function orgCampaignSequenceReportsPath(orgId: string): [string, string, string];
81
+ /**
82
+ * The field on a SEND naming the site it is sent AS.
83
+ *
84
+ * Required on every send written since AGL-3273. The sender, the consent
85
+ * group, the unsubscribe signature, the designs and the experiments all
86
+ * belong to that site, so a send with no site cannot be delivered.
87
+ */
88
+ export declare const CAMPAIGN_SEND_HOST_FIELD = "hostId";
89
+ /** The scope token naming every site in the org, as `scope-tokens.ts` spells it. */
90
+ export declare const CAMPAIGN_ORG_SCOPE = "org";
91
+ /** `host:{hostId}`, the scope token naming one site. */
92
+ export declare function campaignHostScope(hostId: string): string;
93
+ /**
94
+ * The `visibleTo` a send is stamped with: the one site it sends as.
95
+ *
96
+ * Not `['org']`. An org-wide member reads every send regardless, and a
97
+ * collaborator invited to a sibling site has no business reading who this
98
+ * site mailed.
99
+ */
100
+ export declare function campaignSendVisibleTo(hostId: string): string[];
101
+ /**
102
+ * The sites a container is placed on, or `null` when it is on every site.
103
+ *
104
+ * An absent or empty `visibleTo` reads as NO sites, for the reason
105
+ * `visibleToHost` in `scope-tokens.ts` gives: a document nobody scoped is
106
+ * not the same fact as one scoped to everyone, and hiding is the
107
+ * recoverable direction.
108
+ */
109
+ export declare function campaignSiteIds(campaign: Pick<EmailCampaign, 'visibleTo'> | null | undefined): string[] | null;
110
+ /** Whether a container is offered on this site. */
111
+ export declare function campaignPlacedOnHost(campaign: Pick<EmailCampaign, 'visibleTo'> | null | undefined, hostId: string): boolean;
112
+ /**
113
+ * The `visibleTo` a container is stamped with, from a site selection.
114
+ *
115
+ * `null` (or an empty selection) is every site. A selection is deduped and
116
+ * capped at thirty, the ceiling `array-contains-any` and the rules' `hasAny`
117
+ * both impose.
118
+ */
119
+ export declare function campaignVisibleTo(siteIds: readonly string[] | null): string[];
120
+ /**
121
+ * The field on a SEND naming the campaign it belongs to.
122
+ *
123
+ * Not `campaignId`: on a send document that name already means the send's own
124
+ * id — it is what `cid` carries and what the report route addresses — and one
125
+ * word meaning both would be read into the other on the first edit.
126
+ */
127
+ export declare const CAMPAIGN_SEND_CONTAINER_FIELD = "emailCampaignId";
128
+ /**
129
+ * A campaign: the container, not a message.
130
+ *
131
+ * Stored at `orgs/{orgId}/emailCampaigns/{campaignId}`.
132
+ */
133
+ export interface EmailCampaign {
134
+ $id: string;
135
+ /** What the merchant called it. */
136
+ name: string;
137
+ /** When the campaign window opens. Null for a campaign with no dates. */
138
+ startAtMs?: number | null;
139
+ /** When it closes. Null for an open-ended campaign. */
140
+ endAtMs?: number | null;
141
+ /** Org email lists this campaign is aimed at, by list id. */
142
+ listIds?: string[];
143
+ /**
144
+ * The stream this campaign's emails open on.
145
+ *
146
+ * A DEFAULT, not a constraint. The topic decides who a send skips, what the
147
+ * preference page linked from the footer highlights, and which stream a
148
+ * resulting opt-out is recorded against — all facts about one MESSAGE, and
149
+ * one campaign may legitimately carry a newsletter and a promotion. So the
150
+ * composer's picker is what the send records; this is what the picker opens
151
+ * on, which is what stops a "Sales" campaign quietly mailing under
152
+ * `marketing`.
153
+ */
154
+ topicId?: string;
155
+ /**
156
+ * The sites the campaign is placed on, as scope tokens: `['org']` for
157
+ * every site, `['host:{id}', …]` for a set. See {@link campaignSiteIds}.
158
+ */
159
+ visibleTo?: string[];
160
+ /**
161
+ * Whether its emails carry `List-Unsubscribe` and `List-Unsubscribe-Post`
162
+ * (RFC 8058), the header mail clients draw their own Unsubscribe button
163
+ * from (AGL-3307). ON unless stored `false` — absent on every campaign
164
+ * written before the setting existed, which read as on because that is
165
+ * what they always did. Read it through {@link campaignListUnsubscribe}.
166
+ *
167
+ * Off is overridden per send by the bulk guard; see
168
+ * {@link CampaignListUnsubscribeRecord}. The footer's unsubscribe link is
169
+ * in every email either way.
170
+ */
171
+ listUnsubscribe?: boolean;
172
+ createdAtMs?: number;
173
+ createdBy?: string;
174
+ deletedAt?: unknown;
175
+ }
176
+ /**
177
+ * Whether a campaign's emails carry the mail-client unsubscribe button:
178
+ * the shared reader sequences use, with a campaign's default of ON.
179
+ */
180
+ export declare function campaignListUnsubscribe(campaign: Pick<EmailCampaign, 'listUnsubscribe'> | null | undefined): boolean;
181
+ /**
182
+ * What one SEND did with the List-Unsubscribe header, written by the
183
+ * campaign sender on every batch (AGL-3307).
184
+ */
185
+ export interface CampaignListUnsubscribeRecord {
186
+ /** The campaign's setting when it went out. */
187
+ requested?: boolean;
188
+ /** Whether the header was on the messages. */
189
+ on?: boolean;
190
+ /** True when the setting said off and the bulk guard turned it back on. */
191
+ forced?: boolean;
192
+ reason?: ListUnsubscribeForcedReason;
193
+ /** One sentence saying why, for the campaign page. */
194
+ detail?: string;
195
+ /** Campaign messages in the day, this email included, it was judged on. */
196
+ volume?: number;
197
+ /** The threshold it was judged against. */
198
+ threshold?: number;
199
+ atMs?: number;
200
+ }
201
+ /** The sends among these whose header the bulk guard forced back on. */
202
+ export declare function campaignForcedListUnsubscribeSends<T extends Pick<CampaignSend, 'listUnsubscribe'>>(sends: readonly T[] | null | undefined): T[];
203
+ /** One send, as much of it as a list or a rollup needs. */
204
+ export interface CampaignSend {
205
+ $id: string;
206
+ subject?: string;
207
+ /** The audience KIND: `'leads'`, `'members'`, `'segment'`, `'list'`. */
208
+ audience?: string;
209
+ /** The list this send addressed, when the kind is `'list'`. */
210
+ listId?: string;
211
+ /** The segment this send addressed, when the kind is `'segment'`. */
212
+ segmentId?: string;
213
+ /** Which container it belongs to, absent on a send written before them. */
214
+ emailCampaignId?: string;
215
+ /** The site it is sent as. See {@link CAMPAIGN_SEND_HOST_FIELD}. */
216
+ hostId?: string;
217
+ status?: string;
218
+ sentAt?: {
219
+ seconds?: number;
220
+ } | null;
221
+ sendAtMs?: number;
222
+ /**
223
+ * When the record was minted, stamped by every writer that creates one.
224
+ *
225
+ * Absent on a send written before the stamp existed. The lists that draw
226
+ * drafts beside sends order on it through `emailListTimeMs`, which is why
227
+ * it is here rather than only on the loose record shape: a draft carries
228
+ * neither `sentAt` nor `sendAtMs`, so this is the only time it has.
229
+ */
230
+ createdAtMs?: number;
231
+ stats?: SendStats;
232
+ /** How far a send that goes out over several batches has got. */
233
+ resume?: CampaignResume;
234
+ /** What it did with the List-Unsubscribe header. Absent before AGL-3307. */
235
+ listUnsubscribe?: CampaignListUnsubscribeRecord;
236
+ /**
237
+ * Staff review of a send the outbound phishing screen held (AGL-3356).
238
+ * Written by the send core, decided only by staff. See
239
+ * {@link campaignSendHeldForReview}.
240
+ */
241
+ staffReview?: CampaignStaffReview;
242
+ }
243
+ /** A held send's review, as the send core and the staff decision write it. */
244
+ export interface CampaignStaffReview {
245
+ /** `held` until staff decide; then `released` or `rejected`. */
246
+ state?: string;
247
+ /** `HS-…`, the reference the merchant quotes to support. */
248
+ reference?: string;
249
+ reviewId?: string;
250
+ heldAtMs?: number;
251
+ }
252
+ /**
253
+ * Is this send waiting on a staff review (AGL-3356)?
254
+ *
255
+ * A held send is STORED as `scheduled`, parked at a send time no processor
256
+ * run reaches, so the review can put it back on the clock without a second
257
+ * record. Every surface that read the status alone therefore called it
258
+ * "Scheduled" and showed a due date in the year 9999 — and offered "Send
259
+ * now", "Reschedule" and the composer, none of which a merchant may use to
260
+ * release it. This is the one test, so no surface restates it.
261
+ */
262
+ export declare function campaignSendHeldForReview(send: Pick<CampaignSend, 'status' | 'staffReview'> | null | undefined): boolean;
263
+ /**
264
+ * What a held send's surfaces tell the merchant: that it is held, why in
265
+ * general terms, that they cannot release it, and what happens next. One
266
+ * sentence set, so the list, the page and the composer agree.
267
+ */
268
+ export declare function campaignHeldForReviewNotice(send: Pick<CampaignSend, 'staffReview'> | null | undefined): string;
269
+ /**
270
+ * The batch state the sender writes on an email that is still going out.
271
+ *
272
+ * Absent on every send that finished in one batch, and on every send that
273
+ * predates batching — which is why {@link campaignSendProgress} treats a
274
+ * missing record as "this is not a batched send" rather than as zero.
275
+ */
276
+ export interface CampaignResume {
277
+ /** People the email has resolved and not yet addressed. */
278
+ remaining?: number;
279
+ /** Batches it has run. */
280
+ batch?: number;
281
+ /** When the next batch may go, ms. Zero when there is not going to be one. */
282
+ nextAtMs?: number;
283
+ /** Why it stopped short, when it did. */
284
+ stop?: string;
285
+ }
286
+ /**
287
+ * WHAT A SEND IS ACTUALLY DOING, for a row that would otherwise lie.
288
+ *
289
+ * An email larger than one send may carry is delivered over several batches,
290
+ * and between them it is stored as `scheduled` — the state the processor
291
+ * claims, and the only one that resumes it without a second index and a
292
+ * second query. Read literally, that is a row saying "not sent yet" about an
293
+ * email that has already put five hundred messages in five hundred inboxes.
294
+ *
295
+ * So the stored fields are not the sentence. This is: it takes the status,
296
+ * the delivered count and the batch record, and answers what a person needs
297
+ * to see. Derived at read time and never persisted, exactly as
298
+ * `campaignWindowState` beside it is, because it is a description of stored
299
+ * facts and not a fact of its own.
300
+ *
301
+ * ## The four states, and which stored shape each one is
302
+ *
303
+ * - `pending` — `scheduled`, nothing delivered. A campaign waiting for its
304
+ * time. This is what `scheduled` meant before batching and still does.
305
+ * - `sending` — `scheduled` or `sending` with something delivered and more to
306
+ * come. The state this function exists for.
307
+ * - `sent` — finished, whether in one batch or six.
308
+ * - `stopped` — finished with people it never addressed: canceled mid-flight,
309
+ * failed mid-flight, or stopped by the batch guard. The count is what makes
310
+ * this legible rather than alarming — an email that reached 2,400 of 3,000
311
+ * and stopped is a different conversation from one that reached nobody.
312
+ */
313
+ export type CampaignSendProgressState = 'pending' | 'sending' | 'sent' | 'stopped';
314
+ export interface CampaignSendProgress {
315
+ state: CampaignSendProgressState;
316
+ /** Messages this email has delivered. */
317
+ reached: number;
318
+ /**
319
+ * The audience it is working through, when one was recorded. Null when the
320
+ * send never recorded an audience size, which is every send that predates
321
+ * the figure — reported as null rather than as `reached` so a surface does
322
+ * not present a floor as a total.
323
+ */
324
+ audience: number | null;
325
+ /** People it has resolved and not yet addressed. */
326
+ remaining: number;
327
+ /** Batches it has run. Zero for a send that never batched. */
328
+ batch: number;
329
+ /** When the next batch may go, ms. Zero unless {@link state} is `sending`. */
330
+ nextAtMs: number;
331
+ /** One line a surface may show verbatim. */
332
+ label: string;
333
+ }
334
+ export declare function campaignSendProgress(send: CampaignSend | null | undefined): CampaignSendProgress;
335
+ /**
336
+ * What a ROW says about one email, in one word and one line.
337
+ *
338
+ * {@link campaignSendProgress} answers what a send is DOING, and a draft is
339
+ * not doing anything: it has no status the progress states cover, and — since
340
+ * an absent status reads as `sent` and a draft has no counters — asking it
341
+ * about one answers "Sent to 0", which is the worst available sentence about
342
+ * an email nobody has written yet. So the draft is settled here and
343
+ * everything else is deferred, unchanged, to the derivation that owns it.
344
+ *
345
+ * One helper rather than the same two-line branch on four surfaces. The
346
+ * campaigns table, the emails list, an email's own page and a campaign's
347
+ * emails table all draw this, and four copies is how three of them come to
348
+ * say "Scheduled" about a campaign that has delivered five hundred messages.
349
+ */
350
+ export type CampaignSendDisplayState = 'draft' | 'held' | CampaignSendProgressState;
351
+ export interface CampaignSendDisplay {
352
+ state: CampaignSendDisplayState;
353
+ /** One line a surface may show verbatim. */
354
+ label: string;
355
+ /** The progress underneath, for a surface that wants the figures. */
356
+ progress: CampaignSendProgress;
357
+ }
358
+ export declare function campaignSendDisplay(send: CampaignSend | null | undefined): CampaignSendDisplay;
359
+ /**
360
+ * Whether this email is between batches, with more of its audience to reach.
361
+ *
362
+ * The one question two controls on an email's page turn on. It is stored as
363
+ * `scheduled` — the state the processor claims — so a surface reading the
364
+ * status alone offers "Send now" on a campaign that is already going out, and
365
+ * `sendNow` re-resolves the WHOLE audience rather than continuing: everyone
366
+ * already reached would receive a second copy under the same `cid`.
367
+ */
368
+ export declare function campaignSendIsMidFlight(send: CampaignSend | null | undefined): boolean;
369
+ /**
370
+ * The lists ONE SEND addressed — which can be narrower than the lists its
371
+ * campaign is aimed at.
372
+ *
373
+ * A campaign holds the lists a merchant plans to reach; each send inside it
374
+ * picks one audience, and that audience may be a segment or the site's leads
375
+ * rather than any of them. Answering from the send is therefore the only
376
+ * honest answer for a send's own detail page.
377
+ *
378
+ * An array for a document that stores one id, deliberately: the question
379
+ * "which lists did this reach" has a plural answer everywhere it is asked,
380
+ * and a caller that unwraps a single id today is a caller to revisit if a
381
+ * send ever addresses two.
382
+ */
383
+ export declare function campaignSendListIds(send: CampaignSend): string[];
384
+ /**
385
+ * A number summed across sends, with how much of the campaign it covers.
386
+ *
387
+ * `value` is `null` when NO send recorded the field — which is not zero, for
388
+ * the reason `campaign-report.ts` gives at length: an unrecorded delivery
389
+ * count and a delivery count of zero lead a merchant to opposite conclusions
390
+ * about their sending domain.
391
+ *
392
+ * `recorded` below `sends` means the total is a floor. A campaign whose
393
+ * older sends predate the delivery webhook has a real number that describes
394
+ * part of itself, and saying which part is the difference between a total and
395
+ * a guess.
396
+ */
397
+ export interface CampaignAggregate {
398
+ value: number | null;
399
+ /** Sends that recorded this field. */
400
+ recorded: number;
401
+ /** Sends in the campaign. */
402
+ sends: number;
403
+ }
404
+ /** Every rolled-up figure for one campaign. */
405
+ export interface CampaignRollup {
406
+ /** Sends that have actually gone out. */
407
+ sends: number;
408
+ /** Sends still waiting for their send time, having delivered nothing. */
409
+ scheduled: number;
410
+ /**
411
+ * Sends part way through an audience larger than one batch.
412
+ *
413
+ * Counted apart from `scheduled` and from `sends`, because it is neither:
414
+ * mail has gone out, and more is going to. Both of those are facts a
415
+ * merchant reading a campaign row needs, and the stored status carries
416
+ * only the first.
417
+ */
418
+ sending: number;
419
+ /**
420
+ * Emails that have been created and not yet written or sent.
421
+ *
422
+ * Counted apart from `sends` and from `scheduled`, because a draft is
423
+ * neither: it has mailed nobody, and it is not on the clock to.
424
+ */
425
+ drafts: number;
426
+ addressed: CampaignAggregate;
427
+ sent: CampaignAggregate;
428
+ delivered: CampaignAggregate;
429
+ opens: CampaignAggregate;
430
+ uniqueOpens: CampaignAggregate;
431
+ clicks: CampaignAggregate;
432
+ uniqueClicks: CampaignAggregate;
433
+ bounced: CampaignAggregate;
434
+ complained: CampaignAggregate;
435
+ unsubscribes: CampaignAggregate;
436
+ /** Distinct openers over delivered, across every send that recorded both. */
437
+ openRate: SendRate | null;
438
+ /** Distinct clickers over delivered. */
439
+ clickRate: SendRate | null;
440
+ /** Unsubscribes over delivered. */
441
+ unsubscribeRate: SendRate | null;
442
+ /** The most recent send time in the campaign, for ordering a list. */
443
+ lastSentAtMs: number | null;
444
+ }
445
+ /** When a send happened, in epoch milliseconds, or null. */
446
+ export declare function campaignSendAtMs(send: CampaignSend): number | null;
447
+ /**
448
+ * Rolls a campaign's sends into one set of figures.
449
+ *
450
+ * Rates are taken over the sends that recorded BOTH sides, so a campaign
451
+ * whose first send predates the delivery webhook reports the open rate of the
452
+ * sends that can be measured rather than one deflated by a send with no
453
+ * denominator.
454
+ */
455
+ export declare function campaignRollup(sends: CampaignSend[]): CampaignRollup;
456
+ /**
457
+ * Where a campaign stands against its own window.
458
+ *
459
+ * Derived at read time and never persisted — the `status` values on a SEND
460
+ * (`sent`, `scheduled`, `canceled`, `failed`) are stored strings that a
461
+ * processor branches on, and a display state sharing those spellings would
462
+ * eventually be written back.
463
+ */
464
+ export type CampaignWindowState = 'undated' | 'upcoming' | 'running' | 'ended';
465
+ export declare function campaignWindowState(campaign: Pick<EmailCampaign, 'startAtMs' | 'endAtMs'>, nowMs: number): CampaignWindowState;
466
+ /** One row of the campaigns table. */
467
+ export interface CampaignListRow {
468
+ /** The id the detail route resolves — a container id, or a send id. */
469
+ id: string;
470
+ name: string;
471
+ /**
472
+ * True when this row IS a send with no container.
473
+ *
474
+ * A campaign sent before containers existed is shown as a campaign of one
475
+ * rather than dropped from the list, and the detail route falls back to the
476
+ * send's own report for it. Nothing about that send is rewritten.
477
+ */
478
+ legacy: boolean;
479
+ startAtMs: number | null;
480
+ endAtMs: number | null;
481
+ listIds: string[];
482
+ sends: CampaignSend[];
483
+ rollup: CampaignRollup;
484
+ windowState: CampaignWindowState;
485
+ /** For ordering: the campaign's start, else its most recent send. */
486
+ atMs: number | null;
487
+ }
488
+ /**
489
+ * The campaigns table's rows: every container, plus every send that belongs
490
+ * to none.
491
+ *
492
+ * The second half is what makes the container additive. A merchant's history
493
+ * is the sends they already have; a list that showed only containers would
494
+ * read as an empty product on the day this shipped, and a backfill that
495
+ * adopted each old send into a container of one would rewrite documents whose
496
+ * ids are cited by mail already delivered. Adopting them AT READ TIME costs a
497
+ * pass over a list already in memory and rewrites nothing.
498
+ *
499
+ * Newest first, on the start date where there is one and the last send
500
+ * otherwise. Rows with no date at all sort last: they are campaigns nobody
501
+ * has scheduled or sent, and dating them from nothing would be an invention.
502
+ */
503
+ export declare function campaignListRows(campaigns: EmailCampaign[], sends: CampaignSend[], nowMs: number): CampaignListRow[];