@aglyn/plugins-email 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 +58 -0
- package/src/index.d.ts +35 -0
- package/src/index.js +35 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/campaign-design-create-widget.d.ts +21 -0
- package/src/lib/components/campaign-design-create-widget.js +61 -0
- package/src/lib/components/campaign-design-create-widget.js.map +1 -0
- package/src/lib/components/campaign-sender-editor-widget.d.ts +18 -0
- package/src/lib/components/campaign-sender-editor-widget.js +39 -0
- package/src/lib/components/campaign-sender-editor-widget.js.map +1 -0
- package/src/lib/components/campaign-topic-options-widget.d.ts +19 -0
- package/src/lib/components/campaign-topic-options-widget.js +48 -0
- package/src/lib/components/campaign-topic-options-widget.js.map +1 -0
- package/src/lib/components/campaign-topic-select.d.ts +32 -0
- package/src/lib/components/campaign-topic-select.js +87 -0
- package/src/lib/components/campaign-topic-select.js.map +1 -0
- package/src/lib/components/dynamic-list-rule-fields.d.ts +173 -0
- package/src/lib/components/dynamic-list-rule-fields.js +1473 -0
- package/src/lib/components/dynamic-list-rule-fields.js.map +1 -0
- package/src/lib/components/email-blocks.d.ts +111 -0
- package/src/lib/components/email-blocks.js +875 -0
- package/src/lib/components/email-blocks.js.map +1 -0
- package/src/lib/components/email-design-preview.d.ts +62 -0
- package/src/lib/components/email-design-preview.js +174 -0
- package/src/lib/components/email-design-preview.js.map +1 -0
- package/src/lib/components/email-screens-card.d.ts +42 -0
- package/src/lib/components/email-screens-card.js +277 -0
- package/src/lib/components/email-screens-card.js.map +1 -0
- package/src/lib/components/email-template-detail.d.ts +48 -0
- package/src/lib/components/email-template-detail.js +681 -0
- package/src/lib/components/email-template-detail.js.map +1 -0
- package/src/lib/components/email-topic-detail.d.ts +32 -0
- package/src/lib/components/email-topic-detail.js +293 -0
- package/src/lib/components/email-topic-detail.js.map +1 -0
- package/src/lib/components/email-topics-card.d.ts +46 -0
- package/src/lib/components/email-topics-card.js +327 -0
- package/src/lib/components/email-topics-card.js.map +1 -0
- package/src/lib/components/email-zones.d.ts +28 -0
- package/src/lib/components/email-zones.js +20 -0
- package/src/lib/components/email-zones.js.map +1 -0
- package/src/lib/components/emails-console-page.d.ts +32 -0
- package/src/lib/components/emails-console-page.js +229 -0
- package/src/lib/components/emails-console-page.js.map +1 -0
- package/src/lib/components/emails-console-sections.d.ts +36 -0
- package/src/lib/components/emails-console-sections.js +108 -0
- package/src/lib/components/emails-console-sections.js.map +1 -0
- package/src/lib/components/list-detail-card.d.ts +47 -0
- package/src/lib/components/list-detail-card.js +273 -0
- package/src/lib/components/list-detail-card.js.map +1 -0
- package/src/lib/components/list-edit-card.d.ts +11 -0
- package/src/lib/components/list-edit-card.js +287 -0
- package/src/lib/components/list-edit-card.js.map +1 -0
- package/src/lib/components/list-import-drawer.d.ts +22 -0
- package/src/lib/components/list-import-drawer.js +662 -0
- package/src/lib/components/list-import-drawer.js.map +1 -0
- package/src/lib/components/list-members-panel.d.ts +94 -0
- package/src/lib/components/list-members-panel.js +686 -0
- package/src/lib/components/list-members-panel.js.map +1 -0
- package/src/lib/components/lists-card.d.ts +28 -0
- package/src/lib/components/lists-card.js +377 -0
- package/src/lib/components/lists-card.js.map +1 -0
- package/src/lib/components/sending-domain-detail.d.ts +26 -0
- package/src/lib/components/sending-domain-detail.js +496 -0
- package/src/lib/components/sending-domain-detail.js.map +1 -0
- package/src/lib/components/sending-domains-card.d.ts +33 -0
- package/src/lib/components/sending-domains-card.js +962 -0
- package/src/lib/components/sending-domains-card.js.map +1 -0
- package/src/lib/components/sending-sender-drawer.d.ts +94 -0
- package/src/lib/components/sending-sender-drawer.js +543 -0
- package/src/lib/components/sending-sender-drawer.js.map +1 -0
- package/src/lib/components/suppressions-card.d.ts +49 -0
- package/src/lib/components/suppressions-card.js +639 -0
- package/src/lib/components/suppressions-card.js.map +1 -0
- package/src/lib/components/use-org-email-topics.d.ts +79 -0
- package/src/lib/components/use-org-email-topics.js +111 -0
- package/src/lib/components/use-org-email-topics.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +18 -0
- package/src/lib/constants/bundle-common.js +18 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/hooks/use-org-company-options.d.ts +20 -0
- package/src/lib/hooks/use-org-company-options.js +138 -0
- package/src/lib/hooks/use-org-company-options.js.map +1 -0
- package/src/lib/hooks/use-org-contact-fields.d.ts +40 -0
- package/src/lib/hooks/use-org-contact-fields.js +91 -0
- package/src/lib/hooks/use-org-contact-fields.js.map +1 -0
- package/src/lib/hooks/use-org-contact-segments.d.ts +16 -0
- package/src/lib/hooks/use-org-contact-segments.js +55 -0
- package/src/lib/hooks/use-org-contact-segments.js.map +1 -0
- package/src/lib/hooks/use-org-crm-views.d.ts +8 -0
- package/src/lib/hooks/use-org-crm-views.js +74 -0
- package/src/lib/hooks/use-org-crm-views.js.map +1 -0
- package/src/lib/hooks/use-org-lists.d.ts +8 -0
- package/src/lib/hooks/use-org-lists.js +47 -0
- package/src/lib/hooks/use-org-lists.js.map +1 -0
- package/src/lib/model/email-design-document.d.ts +52 -0
- package/src/lib/model/email-design-document.js +62 -0
- package/src/lib/model/email-design-document.js.map +1 -0
- package/src/lib/model/index.d.ts +64 -0
- package/src/lib/model/index.js +71 -0
- package/src/lib/model/index.js.map +1 -0
- package/src/lib/model/sending-domain-status.d.ts +99 -0
- package/src/lib/model/sending-domain-status.js +196 -0
- package/src/lib/model/sending-domain-status.js.map +1 -0
- package/src/lib/model/template-provenance.d.ts +113 -0
- package/src/lib/model/template-provenance.js +107 -0
- package/src/lib/model/template-provenance.js.map +1 -0
- package/src/lib/model/template-report.d.ts +158 -0
- package/src/lib/model/template-report.js +249 -0
- package/src/lib/model/template-report.js.map +1 -0
- package/src/lib/plugin.d.ts +27 -0
- package/src/lib/plugin.js +163 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/server-console.d.ts +116 -0
- package/src/lib/server-console.js +422 -0
- package/src/lib/server-console.js.map +1 -0
- package/src/lib/server-email-drafts.d.ts +104 -0
- package/src/lib/server-email-drafts.js +381 -0
- package/src/lib/server-email-drafts.js.map +1 -0
- package/src/lib/server-list-gate.d.ts +183 -0
- package/src/lib/server-list-gate.js +365 -0
- package/src/lib/server-list-gate.js.map +1 -0
- package/src/lib/server-list-import.d.ts +199 -0
- package/src/lib/server-list-import.js +632 -0
- package/src/lib/server-list-import.js.map +1 -0
- package/src/lib/server-suppressions.d.ts +135 -0
- package/src/lib/server-suppressions.js +295 -0
- package/src/lib/server-suppressions.js.map +1 -0
- package/src/lib/server.d.ts +19 -0
- package/src/lib/server.js +834 -0
- package/src/lib/server.js.map +1 -0
- package/src/lib/site.d.ts +26 -0
- package/src/lib/site.js +81 -0
- package/src/lib/site.js.map +1 -0
- package/src/lib/unsubscribe-link.d.ts +311 -0
- package/src/lib/unsubscribe-link.js +398 -0
- package/src/lib/unsubscribe-link.js.map +1 -0
- package/src/lib/utils/create-email-screen.d.ts +59 -0
- package/src/lib/utils/create-email-screen.js +59 -0
- package/src/lib/utils/create-email-screen.js.map +1 -0
- package/src/lib/utils/generate-preset-id.d.ts +19 -0
- package/src/lib/utils/generate-preset-id.js +25 -0
- package/src/lib/utils/generate-preset-id.js.map +1 -0
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
* THE GATE IN FRONT OF EVERY LIST ENROLLMENT THE CONSOLE MAKES.
|
|
19
|
+
*
|
|
20
|
+
* Who is allowed to change a list's membership, and what is true about each
|
|
21
|
+
* address they name. Three route modules ask it — the audience card's add and
|
|
22
|
+
* preview, the filter search, and the file importer — and they ask this one
|
|
23
|
+
* copy of it.
|
|
24
|
+
*
|
|
25
|
+
* ## Why it is a module of its own
|
|
26
|
+
*
|
|
27
|
+
* It began inside `server-console.ts`, beside its first two callers, and that
|
|
28
|
+
* was right while there were two. The importer made three, and an importer is
|
|
29
|
+
* exactly the surface where a second, laxer idea of "may this person be
|
|
30
|
+
* enrolled" is most tempting: a file is bulk, bulk is slow, and skipping the
|
|
31
|
+
* per-address consent read is the obvious saving. A bulk path that reached the
|
|
32
|
+
* membership without the suppression check and the attestation would be a way
|
|
33
|
+
* to enroll precisely the people the one-at-a-time path refuses, which is the
|
|
34
|
+
* defect class `docs/specs/email-competitive-gaps.md` has a closed P1 entry
|
|
35
|
+
* for.
|
|
36
|
+
*
|
|
37
|
+
* Sharing the gate is also what stops the cheaper failure: two modules that
|
|
38
|
+
* merely agree today, with nothing in either one saying the other exists.
|
|
39
|
+
*
|
|
40
|
+
* ## What it decides, and what it deliberately does not
|
|
41
|
+
*
|
|
42
|
+
* {@link resolveAddresses} answers the questions that are FACTS about a
|
|
43
|
+
* person — a stored refusal, a suppression on either list, an opt-in already
|
|
44
|
+
* on record, a line that is not an address. It never consults an attestation,
|
|
45
|
+
* because an attestation is a fact about the operator and folding it in here
|
|
46
|
+
* would make the preview's numbers move as the box was ticked. The basis is
|
|
47
|
+
* decided once, at the write, by `assignmentBasis`.
|
|
48
|
+
*/
|
|
49
|
+
import { type AssignmentRefusal, type ConsentGroup, type MarketingConsentRecord, type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
50
|
+
/**
|
|
51
|
+
* The most addresses one request may name.
|
|
52
|
+
*
|
|
53
|
+
* A ceiling on the WRITE, not on the audience. It bounds the work one request
|
|
54
|
+
* does — the resolution below is a handful of reads per address — and it can
|
|
55
|
+
* never refuse a person already enrolled or make room by removing one, which
|
|
56
|
+
* is the difference between a batch size and a capacity limit. Nothing in the
|
|
57
|
+
* product caps list membership: `contactsPerHost` is the audience band and it
|
|
58
|
+
* is metered against CONTACTS, in `upsert-contact.ts`, and enrolling somebody
|
|
59
|
+
* on a list writes no contact. If a membership ceiling is ever introduced,
|
|
60
|
+
* this function is where it refuses, because this is where something is
|
|
61
|
+
* added.
|
|
62
|
+
*/
|
|
63
|
+
export declare const LIST_MEMBER_BATCH_MAX = 100;
|
|
64
|
+
/** Everything a list-membership route needs, or the refusal to send back. */
|
|
65
|
+
export type ListContext = {
|
|
66
|
+
ok: true;
|
|
67
|
+
uid: string;
|
|
68
|
+
hostId: string;
|
|
69
|
+
orgId: string;
|
|
70
|
+
listRef: FirebaseFirestore.DocumentReference;
|
|
71
|
+
listName: string;
|
|
72
|
+
} | {
|
|
73
|
+
ok: false;
|
|
74
|
+
status: number;
|
|
75
|
+
body: Record<string, unknown>;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Who is asking, and about which list.
|
|
79
|
+
*
|
|
80
|
+
* ## Two gates, not one — the same two the Inbox assignment route applies
|
|
81
|
+
*
|
|
82
|
+
* A host role is necessary and NOT sufficient. Lists live at
|
|
83
|
+
* `orgs/{orgId}/lists` and their members are contacts, so the rules put both
|
|
84
|
+
* behind `isOrgWideMember()`. An editor invited to ONE site is an org member
|
|
85
|
+
* with `allHosts: false`, and gating an org-wide write on the host role alone
|
|
86
|
+
* would let a single-site collaborator enroll people into an audience every
|
|
87
|
+
* other site in the org can mail — and, through the preview, read the consent
|
|
88
|
+
* record of any address they care to type. The Admin SDK evaluates no rules,
|
|
89
|
+
* so this route is the enforcement rather than an echo of it.
|
|
90
|
+
*
|
|
91
|
+
* The plugin's own gates sit above this and are the dispatcher's: a workspace
|
|
92
|
+
* that has switched the email plugin off, or a site it is disabled for, or a
|
|
93
|
+
* release flag that has not reached this org, all 404 before a handler runs.
|
|
94
|
+
*/
|
|
95
|
+
export declare function resolveListContext(req: Parameters<PluginApiHandler>[0]): Promise<ListContext>;
|
|
96
|
+
/** What would happen to one address, and why. */
|
|
97
|
+
export interface AddressVerdict {
|
|
98
|
+
/** Exactly what the operator typed, so a bad line can be pointed at. */
|
|
99
|
+
input: string;
|
|
100
|
+
/** The normalized address, or `null` when there is not one. */
|
|
101
|
+
email: string | null;
|
|
102
|
+
/**
|
|
103
|
+
* Why this address can never go on the list, or `null`.
|
|
104
|
+
*
|
|
105
|
+
* A HARD refusal only — a stored `declined`, either suppression list, or a
|
|
106
|
+
* line that is not an address. `no-basis` is deliberately NOT one of them:
|
|
107
|
+
* "you have not said you have permission yet" is an unanswered question,
|
|
108
|
+
* not a refusal, and reporting it as one would list the very people an
|
|
109
|
+
* attestation is about to admit under the heading of people it cannot.
|
|
110
|
+
* That distinction is {@link requiresAttestation}.
|
|
111
|
+
*/
|
|
112
|
+
refusal: AssignmentRefusal | null;
|
|
113
|
+
/** True when only an attestation stands between this address and the list. */
|
|
114
|
+
requiresAttestation: boolean;
|
|
115
|
+
/** One sentence of consent facts, in the merchant's terms. */
|
|
116
|
+
summary: string;
|
|
117
|
+
}
|
|
118
|
+
/** The whole answer, per address and in totals. */
|
|
119
|
+
export interface AddressResolution {
|
|
120
|
+
verdicts: AddressVerdict[];
|
|
121
|
+
/** Addresses a stored opt-in already covers — no attestation needed. */
|
|
122
|
+
optedIn: number;
|
|
123
|
+
/** Addresses that need the operator to state they have permission. */
|
|
124
|
+
needAttestation: number;
|
|
125
|
+
/** Addresses nothing can enroll, whatever the operator says. */
|
|
126
|
+
refused: number;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The resolution plus the consent records it was computed from.
|
|
130
|
+
*
|
|
131
|
+
* The records ride along so the write path can derive the basis it STORES
|
|
132
|
+
* from the same read the verdicts were built on. Re-reading them would be a
|
|
133
|
+
* second answer to "what does this person's record say", between which a
|
|
134
|
+
* concurrent edit fits — and the operator would have attested against the
|
|
135
|
+
* first one.
|
|
136
|
+
*/
|
|
137
|
+
export interface ResolvedBatch extends AddressResolution {
|
|
138
|
+
stored: Map<string, MarketingConsentRecord>;
|
|
139
|
+
/**
|
|
140
|
+
* The consent group the resolution was made against, so the WRITE records
|
|
141
|
+
* a basis for exactly the controller the verdicts were computed for.
|
|
142
|
+
*
|
|
143
|
+
* Returned rather than re-resolved by the caller for the same reason
|
|
144
|
+
* `stored` is: two resolutions of the same question are two answers a
|
|
145
|
+
* concurrent edit can fit between, and the operator attested against the
|
|
146
|
+
* first one.
|
|
147
|
+
*/
|
|
148
|
+
group: ConsentGroup;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Every address, normalized, deduplicated and put through the policy.
|
|
152
|
+
*
|
|
153
|
+
* ## Deduplicated on the NORMALIZED address, and reported once
|
|
154
|
+
*
|
|
155
|
+
* A pasted column routinely names the same person twice with different
|
|
156
|
+
* casing, and `enrollListMember` keys the membership from the normalized
|
|
157
|
+
* address, so two lines would be one row. Counting them twice would tell the
|
|
158
|
+
* operator they are attesting for more people than they are.
|
|
159
|
+
*
|
|
160
|
+
* ## An unusable line is REPORTED, never dropped
|
|
161
|
+
*
|
|
162
|
+
* A paste that silently discarded its malformed lines would tell an operator
|
|
163
|
+
* that 100 addresses went on the list when 94 did, and the six they never
|
|
164
|
+
* hear about are the six they typed wrong. Every input line comes back with a
|
|
165
|
+
* verdict, including the ones that are not addresses at all.
|
|
166
|
+
*
|
|
167
|
+
* ## Suppression is attributed, and costs nothing when there is none
|
|
168
|
+
*
|
|
169
|
+
* `filterSendableForHost` answers both lists in one pass and fails CLOSED, so
|
|
170
|
+
* it is asked first; only the addresses it refused are put through the
|
|
171
|
+
* platform half again to find out WHICH list holds them. In the ordinary case
|
|
172
|
+
* that second call is never made.
|
|
173
|
+
*/
|
|
174
|
+
export declare function resolveAddresses(input: {
|
|
175
|
+
hostId: string;
|
|
176
|
+
inputs: readonly string[];
|
|
177
|
+
}): Promise<ResolvedBatch>;
|
|
178
|
+
/** The addresses named by one request, or the refusal to send back. */
|
|
179
|
+
export declare function readAddresses(req: Parameters<PluginApiHandler>[0]): {
|
|
180
|
+
emails: string[];
|
|
181
|
+
} | {
|
|
182
|
+
error: string;
|
|
183
|
+
};
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /**
|
|
18
|
+
* THE GATE IN FRONT OF EVERY LIST ENROLLMENT THE CONSOLE MAKES.
|
|
19
|
+
*
|
|
20
|
+
* Who is allowed to change a list's membership, and what is true about each
|
|
21
|
+
* address they name. Three route modules ask it — the audience card's add and
|
|
22
|
+
* preview, the filter search, and the file importer — and they ask this one
|
|
23
|
+
* copy of it.
|
|
24
|
+
*
|
|
25
|
+
* ## Why it is a module of its own
|
|
26
|
+
*
|
|
27
|
+
* It began inside `server-console.ts`, beside its first two callers, and that
|
|
28
|
+
* was right while there were two. The importer made three, and an importer is
|
|
29
|
+
* exactly the surface where a second, laxer idea of "may this person be
|
|
30
|
+
* enrolled" is most tempting: a file is bulk, bulk is slow, and skipping the
|
|
31
|
+
* per-address consent read is the obvious saving. A bulk path that reached the
|
|
32
|
+
* membership without the suppression check and the attestation would be a way
|
|
33
|
+
* to enroll precisely the people the one-at-a-time path refuses, which is the
|
|
34
|
+
* defect class `docs/specs/email-competitive-gaps.md` has a closed P1 entry
|
|
35
|
+
* for.
|
|
36
|
+
*
|
|
37
|
+
* Sharing the gate is also what stops the cheaper failure: two modules that
|
|
38
|
+
* merely agree today, with nothing in either one saying the other exists.
|
|
39
|
+
*
|
|
40
|
+
* ## What it decides, and what it deliberately does not
|
|
41
|
+
*
|
|
42
|
+
* {@link resolveAddresses} answers the questions that are FACTS about a
|
|
43
|
+
* person — a stored refusal, a suppression on either list, an opt-in already
|
|
44
|
+
* on record, a line that is not an address. It never consults an attestation,
|
|
45
|
+
* because an attestation is a fact about the operator and folding it in here
|
|
46
|
+
* would make the preview's numbers move as the box was ticked. The basis is
|
|
47
|
+
* decided once, at the write, by `assignmentBasis`.
|
|
48
|
+
*/ import { ASSIGNMENT_REFUSAL_MESSAGES, assignmentReadout, isOrgWideMember, normalizeContactEmail, readMarketingBasis } from "@aglyn/aglyn/server";
|
|
49
|
+
import { consentGroupForSite, filterSendableForHost, filterSuppressedEmails, firebaseAdmin, getOrgForHost, orgDataCollectionForHost, resolveOrgMembership } from "@aglyn/tenant-data-admin";
|
|
50
|
+
/**
|
|
51
|
+
* The most addresses one request may name.
|
|
52
|
+
*
|
|
53
|
+
* A ceiling on the WRITE, not on the audience. It bounds the work one request
|
|
54
|
+
* does — the resolution below is a handful of reads per address — and it can
|
|
55
|
+
* never refuse a person already enrolled or make room by removing one, which
|
|
56
|
+
* is the difference between a batch size and a capacity limit. Nothing in the
|
|
57
|
+
* product caps list membership: `contactsPerHost` is the audience band and it
|
|
58
|
+
* is metered against CONTACTS, in `upsert-contact.ts`, and enrolling somebody
|
|
59
|
+
* on a list writes no contact. If a membership ceiling is ever introduced,
|
|
60
|
+
* this function is where it refuses, because this is where something is
|
|
61
|
+
* added.
|
|
62
|
+
*/ export const LIST_MEMBER_BATCH_MAX = 100;
|
|
63
|
+
/** How many contact addresses one `in` query may carry. */ const CONTACT_LOOKUP_CHUNK = 30;
|
|
64
|
+
/**
|
|
65
|
+
* Who is asking, and about which list.
|
|
66
|
+
*
|
|
67
|
+
* ## Two gates, not one — the same two the Inbox assignment route applies
|
|
68
|
+
*
|
|
69
|
+
* A host role is necessary and NOT sufficient. Lists live at
|
|
70
|
+
* `orgs/{orgId}/lists` and their members are contacts, so the rules put both
|
|
71
|
+
* behind `isOrgWideMember()`. An editor invited to ONE site is an org member
|
|
72
|
+
* with `allHosts: false`, and gating an org-wide write on the host role alone
|
|
73
|
+
* would let a single-site collaborator enroll people into an audience every
|
|
74
|
+
* other site in the org can mail — and, through the preview, read the consent
|
|
75
|
+
* record of any address they care to type. The Admin SDK evaluates no rules,
|
|
76
|
+
* so this route is the enforcement rather than an echo of it.
|
|
77
|
+
*
|
|
78
|
+
* The plugin's own gates sit above this and are the dispatcher's: a workspace
|
|
79
|
+
* that has switched the email plugin off, or a site it is disabled for, or a
|
|
80
|
+
* release flag that has not reached this org, all 404 before a handler runs.
|
|
81
|
+
*/ export async function resolveListContext(req) {
|
|
82
|
+
var _ref, _ref1, _req_headers_authorization, _hostSnapshot_get, _ref2, _listSnapshot_get;
|
|
83
|
+
var _req_body, _req_body1, _this;
|
|
84
|
+
const hostId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.hostId) != null ? _ref : '');
|
|
85
|
+
const listId = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.listId) != null ? _ref1 : '');
|
|
86
|
+
if (!hostId || !listId) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
status: 400,
|
|
90
|
+
body: {
|
|
91
|
+
error: 'Missing hostId or listId'
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
|
|
96
|
+
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
|
|
97
|
+
if (!idToken) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
status: 401,
|
|
101
|
+
body: {
|
|
102
|
+
error: 'Unauthenticated'
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
107
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
108
|
+
const hostSnapshot = await firestore.collection('hosts').doc(hostId).get();
|
|
109
|
+
if (!hostSnapshot.exists) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
status: 404,
|
|
113
|
+
body: {
|
|
114
|
+
error: 'Unknown site'
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
|
|
119
|
+
if (memberRole !== 'admin' && memberRole !== 'editor') {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
status: 403,
|
|
123
|
+
body: {
|
|
124
|
+
error: 'Not a site admin or editor'
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const orgId = String((_ref2 = (_this = await getOrgForHost(hostId).catch(()=>null)) == null ? void 0 : _this.orgId) != null ? _ref2 : '');
|
|
129
|
+
if (!orgId) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
status: 404,
|
|
133
|
+
body: {
|
|
134
|
+
error: 'This site has no organization, so it has no lists.'
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const membership = await resolveOrgMembership(decoded.uid, orgId).catch(()=>null);
|
|
139
|
+
const member = membership == null ? void 0 : membership.member;
|
|
140
|
+
const orgWideWriter = isOrgWideMember(member) && ((member == null ? void 0 : member.role) === 'owner' || (member == null ? void 0 : member.role) === 'admin' || (member == null ? void 0 : member.role) === 'editor') && (member == null ? void 0 : member.orgSuspended) !== true;
|
|
141
|
+
if (!orgWideWriter) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
status: 403,
|
|
145
|
+
body: {
|
|
146
|
+
error: 'Marketing lists belong to the whole organization, so changing who ' + 'is on one needs organization-wide access rather than access to ' + 'this site.'
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const listRef = firestore.collection('orgs').doc(orgId).collection('lists').doc(listId);
|
|
151
|
+
const listSnapshot = await listRef.get();
|
|
152
|
+
// A stale or mistyped id must not CREATE a list: a campaign's `list`
|
|
153
|
+
// audience would then read a list nobody set up.
|
|
154
|
+
if (!listSnapshot.exists) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
status: 404,
|
|
158
|
+
body: {
|
|
159
|
+
error: 'Unknown list'
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
uid: decoded.uid,
|
|
166
|
+
hostId,
|
|
167
|
+
orgId,
|
|
168
|
+
listRef,
|
|
169
|
+
listName: String((_listSnapshot_get = listSnapshot.get('name')) != null ? _listSnapshot_get : listId)
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Every address, normalized, deduplicated and put through the policy.
|
|
174
|
+
*
|
|
175
|
+
* ## Deduplicated on the NORMALIZED address, and reported once
|
|
176
|
+
*
|
|
177
|
+
* A pasted column routinely names the same person twice with different
|
|
178
|
+
* casing, and `enrollListMember` keys the membership from the normalized
|
|
179
|
+
* address, so two lines would be one row. Counting them twice would tell the
|
|
180
|
+
* operator they are attesting for more people than they are.
|
|
181
|
+
*
|
|
182
|
+
* ## An unusable line is REPORTED, never dropped
|
|
183
|
+
*
|
|
184
|
+
* A paste that silently discarded its malformed lines would tell an operator
|
|
185
|
+
* that 100 addresses went on the list when 94 did, and the six they never
|
|
186
|
+
* hear about are the six they typed wrong. Every input line comes back with a
|
|
187
|
+
* verdict, including the ones that are not addresses at all.
|
|
188
|
+
*
|
|
189
|
+
* ## Suppression is attributed, and costs nothing when there is none
|
|
190
|
+
*
|
|
191
|
+
* `filterSendableForHost` answers both lists in one pass and fails CLOSED, so
|
|
192
|
+
* it is asked first; only the addresses it refused are put through the
|
|
193
|
+
* platform half again to find out WHICH list holds them. In the ordinary case
|
|
194
|
+
* that second call is never made.
|
|
195
|
+
*/ export async function resolveAddresses(input) {
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
const rows = [];
|
|
198
|
+
for (const raw of input.inputs){
|
|
199
|
+
const email = normalizeContactEmail(raw);
|
|
200
|
+
if (email && seen.has(email)) continue;
|
|
201
|
+
if (email) seen.add(email);
|
|
202
|
+
rows.push({
|
|
203
|
+
input: String(raw != null ? raw : '').trim(),
|
|
204
|
+
email
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const addresses = rows.map((row)=>row.email).filter((email)=>Boolean(email));
|
|
208
|
+
/*
|
|
209
|
+
* The group first, because every answer below is about a CONTROLLER and
|
|
210
|
+
* not about a site: a business running three sites as one sender enrolls
|
|
211
|
+
* into all three at once, and an agency's client — which declared no group
|
|
212
|
+
* — resolves to itself.
|
|
213
|
+
*/ const group = await consentGroupForSite(input.hostId);
|
|
214
|
+
const [suppression, stored] = await Promise.all([
|
|
215
|
+
suppressionFor(input.hostId, addresses),
|
|
216
|
+
storedConsentFor(input.hostId, group, addresses)
|
|
217
|
+
]);
|
|
218
|
+
const unrecorded = readMarketingBasis(null, group);
|
|
219
|
+
const verdicts = rows.map((row)=>{
|
|
220
|
+
var _stored_get, _suppression_get, _suppression_get1;
|
|
221
|
+
if (!row.email) {
|
|
222
|
+
return {
|
|
223
|
+
input: row.input,
|
|
224
|
+
email: null,
|
|
225
|
+
refusal: 'unroutable-address',
|
|
226
|
+
requiresAttestation: false,
|
|
227
|
+
summary: ASSIGNMENT_REFUSAL_MESSAGES['unroutable-address']
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const record = (_stored_get = stored.get(row.email)) != null ? _stored_get : unrecorded;
|
|
231
|
+
const readout = assignmentReadout({
|
|
232
|
+
stored: record,
|
|
233
|
+
suppression: (_suppression_get = suppression.get(row.email)) != null ? _suppression_get : null
|
|
234
|
+
});
|
|
235
|
+
return {
|
|
236
|
+
input: row.input,
|
|
237
|
+
email: row.email,
|
|
238
|
+
/*
|
|
239
|
+
* Straight off the readout, with no attestation in the question.
|
|
240
|
+
*
|
|
241
|
+
* `assignmentReadout` answers what is TRUE about this person — a stored
|
|
242
|
+
* refusal, a suppression, an opt-in, or nothing on record — and none of
|
|
243
|
+
* those changes because the operator did or did not tick a box. Feeding
|
|
244
|
+
* the flag in here would make the preview's answer depend on it, so the
|
|
245
|
+
* count the operator stands behind would move as they answered. The
|
|
246
|
+
* basis itself is decided once, at the write, by `assignmentBasis`.
|
|
247
|
+
*/ refusal: readout.enrollable ? null : (_suppression_get1 = suppression.get(row.email)) != null ? _suppression_get1 : 'declined',
|
|
248
|
+
requiresAttestation: readout.requiresAttestation,
|
|
249
|
+
summary: readout.summary
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
return {
|
|
253
|
+
verdicts,
|
|
254
|
+
stored,
|
|
255
|
+
group,
|
|
256
|
+
optedIn: verdicts.filter((verdict)=>!verdict.refusal && !verdict.requiresAttestation).length,
|
|
257
|
+
needAttestation: verdicts.filter((verdict)=>verdict.requiresAttestation).length,
|
|
258
|
+
refused: verdicts.filter((verdict)=>verdict.refusal && !verdict.requiresAttestation).length
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Which of these addresses is suppressed, and by which list.
|
|
263
|
+
*
|
|
264
|
+
* Composed from the shipped helpers rather than reading the two collections
|
|
265
|
+
* here: normalization, de-duplication and the fail-closed posture live in
|
|
266
|
+
* `email-suppression.ts`, and a second copy of them is a second set of rules
|
|
267
|
+
* for the enrollment check and the send-time check to disagree about — which
|
|
268
|
+
* is exactly the disagreement `an-enrollment-is-not-a-license-to-send.spec.ts`
|
|
269
|
+
* exists to stop.
|
|
270
|
+
*/ async function suppressionFor(hostId, addresses) {
|
|
271
|
+
const refusals = new Map();
|
|
272
|
+
if (!addresses.length) return refusals;
|
|
273
|
+
const sendable = new Set(await filterSendableForHost(hostId, addresses));
|
|
274
|
+
const blocked = addresses.filter((email)=>!sendable.has(email));
|
|
275
|
+
if (!blocked.length) return refusals;
|
|
276
|
+
// Only the blocked ones, and only to say WHICH list. Survivors of the
|
|
277
|
+
// platform half are held by this site's own list.
|
|
278
|
+
const platformSendable = new Set(await filterSuppressedEmails(blocked));
|
|
279
|
+
for (const email of blocked){
|
|
280
|
+
refusals.set(email, platformSendable.has(email) ? 'suppressed-host' : 'suppressed-platform');
|
|
281
|
+
}
|
|
282
|
+
return refusals;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The stored consent facts for these addresses, off the org's contacts.
|
|
286
|
+
*
|
|
287
|
+
* Read UNSCOPED, deliberately, exactly as the Inbox route reads it:
|
|
288
|
+
* `scopedToHost` narrows an org collection to what one site may see, and a
|
|
289
|
+
* refusal filtered out by that narrowing is a refusal this route would then
|
|
290
|
+
* step over — the failure mode is enrolling somebody who said no. It is safe
|
|
291
|
+
* because the caller has already been proved an org-wide member, which is the
|
|
292
|
+
* tier the rules grant the whole org's contacts to.
|
|
293
|
+
*
|
|
294
|
+
* ## A refusal wins over an opt-in when the CRM holds both
|
|
295
|
+
*
|
|
296
|
+
* Nothing guarantees one contact per address — the collection is keyed by
|
|
297
|
+
* resource id, not by email — so two records for one person can disagree.
|
|
298
|
+
* Taking whichever the query happened to answer first would make the outcome
|
|
299
|
+
* depend on document order. A recorded refusal is the answer whenever one
|
|
300
|
+
* exists, which is the same precedence `assignmentBasis` applies within a
|
|
301
|
+
* single record.
|
|
302
|
+
*
|
|
303
|
+
* A failed read falls to `declined` for the whole batch, for the reason the
|
|
304
|
+
* Inbox route states: a read that throws can neither say the person consented
|
|
305
|
+
* nor that they refused, and the direction that costs a retry is the one that
|
|
306
|
+
* does not enroll somebody whose stored refusal we simply failed to see.
|
|
307
|
+
*/ async function storedConsentFor(hostId, group, addresses) {
|
|
308
|
+
const found = new Map();
|
|
309
|
+
if (!addresses.length) return found;
|
|
310
|
+
try {
|
|
311
|
+
const contacts = await orgDataCollectionForHost(hostId, 'contacts');
|
|
312
|
+
const chunks = [];
|
|
313
|
+
for(let at = 0; at < addresses.length; at += CONTACT_LOOKUP_CHUNK){
|
|
314
|
+
chunks.push(addresses.slice(at, at + CONTACT_LOOKUP_CHUNK));
|
|
315
|
+
}
|
|
316
|
+
const snapshots = await Promise.all(chunks.map((chunk)=>contacts.where('email', 'in', chunk).get()));
|
|
317
|
+
for (const snapshot of snapshots){
|
|
318
|
+
for (const doc of snapshot.docs){
|
|
319
|
+
const email = normalizeContactEmail(doc.get('email'));
|
|
320
|
+
if (!email) continue;
|
|
321
|
+
const record = readMarketingBasis(doc.data(), group);
|
|
322
|
+
const already = found.get(email);
|
|
323
|
+
if ((already == null ? void 0 : already.basis) === 'declined') continue;
|
|
324
|
+
if (already && record.basis !== 'declined') continue;
|
|
325
|
+
found.set(email, record);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return found;
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error('[email] consent lookup failed', error);
|
|
331
|
+
const refused = _extends({}, readMarketingBasis(null, group), {
|
|
332
|
+
basis: 'declined',
|
|
333
|
+
// Attributed to nobody: this is what a failed read falls back to, not a
|
|
334
|
+
// refusal anybody recorded.
|
|
335
|
+
assertedBy: null,
|
|
336
|
+
source: null,
|
|
337
|
+
basisAtMs: null,
|
|
338
|
+
capturedAtMs: null
|
|
339
|
+
});
|
|
340
|
+
return new Map(addresses.map((email)=>[
|
|
341
|
+
email,
|
|
342
|
+
refused
|
|
343
|
+
]));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** The addresses named by one request, or the refusal to send back. */ export function readAddresses(req) {
|
|
347
|
+
const body = req.body;
|
|
348
|
+
const raw = Array.isArray(body == null ? void 0 : body.emails) ? body.emails : (body == null ? void 0 : body.email) === undefined ? [] : [
|
|
349
|
+
body.email
|
|
350
|
+
];
|
|
351
|
+
const emails = raw.map((value)=>String(value != null ? value : '').trim()).filter((value)=>value.length > 0);
|
|
352
|
+
if (!emails.length) return {
|
|
353
|
+
error: 'No addresses'
|
|
354
|
+
};
|
|
355
|
+
if (emails.length > LIST_MEMBER_BATCH_MAX) {
|
|
356
|
+
return {
|
|
357
|
+
error: `${emails.length} addresses is more than one go can take. Add up to ` + `${LIST_MEMBER_BATCH_MAX} at a time.`
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
emails
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
//# sourceMappingURL=server-list-gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-list-gate.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 * THE GATE IN FRONT OF EVERY LIST ENROLLMENT THE CONSOLE MAKES.\n *\n * Who is allowed to change a list's membership, and what is true about each\n * address they name. Three route modules ask it — the audience card's add and\n * preview, the filter search, and the file importer — and they ask this one\n * copy of it.\n *\n * ## Why it is a module of its own\n *\n * It began inside `server-console.ts`, beside its first two callers, and that\n * was right while there were two. The importer made three, and an importer is\n * exactly the surface where a second, laxer idea of \"may this person be\n * enrolled\" is most tempting: a file is bulk, bulk is slow, and skipping the\n * per-address consent read is the obvious saving. A bulk path that reached the\n * membership without the suppression check and the attestation would be a way\n * to enroll precisely the people the one-at-a-time path refuses, which is the\n * defect class `docs/specs/email-competitive-gaps.md` has a closed P1 entry\n * for.\n *\n * Sharing the gate is also what stops the cheaper failure: two modules that\n * merely agree today, with nothing in either one saying the other exists.\n *\n * ## What it decides, and what it deliberately does not\n *\n * {@link resolveAddresses} answers the questions that are FACTS about a\n * person — a stored refusal, a suppression on either list, an opt-in already\n * on record, a line that is not an address. It never consults an attestation,\n * because an attestation is a fact about the operator and folding it in here\n * would make the preview's numbers move as the box was ticked. The basis is\n * decided once, at the write, by `assignmentBasis`.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentReadout,\n isOrgWideMember,\n normalizeContactEmail,\n readMarketingBasis,\n type AddressRefusal,\n type AssignmentRefusal,\n type ConsentGroup,\n type MarketingConsentRecord,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n filterSendableForHost,\n filterSuppressedEmails,\n firebaseAdmin,\n getOrgForHost,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n\n/**\n * The most addresses one request may name.\n *\n * A ceiling on the WRITE, not on the audience. It bounds the work one request\n * does — the resolution below is a handful of reads per address — and it can\n * never refuse a person already enrolled or make room by removing one, which\n * is the difference between a batch size and a capacity limit. Nothing in the\n * product caps list membership: `contactsPerHost` is the audience band and it\n * is metered against CONTACTS, in `upsert-contact.ts`, and enrolling somebody\n * on a list writes no contact. If a membership ceiling is ever introduced,\n * this function is where it refuses, because this is where something is\n * added.\n */\nexport const LIST_MEMBER_BATCH_MAX = 100\n\n/** How many contact addresses one `in` query may carry. */\nconst CONTACT_LOOKUP_CHUNK = 30\n\n/** Everything a list-membership route needs, or the refusal to send back. */\nexport type ListContext =\n | {\n ok: true\n uid: string\n hostId: string\n orgId: string\n listRef: FirebaseFirestore.DocumentReference\n listName: string\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\n/**\n * Who is asking, and about which list.\n *\n * ## Two gates, not one — the same two the Inbox assignment route applies\n *\n * A host role is necessary and NOT sufficient. Lists live at\n * `orgs/{orgId}/lists` and their members are contacts, so the rules put both\n * behind `isOrgWideMember()`. An editor invited to ONE site is an org member\n * with `allHosts: false`, and gating an org-wide write on the host role alone\n * would let a single-site collaborator enroll people into an audience every\n * other site in the org can mail — and, through the preview, read the consent\n * record of any address they care to type. The Admin SDK evaluates no rules,\n * so this route is the enforcement rather than an echo of it.\n *\n * The plugin's own gates sit above this and are the dispatcher's: a workspace\n * that has switched the email plugin off, or a site it is disabled for, or a\n * release flag that has not reached this org, all 404 before a handler runs.\n */\nexport async function resolveListContext(\n req: Parameters<PluginApiHandler>[0],\n): Promise<ListContext> {\n const hostId = String(req.body?.hostId ?? '')\n const listId = String(req.body?.listId ?? '')\n if (!hostId || !listId) {\n return {\n ok: false,\n status: 400,\n body: { error: 'Missing hostId or listId' },\n }\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n\n const orgId = String(\n (await getOrgForHost(hostId).catch(() => null))?.orgId ?? '',\n )\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no lists.' },\n }\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n const orgWideWriter =\n isOrgWideMember(member) &&\n (member?.role === 'owner' ||\n member?.role === 'admin' ||\n member?.role === 'editor') &&\n (member as { orgSuspended?: boolean } | undefined)?.orgSuspended !== true\n if (!orgWideWriter) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Marketing lists belong to the whole organization, so changing who ' +\n 'is on one needs organization-wide access rather than access to ' +\n 'this site.',\n },\n }\n }\n\n const listRef = firestore\n .collection('orgs')\n .doc(orgId)\n .collection('lists')\n .doc(listId)\n const listSnapshot = await listRef.get()\n // A stale or mistyped id must not CREATE a list: a campaign's `list`\n // audience would then read a list nobody set up.\n if (!listSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown list' } }\n }\n\n return {\n ok: true,\n uid: decoded.uid,\n hostId,\n orgId,\n listRef,\n listName: String(listSnapshot.get('name') ?? listId),\n }\n}\n\n/** What would happen to one address, and why. */\nexport interface AddressVerdict {\n /** Exactly what the operator typed, so a bad line can be pointed at. */\n input: string\n /** The normalized address, or `null` when there is not one. */\n email: string | null\n /**\n * Why this address can never go on the list, or `null`.\n *\n * A HARD refusal only — a stored `declined`, either suppression list, or a\n * line that is not an address. `no-basis` is deliberately NOT one of them:\n * \"you have not said you have permission yet\" is an unanswered question,\n * not a refusal, and reporting it as one would list the very people an\n * attestation is about to admit under the heading of people it cannot.\n * That distinction is {@link requiresAttestation}.\n */\n refusal: AssignmentRefusal | null\n /** True when only an attestation stands between this address and the list. */\n requiresAttestation: boolean\n /** One sentence of consent facts, in the merchant's terms. */\n summary: string\n}\n\n/** The whole answer, per address and in totals. */\nexport interface AddressResolution {\n verdicts: AddressVerdict[]\n /** Addresses a stored opt-in already covers — no attestation needed. */\n optedIn: number\n /** Addresses that need the operator to state they have permission. */\n needAttestation: number\n /** Addresses nothing can enroll, whatever the operator says. */\n refused: number\n}\n\n/**\n * The resolution plus the consent records it was computed from.\n *\n * The records ride along so the write path can derive the basis it STORES\n * from the same read the verdicts were built on. Re-reading them would be a\n * second answer to \"what does this person's record say\", between which a\n * concurrent edit fits — and the operator would have attested against the\n * first one.\n */\nexport interface ResolvedBatch extends AddressResolution {\n stored: Map<string, MarketingConsentRecord>\n /**\n * The consent group the resolution was made against, so the WRITE records\n * a basis for exactly the controller the verdicts were computed for.\n *\n * Returned rather than re-resolved by the caller for the same reason\n * `stored` is: two resolutions of the same question are two answers a\n * concurrent edit can fit between, and the operator attested against the\n * first one.\n */\n group: ConsentGroup\n}\n\n/**\n * Every address, normalized, deduplicated and put through the policy.\n *\n * ## Deduplicated on the NORMALIZED address, and reported once\n *\n * A pasted column routinely names the same person twice with different\n * casing, and `enrollListMember` keys the membership from the normalized\n * address, so two lines would be one row. Counting them twice would tell the\n * operator they are attesting for more people than they are.\n *\n * ## An unusable line is REPORTED, never dropped\n *\n * A paste that silently discarded its malformed lines would tell an operator\n * that 100 addresses went on the list when 94 did, and the six they never\n * hear about are the six they typed wrong. Every input line comes back with a\n * verdict, including the ones that are not addresses at all.\n *\n * ## Suppression is attributed, and costs nothing when there is none\n *\n * `filterSendableForHost` answers both lists in one pass and fails CLOSED, so\n * it is asked first; only the addresses it refused are put through the\n * platform half again to find out WHICH list holds them. In the ordinary case\n * that second call is never made.\n */\nexport async function resolveAddresses(input: {\n hostId: string\n inputs: readonly string[]\n}): Promise<ResolvedBatch> {\n const seen = new Set<string>()\n const rows: Array<{ input: string; email: string | null }> = []\n for (const raw of input.inputs) {\n const email = normalizeContactEmail(raw)\n if (email && seen.has(email)) continue\n if (email) seen.add(email)\n rows.push({ input: String(raw ?? '').trim(), email })\n }\n\n const addresses = rows\n .map((row) => row.email)\n .filter((email): email is string => Boolean(email))\n\n /*\n * The group first, because every answer below is about a CONTROLLER and\n * not about a site: a business running three sites as one sender enrolls\n * into all three at once, and an agency's client — which declared no group\n * — resolves to itself.\n */\n const group = await consentGroupForSite(input.hostId)\n const [suppression, stored] = await Promise.all([\n suppressionFor(input.hostId, addresses),\n storedConsentFor(input.hostId, group, addresses),\n ])\n\n const unrecorded: MarketingConsentRecord = readMarketingBasis(null, group)\n const verdicts = rows.map((row): AddressVerdict => {\n if (!row.email) {\n return {\n input: row.input,\n email: null,\n refusal: 'unroutable-address',\n requiresAttestation: false,\n summary: ASSIGNMENT_REFUSAL_MESSAGES['unroutable-address'],\n }\n }\n const record = stored.get(row.email) ?? unrecorded\n const readout = assignmentReadout({\n stored: record,\n suppression: suppression.get(row.email) ?? null,\n })\n return {\n input: row.input,\n email: row.email,\n /*\n * Straight off the readout, with no attestation in the question.\n *\n * `assignmentReadout` answers what is TRUE about this person — a stored\n * refusal, a suppression, an opt-in, or nothing on record — and none of\n * those changes because the operator did or did not tick a box. Feeding\n * the flag in here would make the preview's answer depend on it, so the\n * count the operator stands behind would move as they answered. The\n * basis itself is decided once, at the write, by `assignmentBasis`.\n */\n refusal: readout.enrollable\n ? null\n : (suppression.get(row.email) ?? 'declined'),\n requiresAttestation: readout.requiresAttestation,\n summary: readout.summary,\n }\n })\n\n return {\n verdicts,\n stored,\n group,\n optedIn: verdicts.filter(\n (verdict) => !verdict.refusal && !verdict.requiresAttestation,\n ).length,\n needAttestation: verdicts.filter((verdict) => verdict.requiresAttestation)\n .length,\n refused: verdicts.filter(\n (verdict) => verdict.refusal && !verdict.requiresAttestation,\n ).length,\n }\n}\n\n/**\n * Which of these addresses is suppressed, and by which list.\n *\n * Composed from the shipped helpers rather than reading the two collections\n * here: normalization, de-duplication and the fail-closed posture live in\n * `email-suppression.ts`, and a second copy of them is a second set of rules\n * for the enrollment check and the send-time check to disagree about — which\n * is exactly the disagreement `an-enrollment-is-not-a-license-to-send.spec.ts`\n * exists to stop.\n */\nasync function suppressionFor(\n hostId: string,\n addresses: readonly string[],\n): Promise<Map<string, AddressRefusal>> {\n const refusals = new Map<string, AddressRefusal>()\n if (!addresses.length) return refusals\n const sendable = new Set(await filterSendableForHost(hostId, addresses))\n const blocked = addresses.filter((email) => !sendable.has(email))\n if (!blocked.length) return refusals\n // Only the blocked ones, and only to say WHICH list. Survivors of the\n // platform half are held by this site's own list.\n const platformSendable = new Set(await filterSuppressedEmails(blocked))\n for (const email of blocked) {\n refusals.set(\n email,\n platformSendable.has(email) ? 'suppressed-host' : 'suppressed-platform',\n )\n }\n return refusals\n}\n\n/**\n * The stored consent facts for these addresses, off the org's contacts.\n *\n * Read UNSCOPED, deliberately, exactly as the Inbox route reads it:\n * `scopedToHost` narrows an org collection to what one site may see, and a\n * refusal filtered out by that narrowing is a refusal this route would then\n * step over — the failure mode is enrolling somebody who said no. It is safe\n * because the caller has already been proved an org-wide member, which is the\n * tier the rules grant the whole org's contacts to.\n *\n * ## A refusal wins over an opt-in when the CRM holds both\n *\n * Nothing guarantees one contact per address — the collection is keyed by\n * resource id, not by email — so two records for one person can disagree.\n * Taking whichever the query happened to answer first would make the outcome\n * depend on document order. A recorded refusal is the answer whenever one\n * exists, which is the same precedence `assignmentBasis` applies within a\n * single record.\n *\n * A failed read falls to `declined` for the whole batch, for the reason the\n * Inbox route states: a read that throws can neither say the person consented\n * nor that they refused, and the direction that costs a retry is the one that\n * does not enroll somebody whose stored refusal we simply failed to see.\n */\nasync function storedConsentFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<Map<string, MarketingConsentRecord>> {\n const found = new Map<string, MarketingConsentRecord>()\n if (!addresses.length) return found\n try {\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const chunks: string[][] = []\n for (let at = 0; at < addresses.length; at += CONTACT_LOOKUP_CHUNK) {\n chunks.push(addresses.slice(at, at + CONTACT_LOOKUP_CHUNK))\n }\n const snapshots = await Promise.all(\n chunks.map((chunk) => contacts.where('email', 'in', chunk).get()),\n )\n for (const snapshot of snapshots) {\n for (const doc of snapshot.docs) {\n const email = normalizeContactEmail(doc.get('email'))\n if (!email) continue\n const record = readMarketingBasis(\n doc.data() as Record<string, unknown>,\n group,\n )\n const already = found.get(email)\n if (already?.basis === 'declined') continue\n if (already && record.basis !== 'declined') continue\n found.set(email, record)\n }\n }\n return found\n } catch (error) {\n console.error('[email] consent lookup failed', error)\n const refused: MarketingConsentRecord = {\n ...readMarketingBasis(null, group),\n basis: 'declined',\n // Attributed to nobody: this is what a failed read falls back to, not a\n // refusal anybody recorded.\n assertedBy: null,\n source: null,\n basisAtMs: null,\n capturedAtMs: null,\n }\n return new Map(addresses.map((email) => [email, refused]))\n }\n}\n\n/** The addresses named by one request, or the refusal to send back. */\nexport function readAddresses(\n req: Parameters<PluginApiHandler>[0],\n): { emails: string[] } | { error: string } {\n const body = req.body as { emails?: unknown; email?: unknown } | undefined\n const raw = Array.isArray(body?.emails)\n ? body.emails\n : body?.email === undefined\n ? []\n : [body.email]\n const emails = raw\n .map((value) => String(value ?? '').trim())\n .filter((value) => value.length > 0)\n if (!emails.length) return { error: 'No addresses' }\n if (emails.length > LIST_MEMBER_BATCH_MAX) {\n return {\n error:\n `${emails.length} addresses is more than one go can take. Add up to ` +\n `${LIST_MEMBER_BATCH_MAX} at a time.`,\n }\n }\n return { emails }\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentReadout","isOrgWideMember","normalizeContactEmail","readMarketingBasis","consentGroupForSite","filterSendableForHost","filterSuppressedEmails","firebaseAdmin","getOrgForHost","orgDataCollectionForHost","resolveOrgMembership","LIST_MEMBER_BATCH_MAX","CONTACT_LOOKUP_CHUNK","resolveListContext","req","hostSnapshot","listSnapshot","hostId","String","body","listId","ok","status","error","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","firestore","collection","doc","get","exists","memberRole","uid","orgId","catch","membership","member","orgWideWriter","role","orgSuspended","listRef","listName","resolveAddresses","input","seen","Set","rows","raw","inputs","email","has","add","push","trim","addresses","map","row","filter","Boolean","group","suppression","stored","Promise","all","suppressionFor","storedConsentFor","unrecorded","verdicts","refusal","requiresAttestation","summary","record","readout","enrollable","optedIn","verdict","needAttestation","refused","refusals","Map","sendable","blocked","platformSendable","set","found","contacts","chunks","at","snapshots","chunk","where","snapshot","docs","data","already","basis","console","assertedBy","source","basisAtMs","capturedAtMs","readAddresses","Array","isArray","emails","value"],"mappings":"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"}
|