@aglyn/tenant-data-admin 1.0.0-beta.217 → 1.0.0-beta.218

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/tenant-data-admin",
3
- "version": "1.0.0-beta.217",
3
+ "version": "1.0.0-beta.218",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,16 +25,16 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.217",
29
- "@aglyn/shared-util-email": "1.0.0-beta.217",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.217",
31
- "@aglyn/shared-util-first-touch": "1.0.0-beta.217",
32
- "@aglyn/shared-util-http": "1.0.0-beta.217",
33
- "@aglyn/shared-util-tools": "1.0.0-beta.217",
28
+ "@aglyn/aglyn": "1.0.0-beta.218",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.218",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.218",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.218",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.218",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.218",
34
34
  "@msgpack/msgpack": "^3.1.3",
35
35
  "@swc/helpers": "0.5.23",
36
- "sharp": "^0.35.3",
37
- "undici": "^7.29.0"
36
+ "sharp": "^0.35.5",
37
+ "undici": "^8.11.2"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "firebase-admin": "^14.3.0",
@@ -0,0 +1,74 @@
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
+ * WHAT AN ACCOUNT LOCK DOES TO MAIL ADDRESSED BY EMAIL (AGL-3420).
19
+ *
20
+ * The uid-keyed fan-outs already skip a locked account
21
+ * (`isAccountMailWithheld`, AGL-3418). What they cannot reach is mail
22
+ * addressed by email alone — a campaign to a list, a sequence to a contact —
23
+ * so the lock is written onto the suppression lists, keyed by every address
24
+ * the account holds:
25
+ *
26
+ * - **Every lock:** each of the house workspace's sites, `account_lock`, so
27
+ * Aglyn's own campaigns and sequences stop. Other workspaces are not ours
28
+ * to speak for.
29
+ * - **A ban** (`abuse`): the platform list as well, `account_ban`, which
30
+ * the preflight on `sendEmail` refuses on every send by any sender. The
31
+ * account is kept only so the address cannot sign up again; after its
32
+ * notice it is sent nothing.
33
+ *
34
+ * Called after the lock is durable and after its notices have gone, and it
35
+ * never undoes the lock: a failure is reported beside the lock, not instead
36
+ * of it.
37
+ */
38
+ export interface AccountLockMailReport {
39
+ /** The addresses the account holds that were written or lifted. */
40
+ addresses: number;
41
+ /** A source of addresses failed; the set may be short. */
42
+ incomplete: boolean;
43
+ /** Platform ban rows filed (lock) or released (lift). */
44
+ banRows: number;
45
+ /** House sites the per-site rows were written on or lifted from. */
46
+ houseSites: number;
47
+ /** Per-site rows written (lock) or deleted (lift). */
48
+ houseRows: number;
49
+ /** Writes that threw. */
50
+ failed: number;
51
+ }
52
+ interface AccountLockMailInput {
53
+ uid: string;
54
+ /** The Auth record, for its addresses. */
55
+ record: {
56
+ email?: string | null;
57
+ providerData?: readonly {
58
+ email?: string | null;
59
+ }[];
60
+ } | null;
61
+ firestore?: any;
62
+ }
63
+ /** Write a lock onto the lists. `ban` files the platform rows as well. */
64
+ export declare function applyAccountLockToMail(input: AccountLockMailInput & {
65
+ ban: boolean;
66
+ }): Promise<AccountLockMailReport>;
67
+ /**
68
+ * Take a lift off the lists: the account's ban rows (restoring any
69
+ * suppression a ban was filed over) and its house rows.
70
+ */
71
+ export declare function liftAccountLockFromMail(input: AccountLockMailInput & {
72
+ releasedByUid?: string | null;
73
+ }): Promise<AccountLockMailReport>;
74
+ export {};
@@ -0,0 +1,139 @@
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
+ */ import { resolveAccountAddresses } from "./account-addresses.js";
18
+ import { releaseAccountBanSuppressions, releaseAccountLockSuppression, suppressEmail, suppressEmailForAccountLock } from "./email-suppression.js";
19
+ import { getOrgForHost } from "./organizations.js";
20
+ import { platformMarketingHostId } from "./platform-marketing-consent.js";
21
+ /** The house workspace's site ids, or none on an install without one. */ async function houseSiteIds() {
22
+ var _ref;
23
+ var _house_org;
24
+ const houseHostId = platformMarketingHostId();
25
+ if (!houseHostId) return [];
26
+ const house = await getOrgForHost(houseHostId);
27
+ const directory = (_ref = house == null ? void 0 : (_house_org = house.org) == null ? void 0 : _house_org.hosts) != null ? _ref : {};
28
+ const ids = Object.keys(directory).filter((id)=>Boolean(directory[id]));
29
+ return ids.includes(houseHostId) ? ids : [
30
+ houseHostId,
31
+ ...ids
32
+ ];
33
+ }
34
+ async function addressesOf(input) {
35
+ const set = await resolveAccountAddresses(_extends({
36
+ uid: input.uid,
37
+ record: input.record
38
+ }, input.firestore ? {
39
+ firestore: input.firestore
40
+ } : {}));
41
+ return {
42
+ emails: set.addresses.map((entry)=>entry.address),
43
+ incomplete: set.incomplete
44
+ };
45
+ }
46
+ /** Write a lock onto the lists. `ban` files the platform rows as well. */ export async function applyAccountLockToMail(input) {
47
+ const { emails, incomplete } = await addressesOf(input);
48
+ const sites = await houseSiteIds();
49
+ const report = {
50
+ addresses: emails.length,
51
+ incomplete,
52
+ banRows: 0,
53
+ houseSites: sites.length,
54
+ houseRows: 0,
55
+ failed: 0
56
+ };
57
+ for (const email of emails){
58
+ if (input.ban) {
59
+ try {
60
+ await suppressEmail(_extends({
61
+ email,
62
+ reason: 'account_ban',
63
+ subjectUid: input.uid,
64
+ context: 'account-ban',
65
+ stampRecord: false
66
+ }, input.firestore ? {
67
+ firestore: input.firestore
68
+ } : {}));
69
+ report.banRows += 1;
70
+ } catch (error) {
71
+ console.error('[account-lock-mail] ban row failed', input.uid, error);
72
+ report.failed += 1;
73
+ }
74
+ }
75
+ for (const hostId of sites){
76
+ try {
77
+ const wrote = await suppressEmailForAccountLock(_extends({
78
+ hostId,
79
+ email,
80
+ uid: input.uid
81
+ }, input.firestore ? {
82
+ firestore: input.firestore
83
+ } : {}));
84
+ if (wrote) report.houseRows += 1;
85
+ } catch (error) {
86
+ console.error('[account-lock-mail] house row failed', input.uid, hostId, error);
87
+ report.failed += 1;
88
+ }
89
+ }
90
+ }
91
+ return report;
92
+ }
93
+ /**
94
+ * Take a lift off the lists: the account's ban rows (restoring any
95
+ * suppression a ban was filed over) and its house rows.
96
+ */ export async function liftAccountLockFromMail(input) {
97
+ const { emails, incomplete } = await addressesOf(input);
98
+ const sites = await houseSiteIds();
99
+ const report = {
100
+ addresses: emails.length,
101
+ incomplete,
102
+ banRows: 0,
103
+ houseSites: sites.length,
104
+ houseRows: 0,
105
+ failed: 0
106
+ };
107
+ try {
108
+ var _input_releasedByUid;
109
+ report.banRows = await releaseAccountBanSuppressions(_extends({
110
+ uid: input.uid,
111
+ releasedByUid: (_input_releasedByUid = input.releasedByUid) != null ? _input_releasedByUid : null
112
+ }, input.firestore ? {
113
+ firestore: input.firestore
114
+ } : {}));
115
+ } catch (error) {
116
+ console.error('[account-lock-mail] ban release failed', input.uid, error);
117
+ report.failed += 1;
118
+ }
119
+ for (const email of emails){
120
+ for (const hostId of sites){
121
+ try {
122
+ const deleted = await releaseAccountLockSuppression(_extends({
123
+ hostId,
124
+ email,
125
+ uid: input.uid
126
+ }, input.firestore ? {
127
+ firestore: input.firestore
128
+ } : {}));
129
+ if (deleted) report.houseRows += 1;
130
+ } catch (error) {
131
+ console.error('[account-lock-mail] house release failed', input.uid, hostId, error);
132
+ report.failed += 1;
133
+ }
134
+ }
135
+ }
136
+ return report;
137
+ }
138
+
139
+ //# sourceMappingURL=account-lock-mail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/account-lock-mail.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 { resolveAccountAddresses } from './account-addresses'\nimport {\n releaseAccountBanSuppressions,\n releaseAccountLockSuppression,\n suppressEmail,\n suppressEmailForAccountLock,\n} from './email-suppression'\nimport { getOrgForHost } from './organizations'\nimport { platformMarketingHostId } from './platform-marketing-consent'\n\n/**\n * WHAT AN ACCOUNT LOCK DOES TO MAIL ADDRESSED BY EMAIL (AGL-3420).\n *\n * The uid-keyed fan-outs already skip a locked account\n * (`isAccountMailWithheld`, AGL-3418). What they cannot reach is mail\n * addressed by email alone — a campaign to a list, a sequence to a contact —\n * so the lock is written onto the suppression lists, keyed by every address\n * the account holds:\n *\n * - **Every lock:** each of the house workspace's sites, `account_lock`, so\n * Aglyn's own campaigns and sequences stop. Other workspaces are not ours\n * to speak for.\n * - **A ban** (`abuse`): the platform list as well, `account_ban`, which\n * the preflight on `sendEmail` refuses on every send by any sender. The\n * account is kept only so the address cannot sign up again; after its\n * notice it is sent nothing.\n *\n * Called after the lock is durable and after its notices have gone, and it\n * never undoes the lock: a failure is reported beside the lock, not instead\n * of it.\n */\n\nexport interface AccountLockMailReport {\n /** The addresses the account holds that were written or lifted. */\n addresses: number\n /** A source of addresses failed; the set may be short. */\n incomplete: boolean\n /** Platform ban rows filed (lock) or released (lift). */\n banRows: number\n /** House sites the per-site rows were written on or lifted from. */\n houseSites: number\n /** Per-site rows written (lock) or deleted (lift). */\n houseRows: number\n /** Writes that threw. */\n failed: number\n}\n\ninterface AccountLockMailInput {\n uid: string\n /** The Auth record, for its addresses. */\n record: {\n email?: string | null\n providerData?: readonly { email?: string | null }[]\n } | null\n firestore?: any\n}\n\n/** The house workspace's site ids, or none on an install without one. */\nasync function houseSiteIds(): Promise<string[]> {\n const houseHostId = platformMarketingHostId()\n if (!houseHostId) return []\n const house = await getOrgForHost(houseHostId)\n const directory = (house?.org as { hosts?: Record<string, unknown> } | undefined)?.hosts ?? {}\n const ids = Object.keys(directory).filter((id) => Boolean(directory[id]))\n return ids.includes(houseHostId) ? ids : [houseHostId, ...ids]\n}\n\nasync function addressesOf(input: AccountLockMailInput) {\n const set = await resolveAccountAddresses({\n uid: input.uid,\n record: input.record,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n return { emails: set.addresses.map((entry) => entry.address), incomplete: set.incomplete }\n}\n\n/** Write a lock onto the lists. `ban` files the platform rows as well. */\nexport async function applyAccountLockToMail(\n input: AccountLockMailInput & { ban: boolean },\n): Promise<AccountLockMailReport> {\n const { emails, incomplete } = await addressesOf(input)\n const sites = await houseSiteIds()\n const report: AccountLockMailReport = {\n addresses: emails.length,\n incomplete,\n banRows: 0,\n houseSites: sites.length,\n houseRows: 0,\n failed: 0,\n }\n for (const email of emails) {\n if (input.ban) {\n try {\n await suppressEmail({\n email,\n reason: 'account_ban',\n subjectUid: input.uid,\n context: 'account-ban',\n stampRecord: false,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n report.banRows += 1\n } catch (error) {\n console.error('[account-lock-mail] ban row failed', input.uid, error)\n report.failed += 1\n }\n }\n for (const hostId of sites) {\n try {\n const wrote = await suppressEmailForAccountLock({\n hostId,\n email,\n uid: input.uid,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n if (wrote) report.houseRows += 1\n } catch (error) {\n console.error('[account-lock-mail] house row failed', input.uid, hostId, error)\n report.failed += 1\n }\n }\n }\n return report\n}\n\n/**\n * Take a lift off the lists: the account's ban rows (restoring any\n * suppression a ban was filed over) and its house rows.\n */\nexport async function liftAccountLockFromMail(\n input: AccountLockMailInput & { releasedByUid?: string | null },\n): Promise<AccountLockMailReport> {\n const { emails, incomplete } = await addressesOf(input)\n const sites = await houseSiteIds()\n const report: AccountLockMailReport = {\n addresses: emails.length,\n incomplete,\n banRows: 0,\n houseSites: sites.length,\n houseRows: 0,\n failed: 0,\n }\n try {\n report.banRows = await releaseAccountBanSuppressions({\n uid: input.uid,\n releasedByUid: input.releasedByUid ?? null,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n } catch (error) {\n console.error('[account-lock-mail] ban release failed', input.uid, error)\n report.failed += 1\n }\n for (const email of emails) {\n for (const hostId of sites) {\n try {\n const deleted = await releaseAccountLockSuppression({\n hostId,\n email,\n uid: input.uid,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n if (deleted) report.houseRows += 1\n } catch (error) {\n console.error('[account-lock-mail] house release failed', input.uid, hostId, error)\n report.failed += 1\n }\n }\n }\n return report\n}\n"],"names":["resolveAccountAddresses","releaseAccountBanSuppressions","releaseAccountLockSuppression","suppressEmail","suppressEmailForAccountLock","getOrgForHost","platformMarketingHostId","houseSiteIds","house","houseHostId","directory","org","hosts","ids","Object","keys","filter","id","Boolean","includes","addressesOf","input","set","uid","record","firestore","emails","addresses","map","entry","address","incomplete","applyAccountLockToMail","sites","report","length","banRows","houseSites","houseRows","failed","email","ban","reason","subjectUid","context","stampRecord","error","console","hostId","wrote","liftAccountLockFromMail","releasedByUid","deleted"],"mappings":"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"}
@@ -0,0 +1,41 @@
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
+ * WHETHER AUTOMATED PLATFORM MAIL TO ONE ACCOUNT IS WITHHELD (AGL-3418).
19
+ *
20
+ * True for an account under an active user lock (`lockdowns/user--{uid}`)
21
+ * or whose Auth record is disabled. Such an account cannot sign in to act on
22
+ * a usage alert, a digest or a notification, and for a risk lock the mail is
23
+ * worse than useless: it keeps a suspected bad actor informed about the
24
+ * workspace it was locked out of.
25
+ *
26
+ * Discretionary mail only. The lock and unlock notices, security alerts,
27
+ * password and verification mail, erasure confirmations and receipts do not
28
+ * ask this, and must not — they are how the person learns what happened.
29
+ *
30
+ * Pass `authDisabled` when the caller already holds the Auth record, to skip
31
+ * the pool lookup. A lookup that fails reads as "not disabled": the lock
32
+ * record is the authoritative signal, and an outage must not silence mail.
33
+ */
34
+ export declare function isAccountMailWithheld(uid: string | null | undefined, options?: {
35
+ authDisabled?: boolean;
36
+ }): Promise<boolean>;
37
+ /**
38
+ * `uids` minus every account {@link isAccountMailWithheld} withholds, in
39
+ * the order given. The checks run concurrently.
40
+ */
41
+ export declare function withoutMailWithheldAccounts(uids: readonly string[]): Promise<string[]>;
@@ -0,0 +1,52 @@
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 { isLockdownActive } from "@aglyn/aglyn/server";
17
+ import { findUserByUidAcrossPools } from "./auth-pools.js";
18
+ import { getUserLockdown } from "./lockdown.js";
19
+ /**
20
+ * WHETHER AUTOMATED PLATFORM MAIL TO ONE ACCOUNT IS WITHHELD (AGL-3418).
21
+ *
22
+ * True for an account under an active user lock (`lockdowns/user--{uid}`)
23
+ * or whose Auth record is disabled. Such an account cannot sign in to act on
24
+ * a usage alert, a digest or a notification, and for a risk lock the mail is
25
+ * worse than useless: it keeps a suspected bad actor informed about the
26
+ * workspace it was locked out of.
27
+ *
28
+ * Discretionary mail only. The lock and unlock notices, security alerts,
29
+ * password and verification mail, erasure confirmations and receipts do not
30
+ * ask this, and must not — they are how the person learns what happened.
31
+ *
32
+ * Pass `authDisabled` when the caller already holds the Auth record, to skip
33
+ * the pool lookup. A lookup that fails reads as "not disabled": the lock
34
+ * record is the authoritative signal, and an outage must not silence mail.
35
+ */ export async function isAccountMailWithheld(uid, options = {}) {
36
+ var _pooled_record;
37
+ if (!uid) return false;
38
+ if (options.authDisabled) return true;
39
+ if (isLockdownActive(await getUserLockdown(uid), Date.now())) return true;
40
+ if (options.authDisabled === false) return false;
41
+ const pooled = await findUserByUidAcrossPools(uid).catch(()=>null);
42
+ return (pooled == null ? void 0 : (_pooled_record = pooled.record) == null ? void 0 : _pooled_record.disabled) === true;
43
+ }
44
+ /**
45
+ * `uids` minus every account {@link isAccountMailWithheld} withholds, in
46
+ * the order given. The checks run concurrently.
47
+ */ export async function withoutMailWithheldAccounts(uids) {
48
+ const withheld = await Promise.all(uids.map((uid)=>isAccountMailWithheld(uid).catch(()=>false)));
49
+ return uids.filter((_, index)=>!withheld[index]);
50
+ }
51
+
52
+ //# sourceMappingURL=account-mail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/account-mail.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 { isLockdownActive } from '@aglyn/aglyn/server'\nimport { findUserByUidAcrossPools } from './auth-pools'\nimport { getUserLockdown } from './lockdown'\n\n/**\n * WHETHER AUTOMATED PLATFORM MAIL TO ONE ACCOUNT IS WITHHELD (AGL-3418).\n *\n * True for an account under an active user lock (`lockdowns/user--{uid}`)\n * or whose Auth record is disabled. Such an account cannot sign in to act on\n * a usage alert, a digest or a notification, and for a risk lock the mail is\n * worse than useless: it keeps a suspected bad actor informed about the\n * workspace it was locked out of.\n *\n * Discretionary mail only. The lock and unlock notices, security alerts,\n * password and verification mail, erasure confirmations and receipts do not\n * ask this, and must not — they are how the person learns what happened.\n *\n * Pass `authDisabled` when the caller already holds the Auth record, to skip\n * the pool lookup. A lookup that fails reads as \"not disabled\": the lock\n * record is the authoritative signal, and an outage must not silence mail.\n */\nexport async function isAccountMailWithheld(\n uid: string | null | undefined,\n options: { authDisabled?: boolean } = {},\n): Promise<boolean> {\n if (!uid) return false\n if (options.authDisabled) return true\n if (isLockdownActive(await getUserLockdown(uid), Date.now())) return true\n if (options.authDisabled === false) return false\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n return pooled?.record?.disabled === true\n}\n\n/**\n * `uids` minus every account {@link isAccountMailWithheld} withholds, in\n * the order given. The checks run concurrently.\n */\nexport async function withoutMailWithheldAccounts(\n uids: readonly string[],\n): Promise<string[]> {\n const withheld = await Promise.all(\n uids.map((uid) => isAccountMailWithheld(uid).catch(() => false)),\n )\n return uids.filter((_, index) => !withheld[index])\n}\n"],"names":["isLockdownActive","findUserByUidAcrossPools","getUserLockdown","isAccountMailWithheld","uid","options","pooled","authDisabled","Date","now","catch","record","disabled","withoutMailWithheldAccounts","uids","withheld","Promise","all","map","filter","_","index"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,sBAAqB;AACtD,SAASC,wBAAwB,QAAQ,kBAAc;AACvD,SAASC,eAAe,QAAQ,gBAAY;AAE5C;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,eAAeC,sBACpBC,GAA8B,EAC9BC,UAAsC,CAAC,CAAC;QAOjCC;IALP,IAAI,CAACF,KAAK,OAAO;IACjB,IAAIC,QAAQE,YAAY,EAAE,OAAO;IACjC,IAAIP,iBAAiB,MAAME,gBAAgBE,MAAMI,KAAKC,GAAG,KAAK,OAAO;IACrE,IAAIJ,QAAQE,YAAY,KAAK,OAAO,OAAO;IAC3C,MAAMD,SAAS,MAAML,yBAAyBG,KAAKM,KAAK,CAAC,IAAM;IAC/D,OAAOJ,CAAAA,2BAAAA,iBAAAA,OAAQK,MAAM,qBAAdL,eAAgBM,QAAQ,MAAK;AACtC;AAEA;;;CAGC,GACD,OAAO,eAAeC,4BACpBC,IAAuB;IAEvB,MAAMC,WAAW,MAAMC,QAAQC,GAAG,CAChCH,KAAKI,GAAG,CAAC,CAACd,MAAQD,sBAAsBC,KAAKM,KAAK,CAAC,IAAM;IAE3D,OAAOI,KAAKK,MAAM,CAAC,CAACC,GAAGC,QAAU,CAACN,QAAQ,CAACM,MAAM;AACnD"}
@@ -54,7 +54,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
54
54
  import { applyMailGatewayOutcome, assessEmailDeliverability, decidingDeliverabilityFinding, getEmailConfig, isDeliverabilityDomain, isMailDomainIntelFresh, isMailGatewayBlock, isSharedSendingDomain, mailGatewayLedgerKey, mailGatewayOfHost, mailGatewayStanding, mailDomainRefusesMail, MAIL_GATEWAY_DAILY_DELIVERY_CAP, mailGatewayDay, mailGatewayHolds, mailGatewayHoldSentence, MAIL_GATEWAY_LABELS, MAIL_GATEWAY_WINDOW_DAYS, normalizeDeliverabilityEmail, normalizeLedgerSendingDomain, readBounceText, readStoredMailDomainIntel, readStoredMailGatewayLedger, resolveMailDomain, scrubBounceDiagnostic, setEmailDeliverabilityPreflight, bareSenderAddress } from "@aglyn/shared-util-email";
55
55
  import { promises as dns } from "dns";
56
56
  import { isConclusiveDnsCode, lookupAddress, lookupMx } from "./dns-probe.js";
57
- import { suppressEmail } from "./email-suppression.js";
57
+ import { accountBannedRecipients, suppressEmail } from "./email-suppression.js";
58
58
  import { firebaseAdmin } from "./firebase-admin.js";
59
59
  /** The platform MX cache, keyed by recipient domain. */ export const MAIL_DOMAINS_COLLECTION = 'mailDomains';
60
60
  /** The gateway ledger, at the top level and under an organization — see the module note. */ export const MAIL_GATEWAY_LEDGER_COLLECTION = 'mailGatewayLedger';
@@ -432,16 +432,31 @@ function ledgerPath(scope, key) {
432
432
  * reason; holds bulk mail behind a gateway that refused this sending domain
433
433
  * twice. The Resend path's ledger is keyed by the sending domain alone.
434
434
  */ export async function runEmailDeliverabilityPreflight(request, deps = {}) {
435
+ const answer = {
436
+ refused: [],
437
+ held: []
438
+ };
439
+ /*
440
+ * A BANNED ACCOUNT FIRST (AGL-3420), and for every purpose: after its ban
441
+ * notice it is sent nothing — no receipt, no reset, no site's mail —
442
+ * which is the one refusal on this path that transactional mail does not
443
+ * get past. Only a lock or lift notice says it is exempt.
444
+ */ const banned = request.accountBanExempt ? new Set() : await accountBannedRecipients(request.recipients, deps.firestore);
445
+ for (const email of banned){
446
+ answer.refused.push({
447
+ email,
448
+ code: 'account_banned',
449
+ reason: 'This address belongs to a closed account and is not sent mail.'
450
+ });
451
+ }
452
+ const recipients = request.recipients.filter((email)=>!banned.has(email));
453
+ if (!recipients.length) return answer;
435
454
  const verdicts = await readMailDeliverability({
436
- emails: request.recipients,
455
+ emails: recipients,
437
456
  purpose: request.purpose,
438
457
  sendingDomain: request.sendingDomain,
439
458
  scope: null
440
459
  }, deps);
441
- const answer = {
442
- refused: [],
443
- held: []
444
- };
445
460
  for (const [email, verdict] of verdicts){
446
461
  const deciding = decidingDeliverabilityFinding(verdict);
447
462
  if (!deciding) continue;