@aglyn/plugins-email 1.0.0-beta.287 → 1.0.0-beta.289

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-email",
3
- "version": "1.0.0-beta.287",
3
+ "version": "1.0.0-beta.289",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,21 +25,21 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.287",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.287",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.287",
31
- "@aglyn/shared-data-types": "1.0.0-beta.287",
32
- "@aglyn/shared-ui-jsx": "1.0.0-beta.287",
33
- "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.287",
34
- "@aglyn/shared-ui-next": "1.0.0-beta.287",
35
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.287",
36
- "@aglyn/shared-ui-theme": "1.0.0-beta.287",
37
- "@aglyn/shared-util-email": "1.0.0-beta.287",
38
- "@aglyn/shared-util-http": "1.0.0-beta.287",
39
- "@aglyn/shared-util-timestamp": "1.0.0-beta.287",
40
- "@aglyn/shared-util-tools": "1.0.0-beta.287",
41
- "@aglyn/tenant-data-admin": "1.0.0-beta.287",
42
- "@aglyn/tenant-feature-instance": "1.0.0-beta.287",
28
+ "@aglyn/aglyn": "1.0.0-beta.289",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.289",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.289",
31
+ "@aglyn/shared-data-types": "1.0.0-beta.289",
32
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.289",
33
+ "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.289",
34
+ "@aglyn/shared-ui-next": "1.0.0-beta.289",
35
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.289",
36
+ "@aglyn/shared-ui-theme": "1.0.0-beta.289",
37
+ "@aglyn/shared-util-email": "1.0.0-beta.289",
38
+ "@aglyn/shared-util-http": "1.0.0-beta.289",
39
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.289",
40
+ "@aglyn/shared-util-tools": "1.0.0-beta.289",
41
+ "@aglyn/tenant-data-admin": "1.0.0-beta.289",
42
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.289",
43
43
  "@swc/helpers": "0.5.23"
44
44
  },
45
45
  "peerDependencies": {
@@ -0,0 +1,25 @@
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 plugin's DECLARATIONS (AGL-4047): its suppression and list keys, which the one permission
19
+ * catalog lists beside the core keys. Loaded eagerly by the console (client
20
+ * and server) and the tenant's server through the generated declarations
21
+ * manifest, so the role editor offers them, a custom role or a per-member
22
+ * override keeps them, and the site's stamp carries them. Light by
23
+ * construction: nothing here reaches a provider, Firestore or React.
24
+ */
25
+ export declare function registerEmailDeclarations(): void;
@@ -0,0 +1,36 @@
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
+ */ import { registerPluginEntitlements } from "@aglyn/aglyn/plugin-manager/plugin-entitlements";
17
+ import { EMAIL_SITE_PERMISSIONS } from "./model/email-permissions.js";
18
+ let declared = false;
19
+ /**
20
+ * The plugin's DECLARATIONS (AGL-4047): its suppression and list keys, which the one permission
21
+ * catalog lists beside the core keys. Loaded eagerly by the console (client
22
+ * and server) and the tenant's server through the generated declarations
23
+ * manifest, so the role editor offers them, a custom role or a per-member
24
+ * override keeps them, and the site's stamp carries them. Light by
25
+ * construction: nothing here reaches a provider, Firestore or React.
26
+ */ export function registerEmailDeclarations() {
27
+ if (declared) return;
28
+ declared = true;
29
+ registerPluginEntitlements({
30
+ pluginId: 'email',
31
+ orgPermissions: EMAIL_SITE_PERMISSIONS
32
+ });
33
+ }
34
+ registerEmailDeclarations();
35
+
36
+ //# sourceMappingURL=declarations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/declarations.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\nimport { registerPluginEntitlements } from '@aglyn/aglyn/plugin-manager/plugin-entitlements'\nimport { EMAIL_SITE_PERMISSIONS } from './model/email-permissions'\n\nlet declared = false\n\n/**\n * The plugin's DECLARATIONS (AGL-4047): its suppression and list keys, which the one permission\n * catalog lists beside the core keys. Loaded eagerly by the console (client\n * and server) and the tenant's server through the generated declarations\n * manifest, so the role editor offers them, a custom role or a per-member\n * override keeps them, and the site's stamp carries them. Light by\n * construction: nothing here reaches a provider, Firestore or React.\n */\nexport function registerEmailDeclarations(): void {\n if (declared) return\n declared = true\n registerPluginEntitlements({ pluginId: 'email', orgPermissions: EMAIL_SITE_PERMISSIONS })\n}\n\nregisterEmailDeclarations()\n"],"names":["registerPluginEntitlements","EMAIL_SITE_PERMISSIONS","declared","registerEmailDeclarations","pluginId","orgPermissions"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,0BAA0B,QAAQ,kDAAiD;AAC5F,SAASC,sBAAsB,QAAQ,+BAA2B;AAElE,IAAIC,WAAW;AAEf;;;;;;;CAOC,GACD,OAAO,SAASC;IACd,IAAID,UAAU;IACdA,WAAW;IACXF,2BAA2B;QAAEI,UAAU;QAASC,gBAAgBJ;IAAuB;AACzF;AAEAE"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { HostAccessRole } from '@aglyn/aglyn/foundation/definitions/organization.types';
18
+ import type { PluginOrgPermissionDeclaration } from '@aglyn/aglyn/plugin-manager/plugin-entitlements';
19
+ /** Email's keys whose preset is the site role. */
20
+ export type EmailSitePermission = 'email.suppressions' | 'email.lists';
21
+ /** Who did each before it was a key: the site's Admin and Editor. */
22
+ export declare const EMAIL_SITE_ROLE_PRESET: Readonly<Record<HostAccessRole, boolean>>;
23
+ /** The keys as the plugin declares them into the org permission catalog. */
24
+ export declare const EMAIL_SITE_PERMISSIONS: readonly PluginOrgPermissionDeclaration[];
25
+ /** The refusal a door answers without one: names the permission, never a role. */
26
+ export declare function emailSitePermissionRefusal(key: EmailSitePermission): string;
@@ -0,0 +1,62 @@
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
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ /** Who did each before it was a key: the site's Admin and Editor. */ export const EMAIL_SITE_ROLE_PRESET = {
18
+ admin: true,
19
+ editor: true,
20
+ author: false,
21
+ viewer: false
22
+ };
23
+ /** The keys as the plugin declares them into the org permission catalog. */ export const EMAIL_SITE_PERMISSIONS = [
24
+ {
25
+ key: 'email.suppressions',
26
+ label: 'Manage the site’s suppressions',
27
+ description: 'Add addresses to the site’s suppression list and check addresses against it. Follows the site role unless a role or member sets it.',
28
+ roleDefaults: {
29
+ owner: true,
30
+ admin: true,
31
+ editor: true,
32
+ viewer: false
33
+ },
34
+ hostRoleDefaults: _extends({}, EMAIL_SITE_ROLE_PRESET),
35
+ group: 'email',
36
+ groupLabel: 'Email',
37
+ presetFromSiteRole: true
38
+ },
39
+ {
40
+ key: 'email.lists',
41
+ label: 'Add people to marketing lists from a site',
42
+ description: 'Add and remove people on the organization’s marketing lists from a site (only for a member of the whole workspace who may write its lists, as the lists are the organization’s). Follows the site role unless a role or member sets it.',
43
+ roleDefaults: {
44
+ owner: true,
45
+ admin: true,
46
+ editor: true,
47
+ viewer: false
48
+ },
49
+ hostRoleDefaults: _extends({}, EMAIL_SITE_ROLE_PRESET),
50
+ group: 'email',
51
+ groupLabel: 'Email',
52
+ presetFromSiteRole: true
53
+ }
54
+ ];
55
+ /** The refusal a door answers without one: names the permission, never a role. */ export function emailSitePermissionRefusal(key) {
56
+ var _ref;
57
+ var _EMAIL_SITE_PERMISSIONS_find;
58
+ const label = (_ref = (_EMAIL_SITE_PERMISSIONS_find = EMAIL_SITE_PERMISSIONS.find((entry)=>entry.key === key)) == null ? void 0 : _EMAIL_SITE_PERMISSIONS_find.label) != null ? _ref : key;
59
+ return `Your role does not include "${label}" — ask an organization admin`;
60
+ }
61
+
62
+ //# sourceMappingURL=email-permissions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/email/src/lib/model/email-permissions.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\nimport type { HostAccessRole } from '@aglyn/aglyn/foundation/definitions/organization.types'\nimport type { PluginOrgPermissionDeclaration } from '@aglyn/aglyn/plugin-manager/plugin-entitlements'\n\n/*\n * EMAIL'S KEYS (AGL-4047).\n *\n * A site's suppression list, and adding people to the organization's\n * marketing lists from a site, asked for the site's Admin or Editor. Those\n * are now keys whose preset is that site role (`presetFromSiteRole`), so\n * every member keeps exactly the access they had, and a custom role, a\n * member's own permissions or a site's toggle can grant or refuse either\n * one. The lists still ask the organization-wide writer beside it.\n */\n\n/** Email's keys whose preset is the site role. */\nexport type EmailSitePermission = 'email.suppressions' | 'email.lists'\n\n/** Who did each before it was a key: the site's Admin and Editor. */\nexport const EMAIL_SITE_ROLE_PRESET: Readonly<Record<HostAccessRole, boolean>> = {\n admin: true,\n editor: true,\n author: false,\n viewer: false,\n}\n\n/** The keys as the plugin declares them into the org permission catalog. */\nexport const EMAIL_SITE_PERMISSIONS: readonly PluginOrgPermissionDeclaration[] = [\n {\n key: 'email.suppressions',\n label: 'Manage the site’s suppressions',\n description:\n 'Add addresses to the site’s suppression list and check addresses against it. Follows the site role unless a role or member sets it.',\n roleDefaults: { owner: true, admin: true, editor: true, viewer: false },\n hostRoleDefaults: { ...EMAIL_SITE_ROLE_PRESET },\n group: 'email',\n groupLabel: 'Email',\n presetFromSiteRole: true,\n },\n {\n key: 'email.lists',\n label: 'Add people to marketing lists from a site',\n description:\n 'Add and remove people on the organization’s marketing lists from a site (only for a member of the whole workspace who may write its lists, as the lists are the organization’s). Follows the site role unless a role or member sets it.',\n roleDefaults: { owner: true, admin: true, editor: true, viewer: false },\n hostRoleDefaults: { ...EMAIL_SITE_ROLE_PRESET },\n group: 'email',\n groupLabel: 'Email',\n presetFromSiteRole: true,\n },\n]\n\n/** The refusal a door answers without one: names the permission, never a role. */\nexport function emailSitePermissionRefusal(key: EmailSitePermission): string {\n const label = EMAIL_SITE_PERMISSIONS.find((entry) => entry.key === key)?.label ?? key\n return `Your role does not include \"${label}\" — ask an organization admin`\n}\n"],"names":["EMAIL_SITE_ROLE_PRESET","admin","editor","author","viewer","EMAIL_SITE_PERMISSIONS","key","label","description","roleDefaults","owner","hostRoleDefaults","group","groupLabel","presetFromSiteRole","emailSitePermissionRefusal","find","entry"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAmBD,mEAAmE,GACnE,OAAO,MAAMA,yBAAoE;IAC/EC,OAAO;IACPC,QAAQ;IACRC,QAAQ;IACRC,QAAQ;AACV,EAAC;AAED,0EAA0E,GAC1E,OAAO,MAAMC,yBAAoE;IAC/E;QACEC,KAAK;QACLC,OAAO;QACPC,aACE;QACFC,cAAc;YAAEC,OAAO;YAAMT,OAAO;YAAMC,QAAQ;YAAME,QAAQ;QAAM;QACtEO,kBAAkB,aAAKX;QACvBY,OAAO;QACPC,YAAY;QACZC,oBAAoB;IACtB;IACA;QACER,KAAK;QACLC,OAAO;QACPC,aACE;QACFC,cAAc;YAAEC,OAAO;YAAMT,OAAO;YAAMC,QAAQ;YAAME,QAAQ;QAAM;QACtEO,kBAAkB,aAAKX;QACvBY,OAAO;QACPC,YAAY;QACZC,oBAAoB;IACtB;CACD,CAAA;AAED,gFAAgF,GAChF,OAAO,SAASC,2BAA2BT,GAAwB;;QACnDD;IAAd,MAAME,iBAAQF,+BAAAA,uBAAuBW,IAAI,CAAC,CAACC,QAAUA,MAAMX,GAAG,KAAKA,yBAArDD,6BAA2DE,KAAK,mBAAID;IAClF,OAAO,CAAC,4BAA4B,EAAEC,MAAM,6BAA6B,CAAC;AAC5E"}
@@ -50,6 +50,8 @@ import { consentGroupForSite, filterSendableForHost, filterSuppressedEmails, fir
50
50
  // wholesale, and what a deleted contact's refusal still says must be read
51
51
  // under them by the real store and the real merge.
52
52
  import { readContactConsent } from "@aglyn/tenant-data-admin/server/consent-by-address";
53
+ import { holdsStampedSiteKey } from "@aglyn/aglyn/app-utils/host-permissions";
54
+ import { EMAIL_SITE_ROLE_PRESET, emailSitePermissionRefusal } from "./model/email-permissions.js";
53
55
  /**
54
56
  * The most addresses one request may name.
55
57
  *
@@ -81,7 +83,7 @@ import { readContactConsent } from "@aglyn/tenant-data-admin/server/consent-by-a
81
83
  * that has switched the email plugin off, or a site it is disabled for, or a
82
84
  * release flag that has not reached this org, all 404 before a handler runs.
83
85
  */ export async function resolveListContext(req) {
84
- var _ref, _ref1, _req_headers_authorization, _hostSnapshot_get, _ref2, _listSnapshot_get;
86
+ var _ref, _ref1, _req_headers_authorization, _ref2, _listSnapshot_get;
85
87
  var _req_body, _req_body1, _this;
86
88
  const hostId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.hostId) != null ? _ref : '');
87
89
  const listId = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.listId) != null ? _ref1 : '');
@@ -117,13 +119,15 @@ import { readContactConsent } from "@aglyn/tenant-data-admin/server/consent-by-a
117
119
  }
118
120
  };
119
121
  }
120
- const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
121
- if (memberRole !== 'admin' && memberRole !== 'editor') {
122
+ // "Add people to marketing lists from a site" (AGL-4047): the site's
123
+ // stamp, else the preset of the member's site role, the Admin and Editor
124
+ // this asked for. The organization-wide writer is still asked below.
125
+ if (!holdsStampedSiteKey(hostSnapshot, decoded.uid, 'email.lists', EMAIL_SITE_ROLE_PRESET)) {
122
126
  return {
123
127
  ok: false,
124
128
  status: 403,
125
129
  body: {
126
- error: 'Not a site admin or editor'
130
+ error: emailSitePermissionRefusal('email.lists')
127
131
  }
128
132
  };
129
133
  }
@@ -1 +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// The leaf, not the barrel: this plugin's specs substitute both barrels\n// wholesale, and what a deleted contact's refusal still says must be read\n// under them by the real store and the real merge.\nimport { readContactConsent } from '@aglyn/tenant-data-admin/server/consent-by-address'\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/** 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 if (!isOrgWideListWriter(membership?.member)) {\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/**\n * Whether an org member may change who is on the organization's lists: an\n * org-wide owner, admin or editor whose membership is not suspended. The one\n * answer the list routes and the list-member import ask (see\n * {@link resolveListContext} for why a site role is not enough).\n */\nexport function isOrgWideListWriter(member: unknown): boolean {\n const entry = member as\n | { role?: string; orgSuspended?: boolean }\n | null\n | undefined\n return (\n isOrgWideMember(entry as Parameters<typeof isOrgWideMember>[0]) &&\n (entry?.role === 'owner' ||\n entry?.role === 'admin' ||\n entry?.role === 'editor') &&\n entry?.orgSuspended !== true\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 * Each opted-in person's own grants, as `grantEntriesAsRecorded` read them\n * off the SAME contact document `stored` was read from — what a\n * pass-through enrollment carries onto the membership (AGL-3320).\n *\n * From the same read for the reason `stored` is: the basis decided and the\n * sites it covers must describe one version of the person's record.\n */\n grants: Map<string, Record<string, Record<string, unknown>>>\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, grants }] = await Promise.all([\n suppressionFor(input.hostId, group, addresses),\n readContactConsent(\n orgDataCollectionForHost(input.hostId, 'contacts'),\n group,\n addresses,\n ),\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 grants,\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 *\n * Read across the site's consent group, as the send reads it: somebody who\n * unsubscribed from a sibling of a declared group is refused here too, rather\n * than enrolled onto a list whose campaigns will never reach them.\n */\nasync function suppressionFor(\n hostId: string,\n group: ConsentGroup,\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(\n await filterSendableForHost(hostId, addresses, undefined, group),\n )\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, or a group sibling's.\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/** 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","readContactConsent","LIST_MEMBER_BATCH_MAX","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","isOrgWideListWriter","member","listRef","listName","entry","role","orgSuspended","resolveAddresses","input","seen","Set","rows","raw","inputs","email","has","add","push","trim","addresses","map","row","filter","Boolean","group","suppression","stored","grants","Promise","all","suppressionFor","unrecorded","verdicts","refusal","requiresAttestation","summary","record","readout","enrollable","optedIn","verdict","needAttestation","refused","refusals","Map","sendable","blocked","platformSendable","set","readAddresses","Array","isArray","emails","value"],"mappings":"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"}
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// The leaf, not the barrel: this plugin's specs substitute both barrels\n// wholesale, and what a deleted contact's refusal still says must be read\n// under them by the real store and the real merge.\nimport { readContactConsent } from '@aglyn/tenant-data-admin/server/consent-by-address'\nimport { holdsStampedSiteKey } from '@aglyn/aglyn/app-utils/host-permissions'\nimport { EMAIL_SITE_ROLE_PRESET, emailSitePermissionRefusal } from './model/email-permissions'\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/** 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 // \"Add people to marketing lists from a site\" (AGL-4047): the site's\n // stamp, else the preset of the member's site role, the Admin and Editor\n // this asked for. The organization-wide writer is still asked below.\n if (!holdsStampedSiteKey(hostSnapshot, decoded.uid, 'email.lists', EMAIL_SITE_ROLE_PRESET)) {\n return {\n ok: false,\n status: 403,\n body: { error: emailSitePermissionRefusal('email.lists') },\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 if (!isOrgWideListWriter(membership?.member)) {\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/**\n * Whether an org member may change who is on the organization's lists: an\n * org-wide owner, admin or editor whose membership is not suspended. The one\n * answer the list routes and the list-member import ask (see\n * {@link resolveListContext} for why a site role is not enough).\n */\nexport function isOrgWideListWriter(member: unknown): boolean {\n const entry = member as\n | { role?: string; orgSuspended?: boolean }\n | null\n | undefined\n return (\n isOrgWideMember(entry as Parameters<typeof isOrgWideMember>[0]) &&\n (entry?.role === 'owner' ||\n entry?.role === 'admin' ||\n entry?.role === 'editor') &&\n entry?.orgSuspended !== true\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 * Each opted-in person's own grants, as `grantEntriesAsRecorded` read them\n * off the SAME contact document `stored` was read from — what a\n * pass-through enrollment carries onto the membership (AGL-3320).\n *\n * From the same read for the reason `stored` is: the basis decided and the\n * sites it covers must describe one version of the person's record.\n */\n grants: Map<string, Record<string, Record<string, unknown>>>\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, grants }] = await Promise.all([\n suppressionFor(input.hostId, group, addresses),\n readContactConsent(\n orgDataCollectionForHost(input.hostId, 'contacts'),\n group,\n addresses,\n ),\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 grants,\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 *\n * Read across the site's consent group, as the send reads it: somebody who\n * unsubscribed from a sibling of a declared group is refused here too, rather\n * than enrolled onto a list whose campaigns will never reach them.\n */\nasync function suppressionFor(\n hostId: string,\n group: ConsentGroup,\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(\n await filterSendableForHost(hostId, addresses, undefined, group),\n )\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, or a group sibling's.\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/** 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","readContactConsent","holdsStampedSiteKey","EMAIL_SITE_ROLE_PRESET","emailSitePermissionRefusal","LIST_MEMBER_BATCH_MAX","resolveListContext","req","listSnapshot","hostId","String","body","listId","ok","status","error","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","firestore","hostSnapshot","collection","doc","get","exists","uid","orgId","catch","membership","isOrgWideListWriter","member","listRef","listName","entry","role","orgSuspended","resolveAddresses","input","seen","Set","rows","raw","inputs","email","has","add","push","trim","addresses","map","row","filter","Boolean","group","suppression","stored","grants","Promise","all","suppressionFor","unrecorded","verdicts","refusal","requiresAttestation","summary","record","readout","enrollable","optedIn","verdict","needAttestation","refused","refusals","Map","sendable","blocked","platformSendable","set","readAddresses","Array","isArray","emails","value"],"mappings":"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"}
@@ -56,6 +56,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
56
56
  */ import { registerPluginApiRoute } from "@aglyn/aglyn/server";
57
57
  import { emailSearchTokens, emailSuppressionKey, firebaseAdmin, isEmailSuppressed } from "@aglyn/tenant-data-admin";
58
58
  import { FieldValue } from "firebase-admin/firestore";
59
+ import { holdsStampedSiteKey } from "@aglyn/aglyn/app-utils/host-permissions";
60
+ import { EMAIL_SITE_ROLE_PRESET, emailSitePermissionRefusal } from "./model/email-permissions.js";
59
61
  /**
60
62
  * The `reason` a hand-added entry carries.
61
63
  *
@@ -103,7 +105,7 @@ import { FieldValue } from "firebase-admin/firestore";
103
105
  error: 'Unauthenticated'
104
106
  });
105
107
  try {
106
- var _hostSnapshot_get, _body_note, _body_emails;
108
+ var _body_note, _body_emails;
107
109
  const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
108
110
  const firestore = firebaseAdmin.app().firestore();
109
111
  const hostRef = firestore.collection('hosts').doc(hostId);
@@ -123,10 +125,11 @@ import { FieldValue } from "firebase-admin/firestore";
123
125
  * demanding that here would refuse a single-site editor the ability to
124
126
  * honor an opt-out about their own site's mail, which is the opposite of
125
127
  * what a suppression is for.
126
- */ const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
127
- if (memberRole !== 'admin' && memberRole !== 'editor') {
128
+ */ // "Manage the site's suppressions" (AGL-4047): the site's stamp, else the
129
+ // preset of the member's site role, the Admin and Editor this asked for.
130
+ if (!holdsStampedSiteKey(hostSnapshot, decoded.uid, 'email.suppressions', EMAIL_SITE_ROLE_PRESET)) {
128
131
  return res.status(403).json({
129
- error: 'Not a site admin or editor'
132
+ error: emailSitePermissionRefusal('email.suppressions')
130
133
  });
131
134
  }
132
135
  const note = String((_body_note = body.note) != null ? _body_note : '').trim().slice(0, SUPPRESSION_NOTE_MAX);
@@ -240,7 +243,7 @@ import { FieldValue } from "firebase-admin/firestore";
240
243
  error: 'Unauthenticated'
241
244
  });
242
245
  try {
243
- var _hostSnapshot_get, _body_emails;
246
+ var _body_emails;
244
247
  const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
245
248
  const firestore = firebaseAdmin.app().firestore();
246
249
  const hostSnapshot = await firestore.collection('hosts').doc(hostId).get();
@@ -249,10 +252,11 @@ import { FieldValue } from "firebase-admin/firestore";
249
252
  error: 'Unknown site'
250
253
  });
251
254
  }
252
- const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
253
- if (memberRole !== 'admin' && memberRole !== 'editor') {
255
+ // "Manage the site's suppressions" (AGL-4047): the site's stamp, else the
256
+ // preset of the member's site role, the Admin and Editor this asked for.
257
+ if (!holdsStampedSiteKey(hostSnapshot, decoded.uid, 'email.suppressions', EMAIL_SITE_ROLE_PRESET)) {
254
258
  return res.status(403).json({
255
- error: 'Not a site admin or editor'
259
+ error: emailSitePermissionRefusal('email.suppressions')
256
260
  });
257
261
  }
258
262
  const addresses = readSuppressionAddresses((_body_emails = body.emails) != null ? _body_emails : body.email);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-suppressions.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 * Putting somebody on the suppression list by hand.\n *\n * ## What was missing\n *\n * The Suppressions card could show an entry and remove one, and there was no\n * way to add one. Every row in production arrived from a machine — somebody\n * clicking unsubscribe, or the Resend webhook on a permanent bounce or a\n * complaint. So the request a merchant is most likely to receive in words\n * rather than through a link — a reply saying \"please stop emailing me\", a\n * phone call, a message at the counter — had no button.\n *\n * That is a compliance exposure and not only a missing feature: CAN-SPAM\n * requires an opt-out received by ANY means to be honored within ten business\n * days, and every product Aglyn is compared against lets a sender type an\n * address in.\n *\n * ## Why this is a route and not a client write\n *\n * The rules do allow a site editor to write `hosts/{hostId}/suppressions` —\n * that is how the card's Remove button works, and the argument for it holds:\n * removing a row launders no counter and touches no money.\n *\n * ADDING one is a different act, for one concrete reason: **the document id\n * is `sha256` of the normalized address**. A browser computing that itself\n * would be a second derivation of the key that every reader and both writers\n * share, and the failure mode is silent and one-directional — a row filed\n * under `sha256('Bob@x.com')` is invisible to a send path looking up\n * `sha256('bob@x.com')`, so the merchant is told the person is suppressed and\n * the mail keeps going. `emailSuppressionKey` is the one derivation, it lives\n * on the server, and it refuses to guess an id for a value that is not an\n * address rather than filing one under a key nothing will look up.\n *\n * ## Why the per-site list and not the platform one\n *\n * \"Stop emailing me\" said to a merchant is about that merchant's mail. The\n * platform list is what a hard bounce and a complaint write — evidence about\n * the ADDRESS rather than a preference about one sender — and a merchant\n * cannot put somebody on it, exactly as they cannot take somebody off it.\n */\n\nimport {\n registerPluginApiRoute,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n emailSearchTokens,\n emailSuppressionKey,\n firebaseAdmin,\n isEmailSuppressed,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The `reason` a hand-added entry carries.\n *\n * A NEW value beside the three the machines write, not a reuse of one of\n * them. Recording it as `'unsubscribe'` would say the person clicked a link\n * they never saw, and the difference is exactly what a merchant asked to\n * prove an opt-out was honored has to be able to show.\n */\nexport const MANUAL_SUPPRESSION_REASON = 'manual'\n\n/** Bound on the note, so one row stays a small document. */\nexport const SUPPRESSION_NOTE_MAX = 200\n\n/** The most addresses one request may name. */\nexport const SUPPRESSION_ADD_BATCH_MAX = 50\n\n/** What happened to one address the operator typed. */\nexport interface SuppressionAddVerdict {\n /** Exactly what was 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 /** True when this request created the entry. */\n added: boolean\n /** Why not, when `added` is false. */\n refusal?: 'not-an-address' | 'already-suppressed'\n}\n\n/**\n * Splits the request's addresses without deciding anything about them.\n *\n * Newlines AND commas, because an operator pasting from a reply or a\n * spreadsheet produces both, and a paste that silently became one giant\n * \"address\" would be refused as a whole rather than acted on.\n */\nexport function readSuppressionAddresses(raw: unknown): string[] {\n const source = Array.isArray(raw) ? raw.join('\\n') : String(raw ?? '')\n return source\n .split(/[\\n,;]+/)\n .map((line) => line.trim())\n .filter(Boolean)\n .slice(0, SUPPRESSION_ADD_BATCH_MAX)\n}\n\n/**\n * Adds addresses to one site's suppression list.\n *\n * `createdAt` is written only when the document is new, matching both machine\n * writers: a hand-added entry over an existing one must not restamp the date\n * the person actually left, because that is the date a merchant is asked for.\n * An entry that already exists is reported rather than rewritten — a bounce\n * that has been on the list for a month must not be relabeled `manual` and\n * lose the reason a merchant needs to see before removing it.\n */\nexport const emailSuppressionAddHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n /*\n * AN ALLOWLIST, and the SITE's role rather than the organization's.\n *\n * The list being written is `hosts/{hostId}/suppressions` — one site's\n * own — which the rules already grant a site editor. The list-membership\n * routes beside this one additionally demand org-wide access because a\n * marketing list lives on the ORG and every site in it can mail one;\n * demanding that here would refuse a single-site editor the ability to\n * honor an opt-out about their own site's mail, which is the opposite of\n * what a suppression is for.\n */\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not a site admin or editor' })\n }\n\n const note = String(body.note ?? '')\n .trim()\n .slice(0, SUPPRESSION_NOTE_MAX)\n const inputs = readSuppressionAddresses(body.emails ?? body.email)\n if (!inputs.length) {\n return res.status(400).json({ error: 'No address to suppress' })\n }\n\n const suppressions = hostRef.collection('suppressions')\n const results: SuppressionAddVerdict[] = []\n const seen = new Set<string>()\n for (const input of inputs) {\n const key = emailSuppressionKey(input)\n if (!key) {\n results.push({\n input,\n email: null,\n added: false,\n refusal: 'not-an-address',\n })\n continue\n }\n // One line naming the same person twice is one entry, and reporting it\n // twice would tell the operator an address was already suppressed by a\n // request they are still making.\n if (seen.has(key)) continue\n seen.add(key)\n const email = input.trim().toLowerCase()\n const ref = suppressions.doc(key)\n const existing = await ref.get()\n if (existing.exists) {\n results.push({\n input,\n email,\n added: false,\n refusal: 'already-suppressed',\n })\n continue\n }\n await ref.set(\n {\n email,\n // What the list's search and Address filter query (AGL-3321).\n emailTokens: emailSearchTokens(email),\n reason: MANUAL_SUPPRESSION_REASON,\n ...(note ? { note } : {}),\n // WHO recorded it. A suppression is evidence, and evidence with no\n // author answers half the question it is kept for.\n suppressedByUid: decoded.uid,\n createdAt: FieldValue.serverTimestamp(),\n suppressedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n results.push({ input, email, added: true })\n }\n\n return res.status(200).json({\n added: results.filter((result) => result.added).length,\n results,\n })\n } catch (error) {\n console.error('[email] suppression add failed', error)\n return res\n .status(500)\n .json({ error: 'The address could not be suppressed.' })\n }\n}\n\n/**\n * Which of a site's own suppressed addresses are ALSO suppressed\n * platform-wide.\n *\n * ## The hole this closes\n *\n * The two lists are consulted together at send time and were visible\n * separately: a merchant saw their own list and nothing else. So a merchant\n * who removed their site's entry — because the person asked to be re-added,\n * or because a link prescanner unsubscribed them — could still find that the\n * address was never mailed, with no screen anywhere saying why. The platform\n * entry is invisible to them and un-liftable by them, and the only signal was\n * a recipient count that stayed short.\n *\n * ## What it does and does not disclose\n *\n * It answers ONLY for addresses the caller supplies, and the caller is a site\n * admin or editor asking about their own suppression list — a list they can\n * already read row by row. It is not a search: an address the merchant does\n * not already hold produces `false`, which is what an address nobody has\n * suppressed produces too, so nothing here turns into a lookup service for\n * whether a stranger has ever bounced.\n */\nexport const emailSuppressionStatusHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\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 res.status(404).json({ error: 'Unknown site' })\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not a site admin or editor' })\n }\n\n const addresses = readSuppressionAddresses(body.emails ?? body.email)\n if (!addresses.length) return res.status(200).json({ platform: [] })\n /*\n * `isEmailSuppressed` per address, which fails CLOSED — an unreadable\n * list answers \"suppressed\". That posture is right at send time and it is\n * right here too: this screen exists to explain mail that is not\n * arriving, so the reassuring answer is the one that must not be guessed.\n */\n const platform: string[] = []\n for (const address of addresses) {\n if (await isEmailSuppressed(address, firestore)) {\n platform.push(String(address).trim().toLowerCase())\n }\n }\n return res.status(200).json({ platform })\n } catch (error) {\n console.error('[email] suppression status failed', error)\n return res.status(500).json({ error: 'The list could not be checked.' })\n }\n}\n\n/**\n * Registration.\n *\n * Not on the machine-path exemption list in `plugin-api-rate-limit.ts`, and\n * both handlers refuse anything but POST, so both are counted against the\n * console dispatcher's per-subject budget — which is far above a person\n * pressing a button in a browser. The limiter is `consoleApiRateLimitRefusal`\n * in `apps/console`; the visitor one is installed only in `apps/tenant` and\n * has never seen a request to either of these paths.\n */\nexport function registerEmailSuppressionsApi(): void {\n registerPluginApiRoute('email/suppression-add', emailSuppressionAddHandler)\n registerPluginApiRoute(\n 'email/suppression-status',\n emailSuppressionStatusHandler,\n )\n}\n"],"names":["registerPluginApiRoute","emailSearchTokens","emailSuppressionKey","firebaseAdmin","isEmailSuppressed","FieldValue","MANUAL_SUPPRESSION_REASON","SUPPRESSION_NOTE_MAX","SUPPRESSION_ADD_BATCH_MAX","readSuppressionAddresses","raw","source","Array","isArray","join","String","split","map","line","trim","filter","Boolean","slice","emailSuppressionAddHandler","req","res","body","method","status","json","error","JSON","parse","hostId","authorization","headers","idToken","startsWith","length","undefined","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","exists","memberRole","uid","note","inputs","emails","email","suppressions","results","seen","Set","input","key","push","added","refusal","has","add","toLowerCase","ref","existing","set","emailTokens","reason","suppressedByUid","createdAt","serverTimestamp","suppressedAt","merge","result","console","emailSuppressionStatusHandler","addresses","platform","address","registerEmailSuppressionsApi"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-suppressions.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 * Putting somebody on the suppression list by hand.\n *\n * ## What was missing\n *\n * The Suppressions card could show an entry and remove one, and there was no\n * way to add one. Every row in production arrived from a machine — somebody\n * clicking unsubscribe, or the Resend webhook on a permanent bounce or a\n * complaint. So the request a merchant is most likely to receive in words\n * rather than through a link — a reply saying \"please stop emailing me\", a\n * phone call, a message at the counter — had no button.\n *\n * That is a compliance exposure and not only a missing feature: CAN-SPAM\n * requires an opt-out received by ANY means to be honored within ten business\n * days, and every product Aglyn is compared against lets a sender type an\n * address in.\n *\n * ## Why this is a route and not a client write\n *\n * The rules do allow a site editor to write `hosts/{hostId}/suppressions` —\n * that is how the card's Remove button works, and the argument for it holds:\n * removing a row launders no counter and touches no money.\n *\n * ADDING one is a different act, for one concrete reason: **the document id\n * is `sha256` of the normalized address**. A browser computing that itself\n * would be a second derivation of the key that every reader and both writers\n * share, and the failure mode is silent and one-directional — a row filed\n * under `sha256('Bob@x.com')` is invisible to a send path looking up\n * `sha256('bob@x.com')`, so the merchant is told the person is suppressed and\n * the mail keeps going. `emailSuppressionKey` is the one derivation, it lives\n * on the server, and it refuses to guess an id for a value that is not an\n * address rather than filing one under a key nothing will look up.\n *\n * ## Why the per-site list and not the platform one\n *\n * \"Stop emailing me\" said to a merchant is about that merchant's mail. The\n * platform list is what a hard bounce and a complaint write — evidence about\n * the ADDRESS rather than a preference about one sender — and a merchant\n * cannot put somebody on it, exactly as they cannot take somebody off it.\n */\n\nimport {\n registerPluginApiRoute,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n emailSearchTokens,\n emailSuppressionKey,\n firebaseAdmin,\n isEmailSuppressed,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { holdsStampedSiteKey } from '@aglyn/aglyn/app-utils/host-permissions'\nimport { EMAIL_SITE_ROLE_PRESET, emailSitePermissionRefusal } from './model/email-permissions'\n\n/**\n * The `reason` a hand-added entry carries.\n *\n * A NEW value beside the three the machines write, not a reuse of one of\n * them. Recording it as `'unsubscribe'` would say the person clicked a link\n * they never saw, and the difference is exactly what a merchant asked to\n * prove an opt-out was honored has to be able to show.\n */\nexport const MANUAL_SUPPRESSION_REASON = 'manual'\n\n/** Bound on the note, so one row stays a small document. */\nexport const SUPPRESSION_NOTE_MAX = 200\n\n/** The most addresses one request may name. */\nexport const SUPPRESSION_ADD_BATCH_MAX = 50\n\n/** What happened to one address the operator typed. */\nexport interface SuppressionAddVerdict {\n /** Exactly what was 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 /** True when this request created the entry. */\n added: boolean\n /** Why not, when `added` is false. */\n refusal?: 'not-an-address' | 'already-suppressed'\n}\n\n/**\n * Splits the request's addresses without deciding anything about them.\n *\n * Newlines AND commas, because an operator pasting from a reply or a\n * spreadsheet produces both, and a paste that silently became one giant\n * \"address\" would be refused as a whole rather than acted on.\n */\nexport function readSuppressionAddresses(raw: unknown): string[] {\n const source = Array.isArray(raw) ? raw.join('\\n') : String(raw ?? '')\n return source\n .split(/[\\n,;]+/)\n .map((line) => line.trim())\n .filter(Boolean)\n .slice(0, SUPPRESSION_ADD_BATCH_MAX)\n}\n\n/**\n * Adds addresses to one site's suppression list.\n *\n * `createdAt` is written only when the document is new, matching both machine\n * writers: a hand-added entry over an existing one must not restamp the date\n * the person actually left, because that is the date a merchant is asked for.\n * An entry that already exists is reported rather than rewritten — a bounce\n * that has been on the list for a month must not be relabeled `manual` and\n * lose the reason a merchant needs to see before removing it.\n */\nexport const emailSuppressionAddHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n /*\n * AN ALLOWLIST, and the SITE's role rather than the organization's.\n *\n * The list being written is `hosts/{hostId}/suppressions` — one site's\n * own — which the rules already grant a site editor. The list-membership\n * routes beside this one additionally demand org-wide access because a\n * marketing list lives on the ORG and every site in it can mail one;\n * demanding that here would refuse a single-site editor the ability to\n * honor an opt-out about their own site's mail, which is the opposite of\n * what a suppression is for.\n */\n // \"Manage the site's suppressions\" (AGL-4047): the site's stamp, else the\n // preset of the member's site role, the Admin and Editor this asked for.\n if (!holdsStampedSiteKey(hostSnapshot, decoded.uid, 'email.suppressions', EMAIL_SITE_ROLE_PRESET)) {\n return res.status(403).json({ error: emailSitePermissionRefusal('email.suppressions') })\n }\n\n const note = String(body.note ?? '')\n .trim()\n .slice(0, SUPPRESSION_NOTE_MAX)\n const inputs = readSuppressionAddresses(body.emails ?? body.email)\n if (!inputs.length) {\n return res.status(400).json({ error: 'No address to suppress' })\n }\n\n const suppressions = hostRef.collection('suppressions')\n const results: SuppressionAddVerdict[] = []\n const seen = new Set<string>()\n for (const input of inputs) {\n const key = emailSuppressionKey(input)\n if (!key) {\n results.push({\n input,\n email: null,\n added: false,\n refusal: 'not-an-address',\n })\n continue\n }\n // One line naming the same person twice is one entry, and reporting it\n // twice would tell the operator an address was already suppressed by a\n // request they are still making.\n if (seen.has(key)) continue\n seen.add(key)\n const email = input.trim().toLowerCase()\n const ref = suppressions.doc(key)\n const existing = await ref.get()\n if (existing.exists) {\n results.push({\n input,\n email,\n added: false,\n refusal: 'already-suppressed',\n })\n continue\n }\n await ref.set(\n {\n email,\n // What the list's search and Address filter query (AGL-3321).\n emailTokens: emailSearchTokens(email),\n reason: MANUAL_SUPPRESSION_REASON,\n ...(note ? { note } : {}),\n // WHO recorded it. A suppression is evidence, and evidence with no\n // author answers half the question it is kept for.\n suppressedByUid: decoded.uid,\n createdAt: FieldValue.serverTimestamp(),\n suppressedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n results.push({ input, email, added: true })\n }\n\n return res.status(200).json({\n added: results.filter((result) => result.added).length,\n results,\n })\n } catch (error) {\n console.error('[email] suppression add failed', error)\n return res\n .status(500)\n .json({ error: 'The address could not be suppressed.' })\n }\n}\n\n/**\n * Which of a site's own suppressed addresses are ALSO suppressed\n * platform-wide.\n *\n * ## The hole this closes\n *\n * The two lists are consulted together at send time and were visible\n * separately: a merchant saw their own list and nothing else. So a merchant\n * who removed their site's entry — because the person asked to be re-added,\n * or because a link prescanner unsubscribed them — could still find that the\n * address was never mailed, with no screen anywhere saying why. The platform\n * entry is invisible to them and un-liftable by them, and the only signal was\n * a recipient count that stayed short.\n *\n * ## What it does and does not disclose\n *\n * It answers ONLY for addresses the caller supplies, and the caller is a site\n * admin or editor asking about their own suppression list — a list they can\n * already read row by row. It is not a search: an address the merchant does\n * not already hold produces `false`, which is what an address nobody has\n * suppressed produces too, so nothing here turns into a lookup service for\n * whether a stranger has ever bounced.\n */\nexport const emailSuppressionStatusHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\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 res.status(404).json({ error: 'Unknown site' })\n }\n // \"Manage the site's suppressions\" (AGL-4047): the site's stamp, else the\n // preset of the member's site role, the Admin and Editor this asked for.\n if (!holdsStampedSiteKey(hostSnapshot, decoded.uid, 'email.suppressions', EMAIL_SITE_ROLE_PRESET)) {\n return res.status(403).json({ error: emailSitePermissionRefusal('email.suppressions') })\n }\n\n const addresses = readSuppressionAddresses(body.emails ?? body.email)\n if (!addresses.length) return res.status(200).json({ platform: [] })\n /*\n * `isEmailSuppressed` per address, which fails CLOSED — an unreadable\n * list answers \"suppressed\". That posture is right at send time and it is\n * right here too: this screen exists to explain mail that is not\n * arriving, so the reassuring answer is the one that must not be guessed.\n */\n const platform: string[] = []\n for (const address of addresses) {\n if (await isEmailSuppressed(address, firestore)) {\n platform.push(String(address).trim().toLowerCase())\n }\n }\n return res.status(200).json({ platform })\n } catch (error) {\n console.error('[email] suppression status failed', error)\n return res.status(500).json({ error: 'The list could not be checked.' })\n }\n}\n\n/**\n * Registration.\n *\n * Not on the machine-path exemption list in `plugin-api-rate-limit.ts`, and\n * both handlers refuse anything but POST, so both are counted against the\n * console dispatcher's per-subject budget — which is far above a person\n * pressing a button in a browser. The limiter is `consoleApiRateLimitRefusal`\n * in `apps/console`; the visitor one is installed only in `apps/tenant` and\n * has never seen a request to either of these paths.\n */\nexport function registerEmailSuppressionsApi(): void {\n registerPluginApiRoute('email/suppression-add', emailSuppressionAddHandler)\n registerPluginApiRoute(\n 'email/suppression-status',\n emailSuppressionStatusHandler,\n )\n}\n"],"names":["registerPluginApiRoute","emailSearchTokens","emailSuppressionKey","firebaseAdmin","isEmailSuppressed","FieldValue","holdsStampedSiteKey","EMAIL_SITE_ROLE_PRESET","emailSitePermissionRefusal","MANUAL_SUPPRESSION_REASON","SUPPRESSION_NOTE_MAX","SUPPRESSION_ADD_BATCH_MAX","readSuppressionAddresses","raw","source","Array","isArray","join","String","split","map","line","trim","filter","Boolean","slice","emailSuppressionAddHandler","req","res","body","method","status","json","error","JSON","parse","hostId","authorization","headers","idToken","startsWith","length","undefined","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","hostSnapshot","get","exists","uid","note","inputs","emails","email","suppressions","results","seen","Set","input","key","push","added","refusal","has","add","toLowerCase","ref","existing","set","emailTokens","reason","suppressedByUid","createdAt","serverTimestamp","suppressedAt","merge","result","console","emailSuppressionStatusHandler","addresses","platform","address","registerEmailSuppressionsApi"],"mappings":"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"}