@aglyn/shared-ui-email-campaigns 1.0.0-beta.199 → 1.0.0-beta.201

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/shared-ui-email-campaigns",
3
- "version": "1.0.0-beta.199",
3
+ "version": "1.0.0-beta.201",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -29,8 +29,8 @@
29
29
  "./package.json": "./package.json"
30
30
  },
31
31
  "dependencies": {
32
- "@aglyn/shared-ui-jsx": "1.0.0-beta.199",
33
- "@aglyn/shared-util-email": "1.0.0-beta.199",
32
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.201",
33
+ "@aglyn/shared-util-email": "1.0.0-beta.201",
34
34
  "@swc/helpers": "0.5.23"
35
35
  },
36
36
  "peerDependencies": {
@@ -233,7 +233,39 @@ export interface CampaignSend {
233
233
  resume?: CampaignResume;
234
234
  /** What it did with the List-Unsubscribe header. Absent before AGL-3307. */
235
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;
236
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;
237
269
  /**
238
270
  * The batch state the sender writes on an email that is still going out.
239
271
  *
@@ -315,7 +347,7 @@ export declare function campaignSendProgress(send: CampaignSend | null | undefin
315
347
  * emails table all draw this, and four copies is how three of them come to
316
348
  * say "Scheduled" about a campaign that has delivered five hundred messages.
317
349
  */
318
- export type CampaignSendDisplayState = 'draft' | CampaignSendProgressState;
350
+ export type CampaignSendDisplayState = 'draft' | 'held' | CampaignSendProgressState;
319
351
  export interface CampaignSendDisplay {
320
352
  state: CampaignSendDisplayState;
321
353
  /** One line a surface may show verbatim. */
@@ -168,6 +168,30 @@ import { CAMPAIGN_SEQUENCE_REPORTS_COLLECTION, campaignRate } from "./campaign-r
168
168
  return (send == null ? void 0 : (_send_listUnsubscribe = send.listUnsubscribe) == null ? void 0 : _send_listUnsubscribe.forced) === true;
169
169
  });
170
170
  }
171
+ /**
172
+ * Is this send waiting on a staff review (AGL-3356)?
173
+ *
174
+ * A held send is STORED as `scheduled`, parked at a send time no processor
175
+ * run reaches, so the review can put it back on the clock without a second
176
+ * record. Every surface that read the status alone therefore called it
177
+ * "Scheduled" and showed a due date in the year 9999 — and offered "Send
178
+ * now", "Reschedule" and the composer, none of which a merchant may use to
179
+ * release it. This is the one test, so no surface restates it.
180
+ */ export function campaignSendHeldForReview(send) {
181
+ var _ref;
182
+ var _send_staffReview;
183
+ const status = String((_ref = send == null ? void 0 : send.status) != null ? _ref : '');
184
+ return (send == null ? void 0 : (_send_staffReview = send.staffReview) == null ? void 0 : _send_staffReview.state) === 'held' && (status === 'scheduled' || status === 'draft');
185
+ }
186
+ /**
187
+ * What a held send's surfaces tell the merchant: that it is held, why in
188
+ * general terms, that they cannot release it, and what happens next. One
189
+ * sentence set, so the list, the page and the composer agree.
190
+ */ export function campaignHeldForReviewNotice(send) {
191
+ var _send_staffReview;
192
+ const reference = send == null ? void 0 : (_send_staffReview = send.staffReview) == null ? void 0 : _send_staffReview.reference;
193
+ return 'This email is held for review before it sends. New workspaces have ' + 'their first emails checked for impersonation of other businesses, and ' + 'this one needs a person to look at it. It cannot be released, ' + 'rescheduled or edited from here — our team reviews it, and it sends ' + 'automatically if it is approved or is canceled if it is not. ' + 'Nothing has been sent or counted.' + (reference ? ` Quote ${reference} if you contact support.` : '');
194
+ }
171
195
  /** A stored count as a non-negative integer, or 0. */ function progressCount(raw) {
172
196
  const value = Math.floor(Number(raw));
173
197
  return Number.isFinite(value) && value > 0 ? value : 0;
@@ -236,6 +260,14 @@ export function campaignSendProgress(send) {
236
260
  export function campaignSendDisplay(send) {
237
261
  var _ref;
238
262
  const progress = campaignSendProgress(send);
263
+ // Before the draft test: a held send may be stored as either (AGL-3356).
264
+ if (campaignSendHeldForReview(send)) {
265
+ return {
266
+ state: 'held',
267
+ label: 'Held for review',
268
+ progress
269
+ };
270
+ }
239
271
  if (String((_ref = send == null ? void 0 : send.status) != null ? _ref : '') === 'draft') {
240
272
  return {
241
273
  state: 'draft',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/campaign-container.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A CAMPAIGN IS A CONTAINER; A SEND IS ONE MESSAGE INSIDE IT.\n *\n * ## Both belong to the ORGANIZATION (AGL-3273)\n *\n * `orgs/{orgId}/emailCampaigns/{campaignId}` holds a CONTAINER: a name, a\n * window, the lists it aims at. `orgs/{orgId}/campaigns/{sendId}` holds a\n * SEND: one subject, one body, one audience, one set of counters.\n *\n * They lived under one site (`hosts/{hostId}/…`) until the audience side —\n * contacts, lists, topics, sending domains — had long since become the\n * org's. An organization's outbound sequences then had to be filed under a\n * campaign that belonged to its marketing site, and the org-level Leads page\n * could not name a campaign at all. Moving both up is what lets one campaign\n * coordinate a push across every site, a sequence and the leads it produced.\n *\n * ## What stays a SITE fact, and where it is recorded\n *\n * A campaign's reach is {@link EmailCampaign.visibleTo}, the same scope\n * tokens every org-owned resource carries: `['org']` is every site, and\n * `['host:{id}', …]` is the sites it is placed on. It decides which sites'\n * pickers offer the campaign and which scoped collaborators can read it —\n * nothing else, because membership stays a field on the member.\n *\n * A send, by contrast, is always sent AS one site: that site's sender, the\n * consent the person gave that site, the site's unsubscribe signature and\n * its designs. {@link CAMPAIGN_SEND_HOST_FIELD} records which, and the\n * send's `visibleTo` is that one site's token.\n *\n * ## Ids did not change, and that is load-bearing\n *\n * Every unsubscribe link that has ever gone out carries `hostId` and\n * `cid={sendId}`, both inside the link's HMAC, and those emails sit in\n * inboxes forever. So the move copied every document under its existing id,\n * and every reader that starts from a link resolves the send through the\n * site's org (`resolveCampaignSendRef` in `@aglyn/tenant-data-admin`), with the\n * old site path as the fallback for a document the migration has not reached.\n * A member's `campaignIds` names container ids, which likewise never moved.\n *\n * A send written before containers existed carries no\n * {@link CAMPAIGN_SEND_CONTAINER_FIELD}, and {@link campaignListRows}\n * presents it as a container of one rather than hiding it.\n *\n * ## Why the arithmetic is here\n *\n * The same reason `campaign-report.ts` gives for the per-send rates: a\n * denominator chosen in JSX is a denominator nobody tests. Aggregating across\n * sends adds one problem the single-send report does not have — some sends\n * recorded a field and others never did — and summing those into one number\n * silently reports a partial total as a complete one. Every aggregate here\n * therefore reports how many sends it could measure.\n */\n\nimport {\n readListUnsubscribeSetting,\n type ListUnsubscribeForcedReason,\n} from '@aglyn/shared-util-email/list-unsubscribe'\nimport {\n CAMPAIGN_SEQUENCE_REPORTS_COLLECTION,\n campaignRate,\n type CampaignRate,\n type CampaignStats,\n} from './campaign-report'\n\n/** The org collection holding campaign CONTAINERS. */\nexport const EMAIL_CAMPAIGNS_COLLECTION = 'emailCampaigns'\n\n/** The org collection holding SENDS — one document per email. */\nexport const CAMPAIGN_SENDS_COLLECTION = 'campaigns'\n\n/** `orgs/{orgId}/emailCampaigns`, as path segments. */\nexport function orgEmailCampaignsPath(orgId: string): [string, string, string] {\n return ['orgs', orgId, EMAIL_CAMPAIGNS_COLLECTION]\n}\n\n/** `orgs/{orgId}/campaigns`, as path segments. */\nexport function orgCampaignSendsPath(orgId: string): [string, string, string] {\n return ['orgs', orgId, CAMPAIGN_SENDS_COLLECTION]\n}\n\n/** `orgs/{orgId}/campaignSequenceReports`, as path segments. */\nexport function orgCampaignSequenceReportsPath(\n orgId: string,\n): [string, string, string] {\n return ['orgs', orgId, CAMPAIGN_SEQUENCE_REPORTS_COLLECTION]\n}\n\n/**\n * The field on a SEND naming the site it is sent AS.\n *\n * Required on every send written since AGL-3273. The sender, the consent\n * group, the unsubscribe signature, the designs and the experiments all\n * belong to that site, so a send with no site cannot be delivered.\n */\nexport const CAMPAIGN_SEND_HOST_FIELD = 'hostId'\n\n/** The scope token naming every site in the org, as `scope-tokens.ts` spells it. */\nexport const CAMPAIGN_ORG_SCOPE = 'org'\n\n/** `host:{hostId}`, the scope token naming one site. */\nexport function campaignHostScope(hostId: string): string {\n return `host:${hostId}`\n}\n\n/**\n * The `visibleTo` a send is stamped with: the one site it sends as.\n *\n * Not `['org']`. An org-wide member reads every send regardless, and a\n * collaborator invited to a sibling site has no business reading who this\n * site mailed.\n */\nexport function campaignSendVisibleTo(hostId: string): string[] {\n return [campaignHostScope(hostId)]\n}\n\n/**\n * The sites a container is placed on, or `null` when it is on every site.\n *\n * An absent or empty `visibleTo` reads as NO sites, for the reason\n * `visibleToHost` in `scope-tokens.ts` gives: a document nobody scoped is\n * not the same fact as one scoped to everyone, and hiding is the\n * recoverable direction.\n */\nexport function campaignSiteIds(\n campaign: Pick<EmailCampaign, 'visibleTo'> | null | undefined,\n): string[] | null {\n const tokens = campaign?.visibleTo ?? []\n if (tokens.includes(CAMPAIGN_ORG_SCOPE)) return null\n const ids: string[] = []\n for (const token of tokens) {\n if (typeof token === 'string' && token.startsWith('host:') && token.length > 5) {\n ids.push(token.slice(5))\n }\n }\n return ids\n}\n\n/** Whether a container is offered on this site. */\nexport function campaignPlacedOnHost(\n campaign: Pick<EmailCampaign, 'visibleTo'> | null | undefined,\n hostId: string,\n): boolean {\n const ids = campaignSiteIds(campaign)\n return ids === null || ids.includes(hostId)\n}\n\n/**\n * The `visibleTo` a container is stamped with, from a site selection.\n *\n * `null` (or an empty selection) is every site. A selection is deduped and\n * capped at thirty, the ceiling `array-contains-any` and the rules' `hasAny`\n * both impose.\n */\nexport function campaignVisibleTo(siteIds: readonly string[] | null): string[] {\n if (!siteIds || siteIds.length === 0) return [CAMPAIGN_ORG_SCOPE]\n const unique = [...new Set(siteIds.filter((id) => typeof id === 'string' && id))]\n return unique.slice(0, 30).map(campaignHostScope)\n}\n\n/**\n * The field on a SEND naming the campaign it belongs to.\n *\n * Not `campaignId`: on a send document that name already means the send's own\n * id — it is what `cid` carries and what the report route addresses — and one\n * word meaning both would be read into the other on the first edit.\n */\nexport const CAMPAIGN_SEND_CONTAINER_FIELD = 'emailCampaignId'\n\n/**\n * A campaign: the container, not a message.\n *\n * Stored at `orgs/{orgId}/emailCampaigns/{campaignId}`.\n */\nexport interface EmailCampaign {\n $id: string\n /** What the merchant called it. */\n name: string\n /** When the campaign window opens. Null for a campaign with no dates. */\n startAtMs?: number | null\n /** When it closes. Null for an open-ended campaign. */\n endAtMs?: number | null\n /** Org email lists this campaign is aimed at, by list id. */\n listIds?: string[]\n /**\n * The stream this campaign's emails open on.\n *\n * A DEFAULT, not a constraint. The topic decides who a send skips, what the\n * preference page linked from the footer highlights, and which stream a\n * resulting opt-out is recorded against — all facts about one MESSAGE, and\n * one campaign may legitimately carry a newsletter and a promotion. So the\n * composer's picker is what the send records; this is what the picker opens\n * on, which is what stops a \"Sales\" campaign quietly mailing under\n * `marketing`.\n */\n topicId?: string\n /**\n * The sites the campaign is placed on, as scope tokens: `['org']` for\n * every site, `['host:{id}', …]` for a set. See {@link campaignSiteIds}.\n */\n visibleTo?: string[]\n /**\n * Whether its emails carry `List-Unsubscribe` and `List-Unsubscribe-Post`\n * (RFC 8058), the header mail clients draw their own Unsubscribe button\n * from (AGL-3307). ON unless stored `false` — absent on every campaign\n * written before the setting existed, which read as on because that is\n * what they always did. Read it through {@link campaignListUnsubscribe}.\n *\n * Off is overridden per send by the bulk guard; see\n * {@link CampaignListUnsubscribeRecord}. The footer's unsubscribe link is\n * in every email either way.\n */\n listUnsubscribe?: boolean\n createdAtMs?: number\n createdBy?: string\n deletedAt?: unknown\n}\n\n/**\n * Whether a campaign's emails carry the mail-client unsubscribe button:\n * the shared reader sequences use, with a campaign's default of ON.\n */\nexport function campaignListUnsubscribe(\n campaign: Pick<EmailCampaign, 'listUnsubscribe'> | null | undefined,\n): boolean {\n return readListUnsubscribeSetting(campaign?.listUnsubscribe, true)\n}\n\n/**\n * What one SEND did with the List-Unsubscribe header, written by the\n * campaign sender on every batch (AGL-3307).\n */\nexport interface CampaignListUnsubscribeRecord {\n /** The campaign's setting when it went out. */\n requested?: boolean\n /** Whether the header was on the messages. */\n on?: boolean\n /** True when the setting said off and the bulk guard turned it back on. */\n forced?: boolean\n reason?: ListUnsubscribeForcedReason\n /** One sentence saying why, for the campaign page. */\n detail?: string\n /** Campaign messages in the day, this email included, it was judged on. */\n volume?: number\n /** The threshold it was judged against. */\n threshold?: number\n atMs?: number\n}\n\n/** The sends among these whose header the bulk guard forced back on. */\nexport function campaignForcedListUnsubscribeSends<\n T extends Pick<CampaignSend, 'listUnsubscribe'>,\n>(sends: readonly T[] | null | undefined): T[] {\n return (sends ?? []).filter((send) => send?.listUnsubscribe?.forced === true)\n}\n\n/** One send, as much of it as a list or a rollup needs. */\nexport interface CampaignSend {\n $id: string\n subject?: string\n /** The audience KIND: `'leads'`, `'members'`, `'segment'`, `'list'`. */\n audience?: string\n /** The list this send addressed, when the kind is `'list'`. */\n listId?: string\n /** The segment this send addressed, when the kind is `'segment'`. */\n segmentId?: string\n /** Which container it belongs to, absent on a send written before them. */\n emailCampaignId?: string\n /** The site it is sent as. See {@link CAMPAIGN_SEND_HOST_FIELD}. */\n hostId?: string\n status?: string\n sentAt?: { seconds?: number } | null\n sendAtMs?: number\n /**\n * When the record was minted, stamped by every writer that creates one.\n *\n * Absent on a send written before the stamp existed. The lists that draw\n * drafts beside sends order on it through `emailListTimeMs`, which is why\n * it is here rather than only on the loose record shape: a draft carries\n * neither `sentAt` nor `sendAtMs`, so this is the only time it has.\n */\n createdAtMs?: number\n stats?: CampaignStats\n /** How far a send that goes out over several batches has got. */\n resume?: CampaignResume\n /** What it did with the List-Unsubscribe header. Absent before AGL-3307. */\n listUnsubscribe?: CampaignListUnsubscribeRecord\n}\n\n/**\n * The batch state the sender writes on an email that is still going out.\n *\n * Absent on every send that finished in one batch, and on every send that\n * predates batching — which is why {@link campaignSendProgress} treats a\n * missing record as \"this is not a batched send\" rather than as zero.\n */\nexport interface CampaignResume {\n /** People the email has resolved and not yet addressed. */\n remaining?: number\n /** Batches it has run. */\n batch?: number\n /** When the next batch may go, ms. Zero when there is not going to be one. */\n nextAtMs?: number\n /** Why it stopped short, when it did. */\n stop?: string\n}\n\n/**\n * WHAT A SEND IS ACTUALLY DOING, for a row that would otherwise lie.\n *\n * An email larger than one send may carry is delivered over several batches,\n * and between them it is stored as `scheduled` — the state the processor\n * claims, and the only one that resumes it without a second index and a\n * second query. Read literally, that is a row saying \"not sent yet\" about an\n * email that has already put five hundred messages in five hundred inboxes.\n *\n * So the stored fields are not the sentence. This is: it takes the status,\n * the delivered count and the batch record, and answers what a person needs\n * to see. Derived at read time and never persisted, exactly as\n * `campaignWindowState` beside it is, because it is a description of stored\n * facts and not a fact of its own.\n *\n * ## The four states, and which stored shape each one is\n *\n * - `pending` — `scheduled`, nothing delivered. A campaign waiting for its\n * time. This is what `scheduled` meant before batching and still does.\n * - `sending` — `scheduled` or `sending` with something delivered and more to\n * come. The state this function exists for.\n * - `sent` — finished, whether in one batch or six.\n * - `stopped` — finished with people it never addressed: canceled mid-flight,\n * failed mid-flight, or stopped by the batch guard. The count is what makes\n * this legible rather than alarming — an email that reached 2,400 of 3,000\n * and stopped is a different conversation from one that reached nobody.\n */\nexport type CampaignSendProgressState =\n | 'pending'\n | 'sending'\n | 'sent'\n | 'stopped'\n\nexport interface CampaignSendProgress {\n state: CampaignSendProgressState\n /** Messages this email has delivered. */\n reached: number\n /**\n * The audience it is working through, when one was recorded. Null when the\n * send never recorded an audience size, which is every send that predates\n * the figure — reported as null rather than as `reached` so a surface does\n * not present a floor as a total.\n */\n audience: number | null\n /** People it has resolved and not yet addressed. */\n remaining: number\n /** Batches it has run. Zero for a send that never batched. */\n batch: number\n /** When the next batch may go, ms. Zero unless {@link state} is `sending`. */\n nextAtMs: number\n /** One line a surface may show verbatim. */\n label: string\n}\n\n/** A stored count as a non-negative integer, or 0. */\nfunction progressCount(raw: unknown): number {\n const value = Math.floor(Number(raw))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\nexport function campaignSendProgress(\n send: CampaignSend | null | undefined,\n): CampaignSendProgress {\n const status = String(send?.status ?? 'sent')\n const reached = progressCount(send?.stats?.sent)\n const rawAudience = send?.stats?.audienceSize\n const audience =\n rawAudience === undefined || rawAudience === null\n ? null\n : progressCount(rawAudience)\n const resume = send?.resume\n const remaining = progressCount(resume?.remaining)\n const batch = progressCount(resume?.batch)\n const nextAtMs = progressCount(resume?.nextAtMs)\n const of = audience !== null ? ` of ${audience.toLocaleString()}` : ''\n\n // Still going: more to address, and a run that will address it. A campaign\n // a merchant CANCELED is not still going however much is left, which is why\n // the status is read before the remainder.\n if (\n remaining > 0 &&\n nextAtMs > 0 &&\n (status === 'scheduled' || status === 'sending')\n ) {\n return {\n state: 'sending',\n reached,\n audience,\n remaining,\n batch,\n nextAtMs,\n label: `Sending — reached ${reached.toLocaleString()}${of}`,\n }\n }\n // Waiting for its time, with nothing delivered. The pre-batching meaning of\n // `scheduled`, and still the common one.\n if ((status === 'scheduled' || status === 'sending') && reached === 0) {\n return {\n state: 'pending',\n reached: 0,\n audience,\n remaining,\n batch,\n nextAtMs: 0,\n label: status === 'sending' ? 'Sending' : 'Scheduled',\n }\n }\n if (remaining > 0) {\n const why =\n status === 'canceled'\n ? 'canceled'\n : status === 'failed'\n ? 'stopped by an error'\n : 'stopped'\n return {\n state: 'stopped',\n reached,\n audience,\n remaining,\n batch,\n nextAtMs: 0,\n label:\n `Reached ${reached.toLocaleString()}${of} — ${why} with ` +\n `${remaining.toLocaleString()} not addressed`,\n }\n }\n return {\n state: 'sent',\n reached,\n audience,\n remaining: 0,\n batch,\n nextAtMs: 0,\n label:\n batch > 1\n ? `Sent to ${reached.toLocaleString()}${of} over ${batch} runs`\n : `Sent to ${reached.toLocaleString()}${of}`,\n }\n}\n\n/**\n * What a ROW says about one email, in one word and one line.\n *\n * {@link campaignSendProgress} answers what a send is DOING, and a draft is\n * not doing anything: it has no status the progress states cover, and — since\n * an absent status reads as `sent` and a draft has no counters — asking it\n * about one answers \"Sent to 0\", which is the worst available sentence about\n * an email nobody has written yet. So the draft is settled here and\n * everything else is deferred, unchanged, to the derivation that owns it.\n *\n * One helper rather than the same two-line branch on four surfaces. The\n * campaigns table, the emails list, an email's own page and a campaign's\n * emails table all draw this, and four copies is how three of them come to\n * say \"Scheduled\" about a campaign that has delivered five hundred messages.\n */\nexport type CampaignSendDisplayState = 'draft' | CampaignSendProgressState\n\nexport interface CampaignSendDisplay {\n state: CampaignSendDisplayState\n /** One line a surface may show verbatim. */\n label: string\n /** The progress underneath, for a surface that wants the figures. */\n progress: CampaignSendProgress\n}\n\nexport function campaignSendDisplay(\n send: CampaignSend | null | undefined,\n): CampaignSendDisplay {\n const progress = campaignSendProgress(send)\n if (String(send?.status ?? '') === 'draft') {\n return { state: 'draft', label: 'Draft', progress }\n }\n return { state: progress.state, label: progress.label, progress }\n}\n\n/**\n * Whether this email is between batches, with more of its audience to reach.\n *\n * The one question two controls on an email's page turn on. It is stored as\n * `scheduled` — the state the processor claims — so a surface reading the\n * status alone offers \"Send now\" on a campaign that is already going out, and\n * `sendNow` re-resolves the WHOLE audience rather than continuing: everyone\n * already reached would receive a second copy under the same `cid`.\n */\nexport function campaignSendIsMidFlight(\n send: CampaignSend | null | undefined,\n): boolean {\n return campaignSendProgress(send).state === 'sending'\n}\n\n/**\n * The lists ONE SEND addressed — which can be narrower than the lists its\n * campaign is aimed at.\n *\n * A campaign holds the lists a merchant plans to reach; each send inside it\n * picks one audience, and that audience may be a segment or the site's leads\n * rather than any of them. Answering from the send is therefore the only\n * honest answer for a send's own detail page.\n *\n * An array for a document that stores one id, deliberately: the question\n * \"which lists did this reach\" has a plural answer everywhere it is asked,\n * and a caller that unwraps a single id today is a caller to revisit if a\n * send ever addresses two.\n */\nexport function campaignSendListIds(send: CampaignSend): string[] {\n return send.audience === 'list' && send.listId ? [send.listId] : []\n}\n\n/**\n * A number summed across sends, with how much of the campaign it covers.\n *\n * `value` is `null` when NO send recorded the field — which is not zero, for\n * the reason `campaign-report.ts` gives at length: an unrecorded delivery\n * count and a delivery count of zero lead a merchant to opposite conclusions\n * about their sending domain.\n *\n * `recorded` below `sends` means the total is a floor. A campaign whose\n * older sends predate the delivery webhook has a real number that describes\n * part of itself, and saying which part is the difference between a total and\n * a guess.\n */\nexport interface CampaignAggregate {\n value: number | null\n /** Sends that recorded this field. */\n recorded: number\n /** Sends in the campaign. */\n sends: number\n}\n\n/** Every rolled-up figure for one campaign. */\nexport interface CampaignRollup {\n /** Sends that have actually gone out. */\n sends: number\n /** Sends still waiting for their send time, having delivered nothing. */\n scheduled: number\n /**\n * Sends part way through an audience larger than one batch.\n *\n * Counted apart from `scheduled` and from `sends`, because it is neither:\n * mail has gone out, and more is going to. Both of those are facts a\n * merchant reading a campaign row needs, and the stored status carries\n * only the first.\n */\n sending: number\n /**\n * Emails that have been created and not yet written or sent.\n *\n * Counted apart from `sends` and from `scheduled`, because a draft is\n * neither: it has mailed nobody, and it is not on the clock to.\n */\n drafts: number\n addressed: CampaignAggregate\n sent: CampaignAggregate\n delivered: CampaignAggregate\n opens: CampaignAggregate\n uniqueOpens: CampaignAggregate\n clicks: CampaignAggregate\n uniqueClicks: CampaignAggregate\n bounced: CampaignAggregate\n complained: CampaignAggregate\n unsubscribes: CampaignAggregate\n /** Distinct openers over delivered, across every send that recorded both. */\n openRate: CampaignRate | null\n /** Distinct clickers over delivered. */\n clickRate: CampaignRate | null\n /** Unsubscribes over delivered. */\n unsubscribeRate: CampaignRate | null\n /** The most recent send time in the campaign, for ordering a list. */\n lastSentAtMs: number | null\n}\n\nconst aggregate = (\n sends: CampaignSend[],\n read: (stats: CampaignStats) => number | undefined,\n): CampaignAggregate => {\n let total = 0\n let recorded = 0\n for (const send of sends) {\n const value = read(send.stats ?? {})\n // `undefined` is \"this send never recorded it\"; a recorded 0 counts as\n // measured, and moves `recorded` without moving the total.\n if (value === undefined || value === null) continue\n const numeric = Number(value)\n if (!Number.isFinite(numeric)) continue\n total += numeric\n recorded += 1\n }\n return { value: recorded ? total : null, recorded, sends: sends.length }\n}\n\n/** When a send happened, in epoch milliseconds, or null. */\nexport function campaignSendAtMs(send: CampaignSend): number | null {\n const seconds = send.sentAt?.seconds\n if (typeof seconds === 'number') return seconds * 1000\n if (typeof send.sendAtMs === 'number') return send.sendAtMs\n return null\n}\n\n/**\n * Rolls a campaign's sends into one set of figures.\n *\n * Rates are taken over the sends that recorded BOTH sides, so a campaign\n * whose first send predates the delivery webhook reports the open rate of the\n * sends that can be measured rather than one deflated by a send with no\n * denominator.\n */\nexport function campaignRollup(sends: CampaignSend[]): CampaignRollup {\n /*==========================================\n * ONLY MAIL THAT HAS GONE OUT IS A SEND.\n *\n * An email exists from the moment it is created, so this collection now\n * holds records in three states that have mailed nobody — `draft`,\n * `scheduled` and the `sending` claim — and every figure below is about\n * mail that was delivered.\n *\n * Two separate faults if they are left in. The COUNT would report a\n * campaign as having sent three emails when it has sent two and is still\n * writing the third. And every aggregate carries `sends` as the denominator\n * of its own \"recorded by N of M\" label, so an unsent record would enlarge\n * the M — publishing a coverage figure that says the campaign is missing\n * data it was never going to have.\n *=========================================*/\n /*\n * A SEND THAT IS STILL GOING HAS STILL GONE.\n *\n * `emailIsUnsent` reads the stored status, and an email delivering an\n * audience larger than one batch is stored as `scheduled` between runs —\n * so reading it literally drops a send that has put five hundred messages\n * in five hundred inboxes out of every total on the campaign, and counts it\n * under \"scheduled\" as though nothing had happened. `campaignSendProgress`\n * is what tells \"waiting for its time\" from \"part way through\", and only\n * the first is genuinely unsent.\n *\n * A DRAFT is settled by the status because the progress states do not cover\n * it — see `campaignSendDisplay`.\n */\n const notYet = (send: CampaignSend): boolean =>\n String(send.status ?? '') === 'draft' ||\n campaignSendProgress(send).state === 'pending'\n const gone = sends.filter((send) => !notYet(send))\n const measurable = sends.filter(\n (send) => send.stats?.delivered !== undefined,\n )\n const deliveredTotal = aggregate(measurable, (stats) => stats.delivered)\n return {\n sends: gone.filter((send) => send.status !== 'canceled').length,\n // Waiting for its time with nothing delivered — which is what\n // \"scheduled\" meant before an email could be delivered over several runs,\n // and is now narrower than the stored status of that name.\n scheduled: sends.filter(\n (send) => campaignSendProgress(send).state === 'pending',\n ).length,\n sending: sends.filter(\n (send) => campaignSendProgress(send).state === 'sending',\n ).length,\n drafts: sends.filter((send) => send.status === 'draft').length,\n addressed: aggregate(gone, (stats) => stats.recipients),\n sent: aggregate(gone, (stats) => stats.sent),\n delivered: aggregate(gone, (stats) => stats.delivered),\n opens: aggregate(gone, (stats) => stats.opens),\n uniqueOpens: aggregate(gone, (stats) => stats.uniqueOpens),\n clicks: aggregate(gone, (stats) => stats.clicks),\n uniqueClicks: aggregate(gone, (stats) => stats.uniqueClicks),\n bounced: aggregate(gone, (stats) => stats.bounced),\n complained: aggregate(gone, (stats) => stats.complained),\n unsubscribes: aggregate(gone, (stats) => stats.unsubscribes),\n openRate: campaignRate(\n aggregate(measurable, (stats) => stats.uniqueOpens).value ?? undefined,\n deliveredTotal.value ?? undefined,\n 'delivered',\n ),\n clickRate: campaignRate(\n aggregate(measurable, (stats) => stats.uniqueClicks).value ?? undefined,\n deliveredTotal.value ?? undefined,\n 'delivered',\n ),\n unsubscribeRate: campaignRate(\n aggregate(measurable, (stats) => stats.unsubscribes).value ?? undefined,\n deliveredTotal.value ?? undefined,\n 'delivered',\n ),\n lastSentAtMs: sends.reduce<number | null>((latest, send) => {\n const at = campaignSendAtMs(send)\n return at !== null && (latest === null || at > latest) ? at : latest\n }, null),\n }\n}\n\n/**\n * Where a campaign stands against its own window.\n *\n * Derived at read time and never persisted — the `status` values on a SEND\n * (`sent`, `scheduled`, `canceled`, `failed`) are stored strings that a\n * processor branches on, and a display state sharing those spellings would\n * eventually be written back.\n */\nexport type CampaignWindowState = 'undated' | 'upcoming' | 'running' | 'ended'\n\nexport function campaignWindowState(\n campaign: Pick<EmailCampaign, 'startAtMs' | 'endAtMs'>,\n nowMs: number,\n): CampaignWindowState {\n const start = campaign.startAtMs ?? null\n const end = campaign.endAtMs ?? null\n if (start === null && end === null) return 'undated'\n if (start !== null && nowMs < start) return 'upcoming'\n if (end !== null && nowMs > end) return 'ended'\n return 'running'\n}\n\n/** One row of the campaigns table. */\nexport interface CampaignListRow {\n /** The id the detail route resolves — a container id, or a send id. */\n id: string\n name: string\n /**\n * True when this row IS a send with no container.\n *\n * A campaign sent before containers existed is shown as a campaign of one\n * rather than dropped from the list, and the detail route falls back to the\n * send's own report for it. Nothing about that send is rewritten.\n */\n legacy: boolean\n startAtMs: number | null\n endAtMs: number | null\n listIds: string[]\n sends: CampaignSend[]\n rollup: CampaignRollup\n windowState: CampaignWindowState\n /** For ordering: the campaign's start, else its most recent send. */\n atMs: number | null\n}\n\n/**\n * The campaigns table's rows: every container, plus every send that belongs\n * to none.\n *\n * The second half is what makes the container additive. A merchant's history\n * is the sends they already have; a list that showed only containers would\n * read as an empty product on the day this shipped, and a backfill that\n * adopted each old send into a container of one would rewrite documents whose\n * ids are cited by mail already delivered. Adopting them AT READ TIME costs a\n * pass over a list already in memory and rewrites nothing.\n *\n * Newest first, on the start date where there is one and the last send\n * otherwise. Rows with no date at all sort last: they are campaigns nobody\n * has scheduled or sent, and dating them from nothing would be an invention.\n */\nexport function campaignListRows(\n campaigns: EmailCampaign[],\n sends: CampaignSend[],\n nowMs: number,\n): CampaignListRow[] {\n const byCampaign = new Map<string, CampaignSend[]>()\n const orphans: CampaignSend[] = []\n for (const send of sends) {\n const containerId = send.emailCampaignId\n if (!containerId) {\n orphans.push(send)\n continue\n }\n const existing = byCampaign.get(containerId)\n if (existing) existing.push(send)\n else byCampaign.set(containerId, [send])\n }\n\n const rows: CampaignListRow[] = campaigns.map((campaign) => {\n const own = byCampaign.get(campaign.$id) ?? []\n const rollup = campaignRollup(own)\n return {\n id: campaign.$id,\n name: campaign.name || 'Untitled campaign',\n legacy: false,\n startAtMs: campaign.startAtMs ?? null,\n endAtMs: campaign.endAtMs ?? null,\n listIds: campaign.listIds ?? [],\n sends: own,\n rollup,\n windowState: campaignWindowState(campaign, nowMs),\n atMs: campaign.startAtMs ?? rollup.lastSentAtMs ?? null,\n }\n })\n\n for (const send of orphans) {\n const rollup = campaignRollup([send])\n const at = campaignSendAtMs(send)\n rows.push({\n id: send.$id,\n name: send.subject || 'Untitled campaign',\n legacy: true,\n startAtMs: at,\n endAtMs: at,\n listIds: [],\n sends: [send],\n rollup,\n // A send has no window of its own; it happened, or it is going to.\n windowState:\n send.status === 'scheduled'\n ? 'upcoming'\n : at === null\n ? 'undated'\n : 'ended',\n atMs: at,\n })\n }\n\n return rows.sort((a, b) => {\n if (a.atMs === null && b.atMs === null) return a.name.localeCompare(b.name)\n if (a.atMs === null) return 1\n if (b.atMs === null) return -1\n return b.atMs - a.atMs\n })\n}\n"],"names":["readListUnsubscribeSetting","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","campaignRate","EMAIL_CAMPAIGNS_COLLECTION","CAMPAIGN_SENDS_COLLECTION","orgEmailCampaignsPath","orgId","orgCampaignSendsPath","orgCampaignSequenceReportsPath","CAMPAIGN_SEND_HOST_FIELD","CAMPAIGN_ORG_SCOPE","campaignHostScope","hostId","campaignSendVisibleTo","campaignSiteIds","campaign","tokens","visibleTo","includes","ids","token","startsWith","length","push","slice","campaignPlacedOnHost","campaignVisibleTo","siteIds","unique","Set","filter","id","map","CAMPAIGN_SEND_CONTAINER_FIELD","campaignListUnsubscribe","listUnsubscribe","campaignForcedListUnsubscribeSends","sends","send","forced","progressCount","raw","value","Math","floor","Number","isFinite","campaignSendProgress","status","String","reached","stats","sent","rawAudience","audienceSize","audience","undefined","resume","remaining","batch","nextAtMs","of","toLocaleString","state","label","why","campaignSendDisplay","progress","campaignSendIsMidFlight","campaignSendListIds","listId","aggregate","read","total","recorded","numeric","campaignSendAtMs","seconds","sentAt","sendAtMs","campaignRollup","deliveredTotal","notYet","gone","measurable","delivered","scheduled","sending","drafts","addressed","recipients","opens","uniqueOpens","clicks","uniqueClicks","bounced","complained","unsubscribes","openRate","clickRate","unsubscribeRate","lastSentAtMs","reduce","latest","at","campaignWindowState","nowMs","start","startAtMs","end","endAtMs","campaignListRows","campaigns","byCampaign","Map","orphans","containerId","emailCampaignId","existing","get","set","rows","own","$id","rollup","name","legacy","listIds","windowState","atMs","subject","sort","a","b","localeCompare"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/campaign-container.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A CAMPAIGN IS A CONTAINER; A SEND IS ONE MESSAGE INSIDE IT.\n *\n * ## Both belong to the ORGANIZATION (AGL-3273)\n *\n * `orgs/{orgId}/emailCampaigns/{campaignId}` holds a CONTAINER: a name, a\n * window, the lists it aims at. `orgs/{orgId}/campaigns/{sendId}` holds a\n * SEND: one subject, one body, one audience, one set of counters.\n *\n * They lived under one site (`hosts/{hostId}/…`) until the audience side —\n * contacts, lists, topics, sending domains — had long since become the\n * org's. An organization's outbound sequences then had to be filed under a\n * campaign that belonged to its marketing site, and the org-level Leads page\n * could not name a campaign at all. Moving both up is what lets one campaign\n * coordinate a push across every site, a sequence and the leads it produced.\n *\n * ## What stays a SITE fact, and where it is recorded\n *\n * A campaign's reach is {@link EmailCampaign.visibleTo}, the same scope\n * tokens every org-owned resource carries: `['org']` is every site, and\n * `['host:{id}', …]` is the sites it is placed on. It decides which sites'\n * pickers offer the campaign and which scoped collaborators can read it —\n * nothing else, because membership stays a field on the member.\n *\n * A send, by contrast, is always sent AS one site: that site's sender, the\n * consent the person gave that site, the site's unsubscribe signature and\n * its designs. {@link CAMPAIGN_SEND_HOST_FIELD} records which, and the\n * send's `visibleTo` is that one site's token.\n *\n * ## Ids did not change, and that is load-bearing\n *\n * Every unsubscribe link that has ever gone out carries `hostId` and\n * `cid={sendId}`, both inside the link's HMAC, and those emails sit in\n * inboxes forever. So the move copied every document under its existing id,\n * and every reader that starts from a link resolves the send through the\n * site's org (`resolveCampaignSendRef` in `@aglyn/tenant-data-admin`), with the\n * old site path as the fallback for a document the migration has not reached.\n * A member's `campaignIds` names container ids, which likewise never moved.\n *\n * A send written before containers existed carries no\n * {@link CAMPAIGN_SEND_CONTAINER_FIELD}, and {@link campaignListRows}\n * presents it as a container of one rather than hiding it.\n *\n * ## Why the arithmetic is here\n *\n * The same reason `campaign-report.ts` gives for the per-send rates: a\n * denominator chosen in JSX is a denominator nobody tests. Aggregating across\n * sends adds one problem the single-send report does not have — some sends\n * recorded a field and others never did — and summing those into one number\n * silently reports a partial total as a complete one. Every aggregate here\n * therefore reports how many sends it could measure.\n */\n\nimport {\n readListUnsubscribeSetting,\n type ListUnsubscribeForcedReason,\n} from '@aglyn/shared-util-email/list-unsubscribe'\nimport {\n CAMPAIGN_SEQUENCE_REPORTS_COLLECTION,\n campaignRate,\n type CampaignRate,\n type CampaignStats,\n} from './campaign-report'\n\n/** The org collection holding campaign CONTAINERS. */\nexport const EMAIL_CAMPAIGNS_COLLECTION = 'emailCampaigns'\n\n/** The org collection holding SENDS — one document per email. */\nexport const CAMPAIGN_SENDS_COLLECTION = 'campaigns'\n\n/** `orgs/{orgId}/emailCampaigns`, as path segments. */\nexport function orgEmailCampaignsPath(orgId: string): [string, string, string] {\n return ['orgs', orgId, EMAIL_CAMPAIGNS_COLLECTION]\n}\n\n/** `orgs/{orgId}/campaigns`, as path segments. */\nexport function orgCampaignSendsPath(orgId: string): [string, string, string] {\n return ['orgs', orgId, CAMPAIGN_SENDS_COLLECTION]\n}\n\n/** `orgs/{orgId}/campaignSequenceReports`, as path segments. */\nexport function orgCampaignSequenceReportsPath(\n orgId: string,\n): [string, string, string] {\n return ['orgs', orgId, CAMPAIGN_SEQUENCE_REPORTS_COLLECTION]\n}\n\n/**\n * The field on a SEND naming the site it is sent AS.\n *\n * Required on every send written since AGL-3273. The sender, the consent\n * group, the unsubscribe signature, the designs and the experiments all\n * belong to that site, so a send with no site cannot be delivered.\n */\nexport const CAMPAIGN_SEND_HOST_FIELD = 'hostId'\n\n/** The scope token naming every site in the org, as `scope-tokens.ts` spells it. */\nexport const CAMPAIGN_ORG_SCOPE = 'org'\n\n/** `host:{hostId}`, the scope token naming one site. */\nexport function campaignHostScope(hostId: string): string {\n return `host:${hostId}`\n}\n\n/**\n * The `visibleTo` a send is stamped with: the one site it sends as.\n *\n * Not `['org']`. An org-wide member reads every send regardless, and a\n * collaborator invited to a sibling site has no business reading who this\n * site mailed.\n */\nexport function campaignSendVisibleTo(hostId: string): string[] {\n return [campaignHostScope(hostId)]\n}\n\n/**\n * The sites a container is placed on, or `null` when it is on every site.\n *\n * An absent or empty `visibleTo` reads as NO sites, for the reason\n * `visibleToHost` in `scope-tokens.ts` gives: a document nobody scoped is\n * not the same fact as one scoped to everyone, and hiding is the\n * recoverable direction.\n */\nexport function campaignSiteIds(\n campaign: Pick<EmailCampaign, 'visibleTo'> | null | undefined,\n): string[] | null {\n const tokens = campaign?.visibleTo ?? []\n if (tokens.includes(CAMPAIGN_ORG_SCOPE)) return null\n const ids: string[] = []\n for (const token of tokens) {\n if (typeof token === 'string' && token.startsWith('host:') && token.length > 5) {\n ids.push(token.slice(5))\n }\n }\n return ids\n}\n\n/** Whether a container is offered on this site. */\nexport function campaignPlacedOnHost(\n campaign: Pick<EmailCampaign, 'visibleTo'> | null | undefined,\n hostId: string,\n): boolean {\n const ids = campaignSiteIds(campaign)\n return ids === null || ids.includes(hostId)\n}\n\n/**\n * The `visibleTo` a container is stamped with, from a site selection.\n *\n * `null` (or an empty selection) is every site. A selection is deduped and\n * capped at thirty, the ceiling `array-contains-any` and the rules' `hasAny`\n * both impose.\n */\nexport function campaignVisibleTo(siteIds: readonly string[] | null): string[] {\n if (!siteIds || siteIds.length === 0) return [CAMPAIGN_ORG_SCOPE]\n const unique = [...new Set(siteIds.filter((id) => typeof id === 'string' && id))]\n return unique.slice(0, 30).map(campaignHostScope)\n}\n\n/**\n * The field on a SEND naming the campaign it belongs to.\n *\n * Not `campaignId`: on a send document that name already means the send's own\n * id — it is what `cid` carries and what the report route addresses — and one\n * word meaning both would be read into the other on the first edit.\n */\nexport const CAMPAIGN_SEND_CONTAINER_FIELD = 'emailCampaignId'\n\n/**\n * A campaign: the container, not a message.\n *\n * Stored at `orgs/{orgId}/emailCampaigns/{campaignId}`.\n */\nexport interface EmailCampaign {\n $id: string\n /** What the merchant called it. */\n name: string\n /** When the campaign window opens. Null for a campaign with no dates. */\n startAtMs?: number | null\n /** When it closes. Null for an open-ended campaign. */\n endAtMs?: number | null\n /** Org email lists this campaign is aimed at, by list id. */\n listIds?: string[]\n /**\n * The stream this campaign's emails open on.\n *\n * A DEFAULT, not a constraint. The topic decides who a send skips, what the\n * preference page linked from the footer highlights, and which stream a\n * resulting opt-out is recorded against — all facts about one MESSAGE, and\n * one campaign may legitimately carry a newsletter and a promotion. So the\n * composer's picker is what the send records; this is what the picker opens\n * on, which is what stops a \"Sales\" campaign quietly mailing under\n * `marketing`.\n */\n topicId?: string\n /**\n * The sites the campaign is placed on, as scope tokens: `['org']` for\n * every site, `['host:{id}', …]` for a set. See {@link campaignSiteIds}.\n */\n visibleTo?: string[]\n /**\n * Whether its emails carry `List-Unsubscribe` and `List-Unsubscribe-Post`\n * (RFC 8058), the header mail clients draw their own Unsubscribe button\n * from (AGL-3307). ON unless stored `false` — absent on every campaign\n * written before the setting existed, which read as on because that is\n * what they always did. Read it through {@link campaignListUnsubscribe}.\n *\n * Off is overridden per send by the bulk guard; see\n * {@link CampaignListUnsubscribeRecord}. The footer's unsubscribe link is\n * in every email either way.\n */\n listUnsubscribe?: boolean\n createdAtMs?: number\n createdBy?: string\n deletedAt?: unknown\n}\n\n/**\n * Whether a campaign's emails carry the mail-client unsubscribe button:\n * the shared reader sequences use, with a campaign's default of ON.\n */\nexport function campaignListUnsubscribe(\n campaign: Pick<EmailCampaign, 'listUnsubscribe'> | null | undefined,\n): boolean {\n return readListUnsubscribeSetting(campaign?.listUnsubscribe, true)\n}\n\n/**\n * What one SEND did with the List-Unsubscribe header, written by the\n * campaign sender on every batch (AGL-3307).\n */\nexport interface CampaignListUnsubscribeRecord {\n /** The campaign's setting when it went out. */\n requested?: boolean\n /** Whether the header was on the messages. */\n on?: boolean\n /** True when the setting said off and the bulk guard turned it back on. */\n forced?: boolean\n reason?: ListUnsubscribeForcedReason\n /** One sentence saying why, for the campaign page. */\n detail?: string\n /** Campaign messages in the day, this email included, it was judged on. */\n volume?: number\n /** The threshold it was judged against. */\n threshold?: number\n atMs?: number\n}\n\n/** The sends among these whose header the bulk guard forced back on. */\nexport function campaignForcedListUnsubscribeSends<\n T extends Pick<CampaignSend, 'listUnsubscribe'>,\n>(sends: readonly T[] | null | undefined): T[] {\n return (sends ?? []).filter((send) => send?.listUnsubscribe?.forced === true)\n}\n\n/** One send, as much of it as a list or a rollup needs. */\nexport interface CampaignSend {\n $id: string\n subject?: string\n /** The audience KIND: `'leads'`, `'members'`, `'segment'`, `'list'`. */\n audience?: string\n /** The list this send addressed, when the kind is `'list'`. */\n listId?: string\n /** The segment this send addressed, when the kind is `'segment'`. */\n segmentId?: string\n /** Which container it belongs to, absent on a send written before them. */\n emailCampaignId?: string\n /** The site it is sent as. See {@link CAMPAIGN_SEND_HOST_FIELD}. */\n hostId?: string\n status?: string\n sentAt?: { seconds?: number } | null\n sendAtMs?: number\n /**\n * When the record was minted, stamped by every writer that creates one.\n *\n * Absent on a send written before the stamp existed. The lists that draw\n * drafts beside sends order on it through `emailListTimeMs`, which is why\n * it is here rather than only on the loose record shape: a draft carries\n * neither `sentAt` nor `sendAtMs`, so this is the only time it has.\n */\n createdAtMs?: number\n stats?: CampaignStats\n /** How far a send that goes out over several batches has got. */\n resume?: CampaignResume\n /** What it did with the List-Unsubscribe header. Absent before AGL-3307. */\n listUnsubscribe?: CampaignListUnsubscribeRecord\n /**\n * Staff review of a send the outbound phishing screen held (AGL-3356).\n * Written by the send core, decided only by staff. See\n * {@link campaignSendHeldForReview}.\n */\n staffReview?: CampaignStaffReview\n}\n\n/** A held send's review, as the send core and the staff decision write it. */\nexport interface CampaignStaffReview {\n /** `held` until staff decide; then `released` or `rejected`. */\n state?: string\n /** `HS-…`, the reference the merchant quotes to support. */\n reference?: string\n reviewId?: string\n heldAtMs?: number\n}\n\n/**\n * Is this send waiting on a staff review (AGL-3356)?\n *\n * A held send is STORED as `scheduled`, parked at a send time no processor\n * run reaches, so the review can put it back on the clock without a second\n * record. Every surface that read the status alone therefore called it\n * \"Scheduled\" and showed a due date in the year 9999 — and offered \"Send\n * now\", \"Reschedule\" and the composer, none of which a merchant may use to\n * release it. This is the one test, so no surface restates it.\n */\nexport function campaignSendHeldForReview(\n send: Pick<CampaignSend, 'status' | 'staffReview'> | null | undefined,\n): boolean {\n const status = String(send?.status ?? '')\n return (\n send?.staffReview?.state === 'held' &&\n (status === 'scheduled' || status === 'draft')\n )\n}\n\n/**\n * What a held send's surfaces tell the merchant: that it is held, why in\n * general terms, that they cannot release it, and what happens next. One\n * sentence set, so the list, the page and the composer agree.\n */\nexport function campaignHeldForReviewNotice(\n send: Pick<CampaignSend, 'staffReview'> | null | undefined,\n): string {\n const reference = send?.staffReview?.reference\n return (\n 'This email is held for review before it sends. New workspaces have ' +\n 'their first emails checked for impersonation of other businesses, and ' +\n 'this one needs a person to look at it. It cannot be released, ' +\n 'rescheduled or edited from here — our team reviews it, and it sends ' +\n 'automatically if it is approved or is canceled if it is not. ' +\n 'Nothing has been sent or counted.' +\n (reference ? ` Quote ${reference} if you contact support.` : '')\n )\n}\n\n/**\n * The batch state the sender writes on an email that is still going out.\n *\n * Absent on every send that finished in one batch, and on every send that\n * predates batching — which is why {@link campaignSendProgress} treats a\n * missing record as \"this is not a batched send\" rather than as zero.\n */\nexport interface CampaignResume {\n /** People the email has resolved and not yet addressed. */\n remaining?: number\n /** Batches it has run. */\n batch?: number\n /** When the next batch may go, ms. Zero when there is not going to be one. */\n nextAtMs?: number\n /** Why it stopped short, when it did. */\n stop?: string\n}\n\n/**\n * WHAT A SEND IS ACTUALLY DOING, for a row that would otherwise lie.\n *\n * An email larger than one send may carry is delivered over several batches,\n * and between them it is stored as `scheduled` — the state the processor\n * claims, and the only one that resumes it without a second index and a\n * second query. Read literally, that is a row saying \"not sent yet\" about an\n * email that has already put five hundred messages in five hundred inboxes.\n *\n * So the stored fields are not the sentence. This is: it takes the status,\n * the delivered count and the batch record, and answers what a person needs\n * to see. Derived at read time and never persisted, exactly as\n * `campaignWindowState` beside it is, because it is a description of stored\n * facts and not a fact of its own.\n *\n * ## The four states, and which stored shape each one is\n *\n * - `pending` — `scheduled`, nothing delivered. A campaign waiting for its\n * time. This is what `scheduled` meant before batching and still does.\n * - `sending` — `scheduled` or `sending` with something delivered and more to\n * come. The state this function exists for.\n * - `sent` — finished, whether in one batch or six.\n * - `stopped` — finished with people it never addressed: canceled mid-flight,\n * failed mid-flight, or stopped by the batch guard. The count is what makes\n * this legible rather than alarming — an email that reached 2,400 of 3,000\n * and stopped is a different conversation from one that reached nobody.\n */\nexport type CampaignSendProgressState =\n | 'pending'\n | 'sending'\n | 'sent'\n | 'stopped'\n\nexport interface CampaignSendProgress {\n state: CampaignSendProgressState\n /** Messages this email has delivered. */\n reached: number\n /**\n * The audience it is working through, when one was recorded. Null when the\n * send never recorded an audience size, which is every send that predates\n * the figure — reported as null rather than as `reached` so a surface does\n * not present a floor as a total.\n */\n audience: number | null\n /** People it has resolved and not yet addressed. */\n remaining: number\n /** Batches it has run. Zero for a send that never batched. */\n batch: number\n /** When the next batch may go, ms. Zero unless {@link state} is `sending`. */\n nextAtMs: number\n /** One line a surface may show verbatim. */\n label: string\n}\n\n/** A stored count as a non-negative integer, or 0. */\nfunction progressCount(raw: unknown): number {\n const value = Math.floor(Number(raw))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\nexport function campaignSendProgress(\n send: CampaignSend | null | undefined,\n): CampaignSendProgress {\n const status = String(send?.status ?? 'sent')\n const reached = progressCount(send?.stats?.sent)\n const rawAudience = send?.stats?.audienceSize\n const audience =\n rawAudience === undefined || rawAudience === null\n ? null\n : progressCount(rawAudience)\n const resume = send?.resume\n const remaining = progressCount(resume?.remaining)\n const batch = progressCount(resume?.batch)\n const nextAtMs = progressCount(resume?.nextAtMs)\n const of = audience !== null ? ` of ${audience.toLocaleString()}` : ''\n\n // Still going: more to address, and a run that will address it. A campaign\n // a merchant CANCELED is not still going however much is left, which is why\n // the status is read before the remainder.\n if (\n remaining > 0 &&\n nextAtMs > 0 &&\n (status === 'scheduled' || status === 'sending')\n ) {\n return {\n state: 'sending',\n reached,\n audience,\n remaining,\n batch,\n nextAtMs,\n label: `Sending — reached ${reached.toLocaleString()}${of}`,\n }\n }\n // Waiting for its time, with nothing delivered. The pre-batching meaning of\n // `scheduled`, and still the common one.\n if ((status === 'scheduled' || status === 'sending') && reached === 0) {\n return {\n state: 'pending',\n reached: 0,\n audience,\n remaining,\n batch,\n nextAtMs: 0,\n label: status === 'sending' ? 'Sending' : 'Scheduled',\n }\n }\n if (remaining > 0) {\n const why =\n status === 'canceled'\n ? 'canceled'\n : status === 'failed'\n ? 'stopped by an error'\n : 'stopped'\n return {\n state: 'stopped',\n reached,\n audience,\n remaining,\n batch,\n nextAtMs: 0,\n label:\n `Reached ${reached.toLocaleString()}${of} — ${why} with ` +\n `${remaining.toLocaleString()} not addressed`,\n }\n }\n return {\n state: 'sent',\n reached,\n audience,\n remaining: 0,\n batch,\n nextAtMs: 0,\n label:\n batch > 1\n ? `Sent to ${reached.toLocaleString()}${of} over ${batch} runs`\n : `Sent to ${reached.toLocaleString()}${of}`,\n }\n}\n\n/**\n * What a ROW says about one email, in one word and one line.\n *\n * {@link campaignSendProgress} answers what a send is DOING, and a draft is\n * not doing anything: it has no status the progress states cover, and — since\n * an absent status reads as `sent` and a draft has no counters — asking it\n * about one answers \"Sent to 0\", which is the worst available sentence about\n * an email nobody has written yet. So the draft is settled here and\n * everything else is deferred, unchanged, to the derivation that owns it.\n *\n * One helper rather than the same two-line branch on four surfaces. The\n * campaigns table, the emails list, an email's own page and a campaign's\n * emails table all draw this, and four copies is how three of them come to\n * say \"Scheduled\" about a campaign that has delivered five hundred messages.\n */\nexport type CampaignSendDisplayState = 'draft' | 'held' | CampaignSendProgressState\n\nexport interface CampaignSendDisplay {\n state: CampaignSendDisplayState\n /** One line a surface may show verbatim. */\n label: string\n /** The progress underneath, for a surface that wants the figures. */\n progress: CampaignSendProgress\n}\n\nexport function campaignSendDisplay(\n send: CampaignSend | null | undefined,\n): CampaignSendDisplay {\n const progress = campaignSendProgress(send)\n // Before the draft test: a held send may be stored as either (AGL-3356).\n if (campaignSendHeldForReview(send)) {\n return { state: 'held', label: 'Held for review', progress }\n }\n if (String(send?.status ?? '') === 'draft') {\n return { state: 'draft', label: 'Draft', progress }\n }\n return { state: progress.state, label: progress.label, progress }\n}\n\n/**\n * Whether this email is between batches, with more of its audience to reach.\n *\n * The one question two controls on an email's page turn on. It is stored as\n * `scheduled` — the state the processor claims — so a surface reading the\n * status alone offers \"Send now\" on a campaign that is already going out, and\n * `sendNow` re-resolves the WHOLE audience rather than continuing: everyone\n * already reached would receive a second copy under the same `cid`.\n */\nexport function campaignSendIsMidFlight(\n send: CampaignSend | null | undefined,\n): boolean {\n return campaignSendProgress(send).state === 'sending'\n}\n\n/**\n * The lists ONE SEND addressed — which can be narrower than the lists its\n * campaign is aimed at.\n *\n * A campaign holds the lists a merchant plans to reach; each send inside it\n * picks one audience, and that audience may be a segment or the site's leads\n * rather than any of them. Answering from the send is therefore the only\n * honest answer for a send's own detail page.\n *\n * An array for a document that stores one id, deliberately: the question\n * \"which lists did this reach\" has a plural answer everywhere it is asked,\n * and a caller that unwraps a single id today is a caller to revisit if a\n * send ever addresses two.\n */\nexport function campaignSendListIds(send: CampaignSend): string[] {\n return send.audience === 'list' && send.listId ? [send.listId] : []\n}\n\n/**\n * A number summed across sends, with how much of the campaign it covers.\n *\n * `value` is `null` when NO send recorded the field — which is not zero, for\n * the reason `campaign-report.ts` gives at length: an unrecorded delivery\n * count and a delivery count of zero lead a merchant to opposite conclusions\n * about their sending domain.\n *\n * `recorded` below `sends` means the total is a floor. A campaign whose\n * older sends predate the delivery webhook has a real number that describes\n * part of itself, and saying which part is the difference between a total and\n * a guess.\n */\nexport interface CampaignAggregate {\n value: number | null\n /** Sends that recorded this field. */\n recorded: number\n /** Sends in the campaign. */\n sends: number\n}\n\n/** Every rolled-up figure for one campaign. */\nexport interface CampaignRollup {\n /** Sends that have actually gone out. */\n sends: number\n /** Sends still waiting for their send time, having delivered nothing. */\n scheduled: number\n /**\n * Sends part way through an audience larger than one batch.\n *\n * Counted apart from `scheduled` and from `sends`, because it is neither:\n * mail has gone out, and more is going to. Both of those are facts a\n * merchant reading a campaign row needs, and the stored status carries\n * only the first.\n */\n sending: number\n /**\n * Emails that have been created and not yet written or sent.\n *\n * Counted apart from `sends` and from `scheduled`, because a draft is\n * neither: it has mailed nobody, and it is not on the clock to.\n */\n drafts: number\n addressed: CampaignAggregate\n sent: CampaignAggregate\n delivered: CampaignAggregate\n opens: CampaignAggregate\n uniqueOpens: CampaignAggregate\n clicks: CampaignAggregate\n uniqueClicks: CampaignAggregate\n bounced: CampaignAggregate\n complained: CampaignAggregate\n unsubscribes: CampaignAggregate\n /** Distinct openers over delivered, across every send that recorded both. */\n openRate: CampaignRate | null\n /** Distinct clickers over delivered. */\n clickRate: CampaignRate | null\n /** Unsubscribes over delivered. */\n unsubscribeRate: CampaignRate | null\n /** The most recent send time in the campaign, for ordering a list. */\n lastSentAtMs: number | null\n}\n\nconst aggregate = (\n sends: CampaignSend[],\n read: (stats: CampaignStats) => number | undefined,\n): CampaignAggregate => {\n let total = 0\n let recorded = 0\n for (const send of sends) {\n const value = read(send.stats ?? {})\n // `undefined` is \"this send never recorded it\"; a recorded 0 counts as\n // measured, and moves `recorded` without moving the total.\n if (value === undefined || value === null) continue\n const numeric = Number(value)\n if (!Number.isFinite(numeric)) continue\n total += numeric\n recorded += 1\n }\n return { value: recorded ? total : null, recorded, sends: sends.length }\n}\n\n/** When a send happened, in epoch milliseconds, or null. */\nexport function campaignSendAtMs(send: CampaignSend): number | null {\n const seconds = send.sentAt?.seconds\n if (typeof seconds === 'number') return seconds * 1000\n if (typeof send.sendAtMs === 'number') return send.sendAtMs\n return null\n}\n\n/**\n * Rolls a campaign's sends into one set of figures.\n *\n * Rates are taken over the sends that recorded BOTH sides, so a campaign\n * whose first send predates the delivery webhook reports the open rate of the\n * sends that can be measured rather than one deflated by a send with no\n * denominator.\n */\nexport function campaignRollup(sends: CampaignSend[]): CampaignRollup {\n /*==========================================\n * ONLY MAIL THAT HAS GONE OUT IS A SEND.\n *\n * An email exists from the moment it is created, so this collection now\n * holds records in three states that have mailed nobody — `draft`,\n * `scheduled` and the `sending` claim — and every figure below is about\n * mail that was delivered.\n *\n * Two separate faults if they are left in. The COUNT would report a\n * campaign as having sent three emails when it has sent two and is still\n * writing the third. And every aggregate carries `sends` as the denominator\n * of its own \"recorded by N of M\" label, so an unsent record would enlarge\n * the M — publishing a coverage figure that says the campaign is missing\n * data it was never going to have.\n *=========================================*/\n /*\n * A SEND THAT IS STILL GOING HAS STILL GONE.\n *\n * `emailIsUnsent` reads the stored status, and an email delivering an\n * audience larger than one batch is stored as `scheduled` between runs —\n * so reading it literally drops a send that has put five hundred messages\n * in five hundred inboxes out of every total on the campaign, and counts it\n * under \"scheduled\" as though nothing had happened. `campaignSendProgress`\n * is what tells \"waiting for its time\" from \"part way through\", and only\n * the first is genuinely unsent.\n *\n * A DRAFT is settled by the status because the progress states do not cover\n * it — see `campaignSendDisplay`.\n */\n const notYet = (send: CampaignSend): boolean =>\n String(send.status ?? '') === 'draft' ||\n campaignSendProgress(send).state === 'pending'\n const gone = sends.filter((send) => !notYet(send))\n const measurable = sends.filter(\n (send) => send.stats?.delivered !== undefined,\n )\n const deliveredTotal = aggregate(measurable, (stats) => stats.delivered)\n return {\n sends: gone.filter((send) => send.status !== 'canceled').length,\n // Waiting for its time with nothing delivered — which is what\n // \"scheduled\" meant before an email could be delivered over several runs,\n // and is now narrower than the stored status of that name.\n scheduled: sends.filter(\n (send) => campaignSendProgress(send).state === 'pending',\n ).length,\n sending: sends.filter(\n (send) => campaignSendProgress(send).state === 'sending',\n ).length,\n drafts: sends.filter((send) => send.status === 'draft').length,\n addressed: aggregate(gone, (stats) => stats.recipients),\n sent: aggregate(gone, (stats) => stats.sent),\n delivered: aggregate(gone, (stats) => stats.delivered),\n opens: aggregate(gone, (stats) => stats.opens),\n uniqueOpens: aggregate(gone, (stats) => stats.uniqueOpens),\n clicks: aggregate(gone, (stats) => stats.clicks),\n uniqueClicks: aggregate(gone, (stats) => stats.uniqueClicks),\n bounced: aggregate(gone, (stats) => stats.bounced),\n complained: aggregate(gone, (stats) => stats.complained),\n unsubscribes: aggregate(gone, (stats) => stats.unsubscribes),\n openRate: campaignRate(\n aggregate(measurable, (stats) => stats.uniqueOpens).value ?? undefined,\n deliveredTotal.value ?? undefined,\n 'delivered',\n ),\n clickRate: campaignRate(\n aggregate(measurable, (stats) => stats.uniqueClicks).value ?? undefined,\n deliveredTotal.value ?? undefined,\n 'delivered',\n ),\n unsubscribeRate: campaignRate(\n aggregate(measurable, (stats) => stats.unsubscribes).value ?? undefined,\n deliveredTotal.value ?? undefined,\n 'delivered',\n ),\n lastSentAtMs: sends.reduce<number | null>((latest, send) => {\n const at = campaignSendAtMs(send)\n return at !== null && (latest === null || at > latest) ? at : latest\n }, null),\n }\n}\n\n/**\n * Where a campaign stands against its own window.\n *\n * Derived at read time and never persisted — the `status` values on a SEND\n * (`sent`, `scheduled`, `canceled`, `failed`) are stored strings that a\n * processor branches on, and a display state sharing those spellings would\n * eventually be written back.\n */\nexport type CampaignWindowState = 'undated' | 'upcoming' | 'running' | 'ended'\n\nexport function campaignWindowState(\n campaign: Pick<EmailCampaign, 'startAtMs' | 'endAtMs'>,\n nowMs: number,\n): CampaignWindowState {\n const start = campaign.startAtMs ?? null\n const end = campaign.endAtMs ?? null\n if (start === null && end === null) return 'undated'\n if (start !== null && nowMs < start) return 'upcoming'\n if (end !== null && nowMs > end) return 'ended'\n return 'running'\n}\n\n/** One row of the campaigns table. */\nexport interface CampaignListRow {\n /** The id the detail route resolves — a container id, or a send id. */\n id: string\n name: string\n /**\n * True when this row IS a send with no container.\n *\n * A campaign sent before containers existed is shown as a campaign of one\n * rather than dropped from the list, and the detail route falls back to the\n * send's own report for it. Nothing about that send is rewritten.\n */\n legacy: boolean\n startAtMs: number | null\n endAtMs: number | null\n listIds: string[]\n sends: CampaignSend[]\n rollup: CampaignRollup\n windowState: CampaignWindowState\n /** For ordering: the campaign's start, else its most recent send. */\n atMs: number | null\n}\n\n/**\n * The campaigns table's rows: every container, plus every send that belongs\n * to none.\n *\n * The second half is what makes the container additive. A merchant's history\n * is the sends they already have; a list that showed only containers would\n * read as an empty product on the day this shipped, and a backfill that\n * adopted each old send into a container of one would rewrite documents whose\n * ids are cited by mail already delivered. Adopting them AT READ TIME costs a\n * pass over a list already in memory and rewrites nothing.\n *\n * Newest first, on the start date where there is one and the last send\n * otherwise. Rows with no date at all sort last: they are campaigns nobody\n * has scheduled or sent, and dating them from nothing would be an invention.\n */\nexport function campaignListRows(\n campaigns: EmailCampaign[],\n sends: CampaignSend[],\n nowMs: number,\n): CampaignListRow[] {\n const byCampaign = new Map<string, CampaignSend[]>()\n const orphans: CampaignSend[] = []\n for (const send of sends) {\n const containerId = send.emailCampaignId\n if (!containerId) {\n orphans.push(send)\n continue\n }\n const existing = byCampaign.get(containerId)\n if (existing) existing.push(send)\n else byCampaign.set(containerId, [send])\n }\n\n const rows: CampaignListRow[] = campaigns.map((campaign) => {\n const own = byCampaign.get(campaign.$id) ?? []\n const rollup = campaignRollup(own)\n return {\n id: campaign.$id,\n name: campaign.name || 'Untitled campaign',\n legacy: false,\n startAtMs: campaign.startAtMs ?? null,\n endAtMs: campaign.endAtMs ?? null,\n listIds: campaign.listIds ?? [],\n sends: own,\n rollup,\n windowState: campaignWindowState(campaign, nowMs),\n atMs: campaign.startAtMs ?? rollup.lastSentAtMs ?? null,\n }\n })\n\n for (const send of orphans) {\n const rollup = campaignRollup([send])\n const at = campaignSendAtMs(send)\n rows.push({\n id: send.$id,\n name: send.subject || 'Untitled campaign',\n legacy: true,\n startAtMs: at,\n endAtMs: at,\n listIds: [],\n sends: [send],\n rollup,\n // A send has no window of its own; it happened, or it is going to.\n windowState:\n send.status === 'scheduled'\n ? 'upcoming'\n : at === null\n ? 'undated'\n : 'ended',\n atMs: at,\n })\n }\n\n return rows.sort((a, b) => {\n if (a.atMs === null && b.atMs === null) return a.name.localeCompare(b.name)\n if (a.atMs === null) return 1\n if (b.atMs === null) return -1\n return b.atMs - a.atMs\n })\n}\n"],"names":["readListUnsubscribeSetting","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","campaignRate","EMAIL_CAMPAIGNS_COLLECTION","CAMPAIGN_SENDS_COLLECTION","orgEmailCampaignsPath","orgId","orgCampaignSendsPath","orgCampaignSequenceReportsPath","CAMPAIGN_SEND_HOST_FIELD","CAMPAIGN_ORG_SCOPE","campaignHostScope","hostId","campaignSendVisibleTo","campaignSiteIds","campaign","tokens","visibleTo","includes","ids","token","startsWith","length","push","slice","campaignPlacedOnHost","campaignVisibleTo","siteIds","unique","Set","filter","id","map","CAMPAIGN_SEND_CONTAINER_FIELD","campaignListUnsubscribe","listUnsubscribe","campaignForcedListUnsubscribeSends","sends","send","forced","campaignSendHeldForReview","status","String","staffReview","state","campaignHeldForReviewNotice","reference","progressCount","raw","value","Math","floor","Number","isFinite","campaignSendProgress","reached","stats","sent","rawAudience","audienceSize","audience","undefined","resume","remaining","batch","nextAtMs","of","toLocaleString","label","why","campaignSendDisplay","progress","campaignSendIsMidFlight","campaignSendListIds","listId","aggregate","read","total","recorded","numeric","campaignSendAtMs","seconds","sentAt","sendAtMs","campaignRollup","deliveredTotal","notYet","gone","measurable","delivered","scheduled","sending","drafts","addressed","recipients","opens","uniqueOpens","clicks","uniqueClicks","bounced","complained","unsubscribes","openRate","clickRate","unsubscribeRate","lastSentAtMs","reduce","latest","at","campaignWindowState","nowMs","start","startAtMs","end","endAtMs","campaignListRows","campaigns","byCampaign","Map","orphans","containerId","emailCampaignId","existing","get","set","rows","own","$id","rollup","name","legacy","listIds","windowState","atMs","subject","sort","a","b","localeCompare"],"mappings":"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"}
@@ -96,6 +96,13 @@ export interface CampaignStats {
96
96
  /** Of `recipients`, how many asked this site for mail less often than this
97
97
  * send would have arrived. Still subscribed; reached by a later campaign. */
98
98
  cadenceHeld?: number;
99
+ /**
100
+ * Of `recipients`, how many the deliverability check took out (AGL-3328):
101
+ * their domain has no mail server, or their mail gateway refused this
102
+ * sending domain twice in thirty days and delivered nothing.
103
+ */
104
+ noMailServer?: number;
105
+ gatewayHeld?: number;
99
106
  /** Recipients the hourly send governor refused mid-batch. */
100
107
  deferred?: number;
101
108
  /**
@@ -191,6 +191,8 @@
191
191
  addPopulation('consentWithheld', 'Withheld by the consent rule', source.consentWithheld, 'audience', audienceSize);
192
192
  addPopulation('suppressed', 'Already suppressed', source.suppressed, 'addressed', recipients);
193
193
  addPopulation('cadenceHeld', 'Asked for mail less often than this', source.cadenceHeld, 'addressed', recipients);
194
+ addPopulation('noMailServer', 'Excluded: no mail server', source.noMailServer, 'addressed', recipients);
195
+ addPopulation('gatewayHeld', 'Excluded: behind a gateway that refused this sender', source.gatewayHeld, 'addressed', recipients);
194
196
  const caveats = [];
195
197
  if (delivered === null) {
196
198
  caveats.push({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/campaign-report.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * CAMPAIGN REPORTING MATH — the only place a rate is computed.\n *\n * ## Why a pure module and not a component\n *\n * Every number on the report screen is a division, and a division is where\n * email reporting goes wrong. Putting the arithmetic in JSX means the\n * denominator is chosen by whoever writes the next card, in a file nobody\n * tests for arithmetic; putting it here means each rate is named once,\n * carries its own denominator as data, and is provable.\n *\n * ## The rule this module exists to enforce\n *\n * **A rate is a triple — numerator, denominator, and the NAME of the\n * denominator — or it is not reported.** An open rate over `sent` and an open\n * rate over `delivered` are different numbers with the same label, and the\n * gap between them is exactly the mail that bounced. The industry convention\n * is over `delivered`, and a report that quietly used `sent` would read\n * higher than the same campaign measured anywhere else.\n *\n * So {@link CampaignRate} carries `denominatorLabel`, and the screen is\n * required to render it. There is no overload that omits it.\n *\n * ## Why some rates are deliberately absent\n *\n * {@link campaignRate} answers `null`, not zero, when it cannot divide:\n *\n * - **A zero denominator.** 0 opens out of 0 delivered is not a 0% open\n * rate, it is no open rate. Rendering 0% invites the reader to compare it\n * with a campaign that really did fail.\n * - **An UNKNOWN denominator.** `delivered` is counted by the delivery\n * webhook, which was connected after some campaigns were sent. A campaign\n * with 400 sends and no delivery events has an unknown denominator, not a\n * denominator of zero — and dividing by `sent` instead is precisely the\n * flattering substitution above.\n *\n * ## The structural-zero window\n *\n * Click tracking rewrites links in the HTML part. Sends that carried no HTML\n * part were therefore untrackable, and every one of those campaigns reports 0\n * clicks whatever the recipients actually did — a real 0 and a structural 0\n * rendered identically. `send-email.ts` now synthesises an HTML part for a\n * text-only send, so every send after that carries one, and\n * `campaign-send.ts` records {@link CampaignStats.clickTracked} to say so.\n *\n * A campaign with no such marker predates the record. Its click COUNT is\n * still shown — it is a real count of real events — but no click RATE is\n * computed from it, because a rate presents the number as a measurement of\n * the audience and for those campaigns it is a measurement of the sender.\n */\n\n/**\n * The `stats` map on `hosts/{hostId}/campaigns/{campaignId}`.\n *\n * Every field is optional and every reader defaults it, because these are\n * written by three different writers at three different times — the send, the\n * delivery webhook, the unsubscribe handler — and a campaign is a legitimate,\n * readable document from the instant the first of them lands.\n */\nexport interface CampaignStats {\n /*========================================\n * WRITTEN BY THE SEND. Never recomputed.\n *\n * These are the truth of what happened, recorded once by the code that did\n * it. Re-deriving any of them at read time would produce a number that\n * disagrees with the send — the audience has moved on since, suppressions\n * have been added, consent has been recorded — and the recorded one is the\n * one that describes the campaign.\n *=======================================*/\n /** The whole audience the send was taken from, before the per-send cap. */\n audienceSize?: number\n /** `audienceSize` is a FLOOR: audience resolution hit its read ceiling. */\n audienceSizeTruncated?: boolean\n /** Addresses this send ADDRESSED — the audience after the per-send cap. */\n recipients?: number\n /** Messages the provider accepted. The `sent` in \"sent/recipients\". */\n sent?: number\n /** Of the audience, how many carry a recorded marketing consent basis. */\n consented?: number\n /** Of `consented`, how many hold a basis an operator asserted for them. */\n consentedByOperator?: number\n /** Of the audience, how many are reachable only because enforcement is\n * not retroactive — the population a strict consent policy removes. */\n grandfathered?: number\n /** Of the audience, how many the consent rule refused to mail. */\n consentWithheld?: number\n /** Of `recipients`, how many were already suppressed (unsubscribed,\n * bounced or complained on an earlier send). */\n suppressed?: number\n /** Of `recipients`, how many asked this site for mail less often than this\n * send would have arrived. Still subscribed; reached by a later campaign. */\n cadenceHeld?: number\n /** Recipients the hourly send governor refused mid-batch. */\n deferred?: number\n /**\n * This send carried an HTML part, so its links were trackable.\n *\n * Absent on every campaign sent before the field existed — see the\n * structural-zero note in this module's header. Absent is NOT false; it is\n * \"not recorded\", and the report says so rather than guessing.\n */\n clickTracked?: boolean\n /** Per-variant send counts for an A/B campaign. */\n variantSends?: Record<string, number>\n\n /*========================================\n * WRITTEN BY THE DELIVERY WEBHOOK, as increments.\n *=======================================*/\n /** Messages the receiving server accepted. The rate DENOMINATOR. */\n delivered?: number\n /** Open EVENTS. One reader opening four times counts four. */\n opens?: number\n /** Click EVENTS. One reader clicking three links counts three. */\n clicks?: number\n /** Messages whose FIRST open was seen — distinct readers who opened. */\n uniqueOpens?: number\n /** Messages whose FIRST click was seen — distinct readers who clicked. */\n uniqueClicks?: number\n /** Messages that bounced, permanent and transient together. */\n bounced?: number\n /** Recipients who pressed \"report spam\". */\n complained?: number\n\n /*========================================\n * WRITTEN BY THE UNSUBSCRIBE HANDLER.\n *=======================================*/\n /** Recipients who unsubscribed through THIS campaign's link. */\n unsubscribes?: number\n}\n\n/**\n * One rate, with the denominator it was taken over named as data.\n *\n * `denominatorLabel` is not a display nicety. It is the field that makes two\n * numbers called \"open rate\" distinguishable, and the screen renders it\n * beside the percentage for that reason.\n */\nexport interface CampaignRate {\n /** 0–1. Multiply for display; the model never formats. */\n value: number\n numerator: number\n denominator: number\n /** Reader-facing name of the denominator, e.g. `'delivered'`. */\n denominatorLabel: string\n}\n\n/**\n * A rate, or `null` when one cannot honestly be taken.\n *\n * `null` on a zero or unknown denominator — see the module header for why\n * that is not the same as 0%.\n */\nexport function campaignRate(\n numerator: number | undefined,\n denominator: number | undefined,\n denominatorLabel: string,\n): CampaignRate | null {\n const top = Number(numerator ?? 0)\n const bottom = Number(denominator ?? 0)\n if (!Number.isFinite(top) || !Number.isFinite(bottom)) return null\n if (bottom <= 0) return null\n return {\n value: top / bottom,\n numerator: top,\n denominator: bottom,\n denominatorLabel,\n }\n}\n\n/** Why a number the report would otherwise show is being withheld. */\nexport interface CampaignCaveat {\n /** Stable id, so a spec can assert on the caveat rather than its prose. */\n id:\n | 'delivery-unrecorded'\n | 'click-tracking-unrecorded'\n | 'audience-truncated'\n | 'send-deferred'\n /* Raised by `campaign-revenue.ts`, which reports through this shape so\n * the screen has one way of saying \"a number is being withheld and here\n * is why\" rather than one per section. */\n | 'revenue-denominator-unrecorded'\n | 'revenue-multi-currency'\n | 'revenue-mid-flight'\n | 'revenue-mixed-model'\n /* Raised by `campaign-conversions.ts`, through this shape for the same\n * reason: one way of saying \"a number is being withheld, or must not be\n * read the obvious way, and here is why\". The first two are the reasons\n * the four conversion kinds stand apart instead of totalling; the last\n * two qualify the uncredited figure rather than withholding it. */\n | 'conversions-kinds-overlap'\n | 'conversions-web-not-rolled-up'\n | 'conversions-unattributed-is-a-ceiling'\n | 'conversions-total-crosses-hosts'\n /* Raised by the Sequences block below (AGL-3254): the five figures are\n * a funnel read across, never a sum. */\n | 'sequences-funnel-not-summed'\n message: string\n}\n\n/** One population the send measured, for the audience breakdown. */\nexport interface CampaignPopulation {\n id: string\n label: string\n count: number\n /** What this count is a part OF, named. */\n ofLabel: string\n of: number\n}\n\n/** Everything the report screen renders, decided here rather than in JSX. */\nexport interface CampaignReport {\n sent: number\n recipients: number\n delivered: number | null\n opens: number\n clicks: number\n uniqueOpens: number | null\n uniqueClicks: number | null\n bounced: number\n complained: number\n unsubscribes: number\n /** Rates, each `null` when its denominator is zero or unrecorded. */\n rates: {\n /** Accepted by the receiving server, over what the provider accepted. */\n delivery: CampaignRate | null\n /** Distinct readers who opened, over delivered. */\n open: CampaignRate | null\n /** Distinct readers who clicked, over delivered. */\n click: CampaignRate | null\n /**\n * Distinct clickers over distinct OPENERS — a different question from\n * `click`, and the one the two get confused for. It answers \"of the\n * people who read it, how many acted\", not \"of the people who received\n * it\". Reported separately and labelled separately, never as \"click\n * rate\".\n */\n clickToOpen: CampaignRate | null\n /** Bounced over what the provider accepted. */\n bounce: CampaignRate | null\n /** Complaints over delivered — the number mailbox providers judge on. */\n complaint: CampaignRate | null\n /** Unsubscribes through this campaign's link, over delivered. */\n unsubscribe: CampaignRate | null\n }\n populations: CampaignPopulation[]\n caveats: CampaignCaveat[]\n}\n\n/**\n * Turns a stored `stats` map into the report.\n *\n * ## The denominator decisions, in one place\n *\n * - **`delivered`** carries the engagement rates — open, click, complaint,\n * unsubscribe. Mail that bounced was never in front of a human, so\n * including it in the denominator of an open rate depresses a number that\n * describes the audience with a fact about the address list. This is also\n * the convention every other tool reports, which matters: a merchant\n * comparing our figure with their previous ESP's must be comparing the same\n * quantity.\n * - **`sent`** carries the delivery and bounce rates, because those describe\n * what happened to what we handed the provider, and `delivered` is the\n * numerator of one of them — a rate cannot be over itself.\n * - **`uniqueOpens`** is the open-rate numerator, not `opens`. Open EVENTS\n * over recipients can exceed 100% the moment one person opens twice, and a\n * percentage above 100 is how a reader learns the number means something\n * other than what it says. Both are shown; only the distinct count is\n * divided.\n *\n * ## `delivered` unknown vs. zero\n *\n * A campaign predating the delivery webhook records no `delivered` at all.\n * That is reported as `null` and every rate over it is withheld, with a\n * caveat naming the reason — rather than substituting `sent`, which would\n * silently publish the flattered number this module exists to refuse.\n */\nexport function campaignReport(stats: CampaignStats | undefined): CampaignReport {\n const source = stats ?? {}\n const sent = Number(source.sent ?? 0)\n const recipients = Number(source.recipients ?? 0)\n const opens = Number(source.opens ?? 0)\n const clicks = Number(source.clicks ?? 0)\n const bounced = Number(source.bounced ?? 0)\n const complained = Number(source.complained ?? 0)\n const unsubscribes = Number(source.unsubscribes ?? 0)\n\n /*\n * ABSENT, not zero. `stats.delivered` is written only by the delivery\n * webhook, so `undefined` means \"no delivery event has ever been recorded\n * for this campaign\" — which for an old campaign means the webhook was not\n * connected, and for a campaign sent thirty seconds ago means the events\n * are still in flight. Neither is \"nothing was delivered\", and both are\n * ruined by `?? 0`, which would turn the unknown into a hard zero and make\n * every rate over it `null` for the RIGHT answer by the WRONG reasoning —\n * and would render \"0 delivered\" on screen beside \"500 sent\".\n */\n const delivered =\n source.delivered === undefined ? null : Number(source.delivered)\n const uniqueOpens =\n source.uniqueOpens === undefined ? null : Number(source.uniqueOpens)\n const uniqueClicks =\n source.uniqueClicks === undefined ? null : Number(source.uniqueClicks)\n\n /*\n * The click rate is withheld for a campaign that never recorded carrying an\n * HTML part, even when clicks are non-zero and `delivered` is known. See\n * the structural-zero note in the module header: for those sends 0 is the\n * only value the number could ever have taken, so a rate computed from it\n * measures our sending code rather than the recipients.\n */\n const clickTrackable = source.clickTracked === true\n\n const rates: CampaignReport['rates'] = {\n /*\n * `null` when `delivered` is UNRECORDED, and this is the one rate where\n * the numerator can be unknown rather than zero.\n *\n * Everywhere else an absent numerator is a genuine nought — a campaign\n * with delivery events and no opens really does have a 0% open rate, and\n * that is worth showing. Here the numerator IS the unrecorded quantity,\n * so `campaignRate(undefined, 1000, 'sent')` would divide a missing\n * measurement by a real one and publish \"0.0% delivery rate — 0 of 1,000\n * sent\" for a campaign whose delivery events were merely never recorded.\n * That is the flattering-substitution failure this module exists to\n * refuse, running in the other direction: not a rate that reads too high,\n * but a campaign that reads as a total delivery failure.\n */\n delivery:\n delivered === null ? null : campaignRate(delivered, sent, 'sent'),\n open: campaignRate(uniqueOpens ?? undefined, delivered ?? undefined, 'delivered'),\n click: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, delivered ?? undefined, 'delivered')\n : null,\n clickToOpen: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, uniqueOpens ?? undefined, 'unique openers')\n : null,\n bounce: campaignRate(bounced, sent, 'sent'),\n complaint: campaignRate(complained, delivered ?? undefined, 'delivered'),\n unsubscribe: campaignRate(unsubscribes, delivered ?? undefined, 'delivered'),\n }\n\n /*\n * The populations the SEND measured, reported as parts of a named whole\n * rather than as bare counts. \"412 withheld\" invites the question \"out of\n * what\"; the answer is the audience, and it is a different whole from the\n * one `suppressed` is measured against — consent is decided over the whole\n * audience and suppression over the capped recipient list, because that is\n * where each check runs. Netting them into one column would present two\n * different denominators as one.\n */\n const audienceSize = Number(source.audienceSize ?? 0)\n const populations: CampaignPopulation[] = []\n const addPopulation = (\n id: string,\n label: string,\n count: number | undefined,\n ofLabel: string,\n of: number,\n ) => {\n if (count === undefined) return\n populations.push({ id, label, count: Number(count), ofLabel, of })\n }\n addPopulation(\n 'consented',\n 'Had a consent basis',\n source.consented,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentedByOperator',\n 'Consent asserted by an operator',\n source.consentedByOperator,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'grandfathered',\n 'Reachable only because consent is not enforced retroactively',\n source.grandfathered,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentWithheld',\n 'Withheld by the consent rule',\n source.consentWithheld,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'suppressed',\n 'Already suppressed',\n source.suppressed,\n 'addressed',\n recipients,\n )\n addPopulation(\n 'cadenceHeld',\n 'Asked for mail less often than this',\n source.cadenceHeld,\n 'addressed',\n recipients,\n )\n\n const caveats: CampaignCaveat[] = []\n if (delivered === null) {\n caveats.push({\n id: 'delivery-unrecorded',\n message:\n 'No delivery events have been recorded for this campaign, so open, ' +\n 'click, complaint and unsubscribe rates cannot be computed — every ' +\n 'one of them is taken over delivered. Counts below are still real.',\n })\n }\n if (!clickTrackable) {\n caveats.push({\n id: 'click-tracking-unrecorded',\n message:\n 'This send did not record carrying an HTML part. Click tracking ' +\n 'rewrites links in the HTML, so a send without one reports zero ' +\n 'clicks whatever recipients did. The click count is shown; no click ' +\n 'rate is computed from it.',\n })\n }\n if (source.audienceSizeTruncated) {\n caveats.push({\n id: 'audience-truncated',\n message:\n 'Audience resolution stopped at its read ceiling, so the audience ' +\n 'size is a floor — the real audience is at least this large, and ' +\n 'every share taken over it is at most the figure shown.',\n })\n }\n if (Number(source.deferred ?? 0) > 0) {\n caveats.push({\n id: 'send-deferred',\n message:\n `${Number(source.deferred)} recipients were held back by the hourly ` +\n 'send governor and never received this campaign. They are counted in ' +\n 'addressed, not in sent.',\n })\n }\n\n return {\n sent,\n recipients,\n delivered,\n opens,\n clicks,\n uniqueOpens,\n uniqueClicks,\n bounced,\n complained,\n unsubscribes,\n rates,\n populations,\n caveats,\n }\n}\n\n/*==========================================\n * LINK-LEVEL CLICKS.\n *\n * Resend's `email.clicked` payload carries `data.click.link`, the destination\n * the recipient followed; `normalizeResendDeliveryEvents` already reads it\n * into `EmailDeliveryEvent.link`, and the per-recipient delivery log already\n * stores it. What did not exist was an aggregate — which is what \"link\n * clicks\" means, and it cannot be produced from the delivery log without\n * reading every recipient row for the campaign.\n *\n * So it is a WRITE-TIME rollup: one document per campaign,\n * `campaigns/{campaignId}/reports/links`, holding a bounded map. The report\n * reads exactly one document for the whole table.\n *=========================================*/\n\n/**\n * How many distinct destinations one campaign's rollup keeps.\n *\n * A CAP rather than a page size, and it exists because the map lives in a\n * single document with a 1 MiB ceiling. Clicks past the cap are counted in\n * {@link CampaignLinkRollup.overflowClicks} rather than dropped, so the\n * table's total still reconciles with `stats.clicks`.\n */\nexport const CAMPAIGN_LINK_ROLLUP_MAX = 50\n\n/**\n * Reduces a clicked URL to the key the rollup counts under.\n *\n * ## Why the query string is dropped\n *\n * Two reasons, and the second is the one that forces it:\n *\n * 1. **A campaign body goes through `resolveMergeTags` per recipient**, so a\n * link may carry a personalised query. Keying on the full URL would then\n * mint one rollup row per RECIPIENT — the aggregate degenerates into the\n * per-recipient log it exists to summarise, and it blows the cap on the\n * first campaign that does it.\n * 2. **A personalised query can carry the recipient's own address.** The\n * rollup is an aggregate read by everyone on the site's team; it must not\n * become a list of who clicked, and dropping the query is what guarantees\n * it cannot.\n *\n * ⚠️ The cost is real and is stated on the screen: two links to the same page\n * distinguished only by their UTM parameters count as ONE row. That is a\n * known limitation, not an oversight — see the note in the report card.\n *\n * @returns the normalized URL, or `null` for anything unparseable or not\n * http(s). A rollup key must be a URL a merchant recognises.\n */\nexport function campaignLinkKey(link: string | null | undefined): string | null {\n const raw = String(link ?? '').trim()\n if (!raw) return null\n try {\n const url = new URL(raw)\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n // Trailing slash normalised away so `/pricing` and `/pricing/` are one\n // row; the bare origin keeps its slash so the key is still a valid URL.\n const path = url.pathname.length > 1\n ? url.pathname.replace(/\\/+$/, '')\n : url.pathname\n return `${url.origin}${path}`\n } catch {\n return null\n }\n}\n\n/** One destination in the rollup. */\nexport interface CampaignLinkRow {\n url: string\n /** Click EVENTS on this destination. One reader clicking twice counts two. */\n clicks: number\n /** Share of the campaign's counted link clicks. */\n share: CampaignRate | null\n}\n\n/** The stored shape of `campaigns/{campaignId}/reports/links`. */\nexport interface CampaignLinkRollup {\n links?: Record<string, { url?: string; clicks?: number }>\n /** Clicks on destinations past {@link CAMPAIGN_LINK_ROLLUP_MAX}. */\n overflowClicks?: number\n /** Click events that arrived carrying no destination at all. */\n unattributedClicks?: number\n}\n\n/** What the link table renders. */\nexport interface CampaignLinkReport {\n rows: CampaignLinkRow[]\n /** Clicks counted against a named destination — the table's total. */\n attributedClicks: number\n overflowClicks: number\n unattributedClicks: number\n /** True once the cap bit, so the table says it is not the whole list. */\n truncated: boolean\n}\n\n/**\n * The link table, sorted by clicks descending.\n *\n * `share` is over ATTRIBUTED clicks — the clicks this table accounts for —\n * and not over `stats.clicks`. The two differ by the overflow and the\n * unattributed, and a share column that did not sum to 100% because of rows\n * that are not on screen is the kind of arithmetic a reader cannot check.\n * Both excluded figures are returned so the screen can state them.\n */\nexport function campaignLinkReport(\n rollup: CampaignLinkRollup | undefined,\n): CampaignLinkReport {\n const entries = Object.values(rollup?.links ?? {})\n .map((entry) => ({\n url: String(entry?.url ?? ''),\n clicks: Number(entry?.clicks ?? 0),\n }))\n .filter((entry) => entry.url && Number.isFinite(entry.clicks))\n const attributedClicks = entries.reduce((total, one) => total + one.clicks, 0)\n const rows = [...entries]\n .sort((a, b) => b.clicks - a.clicks || a.url.localeCompare(b.url))\n .map((entry) => ({\n ...entry,\n share: campaignRate(entry.clicks, attributedClicks, 'link clicks counted'),\n }))\n const overflowClicks = Number(rollup?.overflowClicks ?? 0)\n return {\n rows,\n attributedClicks,\n overflowClicks,\n unattributedClicks: Number(rollup?.unattributedClicks ?? 0),\n truncated: entries.length >= CAMPAIGN_LINK_ROLLUP_MAX || overflowClicks > 0,\n }\n}\n\n/*==========================================\n * WHAT THE CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign the way a form does — `campaignIds` on its own\n * document — and its outcomes are then the campaign's to report: every\n * person enrolled, the first email each one was sent, the replies, the\n * meetings booked from a sequence link, and the enrolled leads that\n * converted. The Outreach runtime credits each outcome once per enrollment\n * to every campaign the sequence was in when the person was enrolled, into\n * `hosts/{hostId}/campaignSequenceReports/{campaignId}` — one document per\n * campaign, beside the conversions rollup and never inside the campaign\n * document, which the history list and the glance widget read.\n *\n * ## The five figures are a funnel, not a sum\n *\n * Each figure counts ENROLLMENTS at a stage: 40 enrolled, 38 sent, 6\n * replied, 2 meetings, 1 converted. They are read across, never added — a\n * person is in every stage they reached — and the model keeps them as an\n * ordered list rather than a total for the reason the conversion kinds are\n * kept apart.\n *\n * ## Absent is not zero\n *\n * A campaign no sequence was ever in has no document, and a stage nobody\n * reached has no field. Both are `null` here, so the screen can say\n * \"nothing recorded\" for the first and draw a dash for the second, rather\n * than printing a measured 0 replies for a campaign whose sequence went\n * out yesterday.\n *=========================================*/\n\n/**\n * The outcomes the runtime credits, in funnel order. An array first, because\n * the ORDER is the reading order and a second list is a second chance to\n * leave one out; the union is derived from it.\n */\nexport const CAMPAIGN_SEQUENCE_OUTCOMES = [\n 'enrolled',\n 'sent',\n 'replied',\n 'meetings',\n 'converted',\n] as const\n\nexport type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number]\n\n/**\n * The org collection holding one document per campaign container,\n * `orgs/{orgId}/campaignSequenceReports/{campaignId}`.\n */\nexport const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports'\n\n/** What a reader calls each outcome, and what the count means. */\nexport const CAMPAIGN_SEQUENCE_OUTCOME_COPY: Readonly<\n Record<CampaignSequenceOutcome, { label: string; note: string }>\n> = {\n enrolled: { label: 'Enrolled', note: 'people enrolled in a sequence in this campaign' },\n sent: { label: 'Sent', note: 'of them, sent their first sequence email' },\n replied: { label: 'Replied', note: 'of them, who wrote back' },\n meetings: { label: 'Meetings', note: 'bookings made from a sequence link' },\n converted: { label: 'Converted', note: 'enrolled leads that became contacts' },\n}\n\n/**\n * The stored shape of `campaignSequenceReports/{campaignId}`.\n *\n * Read-side only, every field optional: another plugin's runtime wrote it,\n * and a reader that assumed a field was present would throw on the first\n * document written before the field existed.\n */\nexport interface CampaignSequencesRollup {\n byOutcome?: Partial<Record<CampaignSequenceOutcome, number>>\n /** When the runtime last credited anything, epoch ms. */\n updatedAtMs?: number\n}\n\n/** One outcome's figure on screen. */\nexport interface CampaignSequenceFigure {\n outcome: CampaignSequenceOutcome\n label: string\n /** The count, or `null` when the rollup holds no entry for the outcome. */\n value: number | null\n note: string\n}\n\n/** Everything the Sequences section renders. */\nexport interface CampaignSequencesReport {\n /** Always all five, always in {@link CAMPAIGN_SEQUENCE_OUTCOMES} order. */\n figures: CampaignSequenceFigure[]\n /** Whether the rollup document exists at all — see the block header. */\n recorded: boolean\n /** At least one outcome holds a figure. */\n any: boolean\n caveats: CampaignCaveat[]\n}\n\n/** Turns the stored rollup into the Sequences section. */\nexport function campaignSequencesReport(\n rollup: CampaignSequencesRollup | undefined,\n): CampaignSequencesReport {\n const stored = rollup?.byOutcome ?? {}\n const figures: CampaignSequenceFigure[] = CAMPAIGN_SEQUENCE_OUTCOMES.map((outcome) => {\n const raw = stored[outcome]\n const value = Math.floor(Number(raw ?? 0))\n return {\n outcome,\n label: CAMPAIGN_SEQUENCE_OUTCOME_COPY[outcome].label,\n // Unrecorded, negative and non-numeric all read as \"no figure\": the\n // runtime only ever increments, so a stored 0 is not a count either.\n value: raw === undefined || !Number.isFinite(value) || value <= 0 ? null : value,\n note: CAMPAIGN_SEQUENCE_OUTCOME_COPY[outcome].note,\n }\n })\n const any = figures.some((figure) => figure.value !== null)\n const caveats: CampaignCaveat[] = any\n ? [\n {\n id: 'sequences-funnel-not-summed',\n message:\n 'Each figure counts the people who reached that stage, so a person ' +\n 'who replied is counted under Enrolled and Sent as well. Read them ' +\n 'across; they are deliberately not added together.',\n },\n ]\n : []\n return { figures, recorded: rollup !== undefined, any, caveats }\n}\n"],"names":["campaignRate","numerator","denominator","denominatorLabel","top","Number","bottom","isFinite","value","campaignReport","stats","source","sent","recipients","opens","clicks","bounced","complained","unsubscribes","delivered","undefined","uniqueOpens","uniqueClicks","clickTrackable","clickTracked","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","audienceSize","populations","addPopulation","id","label","count","ofLabel","of","push","consented","consentedByOperator","grandfathered","consentWithheld","suppressed","cadenceHeld","caveats","message","audienceSizeTruncated","deferred","CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","link","raw","String","trim","url","URL","protocol","path","pathname","length","replace","origin","campaignLinkReport","rollup","entries","Object","values","links","map","entry","filter","attributedClicks","reduce","total","one","rows","sort","a","b","localeCompare","share","overflowClicks","unattributedClicks","truncated","CAMPAIGN_SEQUENCE_OUTCOMES","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","CAMPAIGN_SEQUENCE_OUTCOME_COPY","enrolled","note","replied","meetings","converted","campaignSequencesReport","stored","byOutcome","figures","outcome","Math","floor","any","some","figure","recorded"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/campaign-report.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * CAMPAIGN REPORTING MATH — the only place a rate is computed.\n *\n * ## Why a pure module and not a component\n *\n * Every number on the report screen is a division, and a division is where\n * email reporting goes wrong. Putting the arithmetic in JSX means the\n * denominator is chosen by whoever writes the next card, in a file nobody\n * tests for arithmetic; putting it here means each rate is named once,\n * carries its own denominator as data, and is provable.\n *\n * ## The rule this module exists to enforce\n *\n * **A rate is a triple — numerator, denominator, and the NAME of the\n * denominator — or it is not reported.** An open rate over `sent` and an open\n * rate over `delivered` are different numbers with the same label, and the\n * gap between them is exactly the mail that bounced. The industry convention\n * is over `delivered`, and a report that quietly used `sent` would read\n * higher than the same campaign measured anywhere else.\n *\n * So {@link CampaignRate} carries `denominatorLabel`, and the screen is\n * required to render it. There is no overload that omits it.\n *\n * ## Why some rates are deliberately absent\n *\n * {@link campaignRate} answers `null`, not zero, when it cannot divide:\n *\n * - **A zero denominator.** 0 opens out of 0 delivered is not a 0% open\n * rate, it is no open rate. Rendering 0% invites the reader to compare it\n * with a campaign that really did fail.\n * - **An UNKNOWN denominator.** `delivered` is counted by the delivery\n * webhook, which was connected after some campaigns were sent. A campaign\n * with 400 sends and no delivery events has an unknown denominator, not a\n * denominator of zero — and dividing by `sent` instead is precisely the\n * flattering substitution above.\n *\n * ## The structural-zero window\n *\n * Click tracking rewrites links in the HTML part. Sends that carried no HTML\n * part were therefore untrackable, and every one of those campaigns reports 0\n * clicks whatever the recipients actually did — a real 0 and a structural 0\n * rendered identically. `send-email.ts` now synthesises an HTML part for a\n * text-only send, so every send after that carries one, and\n * `campaign-send.ts` records {@link CampaignStats.clickTracked} to say so.\n *\n * A campaign with no such marker predates the record. Its click COUNT is\n * still shown — it is a real count of real events — but no click RATE is\n * computed from it, because a rate presents the number as a measurement of\n * the audience and for those campaigns it is a measurement of the sender.\n */\n\n/**\n * The `stats` map on `hosts/{hostId}/campaigns/{campaignId}`.\n *\n * Every field is optional and every reader defaults it, because these are\n * written by three different writers at three different times — the send, the\n * delivery webhook, the unsubscribe handler — and a campaign is a legitimate,\n * readable document from the instant the first of them lands.\n */\nexport interface CampaignStats {\n /*========================================\n * WRITTEN BY THE SEND. Never recomputed.\n *\n * These are the truth of what happened, recorded once by the code that did\n * it. Re-deriving any of them at read time would produce a number that\n * disagrees with the send — the audience has moved on since, suppressions\n * have been added, consent has been recorded — and the recorded one is the\n * one that describes the campaign.\n *=======================================*/\n /** The whole audience the send was taken from, before the per-send cap. */\n audienceSize?: number\n /** `audienceSize` is a FLOOR: audience resolution hit its read ceiling. */\n audienceSizeTruncated?: boolean\n /** Addresses this send ADDRESSED — the audience after the per-send cap. */\n recipients?: number\n /** Messages the provider accepted. The `sent` in \"sent/recipients\". */\n sent?: number\n /** Of the audience, how many carry a recorded marketing consent basis. */\n consented?: number\n /** Of `consented`, how many hold a basis an operator asserted for them. */\n consentedByOperator?: number\n /** Of the audience, how many are reachable only because enforcement is\n * not retroactive — the population a strict consent policy removes. */\n grandfathered?: number\n /** Of the audience, how many the consent rule refused to mail. */\n consentWithheld?: number\n /** Of `recipients`, how many were already suppressed (unsubscribed,\n * bounced or complained on an earlier send). */\n suppressed?: number\n /** Of `recipients`, how many asked this site for mail less often than this\n * send would have arrived. Still subscribed; reached by a later campaign. */\n cadenceHeld?: number\n /**\n * Of `recipients`, how many the deliverability check took out (AGL-3328):\n * their domain has no mail server, or their mail gateway refused this\n * sending domain twice in thirty days and delivered nothing.\n */\n noMailServer?: number\n gatewayHeld?: number\n /** Recipients the hourly send governor refused mid-batch. */\n deferred?: number\n /**\n * This send carried an HTML part, so its links were trackable.\n *\n * Absent on every campaign sent before the field existed — see the\n * structural-zero note in this module's header. Absent is NOT false; it is\n * \"not recorded\", and the report says so rather than guessing.\n */\n clickTracked?: boolean\n /** Per-variant send counts for an A/B campaign. */\n variantSends?: Record<string, number>\n\n /*========================================\n * WRITTEN BY THE DELIVERY WEBHOOK, as increments.\n *=======================================*/\n /** Messages the receiving server accepted. The rate DENOMINATOR. */\n delivered?: number\n /** Open EVENTS. One reader opening four times counts four. */\n opens?: number\n /** Click EVENTS. One reader clicking three links counts three. */\n clicks?: number\n /** Messages whose FIRST open was seen — distinct readers who opened. */\n uniqueOpens?: number\n /** Messages whose FIRST click was seen — distinct readers who clicked. */\n uniqueClicks?: number\n /** Messages that bounced, permanent and transient together. */\n bounced?: number\n /** Recipients who pressed \"report spam\". */\n complained?: number\n\n /*========================================\n * WRITTEN BY THE UNSUBSCRIBE HANDLER.\n *=======================================*/\n /** Recipients who unsubscribed through THIS campaign's link. */\n unsubscribes?: number\n}\n\n/**\n * One rate, with the denominator it was taken over named as data.\n *\n * `denominatorLabel` is not a display nicety. It is the field that makes two\n * numbers called \"open rate\" distinguishable, and the screen renders it\n * beside the percentage for that reason.\n */\nexport interface CampaignRate {\n /** 0–1. Multiply for display; the model never formats. */\n value: number\n numerator: number\n denominator: number\n /** Reader-facing name of the denominator, e.g. `'delivered'`. */\n denominatorLabel: string\n}\n\n/**\n * A rate, or `null` when one cannot honestly be taken.\n *\n * `null` on a zero or unknown denominator — see the module header for why\n * that is not the same as 0%.\n */\nexport function campaignRate(\n numerator: number | undefined,\n denominator: number | undefined,\n denominatorLabel: string,\n): CampaignRate | null {\n const top = Number(numerator ?? 0)\n const bottom = Number(denominator ?? 0)\n if (!Number.isFinite(top) || !Number.isFinite(bottom)) return null\n if (bottom <= 0) return null\n return {\n value: top / bottom,\n numerator: top,\n denominator: bottom,\n denominatorLabel,\n }\n}\n\n/** Why a number the report would otherwise show is being withheld. */\nexport interface CampaignCaveat {\n /** Stable id, so a spec can assert on the caveat rather than its prose. */\n id:\n | 'delivery-unrecorded'\n | 'click-tracking-unrecorded'\n | 'audience-truncated'\n | 'send-deferred'\n /* Raised by `campaign-revenue.ts`, which reports through this shape so\n * the screen has one way of saying \"a number is being withheld and here\n * is why\" rather than one per section. */\n | 'revenue-denominator-unrecorded'\n | 'revenue-multi-currency'\n | 'revenue-mid-flight'\n | 'revenue-mixed-model'\n /* Raised by `campaign-conversions.ts`, through this shape for the same\n * reason: one way of saying \"a number is being withheld, or must not be\n * read the obvious way, and here is why\". The first two are the reasons\n * the four conversion kinds stand apart instead of totalling; the last\n * two qualify the uncredited figure rather than withholding it. */\n | 'conversions-kinds-overlap'\n | 'conversions-web-not-rolled-up'\n | 'conversions-unattributed-is-a-ceiling'\n | 'conversions-total-crosses-hosts'\n /* Raised by the Sequences block below (AGL-3254): the five figures are\n * a funnel read across, never a sum. */\n | 'sequences-funnel-not-summed'\n message: string\n}\n\n/** One population the send measured, for the audience breakdown. */\nexport interface CampaignPopulation {\n id: string\n label: string\n count: number\n /** What this count is a part OF, named. */\n ofLabel: string\n of: number\n}\n\n/** Everything the report screen renders, decided here rather than in JSX. */\nexport interface CampaignReport {\n sent: number\n recipients: number\n delivered: number | null\n opens: number\n clicks: number\n uniqueOpens: number | null\n uniqueClicks: number | null\n bounced: number\n complained: number\n unsubscribes: number\n /** Rates, each `null` when its denominator is zero or unrecorded. */\n rates: {\n /** Accepted by the receiving server, over what the provider accepted. */\n delivery: CampaignRate | null\n /** Distinct readers who opened, over delivered. */\n open: CampaignRate | null\n /** Distinct readers who clicked, over delivered. */\n click: CampaignRate | null\n /**\n * Distinct clickers over distinct OPENERS — a different question from\n * `click`, and the one the two get confused for. It answers \"of the\n * people who read it, how many acted\", not \"of the people who received\n * it\". Reported separately and labelled separately, never as \"click\n * rate\".\n */\n clickToOpen: CampaignRate | null\n /** Bounced over what the provider accepted. */\n bounce: CampaignRate | null\n /** Complaints over delivered — the number mailbox providers judge on. */\n complaint: CampaignRate | null\n /** Unsubscribes through this campaign's link, over delivered. */\n unsubscribe: CampaignRate | null\n }\n populations: CampaignPopulation[]\n caveats: CampaignCaveat[]\n}\n\n/**\n * Turns a stored `stats` map into the report.\n *\n * ## The denominator decisions, in one place\n *\n * - **`delivered`** carries the engagement rates — open, click, complaint,\n * unsubscribe. Mail that bounced was never in front of a human, so\n * including it in the denominator of an open rate depresses a number that\n * describes the audience with a fact about the address list. This is also\n * the convention every other tool reports, which matters: a merchant\n * comparing our figure with their previous ESP's must be comparing the same\n * quantity.\n * - **`sent`** carries the delivery and bounce rates, because those describe\n * what happened to what we handed the provider, and `delivered` is the\n * numerator of one of them — a rate cannot be over itself.\n * - **`uniqueOpens`** is the open-rate numerator, not `opens`. Open EVENTS\n * over recipients can exceed 100% the moment one person opens twice, and a\n * percentage above 100 is how a reader learns the number means something\n * other than what it says. Both are shown; only the distinct count is\n * divided.\n *\n * ## `delivered` unknown vs. zero\n *\n * A campaign predating the delivery webhook records no `delivered` at all.\n * That is reported as `null` and every rate over it is withheld, with a\n * caveat naming the reason — rather than substituting `sent`, which would\n * silently publish the flattered number this module exists to refuse.\n */\nexport function campaignReport(stats: CampaignStats | undefined): CampaignReport {\n const source = stats ?? {}\n const sent = Number(source.sent ?? 0)\n const recipients = Number(source.recipients ?? 0)\n const opens = Number(source.opens ?? 0)\n const clicks = Number(source.clicks ?? 0)\n const bounced = Number(source.bounced ?? 0)\n const complained = Number(source.complained ?? 0)\n const unsubscribes = Number(source.unsubscribes ?? 0)\n\n /*\n * ABSENT, not zero. `stats.delivered` is written only by the delivery\n * webhook, so `undefined` means \"no delivery event has ever been recorded\n * for this campaign\" — which for an old campaign means the webhook was not\n * connected, and for a campaign sent thirty seconds ago means the events\n * are still in flight. Neither is \"nothing was delivered\", and both are\n * ruined by `?? 0`, which would turn the unknown into a hard zero and make\n * every rate over it `null` for the RIGHT answer by the WRONG reasoning —\n * and would render \"0 delivered\" on screen beside \"500 sent\".\n */\n const delivered =\n source.delivered === undefined ? null : Number(source.delivered)\n const uniqueOpens =\n source.uniqueOpens === undefined ? null : Number(source.uniqueOpens)\n const uniqueClicks =\n source.uniqueClicks === undefined ? null : Number(source.uniqueClicks)\n\n /*\n * The click rate is withheld for a campaign that never recorded carrying an\n * HTML part, even when clicks are non-zero and `delivered` is known. See\n * the structural-zero note in the module header: for those sends 0 is the\n * only value the number could ever have taken, so a rate computed from it\n * measures our sending code rather than the recipients.\n */\n const clickTrackable = source.clickTracked === true\n\n const rates: CampaignReport['rates'] = {\n /*\n * `null` when `delivered` is UNRECORDED, and this is the one rate where\n * the numerator can be unknown rather than zero.\n *\n * Everywhere else an absent numerator is a genuine nought — a campaign\n * with delivery events and no opens really does have a 0% open rate, and\n * that is worth showing. Here the numerator IS the unrecorded quantity,\n * so `campaignRate(undefined, 1000, 'sent')` would divide a missing\n * measurement by a real one and publish \"0.0% delivery rate — 0 of 1,000\n * sent\" for a campaign whose delivery events were merely never recorded.\n * That is the flattering-substitution failure this module exists to\n * refuse, running in the other direction: not a rate that reads too high,\n * but a campaign that reads as a total delivery failure.\n */\n delivery:\n delivered === null ? null : campaignRate(delivered, sent, 'sent'),\n open: campaignRate(uniqueOpens ?? undefined, delivered ?? undefined, 'delivered'),\n click: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, delivered ?? undefined, 'delivered')\n : null,\n clickToOpen: clickTrackable\n ? campaignRate(uniqueClicks ?? undefined, uniqueOpens ?? undefined, 'unique openers')\n : null,\n bounce: campaignRate(bounced, sent, 'sent'),\n complaint: campaignRate(complained, delivered ?? undefined, 'delivered'),\n unsubscribe: campaignRate(unsubscribes, delivered ?? undefined, 'delivered'),\n }\n\n /*\n * The populations the SEND measured, reported as parts of a named whole\n * rather than as bare counts. \"412 withheld\" invites the question \"out of\n * what\"; the answer is the audience, and it is a different whole from the\n * one `suppressed` is measured against — consent is decided over the whole\n * audience and suppression over the capped recipient list, because that is\n * where each check runs. Netting them into one column would present two\n * different denominators as one.\n */\n const audienceSize = Number(source.audienceSize ?? 0)\n const populations: CampaignPopulation[] = []\n const addPopulation = (\n id: string,\n label: string,\n count: number | undefined,\n ofLabel: string,\n of: number,\n ) => {\n if (count === undefined) return\n populations.push({ id, label, count: Number(count), ofLabel, of })\n }\n addPopulation(\n 'consented',\n 'Had a consent basis',\n source.consented,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentedByOperator',\n 'Consent asserted by an operator',\n source.consentedByOperator,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'grandfathered',\n 'Reachable only because consent is not enforced retroactively',\n source.grandfathered,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'consentWithheld',\n 'Withheld by the consent rule',\n source.consentWithheld,\n 'audience',\n audienceSize,\n )\n addPopulation(\n 'suppressed',\n 'Already suppressed',\n source.suppressed,\n 'addressed',\n recipients,\n )\n addPopulation(\n 'cadenceHeld',\n 'Asked for mail less often than this',\n source.cadenceHeld,\n 'addressed',\n recipients,\n )\n addPopulation(\n 'noMailServer',\n 'Excluded: no mail server',\n source.noMailServer,\n 'addressed',\n recipients,\n )\n addPopulation(\n 'gatewayHeld',\n 'Excluded: behind a gateway that refused this sender',\n source.gatewayHeld,\n 'addressed',\n recipients,\n )\n\n const caveats: CampaignCaveat[] = []\n if (delivered === null) {\n caveats.push({\n id: 'delivery-unrecorded',\n message:\n 'No delivery events have been recorded for this campaign, so open, ' +\n 'click, complaint and unsubscribe rates cannot be computed — every ' +\n 'one of them is taken over delivered. Counts below are still real.',\n })\n }\n if (!clickTrackable) {\n caveats.push({\n id: 'click-tracking-unrecorded',\n message:\n 'This send did not record carrying an HTML part. Click tracking ' +\n 'rewrites links in the HTML, so a send without one reports zero ' +\n 'clicks whatever recipients did. The click count is shown; no click ' +\n 'rate is computed from it.',\n })\n }\n if (source.audienceSizeTruncated) {\n caveats.push({\n id: 'audience-truncated',\n message:\n 'Audience resolution stopped at its read ceiling, so the audience ' +\n 'size is a floor — the real audience is at least this large, and ' +\n 'every share taken over it is at most the figure shown.',\n })\n }\n if (Number(source.deferred ?? 0) > 0) {\n caveats.push({\n id: 'send-deferred',\n message:\n `${Number(source.deferred)} recipients were held back by the hourly ` +\n 'send governor and never received this campaign. They are counted in ' +\n 'addressed, not in sent.',\n })\n }\n\n return {\n sent,\n recipients,\n delivered,\n opens,\n clicks,\n uniqueOpens,\n uniqueClicks,\n bounced,\n complained,\n unsubscribes,\n rates,\n populations,\n caveats,\n }\n}\n\n/*==========================================\n * LINK-LEVEL CLICKS.\n *\n * Resend's `email.clicked` payload carries `data.click.link`, the destination\n * the recipient followed; `normalizeResendDeliveryEvents` already reads it\n * into `EmailDeliveryEvent.link`, and the per-recipient delivery log already\n * stores it. What did not exist was an aggregate — which is what \"link\n * clicks\" means, and it cannot be produced from the delivery log without\n * reading every recipient row for the campaign.\n *\n * So it is a WRITE-TIME rollup: one document per campaign,\n * `campaigns/{campaignId}/reports/links`, holding a bounded map. The report\n * reads exactly one document for the whole table.\n *=========================================*/\n\n/**\n * How many distinct destinations one campaign's rollup keeps.\n *\n * A CAP rather than a page size, and it exists because the map lives in a\n * single document with a 1 MiB ceiling. Clicks past the cap are counted in\n * {@link CampaignLinkRollup.overflowClicks} rather than dropped, so the\n * table's total still reconciles with `stats.clicks`.\n */\nexport const CAMPAIGN_LINK_ROLLUP_MAX = 50\n\n/**\n * Reduces a clicked URL to the key the rollup counts under.\n *\n * ## Why the query string is dropped\n *\n * Two reasons, and the second is the one that forces it:\n *\n * 1. **A campaign body goes through `resolveMergeTags` per recipient**, so a\n * link may carry a personalised query. Keying on the full URL would then\n * mint one rollup row per RECIPIENT — the aggregate degenerates into the\n * per-recipient log it exists to summarise, and it blows the cap on the\n * first campaign that does it.\n * 2. **A personalised query can carry the recipient's own address.** The\n * rollup is an aggregate read by everyone on the site's team; it must not\n * become a list of who clicked, and dropping the query is what guarantees\n * it cannot.\n *\n * ⚠️ The cost is real and is stated on the screen: two links to the same page\n * distinguished only by their UTM parameters count as ONE row. That is a\n * known limitation, not an oversight — see the note in the report card.\n *\n * @returns the normalized URL, or `null` for anything unparseable or not\n * http(s). A rollup key must be a URL a merchant recognises.\n */\nexport function campaignLinkKey(link: string | null | undefined): string | null {\n const raw = String(link ?? '').trim()\n if (!raw) return null\n try {\n const url = new URL(raw)\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n // Trailing slash normalised away so `/pricing` and `/pricing/` are one\n // row; the bare origin keeps its slash so the key is still a valid URL.\n const path = url.pathname.length > 1\n ? url.pathname.replace(/\\/+$/, '')\n : url.pathname\n return `${url.origin}${path}`\n } catch {\n return null\n }\n}\n\n/** One destination in the rollup. */\nexport interface CampaignLinkRow {\n url: string\n /** Click EVENTS on this destination. One reader clicking twice counts two. */\n clicks: number\n /** Share of the campaign's counted link clicks. */\n share: CampaignRate | null\n}\n\n/** The stored shape of `campaigns/{campaignId}/reports/links`. */\nexport interface CampaignLinkRollup {\n links?: Record<string, { url?: string; clicks?: number }>\n /** Clicks on destinations past {@link CAMPAIGN_LINK_ROLLUP_MAX}. */\n overflowClicks?: number\n /** Click events that arrived carrying no destination at all. */\n unattributedClicks?: number\n}\n\n/** What the link table renders. */\nexport interface CampaignLinkReport {\n rows: CampaignLinkRow[]\n /** Clicks counted against a named destination — the table's total. */\n attributedClicks: number\n overflowClicks: number\n unattributedClicks: number\n /** True once the cap bit, so the table says it is not the whole list. */\n truncated: boolean\n}\n\n/**\n * The link table, sorted by clicks descending.\n *\n * `share` is over ATTRIBUTED clicks — the clicks this table accounts for —\n * and not over `stats.clicks`. The two differ by the overflow and the\n * unattributed, and a share column that did not sum to 100% because of rows\n * that are not on screen is the kind of arithmetic a reader cannot check.\n * Both excluded figures are returned so the screen can state them.\n */\nexport function campaignLinkReport(\n rollup: CampaignLinkRollup | undefined,\n): CampaignLinkReport {\n const entries = Object.values(rollup?.links ?? {})\n .map((entry) => ({\n url: String(entry?.url ?? ''),\n clicks: Number(entry?.clicks ?? 0),\n }))\n .filter((entry) => entry.url && Number.isFinite(entry.clicks))\n const attributedClicks = entries.reduce((total, one) => total + one.clicks, 0)\n const rows = [...entries]\n .sort((a, b) => b.clicks - a.clicks || a.url.localeCompare(b.url))\n .map((entry) => ({\n ...entry,\n share: campaignRate(entry.clicks, attributedClicks, 'link clicks counted'),\n }))\n const overflowClicks = Number(rollup?.overflowClicks ?? 0)\n return {\n rows,\n attributedClicks,\n overflowClicks,\n unattributedClicks: Number(rollup?.unattributedClicks ?? 0),\n truncated: entries.length >= CAMPAIGN_LINK_ROLLUP_MAX || overflowClicks > 0,\n }\n}\n\n/*==========================================\n * WHAT THE CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign the way a form does — `campaignIds` on its own\n * document — and its outcomes are then the campaign's to report: every\n * person enrolled, the first email each one was sent, the replies, the\n * meetings booked from a sequence link, and the enrolled leads that\n * converted. The Outreach runtime credits each outcome once per enrollment\n * to every campaign the sequence was in when the person was enrolled, into\n * `hosts/{hostId}/campaignSequenceReports/{campaignId}` — one document per\n * campaign, beside the conversions rollup and never inside the campaign\n * document, which the history list and the glance widget read.\n *\n * ## The five figures are a funnel, not a sum\n *\n * Each figure counts ENROLLMENTS at a stage: 40 enrolled, 38 sent, 6\n * replied, 2 meetings, 1 converted. They are read across, never added — a\n * person is in every stage they reached — and the model keeps them as an\n * ordered list rather than a total for the reason the conversion kinds are\n * kept apart.\n *\n * ## Absent is not zero\n *\n * A campaign no sequence was ever in has no document, and a stage nobody\n * reached has no field. Both are `null` here, so the screen can say\n * \"nothing recorded\" for the first and draw a dash for the second, rather\n * than printing a measured 0 replies for a campaign whose sequence went\n * out yesterday.\n *=========================================*/\n\n/**\n * The outcomes the runtime credits, in funnel order. An array first, because\n * the ORDER is the reading order and a second list is a second chance to\n * leave one out; the union is derived from it.\n */\nexport const CAMPAIGN_SEQUENCE_OUTCOMES = [\n 'enrolled',\n 'sent',\n 'replied',\n 'meetings',\n 'converted',\n] as const\n\nexport type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number]\n\n/**\n * The org collection holding one document per campaign container,\n * `orgs/{orgId}/campaignSequenceReports/{campaignId}`.\n */\nexport const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports'\n\n/** What a reader calls each outcome, and what the count means. */\nexport const CAMPAIGN_SEQUENCE_OUTCOME_COPY: Readonly<\n Record<CampaignSequenceOutcome, { label: string; note: string }>\n> = {\n enrolled: { label: 'Enrolled', note: 'people enrolled in a sequence in this campaign' },\n sent: { label: 'Sent', note: 'of them, sent their first sequence email' },\n replied: { label: 'Replied', note: 'of them, who wrote back' },\n meetings: { label: 'Meetings', note: 'bookings made from a sequence link' },\n converted: { label: 'Converted', note: 'enrolled leads that became contacts' },\n}\n\n/**\n * The stored shape of `campaignSequenceReports/{campaignId}`.\n *\n * Read-side only, every field optional: another plugin's runtime wrote it,\n * and a reader that assumed a field was present would throw on the first\n * document written before the field existed.\n */\nexport interface CampaignSequencesRollup {\n byOutcome?: Partial<Record<CampaignSequenceOutcome, number>>\n /** When the runtime last credited anything, epoch ms. */\n updatedAtMs?: number\n}\n\n/** One outcome's figure on screen. */\nexport interface CampaignSequenceFigure {\n outcome: CampaignSequenceOutcome\n label: string\n /** The count, or `null` when the rollup holds no entry for the outcome. */\n value: number | null\n note: string\n}\n\n/** Everything the Sequences section renders. */\nexport interface CampaignSequencesReport {\n /** Always all five, always in {@link CAMPAIGN_SEQUENCE_OUTCOMES} order. */\n figures: CampaignSequenceFigure[]\n /** Whether the rollup document exists at all — see the block header. */\n recorded: boolean\n /** At least one outcome holds a figure. */\n any: boolean\n caveats: CampaignCaveat[]\n}\n\n/** Turns the stored rollup into the Sequences section. */\nexport function campaignSequencesReport(\n rollup: CampaignSequencesRollup | undefined,\n): CampaignSequencesReport {\n const stored = rollup?.byOutcome ?? {}\n const figures: CampaignSequenceFigure[] = CAMPAIGN_SEQUENCE_OUTCOMES.map((outcome) => {\n const raw = stored[outcome]\n const value = Math.floor(Number(raw ?? 0))\n return {\n outcome,\n label: CAMPAIGN_SEQUENCE_OUTCOME_COPY[outcome].label,\n // Unrecorded, negative and non-numeric all read as \"no figure\": the\n // runtime only ever increments, so a stored 0 is not a count either.\n value: raw === undefined || !Number.isFinite(value) || value <= 0 ? null : value,\n note: CAMPAIGN_SEQUENCE_OUTCOME_COPY[outcome].note,\n }\n })\n const any = figures.some((figure) => figure.value !== null)\n const caveats: CampaignCaveat[] = any\n ? [\n {\n id: 'sequences-funnel-not-summed',\n message:\n 'Each figure counts the people who reached that stage, so a person ' +\n 'who replied is counted under Enrolled and Sent as well. Read them ' +\n 'across; they are deliberately not added together.',\n },\n ]\n : []\n return { figures, recorded: rollup !== undefined, any, caveats }\n}\n"],"names":["campaignRate","numerator","denominator","denominatorLabel","top","Number","bottom","isFinite","value","campaignReport","stats","source","sent","recipients","opens","clicks","bounced","complained","unsubscribes","delivered","undefined","uniqueOpens","uniqueClicks","clickTrackable","clickTracked","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","audienceSize","populations","addPopulation","id","label","count","ofLabel","of","push","consented","consentedByOperator","grandfathered","consentWithheld","suppressed","cadenceHeld","noMailServer","gatewayHeld","caveats","message","audienceSizeTruncated","deferred","CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","link","raw","String","trim","url","URL","protocol","path","pathname","length","replace","origin","campaignLinkReport","rollup","entries","Object","values","links","map","entry","filter","attributedClicks","reduce","total","one","rows","sort","a","b","localeCompare","share","overflowClicks","unattributedClicks","truncated","CAMPAIGN_SEQUENCE_OUTCOMES","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","CAMPAIGN_SEQUENCE_OUTCOME_COPY","enrolled","note","replied","meetings","converted","campaignSequencesReport","stored","byOutcome","figures","outcome","Math","floor","any","some","figure","recorded"],"mappings":"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"}
@@ -41,11 +41,19 @@
41
41
  *
42
42
  * @returns epoch ms, or 0 when the message has no time at all.
43
43
  */ export function emailSendTimeMs(record) {
44
- var _ref;
44
+ var _ref, _ref1;
45
+ var _record_staffReview;
46
+ // A send held for staff review is parked at a time no run reaches
47
+ // (AGL-3356). That is a mechanism, not a send time, and printed it reads as
48
+ // the year 9999 — so a held send answers "no time", like a draft, and the
49
+ // lists order it by when it was created.
50
+ if ((record == null ? void 0 : (_record_staffReview = record['staffReview']) == null ? void 0 : _record_staffReview['state']) === 'held' && String((_ref = record == null ? void 0 : record['status']) != null ? _ref : '') === 'scheduled') {
51
+ return 0;
52
+ }
45
53
  const sentAt = record == null ? void 0 : record['sentAt'];
46
54
  if (typeof (sentAt == null ? void 0 : sentAt.toMillis) === 'function') return sentAt.toMillis();
47
55
  if (typeof (sentAt == null ? void 0 : sentAt.seconds) === 'number') return sentAt.seconds * 1000;
48
- const scheduled = Number((_ref = record == null ? void 0 : record['sendAtMs']) != null ? _ref : 0);
56
+ const scheduled = Number((_ref1 = record == null ? void 0 : record['sendAtMs']) != null ? _ref1 : 0);
49
57
  return Number.isFinite(scheduled) ? scheduled : 0;
50
58
  }
51
59
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/email-record.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * READING ONE MESSAGE RECORD.\n *\n * A message — an email that was or will be sent — is a document under\n * `hosts/{hostId}/campaigns`. Two facts about that document are awkward\n * enough that every surface reading it gets them wrong the same way, so they\n * are decided here once.\n */\n\n/**\n * When a message went out, or is due to.\n *\n * **There is no one SEND date field.** The send path writes `{status:'sent',\n * sentAt}` from one branch and `{status:'scheduled', sendAtMs}` from another.\n * So a list ordered on either field in Firestore does not mis-sort —\n * `orderBy` DROPS every document missing the ordered field — and half the\n * messages simply vanish. Sorting on this instead keeps both kinds in one\n * ordered list.\n *\n * `sentAt` is a Firestore `Timestamp`, which the client SDK gives as an\n * object with `seconds` and `toMillis()`; a document read from cache while a\n * write is pending can carry a sentinel with neither, which reads as 0 and\n * sorts last rather than throwing.\n *\n * A DRAFT correctly answers 0 here: it has no send time, and inventing one\n * from its creation would put an unsent message on the timeline of mail that\n * went out. Ordering a list is {@link emailListTimeMs}'s job.\n *\n * @returns epoch ms, or 0 when the message has no time at all.\n */\nexport function emailSendTimeMs(\n record: Record<string, any> | null | undefined,\n): number {\n const sentAt = record?.['sentAt'] as\n | { toMillis?: () => number; seconds?: number }\n | undefined\n if (typeof sentAt?.toMillis === 'function') return sentAt.toMillis()\n if (typeof sentAt?.seconds === 'number') return sentAt.seconds * 1000\n const scheduled = Number(record?.['sendAtMs'] ?? 0)\n return Number.isFinite(scheduled) ? scheduled : 0\n}\n\n/**\n * The field every writer of a message stamps when it MINTS the document.\n *\n * The one date on every message, which is what neither `sentAt` nor\n * `sendAtMs` is — each is written by one branch of the send path and neither\n * is on both kinds. A field carried by every record is the precondition for\n * ordering this collection in Firestore rather than in the browser, because\n * `orderBy` drops documents that lack the ordered field instead of\n * mis-sorting them.\n *\n * Epoch milliseconds rather than a `Timestamp`, matching `sendAtMs` beside\n * it: the two are compared against each other on every list that draws\n * drafts and sends together, and a comparison that has to unwrap one side\n * first is a comparison somebody writes wrong.\n */\nexport const EMAIL_CREATED_AT_FIELD = 'createdAtMs'\n\n/**\n * When a message was created, for the records that carry it.\n *\n * `null` — not 0 — for a record carrying no {@link EMAIL_CREATED_AT_FIELD},\n * because 0 is a real instant at the far end of the sort and \"we do not know\"\n * is not \"1970\". A null orders by the record's send time instead, so the list\n * is correct for a record from any era.\n */\nexport function emailCreatedAtMs(\n record: Record<string, any> | null | undefined,\n): number | null {\n const created = Number(record?.[EMAIL_CREATED_AT_FIELD] ?? Number.NaN)\n return Number.isFinite(created) && created > 0 ? created : null\n}\n\n/**\n * WHERE A MESSAGE SITS IN A LIST OF MESSAGES.\n *\n * Its send time where it has one, and its creation time where it does not.\n *\n * A draft has neither `sentAt` nor `sendAtMs`, so ordering a list on the send\n * time alone gives every draft the key 0 and files it below mail sent years\n * ago — the newest thing a merchant did, at the bottom of the page, behind\n * whatever paging the list has. Falling back to creation puts it where the\n * merchant left it.\n *\n * The fallback is deliberately not the other way round. A SENT message orders\n * by when it went out, never by when it was drafted: the list is a record of\n * what happened, and a message drafted in March and sent in June belongs in\n * June.\n *\n * @returns epoch ms, or 0 for a record with no time of any kind — a draft\n * written before the created stamp existed, which sorts last exactly as it\n * did before.\n */\nexport function emailListTimeMs(\n record: Record<string, any> | null | undefined,\n): number {\n return emailSendTimeMs(record) || emailCreatedAtMs(record) || 0\n}\n\n/**\n * Reader-facing text for a message's state.\n *\n * The KEYS are persisted values written by the send path and read by the\n * scheduled-campaign processor — nothing here may change them, including\n * `canceled`, which is already the American spelling and is a stored enum in\n * either case. Only the right-hand side is display text, and an unrecognized\n * state falls through as itself rather than being flattened into one of\n * these: a state this list cannot name is worth seeing.\n *\n * `draft` is an email that has been created and not sent. `sending` is the\n * claim the scheduled processor and the send-now route both take before they\n * mail, and it is named here because a merchant who reloads mid-send would\n * otherwise be shown the raw token.\n *\n * ⚠️ **Not what a console row should draw.** This names the STORED value, and\n * an email delivering an audience larger than one batch is stored as\n * `scheduled` between runs — so a row rendering this says \"Scheduled\" about a\n * send that has already put five hundred messages in five hundred inboxes.\n * `campaignSendDisplay` in `campaign-container.ts` reads the counters beside\n * the status and is what every surface here draws. This remains the honest\n * answer to the narrower question of what one stored token means.\n */\nexport function emailStateLabel(status: unknown): string {\n const value = String(status ?? '')\n if (value === 'sent') return 'Sent'\n if (value === 'scheduled') return 'Scheduled'\n if (value === 'canceled') return 'Canceled'\n if (value === 'draft') return 'Draft'\n if (value === 'sending') return 'Sending'\n return value || 'Unknown'\n}\n\n/**\n * Whether an email has yet been mailed to anybody.\n *\n * The surfaces that report on a send need this because an unsent email has no\n * `stats` at all, and a report that divides into an absent denominator\n * presents \"not sent yet\" as a delivery rate of 0% — the same class of fault\n * as a rate rendered over a denominator it does not name.\n */\nexport function emailIsUnsent(\n record: Record<string, any> | null | undefined,\n): boolean {\n const value = String(record?.['status'] ?? '')\n return value === 'draft' || value === 'scheduled' || value === 'sending'\n}\n\n/**\n * Reader-facing names for the built-in audience kinds.\n *\n * The KEYS are the persisted `audience` values written on every message since\n * the composer shipped; only the values on the right are display text. A\n * `list` audience is deliberately absent — it is not a kind of audience, it\n * is a NAMED one, and naming it is {@link emailAudienceLabel}'s job.\n */\nexport const EMAIL_AUDIENCE_LABELS: Record<string, string> = {\n leads: 'All leads',\n members: 'All site members',\n segment: 'A contact segment',\n manual: 'Addresses typed into the composer',\n}\n\n/**\n * Which audience a message went to, named for a reader.\n *\n * A list send is named by the name the SEND recorded, never by the name the\n * list carries today: resolving it now would let a rename rewrite the history\n * of a message that went out months ago, and a deletion erase it. A send that\n * recorded no name says so instead of printing a document id, which is not\n * something a merchant recognises as a list.\n */\nexport function emailAudienceLabel(\n record: Record<string, any> | null | undefined,\n): string {\n const audience = String(record?.['audience'] ?? '')\n if (audience !== 'list') {\n return EMAIL_AUDIENCE_LABELS[audience] ?? audience ?? 'Not recorded'\n }\n const name = String(record?.['listName'] ?? '')\n if (name) return name\n return record?.['listId']\n ? 'A list this send did not name'\n : 'A list this send did not record'\n}\n\n/**\n * The sender a message actually left with, as this record has it.\n *\n * `recorded` is the whole point of the shape. The address a send goes out on\n * is stamped by the send — see `sentAsStamp` — and every message sent before\n * that stamp existed carries nothing, so a surface has to be able to tell an\n * unrecorded sender apart from one it could compose. Composing it is the\n * thing that must not happen: a site's sending identity is a setting, and\n * resolving it now would answer \"what would this send as today\" on a report\n * whose subject is a message that went out months ago, under whatever address\n * the site held then.\n *\n * The same rule {@link emailAudienceLabel} follows for a list name, and for\n * the same reason: the report says what happened, not what would happen now.\n *\n * `replyTo` is null on a message that set none, which is not missing\n * information — a message with no `Reply-To:` takes replies at its `from`,\n * and that is a fact the record states by omission rather than one it lost.\n * The difference only reads correctly when `recorded` is true, which is why\n * a caller must consult that first.\n */\nexport interface EmailSentAs {\n /** False on a message sent before the send began stamping its sender. */\n recorded: boolean\n from: string | null\n /** The display name recipients saw, or null when the address stood alone. */\n fromName: string | null\n /** Where replies were pointed, or null when they go to `from`. */\n replyTo: string | null\n}\n\nexport function emailSentAs(\n record: Record<string, any> | null | undefined,\n): EmailSentAs {\n const stamp = record?.['sentAs'] as Record<string, any> | null | undefined\n const from = String(stamp?.['from'] ?? '').trim()\n // The ADDRESS is what makes the stamp a record of a send. A `sentAs` map\n // carrying only a display name is a partial write, and reporting it as\n // recorded would put a name on screen beside an address nothing supplied.\n if (!from) {\n return { recorded: false, from: null, fromName: null, replyTo: null }\n }\n return {\n recorded: true,\n from,\n fromName: String(stamp?.['fromName'] ?? '').trim() || null,\n replyTo: String(stamp?.['replyTo'] ?? '').trim() || null,\n }\n}\n"],"names":["emailSendTimeMs","record","sentAt","toMillis","seconds","scheduled","Number","isFinite","EMAIL_CREATED_AT_FIELD","emailCreatedAtMs","created","NaN","emailListTimeMs","emailStateLabel","status","value","String","emailIsUnsent","EMAIL_AUDIENCE_LABELS","leads","members","segment","manual","emailAudienceLabel","audience","name","emailSentAs","stamp","from","trim","recorded","fromName","replyTo"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;CAOC,GAED;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASA,gBACdC,MAA8C;;IAE9C,MAAMC,SAASD,0BAAAA,MAAQ,CAAC,SAAS;IAGjC,IAAI,QAAOC,0BAAAA,OAAQC,QAAQ,MAAK,YAAY,OAAOD,OAAOC,QAAQ;IAClE,IAAI,QAAOD,0BAAAA,OAAQE,OAAO,MAAK,UAAU,OAAOF,OAAOE,OAAO,GAAG;IACjE,MAAMC,YAAYC,eAAOL,0BAAAA,MAAQ,CAAC,WAAW,mBAAI;IACjD,OAAOK,OAAOC,QAAQ,CAACF,aAAaA,YAAY;AAClD;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAMG,yBAAyB,cAAa;AAEnD;;;;;;;CAOC,GACD,OAAO,SAASC,iBACdR,MAA8C;;IAE9C,MAAMS,UAAUJ,eAAOL,0BAAAA,MAAQ,CAACO,uBAAuB,mBAAIF,OAAOK,GAAG;IACrE,OAAOL,OAAOC,QAAQ,CAACG,YAAYA,UAAU,IAAIA,UAAU;AAC7D;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASE,gBACdX,MAA8C;IAE9C,OAAOD,gBAAgBC,WAAWQ,iBAAiBR,WAAW;AAChE;AAEA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASY,gBAAgBC,MAAe;IAC7C,MAAMC,QAAQC,OAAOF,iBAAAA,SAAU;IAC/B,IAAIC,UAAU,QAAQ,OAAO;IAC7B,IAAIA,UAAU,aAAa,OAAO;IAClC,IAAIA,UAAU,YAAY,OAAO;IACjC,IAAIA,UAAU,SAAS,OAAO;IAC9B,IAAIA,UAAU,WAAW,OAAO;IAChC,OAAOA,SAAS;AAClB;AAEA;;;;;;;CAOC,GACD,OAAO,SAASE,cACdhB,MAA8C;;IAE9C,MAAMc,QAAQC,eAAOf,0BAAAA,MAAQ,CAAC,SAAS,mBAAI;IAC3C,OAAOc,UAAU,WAAWA,UAAU,eAAeA,UAAU;AACjE;AAEA;;;;;;;CAOC,GACD,OAAO,MAAMG,wBAAgD;IAC3DC,OAAO;IACPC,SAAS;IACTC,SAAS;IACTC,QAAQ;AACV,EAAC;AAED;;;;;;;;CAQC,GACD,OAAO,SAASC,mBACdtB,MAA8C;;IAE9C,MAAMuB,WAAWR,eAAOf,0BAAAA,MAAQ,CAAC,WAAW,mBAAI;IAChD,IAAIuB,aAAa,QAAQ;YAChBN,OAAAA;QAAP,QAAOA,SAAAA,kCAAAA,qBAAqB,CAACM,SAAS,YAA/BN,kCAAmCM,oBAAnCN,QAA+C;IACxD;IACA,MAAMO,OAAOT,gBAAOf,0BAAAA,MAAQ,CAAC,WAAW,oBAAI;IAC5C,IAAIwB,MAAM,OAAOA;IACjB,OAAOxB,CAAAA,0BAAAA,MAAQ,CAAC,SAAS,IACrB,kCACA;AACN;AAiCA,OAAO,SAASyB,YACdzB,MAA8C;;IAE9C,MAAM0B,QAAQ1B,0BAAAA,MAAQ,CAAC,SAAS;IAChC,MAAM2B,OAAOZ,eAAOW,yBAAAA,KAAO,CAAC,OAAO,mBAAI,IAAIE,IAAI;IAC/C,yEAAyE;IACzE,uEAAuE;IACvE,0EAA0E;IAC1E,IAAI,CAACD,MAAM;QACT,OAAO;YAAEE,UAAU;YAAOF,MAAM;YAAMG,UAAU;YAAMC,SAAS;QAAK;IACtE;IACA,OAAO;QACLF,UAAU;QACVF;QACAG,UAAUf,gBAAOW,yBAAAA,KAAO,CAAC,WAAW,oBAAI,IAAIE,IAAI,MAAM;QACtDG,SAAShB,gBAAOW,yBAAAA,KAAO,CAAC,UAAU,oBAAI,IAAIE,IAAI,MAAM;IACtD;AACF"}
1
+ {"version":3,"sources":["../../../../../../../../libs/shared/ui/email-campaigns/src/lib/model/email-record.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * READING ONE MESSAGE RECORD.\n *\n * A message — an email that was or will be sent — is a document under\n * `hosts/{hostId}/campaigns`. Two facts about that document are awkward\n * enough that every surface reading it gets them wrong the same way, so they\n * are decided here once.\n */\n\n/**\n * When a message went out, or is due to.\n *\n * **There is no one SEND date field.** The send path writes `{status:'sent',\n * sentAt}` from one branch and `{status:'scheduled', sendAtMs}` from another.\n * So a list ordered on either field in Firestore does not mis-sort —\n * `orderBy` DROPS every document missing the ordered field — and half the\n * messages simply vanish. Sorting on this instead keeps both kinds in one\n * ordered list.\n *\n * `sentAt` is a Firestore `Timestamp`, which the client SDK gives as an\n * object with `seconds` and `toMillis()`; a document read from cache while a\n * write is pending can carry a sentinel with neither, which reads as 0 and\n * sorts last rather than throwing.\n *\n * A DRAFT correctly answers 0 here: it has no send time, and inventing one\n * from its creation would put an unsent message on the timeline of mail that\n * went out. Ordering a list is {@link emailListTimeMs}'s job.\n *\n * @returns epoch ms, or 0 when the message has no time at all.\n */\nexport function emailSendTimeMs(\n record: Record<string, any> | null | undefined,\n): number {\n // A send held for staff review is parked at a time no run reaches\n // (AGL-3356). That is a mechanism, not a send time, and printed it reads as\n // the year 9999 — so a held send answers \"no time\", like a draft, and the\n // lists order it by when it was created.\n if (\n record?.['staffReview']?.['state'] === 'held' &&\n String(record?.['status'] ?? '') === 'scheduled'\n ) {\n return 0\n }\n const sentAt = record?.['sentAt'] as\n | { toMillis?: () => number; seconds?: number }\n | undefined\n if (typeof sentAt?.toMillis === 'function') return sentAt.toMillis()\n if (typeof sentAt?.seconds === 'number') return sentAt.seconds * 1000\n const scheduled = Number(record?.['sendAtMs'] ?? 0)\n return Number.isFinite(scheduled) ? scheduled : 0\n}\n\n/**\n * The field every writer of a message stamps when it MINTS the document.\n *\n * The one date on every message, which is what neither `sentAt` nor\n * `sendAtMs` is — each is written by one branch of the send path and neither\n * is on both kinds. A field carried by every record is the precondition for\n * ordering this collection in Firestore rather than in the browser, because\n * `orderBy` drops documents that lack the ordered field instead of\n * mis-sorting them.\n *\n * Epoch milliseconds rather than a `Timestamp`, matching `sendAtMs` beside\n * it: the two are compared against each other on every list that draws\n * drafts and sends together, and a comparison that has to unwrap one side\n * first is a comparison somebody writes wrong.\n */\nexport const EMAIL_CREATED_AT_FIELD = 'createdAtMs'\n\n/**\n * When a message was created, for the records that carry it.\n *\n * `null` — not 0 — for a record carrying no {@link EMAIL_CREATED_AT_FIELD},\n * because 0 is a real instant at the far end of the sort and \"we do not know\"\n * is not \"1970\". A null orders by the record's send time instead, so the list\n * is correct for a record from any era.\n */\nexport function emailCreatedAtMs(\n record: Record<string, any> | null | undefined,\n): number | null {\n const created = Number(record?.[EMAIL_CREATED_AT_FIELD] ?? Number.NaN)\n return Number.isFinite(created) && created > 0 ? created : null\n}\n\n/**\n * WHERE A MESSAGE SITS IN A LIST OF MESSAGES.\n *\n * Its send time where it has one, and its creation time where it does not.\n *\n * A draft has neither `sentAt` nor `sendAtMs`, so ordering a list on the send\n * time alone gives every draft the key 0 and files it below mail sent years\n * ago — the newest thing a merchant did, at the bottom of the page, behind\n * whatever paging the list has. Falling back to creation puts it where the\n * merchant left it.\n *\n * The fallback is deliberately not the other way round. A SENT message orders\n * by when it went out, never by when it was drafted: the list is a record of\n * what happened, and a message drafted in March and sent in June belongs in\n * June.\n *\n * @returns epoch ms, or 0 for a record with no time of any kind — a draft\n * written before the created stamp existed, which sorts last exactly as it\n * did before.\n */\nexport function emailListTimeMs(\n record: Record<string, any> | null | undefined,\n): number {\n return emailSendTimeMs(record) || emailCreatedAtMs(record) || 0\n}\n\n/**\n * Reader-facing text for a message's state.\n *\n * The KEYS are persisted values written by the send path and read by the\n * scheduled-campaign processor — nothing here may change them, including\n * `canceled`, which is already the American spelling and is a stored enum in\n * either case. Only the right-hand side is display text, and an unrecognized\n * state falls through as itself rather than being flattened into one of\n * these: a state this list cannot name is worth seeing.\n *\n * `draft` is an email that has been created and not sent. `sending` is the\n * claim the scheduled processor and the send-now route both take before they\n * mail, and it is named here because a merchant who reloads mid-send would\n * otherwise be shown the raw token.\n *\n * ⚠️ **Not what a console row should draw.** This names the STORED value, and\n * an email delivering an audience larger than one batch is stored as\n * `scheduled` between runs — so a row rendering this says \"Scheduled\" about a\n * send that has already put five hundred messages in five hundred inboxes.\n * `campaignSendDisplay` in `campaign-container.ts` reads the counters beside\n * the status and is what every surface here draws. This remains the honest\n * answer to the narrower question of what one stored token means.\n */\nexport function emailStateLabel(status: unknown): string {\n const value = String(status ?? '')\n if (value === 'sent') return 'Sent'\n if (value === 'scheduled') return 'Scheduled'\n if (value === 'canceled') return 'Canceled'\n if (value === 'draft') return 'Draft'\n if (value === 'sending') return 'Sending'\n return value || 'Unknown'\n}\n\n/**\n * Whether an email has yet been mailed to anybody.\n *\n * The surfaces that report on a send need this because an unsent email has no\n * `stats` at all, and a report that divides into an absent denominator\n * presents \"not sent yet\" as a delivery rate of 0% — the same class of fault\n * as a rate rendered over a denominator it does not name.\n */\nexport function emailIsUnsent(\n record: Record<string, any> | null | undefined,\n): boolean {\n const value = String(record?.['status'] ?? '')\n return value === 'draft' || value === 'scheduled' || value === 'sending'\n}\n\n/**\n * Reader-facing names for the built-in audience kinds.\n *\n * The KEYS are the persisted `audience` values written on every message since\n * the composer shipped; only the values on the right are display text. A\n * `list` audience is deliberately absent — it is not a kind of audience, it\n * is a NAMED one, and naming it is {@link emailAudienceLabel}'s job.\n */\nexport const EMAIL_AUDIENCE_LABELS: Record<string, string> = {\n leads: 'All leads',\n members: 'All site members',\n segment: 'A contact segment',\n manual: 'Addresses typed into the composer',\n}\n\n/**\n * Which audience a message went to, named for a reader.\n *\n * A list send is named by the name the SEND recorded, never by the name the\n * list carries today: resolving it now would let a rename rewrite the history\n * of a message that went out months ago, and a deletion erase it. A send that\n * recorded no name says so instead of printing a document id, which is not\n * something a merchant recognises as a list.\n */\nexport function emailAudienceLabel(\n record: Record<string, any> | null | undefined,\n): string {\n const audience = String(record?.['audience'] ?? '')\n if (audience !== 'list') {\n return EMAIL_AUDIENCE_LABELS[audience] ?? audience ?? 'Not recorded'\n }\n const name = String(record?.['listName'] ?? '')\n if (name) return name\n return record?.['listId']\n ? 'A list this send did not name'\n : 'A list this send did not record'\n}\n\n/**\n * The sender a message actually left with, as this record has it.\n *\n * `recorded` is the whole point of the shape. The address a send goes out on\n * is stamped by the send — see `sentAsStamp` — and every message sent before\n * that stamp existed carries nothing, so a surface has to be able to tell an\n * unrecorded sender apart from one it could compose. Composing it is the\n * thing that must not happen: a site's sending identity is a setting, and\n * resolving it now would answer \"what would this send as today\" on a report\n * whose subject is a message that went out months ago, under whatever address\n * the site held then.\n *\n * The same rule {@link emailAudienceLabel} follows for a list name, and for\n * the same reason: the report says what happened, not what would happen now.\n *\n * `replyTo` is null on a message that set none, which is not missing\n * information — a message with no `Reply-To:` takes replies at its `from`,\n * and that is a fact the record states by omission rather than one it lost.\n * The difference only reads correctly when `recorded` is true, which is why\n * a caller must consult that first.\n */\nexport interface EmailSentAs {\n /** False on a message sent before the send began stamping its sender. */\n recorded: boolean\n from: string | null\n /** The display name recipients saw, or null when the address stood alone. */\n fromName: string | null\n /** Where replies were pointed, or null when they go to `from`. */\n replyTo: string | null\n}\n\nexport function emailSentAs(\n record: Record<string, any> | null | undefined,\n): EmailSentAs {\n const stamp = record?.['sentAs'] as Record<string, any> | null | undefined\n const from = String(stamp?.['from'] ?? '').trim()\n // The ADDRESS is what makes the stamp a record of a send. A `sentAs` map\n // carrying only a display name is a partial write, and reporting it as\n // recorded would put a name on screen beside an address nothing supplied.\n if (!from) {\n return { recorded: false, from: null, fromName: null, replyTo: null }\n }\n return {\n recorded: true,\n from,\n fromName: String(stamp?.['fromName'] ?? '').trim() || null,\n replyTo: String(stamp?.['replyTo'] ?? '').trim() || null,\n }\n}\n"],"names":["emailSendTimeMs","record","String","sentAt","toMillis","seconds","scheduled","Number","isFinite","EMAIL_CREATED_AT_FIELD","emailCreatedAtMs","created","NaN","emailListTimeMs","emailStateLabel","status","value","emailIsUnsent","EMAIL_AUDIENCE_LABELS","leads","members","segment","manual","emailAudienceLabel","audience","name","emailSentAs","stamp","from","trim","recorded","fromName","replyTo"],"mappings":"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"}