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