@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.
Files changed (144) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +58 -0
  4. package/src/index.d.ts +35 -0
  5. package/src/index.js +35 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/campaign-design-create-widget.d.ts +21 -0
  8. package/src/lib/components/campaign-design-create-widget.js +61 -0
  9. package/src/lib/components/campaign-design-create-widget.js.map +1 -0
  10. package/src/lib/components/campaign-sender-editor-widget.d.ts +18 -0
  11. package/src/lib/components/campaign-sender-editor-widget.js +39 -0
  12. package/src/lib/components/campaign-sender-editor-widget.js.map +1 -0
  13. package/src/lib/components/campaign-topic-options-widget.d.ts +19 -0
  14. package/src/lib/components/campaign-topic-options-widget.js +48 -0
  15. package/src/lib/components/campaign-topic-options-widget.js.map +1 -0
  16. package/src/lib/components/campaign-topic-select.d.ts +32 -0
  17. package/src/lib/components/campaign-topic-select.js +87 -0
  18. package/src/lib/components/campaign-topic-select.js.map +1 -0
  19. package/src/lib/components/dynamic-list-rule-fields.d.ts +173 -0
  20. package/src/lib/components/dynamic-list-rule-fields.js +1473 -0
  21. package/src/lib/components/dynamic-list-rule-fields.js.map +1 -0
  22. package/src/lib/components/email-blocks.d.ts +111 -0
  23. package/src/lib/components/email-blocks.js +875 -0
  24. package/src/lib/components/email-blocks.js.map +1 -0
  25. package/src/lib/components/email-design-preview.d.ts +62 -0
  26. package/src/lib/components/email-design-preview.js +174 -0
  27. package/src/lib/components/email-design-preview.js.map +1 -0
  28. package/src/lib/components/email-screens-card.d.ts +42 -0
  29. package/src/lib/components/email-screens-card.js +277 -0
  30. package/src/lib/components/email-screens-card.js.map +1 -0
  31. package/src/lib/components/email-template-detail.d.ts +48 -0
  32. package/src/lib/components/email-template-detail.js +681 -0
  33. package/src/lib/components/email-template-detail.js.map +1 -0
  34. package/src/lib/components/email-topic-detail.d.ts +32 -0
  35. package/src/lib/components/email-topic-detail.js +293 -0
  36. package/src/lib/components/email-topic-detail.js.map +1 -0
  37. package/src/lib/components/email-topics-card.d.ts +46 -0
  38. package/src/lib/components/email-topics-card.js +327 -0
  39. package/src/lib/components/email-topics-card.js.map +1 -0
  40. package/src/lib/components/email-zones.d.ts +28 -0
  41. package/src/lib/components/email-zones.js +20 -0
  42. package/src/lib/components/email-zones.js.map +1 -0
  43. package/src/lib/components/emails-console-page.d.ts +32 -0
  44. package/src/lib/components/emails-console-page.js +229 -0
  45. package/src/lib/components/emails-console-page.js.map +1 -0
  46. package/src/lib/components/emails-console-sections.d.ts +36 -0
  47. package/src/lib/components/emails-console-sections.js +108 -0
  48. package/src/lib/components/emails-console-sections.js.map +1 -0
  49. package/src/lib/components/list-detail-card.d.ts +47 -0
  50. package/src/lib/components/list-detail-card.js +273 -0
  51. package/src/lib/components/list-detail-card.js.map +1 -0
  52. package/src/lib/components/list-edit-card.d.ts +11 -0
  53. package/src/lib/components/list-edit-card.js +287 -0
  54. package/src/lib/components/list-edit-card.js.map +1 -0
  55. package/src/lib/components/list-import-drawer.d.ts +22 -0
  56. package/src/lib/components/list-import-drawer.js +662 -0
  57. package/src/lib/components/list-import-drawer.js.map +1 -0
  58. package/src/lib/components/list-members-panel.d.ts +94 -0
  59. package/src/lib/components/list-members-panel.js +686 -0
  60. package/src/lib/components/list-members-panel.js.map +1 -0
  61. package/src/lib/components/lists-card.d.ts +28 -0
  62. package/src/lib/components/lists-card.js +377 -0
  63. package/src/lib/components/lists-card.js.map +1 -0
  64. package/src/lib/components/sending-domain-detail.d.ts +26 -0
  65. package/src/lib/components/sending-domain-detail.js +496 -0
  66. package/src/lib/components/sending-domain-detail.js.map +1 -0
  67. package/src/lib/components/sending-domains-card.d.ts +33 -0
  68. package/src/lib/components/sending-domains-card.js +962 -0
  69. package/src/lib/components/sending-domains-card.js.map +1 -0
  70. package/src/lib/components/sending-sender-drawer.d.ts +94 -0
  71. package/src/lib/components/sending-sender-drawer.js +543 -0
  72. package/src/lib/components/sending-sender-drawer.js.map +1 -0
  73. package/src/lib/components/suppressions-card.d.ts +49 -0
  74. package/src/lib/components/suppressions-card.js +639 -0
  75. package/src/lib/components/suppressions-card.js.map +1 -0
  76. package/src/lib/components/use-org-email-topics.d.ts +79 -0
  77. package/src/lib/components/use-org-email-topics.js +111 -0
  78. package/src/lib/components/use-org-email-topics.js.map +1 -0
  79. package/src/lib/constants/bundle-common.d.ts +18 -0
  80. package/src/lib/constants/bundle-common.js +18 -0
  81. package/src/lib/constants/bundle-common.js.map +1 -0
  82. package/src/lib/hooks/use-org-company-options.d.ts +20 -0
  83. package/src/lib/hooks/use-org-company-options.js +138 -0
  84. package/src/lib/hooks/use-org-company-options.js.map +1 -0
  85. package/src/lib/hooks/use-org-contact-fields.d.ts +40 -0
  86. package/src/lib/hooks/use-org-contact-fields.js +91 -0
  87. package/src/lib/hooks/use-org-contact-fields.js.map +1 -0
  88. package/src/lib/hooks/use-org-contact-segments.d.ts +16 -0
  89. package/src/lib/hooks/use-org-contact-segments.js +55 -0
  90. package/src/lib/hooks/use-org-contact-segments.js.map +1 -0
  91. package/src/lib/hooks/use-org-crm-views.d.ts +8 -0
  92. package/src/lib/hooks/use-org-crm-views.js +74 -0
  93. package/src/lib/hooks/use-org-crm-views.js.map +1 -0
  94. package/src/lib/hooks/use-org-lists.d.ts +8 -0
  95. package/src/lib/hooks/use-org-lists.js +47 -0
  96. package/src/lib/hooks/use-org-lists.js.map +1 -0
  97. package/src/lib/model/email-design-document.d.ts +52 -0
  98. package/src/lib/model/email-design-document.js +62 -0
  99. package/src/lib/model/email-design-document.js.map +1 -0
  100. package/src/lib/model/index.d.ts +64 -0
  101. package/src/lib/model/index.js +71 -0
  102. package/src/lib/model/index.js.map +1 -0
  103. package/src/lib/model/sending-domain-status.d.ts +99 -0
  104. package/src/lib/model/sending-domain-status.js +196 -0
  105. package/src/lib/model/sending-domain-status.js.map +1 -0
  106. package/src/lib/model/template-provenance.d.ts +113 -0
  107. package/src/lib/model/template-provenance.js +107 -0
  108. package/src/lib/model/template-provenance.js.map +1 -0
  109. package/src/lib/model/template-report.d.ts +158 -0
  110. package/src/lib/model/template-report.js +249 -0
  111. package/src/lib/model/template-report.js.map +1 -0
  112. package/src/lib/plugin.d.ts +27 -0
  113. package/src/lib/plugin.js +163 -0
  114. package/src/lib/plugin.js.map +1 -0
  115. package/src/lib/server-console.d.ts +116 -0
  116. package/src/lib/server-console.js +422 -0
  117. package/src/lib/server-console.js.map +1 -0
  118. package/src/lib/server-email-drafts.d.ts +104 -0
  119. package/src/lib/server-email-drafts.js +381 -0
  120. package/src/lib/server-email-drafts.js.map +1 -0
  121. package/src/lib/server-list-gate.d.ts +183 -0
  122. package/src/lib/server-list-gate.js +365 -0
  123. package/src/lib/server-list-gate.js.map +1 -0
  124. package/src/lib/server-list-import.d.ts +199 -0
  125. package/src/lib/server-list-import.js +632 -0
  126. package/src/lib/server-list-import.js.map +1 -0
  127. package/src/lib/server-suppressions.d.ts +135 -0
  128. package/src/lib/server-suppressions.js +295 -0
  129. package/src/lib/server-suppressions.js.map +1 -0
  130. package/src/lib/server.d.ts +19 -0
  131. package/src/lib/server.js +834 -0
  132. package/src/lib/server.js.map +1 -0
  133. package/src/lib/site.d.ts +26 -0
  134. package/src/lib/site.js +81 -0
  135. package/src/lib/site.js.map +1 -0
  136. package/src/lib/unsubscribe-link.d.ts +311 -0
  137. package/src/lib/unsubscribe-link.js +398 -0
  138. package/src/lib/unsubscribe-link.js.map +1 -0
  139. package/src/lib/utils/create-email-screen.d.ts +59 -0
  140. package/src/lib/utils/create-email-screen.js +59 -0
  141. package/src/lib/utils/create-email-screen.js.map +1 -0
  142. package/src/lib/utils/generate-preset-id.d.ts +19 -0
  143. package/src/lib/utils/generate-preset-id.js +25 -0
  144. 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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"}