@aglyn/tenant-data-admin 1.0.0-beta.205 → 1.0.0-beta.207
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 +7 -7
- package/src/lib/server/connect-payout-failure.js +18 -3
- package/src/lib/server/connect-payout-failure.js.map +1 -1
- package/src/lib/server/email-deliverability.js +19 -1
- package/src/lib/server/email-deliverability.js.map +1 -1
- package/src/lib/server/email-send-rate.js +32 -1
- package/src/lib/server/email-send-rate.js.map +1 -1
- package/src/lib/server/host-memberships.d.ts +20 -0
- package/src/lib/server/host-memberships.js +29 -1
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/notifications.d.ts +13 -0
- package/src/lib/server/notifications.js +35 -23
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +215 -0
- package/src/lib/server/operator-alerts.js +683 -0
- package/src/lib/server/operator-alerts.js.map +1 -0
- package/src/lib/server/operator-health.d.ts +59 -0
- package/src/lib/server/operator-health.js +200 -0
- package/src/lib/server/operator-health.js.map +1 -0
- package/src/lib/server/staff-alert-email.d.ts +18 -5
- package/src/lib/server/staff-alert-email.js +36 -5
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/transfer-host.d.ts +52 -0
- package/src/lib/server/transfer-host.js +377 -0
- package/src/lib/server/transfer-host.js.map +1 -0
- package/src/tenancy.d.ts +2 -0
- package/src/tenancy.js +4 -0
- package/src/tenancy.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-data-admin",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.207",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.207",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.207",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.207",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.207",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.207",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.207",
|
|
34
34
|
"@msgpack/msgpack": "^3.1.3",
|
|
35
35
|
"@swc/helpers": "0.5.23",
|
|
36
36
|
"sharp": "^0.35.3",
|
|
@@ -14,7 +14,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import {
|
|
17
|
+
*/ import { formatOperatorAlertAmount } from "@aglyn/aglyn/app-utils/operator-alerts";
|
|
18
|
+
import { firebaseAdmin } from "./firebase-admin.js";
|
|
18
19
|
import { updateExisting } from "./update-existing.js";
|
|
19
20
|
function cents(value) {
|
|
20
21
|
const amount = Math.round(Number(value != null ? value : 0));
|
|
@@ -42,8 +43,8 @@ function text(value) {
|
|
|
42
43
|
* answer — the other plugin's collection binds this account, or nothing
|
|
43
44
|
* does — and never means the failure went unrecorded.
|
|
44
45
|
*/ export async function recordConnectPayoutFailure(collection, input) {
|
|
45
|
-
var _ref, _text;
|
|
46
|
-
var _input_object, _input_object1, _input_object2, _input_object3, _input_object4;
|
|
46
|
+
var _ref, _text, _text1;
|
|
47
|
+
var _input_object, _input_object1, _input_object2, _input_object3, _input_object4, _input_object5;
|
|
47
48
|
const accountId = String((_ref = input == null ? void 0 : input.accountId) != null ? _ref : '').trim();
|
|
48
49
|
const stripeId = text(input == null ? void 0 : (_input_object = input.object) == null ? void 0 : _input_object.id);
|
|
49
50
|
if (!accountId || !stripeId) return 0;
|
|
@@ -74,6 +75,20 @@ function text(value) {
|
|
|
74
75
|
}), {
|
|
75
76
|
merge: true
|
|
76
77
|
});
|
|
78
|
+
// Money due to a merchant or seller did not arrive, and a human decides
|
|
79
|
+
// what happens next (AGL-3377). Loaded lazily, like everything this module
|
|
80
|
+
// only needs once a failure is real.
|
|
81
|
+
const { raiseOperatorAlert } = await import("./operator-alerts.js");
|
|
82
|
+
await raiseOperatorAlert('billing.connectPayoutFailed', {
|
|
83
|
+
dedupeKey: stripeId,
|
|
84
|
+
context: {
|
|
85
|
+
kind: input.kind,
|
|
86
|
+
stripeId,
|
|
87
|
+
account: accountId,
|
|
88
|
+
reason,
|
|
89
|
+
amount: formatOperatorAlertAmount(amountCents, (_text1 = text((_input_object5 = input.object) == null ? void 0 : _input_object5.currency)) != null ? _text1 : 'usd')
|
|
90
|
+
}
|
|
91
|
+
});
|
|
77
92
|
// `updateExisting` rather than a merge-set, for the reason its sibling
|
|
78
93
|
// states: the refs come from a query, and a merge-set would resurrect a
|
|
79
94
|
// profile erased in between as a stub holding nothing but a payout warning.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/connect-payout-failure.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 { firebaseAdmin } from './firebase-admin'\nimport { updateExisting } from './update-existing'\n\n/**\n * A PAYOUT THAT NEVER LANDED, RECORDED.\n *\n * ## What was invisible, and to whom\n *\n * Nothing in this codebase handled `payout.failed` or `transfer.failed`. The\n * consequences ran in opposite directions on the two sides of the same money:\n *\n * - **The merchant** saw a storefront that looked healthy — orders settling,\n * the payments card reporting enabled — while the funds accumulated in a\n * Connect account that could not release them. Aglyn stored no balance and\n * no payout schedule, so nothing in the console could contradict that.\n * - **The publisher** saw a seller ledger whose payout figure is derived from\n * `transferCents`, which is what Stripe was ASKED to move. A failed payout\n * leaves that number untouched, so the ledger actively asserted money had\n * arrived when it had not.\n *\n * And Aglyn held no record at all, so there was nothing to reconcile against,\n * no history, and no way to answer \"has this happened to this account before\".\n *\n * ## Recorded, surfaced — never retried\n *\n * This module only writes things down. It does NOT re-issue a payout or a\n * transfer, deliberately: Stripe runs its own retry schedule, and a second\n * transfer against an account that has just refused one is how a duplicate\n * lands. A human decides what happens next, which is the same posture the\n * refund-reversal recovery queue takes for the money Stripe refuses to pull\n * back — the sibling family of problem.\n *\n * ## Two records, because they answer different questions\n *\n * `connectPayoutFailures/{stripeId}` is the durable HISTORY, keyed by the\n * Stripe id so a redelivery converges rather than duplicating. It is written\n * even when no profile in the named collection binds the account, because\n * \"money did not arrive and we cannot say whose\" is precisely the state that\n * must not be silent.\n *\n * The profile documents get a MIRROR of the latest failure, because that is\n * the document the merchant and publisher surfaces already read. A card that\n * had to open a second collection would be a second, disagreeing read of the\n * same fact.\n */\n\n/** Which leg of the money movement failed. */\nexport type ConnectPayoutFailureKind = 'payout' | 'transfer'\n\nexport interface ConnectPayoutFailureInput {\n /**\n * `payout` is the connected account's balance reaching its bank, and its\n * account id comes from `event.account` — the Payout object's own\n * `destination` is the BANK, not the Connect account. `transfer` is the\n * platform's balance reaching the connected account, a platform event whose\n * `destination` IS the account.\n */\n kind: ConnectPayoutFailureKind\n /** `event.data.object` — the Payout or Transfer. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n object: any\n /** The connected account the money was headed for. */\n accountId: string\n /** `event.livemode`. */\n livemode?: unknown\n}\n\nfunction cents(value: unknown): number {\n const amount = Math.round(Number(value ?? 0))\n return Number.isFinite(amount) ? amount : 0\n}\n\nfunction text(value: unknown): string | null {\n return typeof value === 'string' && value.trim() ? value.trim() : null\n}\n\n/**\n * The sentence a merchant reads. Stripe's `failure_message` is written for a\n * developer and is often absent, so the code is kept beside it and a missing\n * pair still produces something a human can act on rather than an empty\n * warning that looks like a rendering fault.\n */\nexport function payoutFailureReason(object: unknown): string {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const source = object as any\n return (\n text(source?.failure_message) ??\n text(source?.failure_code) ??\n 'Stripe did not say why'\n )\n}\n\n/**\n * Records one failed payout or transfer, and mirrors it onto the profiles in\n * `collection` that bind the account.\n *\n * @returns how many profile documents were mirrored onto. `0` is an ordinary\n * answer — the other plugin's collection binds this account, or nothing\n * does — and never means the failure went unrecorded.\n */\nexport async function recordConnectPayoutFailure(\n collection: string,\n input: ConnectPayoutFailureInput,\n): Promise<number> {\n const accountId = String(input?.accountId ?? '').trim()\n const stripeId = text(input?.object?.id)\n if (!accountId || !stripeId) return 0\n const firestore = firebaseAdmin.app().firestore()\n const amountCents = cents(input.object?.amount)\n const reason = payoutFailureReason(input.object)\n const failedAtMs = Date.now()\n\n const matches = await firestore\n .collection(collection)\n .where('stripeAccountId', '==', accountId)\n .get()\n\n // The durable record FIRST, and unconditionally. An account no profile binds\n // is the case with the least visibility and the most need of a written\n // trace; making the history conditional on a successful join would lose\n // exactly the failures nobody could otherwise explain.\n await firestore\n .collection('connectPayoutFailures')\n .doc(stripeId)\n .set(\n {\n kind: input.kind,\n accountId,\n amountCents,\n currency: text(input.object?.currency) ?? 'usd',\n failureCode: text(input.object?.failure_code),\n failureMessage: text(input.object?.failure_message),\n reason,\n failedAtMs,\n ...(typeof input.livemode === 'boolean'\n ? { livemode: input.livemode }\n : {}),\n // A lead a human can search, accumulated across both plugins: the same\n // account can be bound by a storefront profile and a publisher profile,\n // and each plugin records only its own.\n ...(matches.docs.length\n ? {\n boundProfiles: firebaseAdmin.firestore.FieldValue.arrayUnion(\n ...matches.docs.map((doc) => `${collection}/${doc.id}`),\n ),\n }\n : {}),\n recordedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n // `updateExisting` rather than a merge-set, for the reason its sibling\n // states: the refs come from a query, and a merge-set would resurrect a\n // profile erased in between as a stub holding nothing but a payout warning.\n let mirrored = 0\n for (const doc of matches.docs) {\n if (\n await updateExisting(doc.ref, {\n lastPayoutFailureId: stripeId,\n lastPayoutFailureKind: input.kind,\n lastPayoutFailureAtMs: failedAtMs,\n lastPayoutFailureCents: amountCents,\n lastPayoutFailureReason: reason,\n })\n ) {\n mirrored += 1\n }\n }\n return mirrored\n}\n\n/**\n * Clears the mirrored warning when a later payout to the same account\n * succeeds.\n *\n * NOT a retry and not a correction of the history: `connectPayoutFailures`\n * keeps every failure, because \"has this account failed before\" is a question\n * the record exists to answer. This only stops the merchant's card asserting a\n * problem that has since resolved — a stale warning trains people to ignore\n * the surface, which costs more than it saves.\n */\nexport async function clearConnectPayoutFailure(\n collection: string,\n accountId: string,\n): Promise<number> {\n const id = String(accountId ?? '').trim()\n if (!id) return 0\n const firestore = firebaseAdmin.app().firestore()\n const matches = await firestore\n .collection(collection)\n .where('stripeAccountId', '==', id)\n .get()\n let cleared = 0\n for (const doc of matches.docs) {\n // Only touch a profile that actually carries a warning, so an ordinary\n // successful payout does not write to every profile on every event.\n if (doc.get('lastPayoutFailureAtMs') == null) continue\n const remove = firebaseAdmin.firestore.FieldValue.delete()\n if (\n await updateExisting(doc.ref, {\n lastPayoutFailureId: remove,\n lastPayoutFailureKind: remove,\n lastPayoutFailureAtMs: remove,\n lastPayoutFailureCents: remove,\n lastPayoutFailureReason: remove,\n })\n ) {\n cleared += 1\n }\n }\n return cleared\n}\n"],"names":["firebaseAdmin","updateExisting","cents","value","amount","Math","round","Number","isFinite","text","trim","payoutFailureReason","object","source","failure_message","failure_code","recordConnectPayoutFailure","collection","input","accountId","String","stripeId","id","firestore","app","amountCents","reason","failedAtMs","Date","now","matches","where","get","doc","set","kind","currency","failureCode","failureMessage","livemode","docs","length","boundProfiles","FieldValue","arrayUnion","map","recordedAt","serverTimestamp","merge","mirrored","ref","lastPayoutFailureId","lastPayoutFailureKind","lastPayoutFailureAtMs","lastPayoutFailureCents","lastPayoutFailureReason","clearConnectPayoutFailure","cleared","remove","delete"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/connect-payout-failure.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 { formatOperatorAlertAmount } from '@aglyn/aglyn/app-utils/operator-alerts'\nimport { firebaseAdmin } from './firebase-admin'\nimport { updateExisting } from './update-existing'\n\n/**\n * A PAYOUT THAT NEVER LANDED, RECORDED.\n *\n * ## What was invisible, and to whom\n *\n * Nothing in this codebase handled `payout.failed` or `transfer.failed`. The\n * consequences ran in opposite directions on the two sides of the same money:\n *\n * - **The merchant** saw a storefront that looked healthy — orders settling,\n * the payments card reporting enabled — while the funds accumulated in a\n * Connect account that could not release them. Aglyn stored no balance and\n * no payout schedule, so nothing in the console could contradict that.\n * - **The publisher** saw a seller ledger whose payout figure is derived from\n * `transferCents`, which is what Stripe was ASKED to move. A failed payout\n * leaves that number untouched, so the ledger actively asserted money had\n * arrived when it had not.\n *\n * And Aglyn held no record at all, so there was nothing to reconcile against,\n * no history, and no way to answer \"has this happened to this account before\".\n *\n * ## Recorded, surfaced — never retried\n *\n * This module only writes things down. It does NOT re-issue a payout or a\n * transfer, deliberately: Stripe runs its own retry schedule, and a second\n * transfer against an account that has just refused one is how a duplicate\n * lands. A human decides what happens next, which is the same posture the\n * refund-reversal recovery queue takes for the money Stripe refuses to pull\n * back — the sibling family of problem.\n *\n * ## Two records, because they answer different questions\n *\n * `connectPayoutFailures/{stripeId}` is the durable HISTORY, keyed by the\n * Stripe id so a redelivery converges rather than duplicating. It is written\n * even when no profile in the named collection binds the account, because\n * \"money did not arrive and we cannot say whose\" is precisely the state that\n * must not be silent.\n *\n * The profile documents get a MIRROR of the latest failure, because that is\n * the document the merchant and publisher surfaces already read. A card that\n * had to open a second collection would be a second, disagreeing read of the\n * same fact.\n */\n\n/** Which leg of the money movement failed. */\nexport type ConnectPayoutFailureKind = 'payout' | 'transfer'\n\nexport interface ConnectPayoutFailureInput {\n /**\n * `payout` is the connected account's balance reaching its bank, and its\n * account id comes from `event.account` — the Payout object's own\n * `destination` is the BANK, not the Connect account. `transfer` is the\n * platform's balance reaching the connected account, a platform event whose\n * `destination` IS the account.\n */\n kind: ConnectPayoutFailureKind\n /** `event.data.object` — the Payout or Transfer. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n object: any\n /** The connected account the money was headed for. */\n accountId: string\n /** `event.livemode`. */\n livemode?: unknown\n}\n\nfunction cents(value: unknown): number {\n const amount = Math.round(Number(value ?? 0))\n return Number.isFinite(amount) ? amount : 0\n}\n\nfunction text(value: unknown): string | null {\n return typeof value === 'string' && value.trim() ? value.trim() : null\n}\n\n/**\n * The sentence a merchant reads. Stripe's `failure_message` is written for a\n * developer and is often absent, so the code is kept beside it and a missing\n * pair still produces something a human can act on rather than an empty\n * warning that looks like a rendering fault.\n */\nexport function payoutFailureReason(object: unknown): string {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const source = object as any\n return (\n text(source?.failure_message) ??\n text(source?.failure_code) ??\n 'Stripe did not say why'\n )\n}\n\n/**\n * Records one failed payout or transfer, and mirrors it onto the profiles in\n * `collection` that bind the account.\n *\n * @returns how many profile documents were mirrored onto. `0` is an ordinary\n * answer — the other plugin's collection binds this account, or nothing\n * does — and never means the failure went unrecorded.\n */\nexport async function recordConnectPayoutFailure(\n collection: string,\n input: ConnectPayoutFailureInput,\n): Promise<number> {\n const accountId = String(input?.accountId ?? '').trim()\n const stripeId = text(input?.object?.id)\n if (!accountId || !stripeId) return 0\n const firestore = firebaseAdmin.app().firestore()\n const amountCents = cents(input.object?.amount)\n const reason = payoutFailureReason(input.object)\n const failedAtMs = Date.now()\n\n const matches = await firestore\n .collection(collection)\n .where('stripeAccountId', '==', accountId)\n .get()\n\n // The durable record FIRST, and unconditionally. An account no profile binds\n // is the case with the least visibility and the most need of a written\n // trace; making the history conditional on a successful join would lose\n // exactly the failures nobody could otherwise explain.\n await firestore\n .collection('connectPayoutFailures')\n .doc(stripeId)\n .set(\n {\n kind: input.kind,\n accountId,\n amountCents,\n currency: text(input.object?.currency) ?? 'usd',\n failureCode: text(input.object?.failure_code),\n failureMessage: text(input.object?.failure_message),\n reason,\n failedAtMs,\n ...(typeof input.livemode === 'boolean'\n ? { livemode: input.livemode }\n : {}),\n // A lead a human can search, accumulated across both plugins: the same\n // account can be bound by a storefront profile and a publisher profile,\n // and each plugin records only its own.\n ...(matches.docs.length\n ? {\n boundProfiles: firebaseAdmin.firestore.FieldValue.arrayUnion(\n ...matches.docs.map((doc) => `${collection}/${doc.id}`),\n ),\n }\n : {}),\n recordedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n // Money due to a merchant or seller did not arrive, and a human decides\n // what happens next (AGL-3377). Loaded lazily, like everything this module\n // only needs once a failure is real.\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert('billing.connectPayoutFailed', {\n dedupeKey: stripeId,\n context: {\n kind: input.kind,\n stripeId,\n account: accountId,\n reason,\n amount: formatOperatorAlertAmount(amountCents, text(input.object?.currency) ?? 'usd'),\n },\n })\n\n // `updateExisting` rather than a merge-set, for the reason its sibling\n // states: the refs come from a query, and a merge-set would resurrect a\n // profile erased in between as a stub holding nothing but a payout warning.\n let mirrored = 0\n for (const doc of matches.docs) {\n if (\n await updateExisting(doc.ref, {\n lastPayoutFailureId: stripeId,\n lastPayoutFailureKind: input.kind,\n lastPayoutFailureAtMs: failedAtMs,\n lastPayoutFailureCents: amountCents,\n lastPayoutFailureReason: reason,\n })\n ) {\n mirrored += 1\n }\n }\n return mirrored\n}\n\n/**\n * Clears the mirrored warning when a later payout to the same account\n * succeeds.\n *\n * NOT a retry and not a correction of the history: `connectPayoutFailures`\n * keeps every failure, because \"has this account failed before\" is a question\n * the record exists to answer. This only stops the merchant's card asserting a\n * problem that has since resolved — a stale warning trains people to ignore\n * the surface, which costs more than it saves.\n */\nexport async function clearConnectPayoutFailure(\n collection: string,\n accountId: string,\n): Promise<number> {\n const id = String(accountId ?? '').trim()\n if (!id) return 0\n const firestore = firebaseAdmin.app().firestore()\n const matches = await firestore\n .collection(collection)\n .where('stripeAccountId', '==', id)\n .get()\n let cleared = 0\n for (const doc of matches.docs) {\n // Only touch a profile that actually carries a warning, so an ordinary\n // successful payout does not write to every profile on every event.\n if (doc.get('lastPayoutFailureAtMs') == null) continue\n const remove = firebaseAdmin.firestore.FieldValue.delete()\n if (\n await updateExisting(doc.ref, {\n lastPayoutFailureId: remove,\n lastPayoutFailureKind: remove,\n lastPayoutFailureAtMs: remove,\n lastPayoutFailureCents: remove,\n lastPayoutFailureReason: remove,\n })\n ) {\n cleared += 1\n }\n }\n return cleared\n}\n"],"names":["formatOperatorAlertAmount","firebaseAdmin","updateExisting","cents","value","amount","Math","round","Number","isFinite","text","trim","payoutFailureReason","object","source","failure_message","failure_code","recordConnectPayoutFailure","collection","input","accountId","String","stripeId","id","firestore","app","amountCents","reason","failedAtMs","Date","now","matches","where","get","doc","set","kind","currency","failureCode","failureMessage","livemode","docs","length","boundProfiles","FieldValue","arrayUnion","map","recordedAt","serverTimestamp","merge","raiseOperatorAlert","dedupeKey","context","account","mirrored","ref","lastPayoutFailureId","lastPayoutFailureKind","lastPayoutFailureAtMs","lastPayoutFailureCents","lastPayoutFailureReason","clearConnectPayoutFailure","cleared","remove","delete"],"mappings":"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"}
|
|
@@ -51,7 +51,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
51
51
|
* public ones and the runtime's own, and stands only when both agree; and a
|
|
52
52
|
* stale "no MX" is never reused when a fresh lookup cannot be made.
|
|
53
53
|
*==========================================*/ import { isPublicMailboxDomain } from "@aglyn/aglyn/app-utils/crm";
|
|
54
|
-
import { applyMailGatewayOutcome, assessEmailDeliverability, decidingDeliverabilityFinding, getEmailConfig, isDeliverabilityDomain, isMailDomainIntelFresh, isMailGatewayBlock, isSharedSendingDomain, mailGatewayLedgerKey, mailGatewayOfHost, mailGatewayStanding, mailDomainRefusesMail, MAIL_GATEWAY_DAILY_DELIVERY_CAP, mailGatewayDay, mailGatewayHolds, MAIL_GATEWAY_WINDOW_DAYS, normalizeDeliverabilityEmail, normalizeLedgerSendingDomain, readBounceText, readStoredMailDomainIntel, readStoredMailGatewayLedger, resolveMailDomain, scrubBounceDiagnostic, setEmailDeliverabilityPreflight, bareSenderAddress } from "@aglyn/shared-util-email";
|
|
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
57
|
import { suppressEmail } from "./email-suppression.js";
|
|
@@ -310,6 +310,24 @@ function ledgerPath(scope, key) {
|
|
|
310
310
|
return next;
|
|
311
311
|
});
|
|
312
312
|
rememberLedger(path, written != null ? written : null, input.atMs);
|
|
313
|
+
// A named gateway that now holds a SHARED domain stops every site on it
|
|
314
|
+
// from reaching everyone behind that gateway, and no single workspace
|
|
315
|
+
// owns the problem to notice it (AGL-3377). Once a day per pair.
|
|
316
|
+
if (!scope && input.outcome !== 'delivered' && written && isSharedMailSendingDomain(sendingDomain)) {
|
|
317
|
+
const standing = mailGatewayStanding(input.gateway, written, input.atMs);
|
|
318
|
+
if (mailGatewayHolds(standing)) {
|
|
319
|
+
var _MAIL_GATEWAY_LABELS_input_gateway;
|
|
320
|
+
const { raiseOperatorAlert } = await import("./operator-alerts.js");
|
|
321
|
+
await raiseOperatorAlert('deliverability.gatewayBlocking', {
|
|
322
|
+
dedupeKey: key,
|
|
323
|
+
context: {
|
|
324
|
+
gateway: (_MAIL_GATEWAY_LABELS_input_gateway = MAIL_GATEWAY_LABELS[input.gateway]) != null ? _MAIL_GATEWAY_LABELS_input_gateway : input.gateway,
|
|
325
|
+
domain: sendingDomain,
|
|
326
|
+
detail: mailGatewayHoldSentence(input.gateway, standing.blocked30)
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
313
331
|
} catch (error) {
|
|
314
332
|
console.error(`[deliverability] the ledger could not count a ${input.outcome} for ${sendingDomain} at ${input.gateway}`, error);
|
|
315
333
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-deliverability.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 PLATFORM'S DELIVERABILITY STORE (AGL-3328).\n *\n * The durable half of `@aglyn/shared-util-email`'s deliverability engine:\n * the DNS lookups, the cache and the ledger, and the preflight this module\n * installs on `sendEmail` when it loads — the way `email-send-rate` installs\n * the governor, so every server surface that imports this library's barrel\n * gets it without a call at each entrypoint.\n *\n * ## Two collections at the top level, one under an organization\n *\n * - `mailDomains/{domain}` — a recipient domain's MX answer. A fact about\n * the world, not about an organization, so it is cached ONCE for the whole\n * platform: the first organization to write to a domain warms it for every\n * other. A week for a domain that takes mail, a day for one that does not.\n * - `mailGatewayLedger/{sendingDomain}~{gateway}` — what a gateway did with\n * one sending domain's mail: the Resend path's ledger. A Resend sending\n * domain is one name on one provider account — the platform's own, a\n * shared pool member, or one site's verified domain — so the domain alone\n * is the key, and a pooled or platform domain is flagged `shared`: a block\n * there hurts every tenant, and the staff console shows it.\n * - `orgs/{orgId}/mailGatewayLedger/{sendingDomain}~{gateway}` — the same\n * ledger for a connected mailbox (Sequences). A mailbox's domain is not\n * one organization's — two workspaces can each connect a `gmail.com`\n * mailbox — so its record stays with the organization that sends from it.\n *\n * All three are server-written; clients read them only as the rules allow,\n * staff for the top level and members for their organization's.\n *\n * ## Failing open, and asking twice before saying no\n *\n * Every read here fails OPEN: a resolver that cannot answer, a store that\n * cannot be read, is \"unknown\", and unknown sends. The one answer that stops\n * mail — \"this domain has no MX\" — is asked of two resolvers, the pinned\n * public ones and the runtime's own, and stands only when both agree; and a\n * stale \"no MX\" is never reused when a fresh lookup cannot be made.\n *==========================================*/\n\nimport { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport {\n applyMailGatewayOutcome,\n assessEmailDeliverability,\n decidingDeliverabilityFinding,\n type EmailDeliverabilityPreflightRequest,\n type EmailDeliverabilityPreflightVerdict,\n type EmailDeliverabilityVerdict,\n type EmailDeliveryEvent,\n type EmailSendPurpose,\n getEmailConfig,\n isDeliverabilityDomain,\n isMailDomainIntelFresh,\n isMailGatewayBlock,\n isSharedSendingDomain,\n type MailDnsResolver,\n type MailDomainIntel,\n type MailGateway,\n type MailGatewayLedger,\n mailGatewayLedgerKey,\n mailGatewayOfHost,\n type MailGatewayOutcome,\n mailGatewayStanding,\n type MailGatewayStanding,\n mailDomainRefusesMail,\n MAIL_GATEWAY_DAILY_DELIVERY_CAP,\n mailGatewayDay,\n mailGatewayHolds,\n MAIL_GATEWAY_WINDOW_DAYS,\n normalizeDeliverabilityEmail,\n normalizeLedgerSendingDomain,\n readBounceText,\n readStoredMailDomainIntel,\n readStoredMailGatewayLedger,\n resolveMailDomain,\n scrubBounceDiagnostic,\n setEmailDeliverabilityPreflight,\n bareSenderAddress,\n} from '@aglyn/shared-util-email'\nimport { promises as dns } from 'dns'\nimport { isConclusiveDnsCode, lookupAddress, lookupMx } from './dns-probe'\nimport type { EmailDeliveryEventOutcome } from './email-delivery-log'\nimport { suppressEmail } from './email-suppression'\nimport { firebaseAdmin } from './firebase-admin'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The platform MX cache, keyed by recipient domain. */\nexport const MAIL_DOMAINS_COLLECTION = 'mailDomains'\n\n/** The gateway ledger, at the top level and under an organization — see the module note. */\nexport const MAIL_GATEWAY_LEDGER_COLLECTION = 'mailGatewayLedger'\n\n/**\n * Where a ledger lives: `null` for the top level (a Resend sending domain),\n * or the organization a connected mailbox belongs to.\n */\nexport type MailGatewayLedgerScope = { orgId: string } | null\n\n/** What the reads and writes here need beside their input; every field defaults to the platform's. */\nexport interface MailDeliverabilityDeps {\n firestore?: Firestore\n resolver?: MailDnsResolver\n nowMs?: number\n}\n\nconst defaultFirestore = (): Firestore => firebaseAdmin.app().firestore() as Firestore\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\n/** Domains looked up at once when a batch is cold. */\nconst LOOKUP_CONCURRENCY = 16\n\n/*==========================================\n * DNS\n *==========================================*/\n\n/** A resolver rejection that means nobody answered, which every caller reads as \"unknown\". */\nfunction unanswered(domain: string, what: string): Error {\n return Object.assign(new Error(`the ${what} lookup for ${domain} went unanswered`), { code: 'EUNANSWERED' })\n}\n\n/**\n * The platform's resolver: the pinned public resolvers first, and — for the\n * one answer that stops mail, \"no MX\" — the runtime's own as a second\n * opinion that must agree. See the module note.\n */\nexport function platformMailDnsResolver(): MailDnsResolver {\n return {\n async resolveMx(domain) {\n const pinned = await lookupMx(domain)\n if (!pinned.answered) throw unanswered(domain, 'MX')\n if (pinned.records.length) return pinned.records\n try {\n return (await dns.resolveMx(domain)).map((record) => ({\n exchange: String(record?.exchange ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.$/, ''),\n priority: Number(record?.priority) || 0,\n }))\n } catch (error) {\n if (isConclusiveDnsCode((error as NodeJS.ErrnoException)?.code)) return []\n throw unanswered(domain, 'MX')\n }\n },\n async resolveAddress(domain) {\n const result = await lookupAddress(domain)\n if (!result.answered) throw unanswered(domain, 'address')\n return result.records.length > 0\n },\n }\n}\n\n/*==========================================\n * THE MX CACHE\n *==========================================*/\n\n/**\n * A process-lifetime copy of the answers already read, so a campaign's\n * five hundred messages to one domain ask the store once. Bounded, oldest\n * out first; trusted exactly as long as the stored answer is.\n */\nconst domainMemory = new Map<string, MailDomainIntel>()\nconst DOMAIN_MEMORY_MAX = 5_000\n\nfunction rememberDomain(intel: MailDomainIntel): void {\n if (domainMemory.has(intel.domain)) domainMemory.delete(intel.domain)\n else if (domainMemory.size >= DOMAIN_MEMORY_MAX) {\n const oldest = domainMemory.keys().next().value\n if (oldest !== undefined) domainMemory.delete(oldest)\n }\n domainMemory.set(intel.domain, intel)\n}\n\n/** A ledger read, remembered for a minute so a batch does not re-read it per message. */\nconst ledgerMemory = new Map<string, { ledger: MailGatewayLedger | null; readAtMs: number }>()\nconst LEDGER_MEMORY_MS = 60_000\nconst LEDGER_MEMORY_MAX = 2_000\n\nfunction rememberLedger(path: string, ledger: MailGatewayLedger | null, nowMs: number): void {\n if (ledgerMemory.has(path)) ledgerMemory.delete(path)\n else if (ledgerMemory.size >= LEDGER_MEMORY_MAX) {\n const oldest = ledgerMemory.keys().next().value\n if (oldest !== undefined) ledgerMemory.delete(oldest)\n }\n ledgerMemory.set(path, { ledger, readAtMs: nowMs })\n}\n\n/** Test seam: forget everything remembered in this process. */\nexport function resetMailDeliverabilityMemoryForTests(): void {\n domainMemory.clear()\n ledgerMemory.clear()\n}\n\n/** Runs `work` over `items`, at most `limit` at a time. */\nasync function eachLimited<T>(items: readonly T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {\n let next = 0\n const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {\n while (next < items.length) {\n const item = items[next]\n next += 1\n await work(item)\n }\n })\n await Promise.all(runners)\n}\n\n/**\n * Each domain's MX answer, keyed by domain: remembered, stored and fresh, or\n * looked up and stored. `null` for a domain that could not be looked up and\n * has no answer that may stand — see the module note.\n */\nexport async function readMailDomains(\n domains: readonly string[],\n deps: MailDeliverabilityDeps = {},\n): Promise<Map<string, MailDomainIntel | null>> {\n const nowMs = deps.nowMs ?? Date.now()\n const answers = new Map<string, MailDomainIntel | null>()\n const wanted = [...new Set(domains.map((domain) => String(domain ?? '').trim().toLowerCase()))].filter(\n isDeliverabilityDomain,\n )\n const missing: string[] = []\n for (const domain of wanted) {\n const remembered = domainMemory.get(domain)\n if (remembered && isMailDomainIntelFresh(remembered, nowMs)) answers.set(domain, remembered)\n else missing.push(domain)\n }\n if (!missing.length) return answers\n\n const firestore = deps.firestore ?? defaultFirestore()\n const collection = firestore.collection(MAIL_DOMAINS_COLLECTION)\n const stored = new Map<string, MailDomainIntel | null>()\n try {\n for (let start = 0; start < missing.length; start += GET_ALL_CHUNK) {\n const chunk = missing.slice(start, start + GET_ALL_CHUNK)\n const snapshots = await firestore.getAll(...chunk.map((domain) => collection.doc(domain)))\n chunk.forEach((domain, index) => {\n const snapshot = snapshots[index]\n stored.set(domain, readStoredMailDomainIntel(domain, snapshot?.exists ? snapshot.data() : undefined))\n })\n }\n } catch (error) {\n console.error('[deliverability] the MX cache could not be read; looking the domains up', error)\n }\n\n const stale: string[] = []\n for (const domain of missing) {\n const answer = stored.get(domain) ?? null\n if (answer && isMailDomainIntelFresh(answer, nowMs)) {\n answers.set(domain, answer)\n rememberDomain(answer)\n } else {\n stale.push(domain)\n }\n }\n\n const resolver = deps.resolver ?? platformMailDnsResolver()\n await eachLimited(stale, LOOKUP_CONCURRENCY, async (domain) => {\n const previous = stored.get(domain) ?? null\n let fresh: MailDomainIntel\n try {\n fresh = await resolveMailDomain(resolver, domain, nowMs)\n } catch (error) {\n // Nobody answered. A stale \"takes mail\" still says the domain takes\n // mail; a stale \"takes none\" is not reused to stop a send.\n console.warn(`[deliverability] the MX of ${domain} could not be looked up`, (error as Error)?.message ?? error)\n answers.set(domain, previous && !mailDomainRefusesMail(previous.status) ? previous : null)\n return\n }\n answers.set(domain, fresh)\n rememberDomain(fresh)\n try {\n await collection.doc(domain).set({\n domain,\n status: fresh.status,\n mx: fresh.mx,\n gateway: fresh.gateway,\n resolvedAtMs: fresh.resolvedAtMs,\n })\n } catch (error) {\n // The cache is a convenience; the answer is already in hand.\n console.error(`[deliverability] the MX of ${domain} could not be cached`, error)\n }\n })\n return answers\n}\n\n/*==========================================\n * THE LEDGER\n *==========================================*/\n\nfunction ledgerCollection(firestore: Firestore, scope: MailGatewayLedgerScope) {\n return scope\n ? firestore.collection('orgs').doc(scope.orgId).collection(MAIL_GATEWAY_LEDGER_COLLECTION)\n : firestore.collection(MAIL_GATEWAY_LEDGER_COLLECTION)\n}\n\nfunction ledgerPath(scope: MailGatewayLedgerScope, key: string): string {\n return scope ? `orgs/${scope.orgId}/${MAIL_GATEWAY_LEDGER_COLLECTION}/${key}` : `${MAIL_GATEWAY_LEDGER_COLLECTION}/${key}`\n}\n\n/** The platform's own sending domain: `USAGE_EMAIL_FROM`'s. */\nfunction platformFromDomain(): string | null {\n const address = bareSenderAddress(getEmailConfig().from)\n return address ? address.slice(address.lastIndexOf('@') + 1) : null\n}\n\n/**\n * Whether a block on this sending domain hurts every tenant — Aglyn's own\n * domain or a shared pool member — which the staff console surfaces first.\n */\nexport function isSharedMailSendingDomain(domain: string | null | undefined): boolean {\n const name = normalizeLedgerSendingDomain(domain)\n if (!name) return false\n return isSharedSendingDomain(name) || name === platformFromDomain()\n}\n\n/** The ledger of each gateway named for one sending domain, keyed by gateway; `null` where none exists yet. */\nexport async function readMailGatewayLedgers(\n scope: MailGatewayLedgerScope,\n sendingDomain: string,\n gateways: readonly MailGateway[],\n deps: MailDeliverabilityDeps = {},\n): Promise<Map<MailGateway, MailGatewayLedger | null>> {\n const nowMs = deps.nowMs ?? Date.now()\n const domain = normalizeLedgerSendingDomain(sendingDomain)\n const answers = new Map<MailGateway, MailGatewayLedger | null>()\n const wanted = [...new Set(gateways)].filter((gateway) => gateway !== 'none')\n if (!domain || !wanted.length) return answers\n const unread: MailGateway[] = []\n for (const gateway of wanted) {\n const remembered = ledgerMemory.get(ledgerPath(scope, mailGatewayLedgerKey(domain, gateway)))\n if (remembered && remembered.readAtMs + LEDGER_MEMORY_MS > nowMs) answers.set(gateway, remembered.ledger)\n else unread.push(gateway)\n }\n if (!unread.length) return answers\n const firestore = deps.firestore ?? defaultFirestore()\n const collection = ledgerCollection(firestore, scope)\n const keys = unread.map((gateway) => mailGatewayLedgerKey(domain, gateway))\n const snapshots = await firestore.getAll(...keys.map((key) => collection.doc(key)))\n unread.forEach((gateway, index) => {\n const snapshot = snapshots[index]\n const ledger = readStoredMailGatewayLedger(keys[index], snapshot?.exists ? snapshot.data() : undefined)\n answers.set(gateway, ledger)\n rememberLedger(ledgerPath(scope, keys[index]), ledger, nowMs)\n })\n return answers\n}\n\n/**\n * Counts one outcome on a sending domain's ledger with a gateway. Never\n * throws: the send or the bounce it records has already happened, and a\n * lost count understates a gateway while a thrown one loses the step that\n * called. A delivery past the day's cap costs no write at all.\n */\nexport async function recordMailGatewayOutcome(\n scope: MailGatewayLedgerScope,\n input: {\n sendingDomain: string\n gateway: MailGateway\n outcome: MailGatewayOutcome\n count?: number\n atMs: number\n /** A refusal's diagnostic, scrubbed here of addresses before it is kept. */\n detail?: string | null\n },\n deps: MailDeliverabilityDeps = {},\n): Promise<void> {\n const sendingDomain = normalizeLedgerSendingDomain(input.sendingDomain)\n if (!sendingDomain || input.gateway === 'none') return\n const key = mailGatewayLedgerKey(sendingDomain, input.gateway)\n const path = ledgerPath(scope, key)\n if (input.outcome === 'delivered') {\n const remembered = ledgerMemory.get(path)?.ledger\n const today = remembered?.days?.[mailGatewayDay(input.atMs)]\n if ((today?.delivered ?? 0) >= MAIL_GATEWAY_DAILY_DELIVERY_CAP) return\n }\n try {\n const firestore = deps.firestore ?? defaultFirestore()\n const ref = ledgerCollection(firestore, scope).doc(key)\n const written = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const current = readStoredMailGatewayLedger(key, snapshot.exists ? snapshot.data() : undefined)\n const next = applyMailGatewayOutcome(current, {\n sendingDomain,\n gateway: input.gateway,\n outcome: input.outcome,\n count: input.count,\n atMs: input.atMs,\n detail: scrubBounceDiagnostic(input.detail),\n })\n if (!next) return current\n transaction.set(ref, {\n ...next,\n ...(scope ? { orgId: scope.orgId } : { shared: isSharedMailSendingDomain(sendingDomain) }),\n })\n return next\n })\n rememberLedger(path, written ?? null, input.atMs)\n } catch (error) {\n console.error(`[deliverability] the ledger could not count a ${input.outcome} for ${sendingDomain} at ${input.gateway}`, error)\n }\n}\n\n/*==========================================\n * THE VERDICTS\n *==========================================*/\n\n/** What a batch read needs to know about the mail being sent. */\nexport interface MailDeliverabilityQuestion {\n emails: readonly string[]\n purpose: EmailSendPurpose\n /** The domain the mail leaves from; without one, no ledger is read and nothing is held. */\n sendingDomain: string | null\n scope: MailGatewayLedgerScope\n /** Addresses a person already released the gateway hold for. */\n holdReleased?: (email: string) => boolean\n}\n\n/**\n * The deliverability verdict for every address, keyed by the address as\n * given: the MX answers read (and refreshed) above, joined to the sending\n * domain's ledger with each gateway they name. A ledger that cannot be read\n * reads as no history, which holds nothing.\n */\nexport async function readMailDeliverability(\n question: MailDeliverabilityQuestion,\n deps: MailDeliverabilityDeps = {},\n): Promise<Map<string, EmailDeliverabilityVerdict>> {\n const nowMs = deps.nowMs ?? Date.now()\n const normalized = new Map<string, string | null>()\n for (const email of question.emails) normalized.set(email, normalizeDeliverabilityEmail(email))\n const domains = [...normalized.values()]\n .filter((email): email is string => Boolean(email))\n .map((email) => email.slice(email.lastIndexOf('@') + 1))\n const intel = await readMailDomains(domains, { ...deps, nowMs })\n\n const sendingDomain = normalizeLedgerSendingDomain(question.sendingDomain)\n let ledgers = new Map<MailGateway, MailGatewayLedger | null>()\n if (sendingDomain) {\n const gateways = [...intel.values()]\n .filter((entry): entry is MailDomainIntel => Boolean(entry))\n .map((entry) => entry.gateway)\n try {\n ledgers = await readMailGatewayLedgers(question.scope, sendingDomain, gateways, { ...deps, nowMs })\n } catch (error) {\n console.error('[deliverability] the gateway ledger could not be read; reading as no history', error)\n }\n }\n\n const verdicts = new Map<string, EmailDeliverabilityVerdict>()\n for (const [given, email] of normalized) {\n const domain = email ? email.slice(email.lastIndexOf('@') + 1) : null\n const answer = domain ? (intel.get(domain) ?? null) : null\n const standing: MailGatewayStanding | null =\n answer && sendingDomain ? mailGatewayStanding(answer.gateway, ledgers.get(answer.gateway) ?? null, nowMs) : null\n verdicts.set(\n given,\n assessEmailDeliverability({\n email: given,\n purpose: question.purpose,\n intel: answer,\n gateway: standing,\n publicMailbox: domain ? isPublicMailboxDomain(domain) : false,\n holdReleased: email ? question.holdReleased?.(email) === true : false,\n }),\n )\n }\n return verdicts\n}\n\n/** Whether a verdict refuses because the domain takes no mail — the refusal that suppresses. */\nexport function isNoMailServerVerdict(verdict: EmailDeliverabilityVerdict | null | undefined): boolean {\n const deciding = verdict ? decidingDeliverabilityFinding(verdict) : null\n return deciding?.code === 'no_mx' || deciding?.code === 'null_mx'\n}\n\n/** Whether a verdict holds bulk mail for its gateway. */\nexport function isGatewayHeldVerdict(verdict: EmailDeliverabilityVerdict | null | undefined): boolean {\n const deciding = verdict ? decidingDeliverabilityFinding(verdict) : null\n return deciding?.code === 'gateway_held'\n}\n\n/** A campaign's audience after the checks, with who was excluded and why. */\nexport interface DeliverableRecipients {\n deliverable: string[]\n /** Addresses whose domain takes no mail. */\n noMailServer: string[]\n /** Addresses behind a gateway that refused this sending domain twice lately. */\n gatewayHeld: string[]\n}\n\n/**\n * A bulk audience with the addresses that would bounce, or that sit behind\n * a gateway holding this sending domain, taken out — the count a campaign's\n * review shows before Send, and the filter its batches run. Fails open: an\n * error keeps everyone.\n */\nexport async function filterDeliverableRecipients(\n input: { emails: readonly string[]; sendingDomain: string | null; purpose?: EmailSendPurpose },\n deps: MailDeliverabilityDeps = {},\n): Promise<DeliverableRecipients> {\n const answer: DeliverableRecipients = { deliverable: [], noMailServer: [], gatewayHeld: [] }\n let verdicts: Map<string, EmailDeliverabilityVerdict>\n try {\n verdicts = await readMailDeliverability(\n { emails: input.emails, purpose: input.purpose ?? 'bulk', sendingDomain: input.sendingDomain, scope: null },\n deps,\n )\n } catch (error) {\n console.error('[deliverability] the audience could not be checked; keeping everyone', error)\n return { ...answer, deliverable: [...input.emails] }\n }\n for (const email of input.emails) {\n const verdict = verdicts.get(email)\n if (isNoMailServerVerdict(verdict)) answer.noMailServer.push(email)\n else if (isGatewayHeldVerdict(verdict)) answer.gatewayHeld.push(email)\n else answer.deliverable.push(email)\n }\n return answer\n}\n\n/*==========================================\n * THE PREFLIGHT ON `sendEmail`\n *==========================================*/\n\n/**\n * What `sendEmail` asks before a message leaves — see the seam in\n * `@aglyn/shared-util-email`. Refuses every recipient whose domain takes no\n * mail, and files the address on the platform suppression list with its own\n * reason; holds bulk mail behind a gateway that refused this sending domain\n * twice. The Resend path's ledger is keyed by the sending domain alone.\n */\nexport async function runEmailDeliverabilityPreflight(\n request: EmailDeliverabilityPreflightRequest,\n deps: MailDeliverabilityDeps = {},\n): Promise<EmailDeliverabilityPreflightVerdict> {\n const verdicts = await readMailDeliverability(\n {\n emails: request.recipients,\n purpose: request.purpose,\n sendingDomain: request.sendingDomain,\n scope: null,\n },\n deps,\n )\n const answer: EmailDeliverabilityPreflightVerdict = { refused: [], held: [] }\n for (const [email, verdict] of verdicts) {\n const deciding = decidingDeliverabilityFinding(verdict)\n if (!deciding) continue\n if (isNoMailServerVerdict(verdict)) {\n answer.refused.push({ email, code: deciding.code, reason: deciding.message })\n await suppressEmail({\n email,\n reason: 'no_mail_server',\n context: request.context ?? null,\n hostId: request.hostId ?? null,\n ...(deps.firestore ? { firestore: deps.firestore } : {}),\n }).catch((error) => {\n console.error('[deliverability] a refused address could not be suppressed', error)\n })\n } else if (isGatewayHeldVerdict(verdict) && verdict.gateway) {\n answer.held.push({ email, gateway: verdict.gateway.gateway, reason: deciding.message })\n }\n }\n return answer\n}\n\n/**\n * Puts the preflight on `sendEmail`'s path. **Called at module load**, from\n * the bottom of this file, for the reason `installEmailSendGovernor` is.\n * Idempotent; the closure holds no state of its own.\n */\nexport function installEmailDeliverabilityPreflight(): void {\n // A harness that stands a partial email library in for the real one\n // still loads this module; the seam is simply not there to install on.\n if (typeof setEmailDeliverabilityPreflight !== 'function') return\n setEmailDeliverabilityPreflight((request) => runEmailDeliverabilityPreflight(request))\n}\n\n/*==========================================\n * WHAT THE PROVIDER'S EVENTS TEACH THE LEDGER\n *==========================================*/\n\n/** The events the ledger learns from, keyed the way the delivery log decides \"first\". */\nconst outcomeKey = (event: { providerMessageId: string; to: string; type: string }) =>\n `${event.providerMessageId}|${event.to}|${event.type}`\n\n/**\n * Counts what a Resend webhook reported on the sending domain's ledger: a\n * delivery credits the gateway in front of the recipient, and a permanent\n * bounce that reads as the gateway refusing the SENDER — not an unknown\n * address — counts as a block, filed under the gateway the bounce names\n * (its remote server, or its own words) or else the domain's MX. Only an\n * event the delivery log saw for the first time counts, so a redelivered\n * webhook counts nothing twice. Answers how many outcomes it recorded.\n */\nexport async function recordDeliverabilityFromDeliveryEvents(\n events: readonly EmailDeliveryEvent[],\n outcomes: readonly EmailDeliveryEventOutcome[] | null,\n deps: MailDeliverabilityDeps = {},\n): Promise<number> {\n const first = new Set((outcomes ?? []).filter((outcome) => outcome.firstOfType).map(outcomeKey))\n const counted = events.filter((event) => {\n if (!first.has(outcomeKey(event))) return false\n if (!normalizeLedgerSendingDomain(bareSenderAddress(event.from))) return false\n if (event.type === 'delivered') return true\n return (\n event.type === 'bounced' &&\n event.bounceType === 'permanent' &&\n isMailGatewayBlock({ status: event.bounceStatus ?? null, diagnostic: event.detail })\n )\n })\n if (!counted.length) return 0\n const intel = await readMailDomains(\n counted.map((event) => event.to.slice(event.to.lastIndexOf('@') + 1)),\n deps,\n ).catch(() => new Map<string, MailDomainIntel | null>())\n let recorded = 0\n for (const event of counted) {\n const sender = bareSenderAddress(event.from) as string\n const sendingDomain = sender.slice(sender.lastIndexOf('@') + 1)\n const known = intel.get(event.to.slice(event.to.lastIndexOf('@') + 1))?.gateway ?? null\n const gateway =\n event.type === 'bounced'\n ? (mailGatewayOfHost(event.remoteMta) ?? readBounceText(event.detail).gateway ?? known)\n : known\n if (!gateway || gateway === 'none') continue\n // `other` is many unrelated exchanges and never holds, so a delivery\n // there would be a write that decides nothing; a refusal is still kept.\n if (event.type === 'delivered' && gateway === 'other') continue\n await recordMailGatewayOutcome(\n null,\n {\n sendingDomain,\n gateway,\n outcome: event.type === 'bounced' ? 'blocked' : 'delivered',\n atMs: event.at,\n detail: event.type === 'bounced' ? event.detail : null,\n },\n deps,\n )\n recorded += 1\n }\n return recorded\n}\n\n/*==========================================\n * THE STAFF READ\n *==========================================*/\n\n/** One row of the platform ledger as the staff console shows it. */\nexport interface MailGatewayLedgerRow extends MailGatewayLedger {\n id: string\n /** A pooled or platform sending domain: a block there hurts every tenant. */\n shared: boolean\n standing: MailGatewayStanding\n /** Whether bulk mail from this domain into this gateway is held today. */\n holds: boolean\n}\n\n/** One stored top-level ledger as a staff row; `null` for an unreadable one. */\nexport function mailGatewayLedgerRow(\n id: string,\n data: Record<string, unknown> | null | undefined,\n nowMs: number,\n): MailGatewayLedgerRow | null {\n const ledger = readStoredMailGatewayLedger(id, data)\n if (!ledger) return null\n const standing = mailGatewayStanding(ledger.gateway, ledger, nowMs)\n return {\n ...ledger,\n id,\n shared: data?.['shared'] === true || isSharedMailSendingDomain(ledger.sendingDomain),\n standing,\n holds: mailGatewayHolds(standing),\n }\n}\n\n/** The first instant of the hold's window: midnight UTC of its oldest day. */\nexport function mailGatewayWindowStartMs(nowMs: number): number {\n return Date.parse(`${mailGatewayDay(nowMs - (MAIL_GATEWAY_WINDOW_DAYS - 1) * 86_400_000)}T00:00:00.000Z`)\n}\n\n/** The most rows the refused-lately read returns; past it the answer says it was cut. */\nexport const MAIL_GATEWAY_REFUSED_READ_LIMIT = 500\n\n/**\n * Every top-level ledger with a refusal inside the hold's window, most\n * recent refusal first — the complete set a hold can come from, since a\n * hold needs refusals in that window and `lastBlockedAtMs` is the latest of\n * them. One range on one field, which the automatic index serves; nothing\n * is filtered after the read. `truncated` when there were more than `limit`.\n */\nexport async function listRecentlyRefusedMailGatewayLedgers(\n options: { limit?: number } = {},\n deps: MailDeliverabilityDeps = {},\n): Promise<{ rows: MailGatewayLedgerRow[]; sinceMs: number; truncated: boolean }> {\n const nowMs = deps.nowMs ?? Date.now()\n const firestore = deps.firestore ?? defaultFirestore()\n const limit = Math.min(Math.max(1, Math.floor(Number(options.limit) || MAIL_GATEWAY_REFUSED_READ_LIMIT)), MAIL_GATEWAY_REFUSED_READ_LIMIT)\n const sinceMs = mailGatewayWindowStartMs(nowMs)\n const snapshot = await firestore\n .collection(MAIL_GATEWAY_LEDGER_COLLECTION)\n .where('lastBlockedAtMs', '>=', sinceMs)\n .orderBy('lastBlockedAtMs', 'desc')\n .limit(limit + 1)\n .get()\n const rows: MailGatewayLedgerRow[] = []\n for (const doc of snapshot.docs.slice(0, limit)) {\n const row = mailGatewayLedgerRow(doc.id, doc.data() as Record<string, unknown>, nowMs)\n if (row) rows.push(row)\n }\n return { rows, sinceMs, truncated: snapshot.docs.length > limit }\n}\n\ninstallEmailDeliverabilityPreflight()\n"],"names":["isPublicMailboxDomain","applyMailGatewayOutcome","assessEmailDeliverability","decidingDeliverabilityFinding","getEmailConfig","isDeliverabilityDomain","isMailDomainIntelFresh","isMailGatewayBlock","isSharedSendingDomain","mailGatewayLedgerKey","mailGatewayOfHost","mailGatewayStanding","mailDomainRefusesMail","MAIL_GATEWAY_DAILY_DELIVERY_CAP","mailGatewayDay","mailGatewayHolds","MAIL_GATEWAY_WINDOW_DAYS","normalizeDeliverabilityEmail","normalizeLedgerSendingDomain","readBounceText","readStoredMailDomainIntel","readStoredMailGatewayLedger","resolveMailDomain","scrubBounceDiagnostic","setEmailDeliverabilityPreflight","bareSenderAddress","promises","dns","isConclusiveDnsCode","lookupAddress","lookupMx","suppressEmail","firebaseAdmin","MAIL_DOMAINS_COLLECTION","MAIL_GATEWAY_LEDGER_COLLECTION","defaultFirestore","app","firestore","GET_ALL_CHUNK","LOOKUP_CONCURRENCY","unanswered","domain","what","Object","assign","Error","code","platformMailDnsResolver","resolveMx","pinned","answered","records","length","map","record","exchange","String","trim","toLowerCase","replace","priority","Number","error","resolveAddress","result","domainMemory","Map","DOMAIN_MEMORY_MAX","rememberDomain","intel","has","delete","size","oldest","keys","next","value","undefined","set","ledgerMemory","LEDGER_MEMORY_MS","LEDGER_MEMORY_MAX","rememberLedger","path","ledger","nowMs","readAtMs","resetMailDeliverabilityMemoryForTests","clear","eachLimited","items","limit","work","runners","Array","from","Math","min","item","Promise","all","readMailDomains","domains","deps","Date","now","answers","wanted","Set","filter","missing","remembered","get","push","collection","stored","start","chunk","slice","snapshots","getAll","doc","forEach","index","snapshot","exists","data","console","stale","answer","resolver","previous","fresh","warn","message","status","mx","gateway","resolvedAtMs","ledgerCollection","scope","orgId","ledgerPath","key","platformFromDomain","address","lastIndexOf","isSharedMailSendingDomain","name","readMailGatewayLedgers","sendingDomain","gateways","unread","recordMailGatewayOutcome","input","outcome","today","days","atMs","delivered","ref","written","runTransaction","transaction","current","count","detail","shared","readMailDeliverability","question","normalized","email","emails","values","Boolean","ledgers","entry","verdicts","given","standing","purpose","publicMailbox","holdReleased","isNoMailServerVerdict","verdict","deciding","isGatewayHeldVerdict","filterDeliverableRecipients","deliverable","noMailServer","gatewayHeld","runEmailDeliverabilityPreflight","request","recipients","refused","held","reason","context","hostId","catch","installEmailDeliverabilityPreflight","outcomeKey","event","providerMessageId","to","type","recordDeliverabilityFromDeliveryEvents","events","outcomes","first","firstOfType","counted","bounceType","bounceStatus","diagnostic","recorded","sender","known","remoteMta","at","mailGatewayLedgerRow","id","holds","mailGatewayWindowStartMs","parse","MAIL_GATEWAY_REFUSED_READ_LIMIT","listRecentlyRefusedMailGatewayLedgers","options","max","floor","sinceMs","where","orderBy","rows","docs","row","truncated"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-deliverability.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 PLATFORM'S DELIVERABILITY STORE (AGL-3328).\n *\n * The durable half of `@aglyn/shared-util-email`'s deliverability engine:\n * the DNS lookups, the cache and the ledger, and the preflight this module\n * installs on `sendEmail` when it loads — the way `email-send-rate` installs\n * the governor, so every server surface that imports this library's barrel\n * gets it without a call at each entrypoint.\n *\n * ## Two collections at the top level, one under an organization\n *\n * - `mailDomains/{domain}` — a recipient domain's MX answer. A fact about\n * the world, not about an organization, so it is cached ONCE for the whole\n * platform: the first organization to write to a domain warms it for every\n * other. A week for a domain that takes mail, a day for one that does not.\n * - `mailGatewayLedger/{sendingDomain}~{gateway}` — what a gateway did with\n * one sending domain's mail: the Resend path's ledger. A Resend sending\n * domain is one name on one provider account — the platform's own, a\n * shared pool member, or one site's verified domain — so the domain alone\n * is the key, and a pooled or platform domain is flagged `shared`: a block\n * there hurts every tenant, and the staff console shows it.\n * - `orgs/{orgId}/mailGatewayLedger/{sendingDomain}~{gateway}` — the same\n * ledger for a connected mailbox (Sequences). A mailbox's domain is not\n * one organization's — two workspaces can each connect a `gmail.com`\n * mailbox — so its record stays with the organization that sends from it.\n *\n * All three are server-written; clients read them only as the rules allow,\n * staff for the top level and members for their organization's.\n *\n * ## Failing open, and asking twice before saying no\n *\n * Every read here fails OPEN: a resolver that cannot answer, a store that\n * cannot be read, is \"unknown\", and unknown sends. The one answer that stops\n * mail — \"this domain has no MX\" — is asked of two resolvers, the pinned\n * public ones and the runtime's own, and stands only when both agree; and a\n * stale \"no MX\" is never reused when a fresh lookup cannot be made.\n *==========================================*/\n\nimport { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport {\n applyMailGatewayOutcome,\n assessEmailDeliverability,\n decidingDeliverabilityFinding,\n type EmailDeliverabilityPreflightRequest,\n type EmailDeliverabilityPreflightVerdict,\n type EmailDeliverabilityVerdict,\n type EmailDeliveryEvent,\n type EmailSendPurpose,\n getEmailConfig,\n isDeliverabilityDomain,\n isMailDomainIntelFresh,\n isMailGatewayBlock,\n isSharedSendingDomain,\n type MailDnsResolver,\n type MailDomainIntel,\n type MailGateway,\n type MailGatewayLedger,\n mailGatewayLedgerKey,\n mailGatewayOfHost,\n type MailGatewayOutcome,\n mailGatewayStanding,\n type MailGatewayStanding,\n mailDomainRefusesMail,\n MAIL_GATEWAY_DAILY_DELIVERY_CAP,\n mailGatewayDay,\n mailGatewayHolds,\n mailGatewayHoldSentence,\n MAIL_GATEWAY_LABELS,\n MAIL_GATEWAY_WINDOW_DAYS,\n normalizeDeliverabilityEmail,\n normalizeLedgerSendingDomain,\n readBounceText,\n readStoredMailDomainIntel,\n readStoredMailGatewayLedger,\n resolveMailDomain,\n scrubBounceDiagnostic,\n setEmailDeliverabilityPreflight,\n bareSenderAddress,\n} from '@aglyn/shared-util-email'\nimport { promises as dns } from 'dns'\nimport { isConclusiveDnsCode, lookupAddress, lookupMx } from './dns-probe'\nimport type { EmailDeliveryEventOutcome } from './email-delivery-log'\nimport { suppressEmail } from './email-suppression'\nimport { firebaseAdmin } from './firebase-admin'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The platform MX cache, keyed by recipient domain. */\nexport const MAIL_DOMAINS_COLLECTION = 'mailDomains'\n\n/** The gateway ledger, at the top level and under an organization — see the module note. */\nexport const MAIL_GATEWAY_LEDGER_COLLECTION = 'mailGatewayLedger'\n\n/**\n * Where a ledger lives: `null` for the top level (a Resend sending domain),\n * or the organization a connected mailbox belongs to.\n */\nexport type MailGatewayLedgerScope = { orgId: string } | null\n\n/** What the reads and writes here need beside their input; every field defaults to the platform's. */\nexport interface MailDeliverabilityDeps {\n firestore?: Firestore\n resolver?: MailDnsResolver\n nowMs?: number\n}\n\nconst defaultFirestore = (): Firestore => firebaseAdmin.app().firestore() as Firestore\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\n/** Domains looked up at once when a batch is cold. */\nconst LOOKUP_CONCURRENCY = 16\n\n/*==========================================\n * DNS\n *==========================================*/\n\n/** A resolver rejection that means nobody answered, which every caller reads as \"unknown\". */\nfunction unanswered(domain: string, what: string): Error {\n return Object.assign(new Error(`the ${what} lookup for ${domain} went unanswered`), { code: 'EUNANSWERED' })\n}\n\n/**\n * The platform's resolver: the pinned public resolvers first, and — for the\n * one answer that stops mail, \"no MX\" — the runtime's own as a second\n * opinion that must agree. See the module note.\n */\nexport function platformMailDnsResolver(): MailDnsResolver {\n return {\n async resolveMx(domain) {\n const pinned = await lookupMx(domain)\n if (!pinned.answered) throw unanswered(domain, 'MX')\n if (pinned.records.length) return pinned.records\n try {\n return (await dns.resolveMx(domain)).map((record) => ({\n exchange: String(record?.exchange ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.$/, ''),\n priority: Number(record?.priority) || 0,\n }))\n } catch (error) {\n if (isConclusiveDnsCode((error as NodeJS.ErrnoException)?.code)) return []\n throw unanswered(domain, 'MX')\n }\n },\n async resolveAddress(domain) {\n const result = await lookupAddress(domain)\n if (!result.answered) throw unanswered(domain, 'address')\n return result.records.length > 0\n },\n }\n}\n\n/*==========================================\n * THE MX CACHE\n *==========================================*/\n\n/**\n * A process-lifetime copy of the answers already read, so a campaign's\n * five hundred messages to one domain ask the store once. Bounded, oldest\n * out first; trusted exactly as long as the stored answer is.\n */\nconst domainMemory = new Map<string, MailDomainIntel>()\nconst DOMAIN_MEMORY_MAX = 5_000\n\nfunction rememberDomain(intel: MailDomainIntel): void {\n if (domainMemory.has(intel.domain)) domainMemory.delete(intel.domain)\n else if (domainMemory.size >= DOMAIN_MEMORY_MAX) {\n const oldest = domainMemory.keys().next().value\n if (oldest !== undefined) domainMemory.delete(oldest)\n }\n domainMemory.set(intel.domain, intel)\n}\n\n/** A ledger read, remembered for a minute so a batch does not re-read it per message. */\nconst ledgerMemory = new Map<string, { ledger: MailGatewayLedger | null; readAtMs: number }>()\nconst LEDGER_MEMORY_MS = 60_000\nconst LEDGER_MEMORY_MAX = 2_000\n\nfunction rememberLedger(path: string, ledger: MailGatewayLedger | null, nowMs: number): void {\n if (ledgerMemory.has(path)) ledgerMemory.delete(path)\n else if (ledgerMemory.size >= LEDGER_MEMORY_MAX) {\n const oldest = ledgerMemory.keys().next().value\n if (oldest !== undefined) ledgerMemory.delete(oldest)\n }\n ledgerMemory.set(path, { ledger, readAtMs: nowMs })\n}\n\n/** Test seam: forget everything remembered in this process. */\nexport function resetMailDeliverabilityMemoryForTests(): void {\n domainMemory.clear()\n ledgerMemory.clear()\n}\n\n/** Runs `work` over `items`, at most `limit` at a time. */\nasync function eachLimited<T>(items: readonly T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {\n let next = 0\n const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {\n while (next < items.length) {\n const item = items[next]\n next += 1\n await work(item)\n }\n })\n await Promise.all(runners)\n}\n\n/**\n * Each domain's MX answer, keyed by domain: remembered, stored and fresh, or\n * looked up and stored. `null` for a domain that could not be looked up and\n * has no answer that may stand — see the module note.\n */\nexport async function readMailDomains(\n domains: readonly string[],\n deps: MailDeliverabilityDeps = {},\n): Promise<Map<string, MailDomainIntel | null>> {\n const nowMs = deps.nowMs ?? Date.now()\n const answers = new Map<string, MailDomainIntel | null>()\n const wanted = [...new Set(domains.map((domain) => String(domain ?? '').trim().toLowerCase()))].filter(\n isDeliverabilityDomain,\n )\n const missing: string[] = []\n for (const domain of wanted) {\n const remembered = domainMemory.get(domain)\n if (remembered && isMailDomainIntelFresh(remembered, nowMs)) answers.set(domain, remembered)\n else missing.push(domain)\n }\n if (!missing.length) return answers\n\n const firestore = deps.firestore ?? defaultFirestore()\n const collection = firestore.collection(MAIL_DOMAINS_COLLECTION)\n const stored = new Map<string, MailDomainIntel | null>()\n try {\n for (let start = 0; start < missing.length; start += GET_ALL_CHUNK) {\n const chunk = missing.slice(start, start + GET_ALL_CHUNK)\n const snapshots = await firestore.getAll(...chunk.map((domain) => collection.doc(domain)))\n chunk.forEach((domain, index) => {\n const snapshot = snapshots[index]\n stored.set(domain, readStoredMailDomainIntel(domain, snapshot?.exists ? snapshot.data() : undefined))\n })\n }\n } catch (error) {\n console.error('[deliverability] the MX cache could not be read; looking the domains up', error)\n }\n\n const stale: string[] = []\n for (const domain of missing) {\n const answer = stored.get(domain) ?? null\n if (answer && isMailDomainIntelFresh(answer, nowMs)) {\n answers.set(domain, answer)\n rememberDomain(answer)\n } else {\n stale.push(domain)\n }\n }\n\n const resolver = deps.resolver ?? platformMailDnsResolver()\n await eachLimited(stale, LOOKUP_CONCURRENCY, async (domain) => {\n const previous = stored.get(domain) ?? null\n let fresh: MailDomainIntel\n try {\n fresh = await resolveMailDomain(resolver, domain, nowMs)\n } catch (error) {\n // Nobody answered. A stale \"takes mail\" still says the domain takes\n // mail; a stale \"takes none\" is not reused to stop a send.\n console.warn(`[deliverability] the MX of ${domain} could not be looked up`, (error as Error)?.message ?? error)\n answers.set(domain, previous && !mailDomainRefusesMail(previous.status) ? previous : null)\n return\n }\n answers.set(domain, fresh)\n rememberDomain(fresh)\n try {\n await collection.doc(domain).set({\n domain,\n status: fresh.status,\n mx: fresh.mx,\n gateway: fresh.gateway,\n resolvedAtMs: fresh.resolvedAtMs,\n })\n } catch (error) {\n // The cache is a convenience; the answer is already in hand.\n console.error(`[deliverability] the MX of ${domain} could not be cached`, error)\n }\n })\n return answers\n}\n\n/*==========================================\n * THE LEDGER\n *==========================================*/\n\nfunction ledgerCollection(firestore: Firestore, scope: MailGatewayLedgerScope) {\n return scope\n ? firestore.collection('orgs').doc(scope.orgId).collection(MAIL_GATEWAY_LEDGER_COLLECTION)\n : firestore.collection(MAIL_GATEWAY_LEDGER_COLLECTION)\n}\n\nfunction ledgerPath(scope: MailGatewayLedgerScope, key: string): string {\n return scope ? `orgs/${scope.orgId}/${MAIL_GATEWAY_LEDGER_COLLECTION}/${key}` : `${MAIL_GATEWAY_LEDGER_COLLECTION}/${key}`\n}\n\n/** The platform's own sending domain: `USAGE_EMAIL_FROM`'s. */\nfunction platformFromDomain(): string | null {\n const address = bareSenderAddress(getEmailConfig().from)\n return address ? address.slice(address.lastIndexOf('@') + 1) : null\n}\n\n/**\n * Whether a block on this sending domain hurts every tenant — Aglyn's own\n * domain or a shared pool member — which the staff console surfaces first.\n */\nexport function isSharedMailSendingDomain(domain: string | null | undefined): boolean {\n const name = normalizeLedgerSendingDomain(domain)\n if (!name) return false\n return isSharedSendingDomain(name) || name === platformFromDomain()\n}\n\n/** The ledger of each gateway named for one sending domain, keyed by gateway; `null` where none exists yet. */\nexport async function readMailGatewayLedgers(\n scope: MailGatewayLedgerScope,\n sendingDomain: string,\n gateways: readonly MailGateway[],\n deps: MailDeliverabilityDeps = {},\n): Promise<Map<MailGateway, MailGatewayLedger | null>> {\n const nowMs = deps.nowMs ?? Date.now()\n const domain = normalizeLedgerSendingDomain(sendingDomain)\n const answers = new Map<MailGateway, MailGatewayLedger | null>()\n const wanted = [...new Set(gateways)].filter((gateway) => gateway !== 'none')\n if (!domain || !wanted.length) return answers\n const unread: MailGateway[] = []\n for (const gateway of wanted) {\n const remembered = ledgerMemory.get(ledgerPath(scope, mailGatewayLedgerKey(domain, gateway)))\n if (remembered && remembered.readAtMs + LEDGER_MEMORY_MS > nowMs) answers.set(gateway, remembered.ledger)\n else unread.push(gateway)\n }\n if (!unread.length) return answers\n const firestore = deps.firestore ?? defaultFirestore()\n const collection = ledgerCollection(firestore, scope)\n const keys = unread.map((gateway) => mailGatewayLedgerKey(domain, gateway))\n const snapshots = await firestore.getAll(...keys.map((key) => collection.doc(key)))\n unread.forEach((gateway, index) => {\n const snapshot = snapshots[index]\n const ledger = readStoredMailGatewayLedger(keys[index], snapshot?.exists ? snapshot.data() : undefined)\n answers.set(gateway, ledger)\n rememberLedger(ledgerPath(scope, keys[index]), ledger, nowMs)\n })\n return answers\n}\n\n/**\n * Counts one outcome on a sending domain's ledger with a gateway. Never\n * throws: the send or the bounce it records has already happened, and a\n * lost count understates a gateway while a thrown one loses the step that\n * called. A delivery past the day's cap costs no write at all.\n */\nexport async function recordMailGatewayOutcome(\n scope: MailGatewayLedgerScope,\n input: {\n sendingDomain: string\n gateway: MailGateway\n outcome: MailGatewayOutcome\n count?: number\n atMs: number\n /** A refusal's diagnostic, scrubbed here of addresses before it is kept. */\n detail?: string | null\n },\n deps: MailDeliverabilityDeps = {},\n): Promise<void> {\n const sendingDomain = normalizeLedgerSendingDomain(input.sendingDomain)\n if (!sendingDomain || input.gateway === 'none') return\n const key = mailGatewayLedgerKey(sendingDomain, input.gateway)\n const path = ledgerPath(scope, key)\n if (input.outcome === 'delivered') {\n const remembered = ledgerMemory.get(path)?.ledger\n const today = remembered?.days?.[mailGatewayDay(input.atMs)]\n if ((today?.delivered ?? 0) >= MAIL_GATEWAY_DAILY_DELIVERY_CAP) return\n }\n try {\n const firestore = deps.firestore ?? defaultFirestore()\n const ref = ledgerCollection(firestore, scope).doc(key)\n const written = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const current = readStoredMailGatewayLedger(key, snapshot.exists ? snapshot.data() : undefined)\n const next = applyMailGatewayOutcome(current, {\n sendingDomain,\n gateway: input.gateway,\n outcome: input.outcome,\n count: input.count,\n atMs: input.atMs,\n detail: scrubBounceDiagnostic(input.detail),\n })\n if (!next) return current\n transaction.set(ref, {\n ...next,\n ...(scope ? { orgId: scope.orgId } : { shared: isSharedMailSendingDomain(sendingDomain) }),\n })\n return next\n })\n rememberLedger(path, written ?? null, input.atMs)\n // A named gateway that now holds a SHARED domain stops every site on it\n // from reaching everyone behind that gateway, and no single workspace\n // owns the problem to notice it (AGL-3377). Once a day per pair.\n if (!scope && input.outcome !== 'delivered' && written && isSharedMailSendingDomain(sendingDomain)) {\n const standing = mailGatewayStanding(input.gateway, written, input.atMs)\n if (mailGatewayHolds(standing)) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert('deliverability.gatewayBlocking', {\n dedupeKey: key,\n context: {\n gateway: MAIL_GATEWAY_LABELS[input.gateway] ?? input.gateway,\n domain: sendingDomain,\n detail: mailGatewayHoldSentence(input.gateway, standing.blocked30),\n },\n })\n }\n }\n } catch (error) {\n console.error(`[deliverability] the ledger could not count a ${input.outcome} for ${sendingDomain} at ${input.gateway}`, error)\n }\n}\n\n/*==========================================\n * THE VERDICTS\n *==========================================*/\n\n/** What a batch read needs to know about the mail being sent. */\nexport interface MailDeliverabilityQuestion {\n emails: readonly string[]\n purpose: EmailSendPurpose\n /** The domain the mail leaves from; without one, no ledger is read and nothing is held. */\n sendingDomain: string | null\n scope: MailGatewayLedgerScope\n /** Addresses a person already released the gateway hold for. */\n holdReleased?: (email: string) => boolean\n}\n\n/**\n * The deliverability verdict for every address, keyed by the address as\n * given: the MX answers read (and refreshed) above, joined to the sending\n * domain's ledger with each gateway they name. A ledger that cannot be read\n * reads as no history, which holds nothing.\n */\nexport async function readMailDeliverability(\n question: MailDeliverabilityQuestion,\n deps: MailDeliverabilityDeps = {},\n): Promise<Map<string, EmailDeliverabilityVerdict>> {\n const nowMs = deps.nowMs ?? Date.now()\n const normalized = new Map<string, string | null>()\n for (const email of question.emails) normalized.set(email, normalizeDeliverabilityEmail(email))\n const domains = [...normalized.values()]\n .filter((email): email is string => Boolean(email))\n .map((email) => email.slice(email.lastIndexOf('@') + 1))\n const intel = await readMailDomains(domains, { ...deps, nowMs })\n\n const sendingDomain = normalizeLedgerSendingDomain(question.sendingDomain)\n let ledgers = new Map<MailGateway, MailGatewayLedger | null>()\n if (sendingDomain) {\n const gateways = [...intel.values()]\n .filter((entry): entry is MailDomainIntel => Boolean(entry))\n .map((entry) => entry.gateway)\n try {\n ledgers = await readMailGatewayLedgers(question.scope, sendingDomain, gateways, { ...deps, nowMs })\n } catch (error) {\n console.error('[deliverability] the gateway ledger could not be read; reading as no history', error)\n }\n }\n\n const verdicts = new Map<string, EmailDeliverabilityVerdict>()\n for (const [given, email] of normalized) {\n const domain = email ? email.slice(email.lastIndexOf('@') + 1) : null\n const answer = domain ? (intel.get(domain) ?? null) : null\n const standing: MailGatewayStanding | null =\n answer && sendingDomain ? mailGatewayStanding(answer.gateway, ledgers.get(answer.gateway) ?? null, nowMs) : null\n verdicts.set(\n given,\n assessEmailDeliverability({\n email: given,\n purpose: question.purpose,\n intel: answer,\n gateway: standing,\n publicMailbox: domain ? isPublicMailboxDomain(domain) : false,\n holdReleased: email ? question.holdReleased?.(email) === true : false,\n }),\n )\n }\n return verdicts\n}\n\n/** Whether a verdict refuses because the domain takes no mail — the refusal that suppresses. */\nexport function isNoMailServerVerdict(verdict: EmailDeliverabilityVerdict | null | undefined): boolean {\n const deciding = verdict ? decidingDeliverabilityFinding(verdict) : null\n return deciding?.code === 'no_mx' || deciding?.code === 'null_mx'\n}\n\n/** Whether a verdict holds bulk mail for its gateway. */\nexport function isGatewayHeldVerdict(verdict: EmailDeliverabilityVerdict | null | undefined): boolean {\n const deciding = verdict ? decidingDeliverabilityFinding(verdict) : null\n return deciding?.code === 'gateway_held'\n}\n\n/** A campaign's audience after the checks, with who was excluded and why. */\nexport interface DeliverableRecipients {\n deliverable: string[]\n /** Addresses whose domain takes no mail. */\n noMailServer: string[]\n /** Addresses behind a gateway that refused this sending domain twice lately. */\n gatewayHeld: string[]\n}\n\n/**\n * A bulk audience with the addresses that would bounce, or that sit behind\n * a gateway holding this sending domain, taken out — the count a campaign's\n * review shows before Send, and the filter its batches run. Fails open: an\n * error keeps everyone.\n */\nexport async function filterDeliverableRecipients(\n input: { emails: readonly string[]; sendingDomain: string | null; purpose?: EmailSendPurpose },\n deps: MailDeliverabilityDeps = {},\n): Promise<DeliverableRecipients> {\n const answer: DeliverableRecipients = { deliverable: [], noMailServer: [], gatewayHeld: [] }\n let verdicts: Map<string, EmailDeliverabilityVerdict>\n try {\n verdicts = await readMailDeliverability(\n { emails: input.emails, purpose: input.purpose ?? 'bulk', sendingDomain: input.sendingDomain, scope: null },\n deps,\n )\n } catch (error) {\n console.error('[deliverability] the audience could not be checked; keeping everyone', error)\n return { ...answer, deliverable: [...input.emails] }\n }\n for (const email of input.emails) {\n const verdict = verdicts.get(email)\n if (isNoMailServerVerdict(verdict)) answer.noMailServer.push(email)\n else if (isGatewayHeldVerdict(verdict)) answer.gatewayHeld.push(email)\n else answer.deliverable.push(email)\n }\n return answer\n}\n\n/*==========================================\n * THE PREFLIGHT ON `sendEmail`\n *==========================================*/\n\n/**\n * What `sendEmail` asks before a message leaves — see the seam in\n * `@aglyn/shared-util-email`. Refuses every recipient whose domain takes no\n * mail, and files the address on the platform suppression list with its own\n * reason; holds bulk mail behind a gateway that refused this sending domain\n * twice. The Resend path's ledger is keyed by the sending domain alone.\n */\nexport async function runEmailDeliverabilityPreflight(\n request: EmailDeliverabilityPreflightRequest,\n deps: MailDeliverabilityDeps = {},\n): Promise<EmailDeliverabilityPreflightVerdict> {\n const verdicts = await readMailDeliverability(\n {\n emails: request.recipients,\n purpose: request.purpose,\n sendingDomain: request.sendingDomain,\n scope: null,\n },\n deps,\n )\n const answer: EmailDeliverabilityPreflightVerdict = { refused: [], held: [] }\n for (const [email, verdict] of verdicts) {\n const deciding = decidingDeliverabilityFinding(verdict)\n if (!deciding) continue\n if (isNoMailServerVerdict(verdict)) {\n answer.refused.push({ email, code: deciding.code, reason: deciding.message })\n await suppressEmail({\n email,\n reason: 'no_mail_server',\n context: request.context ?? null,\n hostId: request.hostId ?? null,\n ...(deps.firestore ? { firestore: deps.firestore } : {}),\n }).catch((error) => {\n console.error('[deliverability] a refused address could not be suppressed', error)\n })\n } else if (isGatewayHeldVerdict(verdict) && verdict.gateway) {\n answer.held.push({ email, gateway: verdict.gateway.gateway, reason: deciding.message })\n }\n }\n return answer\n}\n\n/**\n * Puts the preflight on `sendEmail`'s path. **Called at module load**, from\n * the bottom of this file, for the reason `installEmailSendGovernor` is.\n * Idempotent; the closure holds no state of its own.\n */\nexport function installEmailDeliverabilityPreflight(): void {\n // A harness that stands a partial email library in for the real one\n // still loads this module; the seam is simply not there to install on.\n if (typeof setEmailDeliverabilityPreflight !== 'function') return\n setEmailDeliverabilityPreflight((request) => runEmailDeliverabilityPreflight(request))\n}\n\n/*==========================================\n * WHAT THE PROVIDER'S EVENTS TEACH THE LEDGER\n *==========================================*/\n\n/** The events the ledger learns from, keyed the way the delivery log decides \"first\". */\nconst outcomeKey = (event: { providerMessageId: string; to: string; type: string }) =>\n `${event.providerMessageId}|${event.to}|${event.type}`\n\n/**\n * Counts what a Resend webhook reported on the sending domain's ledger: a\n * delivery credits the gateway in front of the recipient, and a permanent\n * bounce that reads as the gateway refusing the SENDER — not an unknown\n * address — counts as a block, filed under the gateway the bounce names\n * (its remote server, or its own words) or else the domain's MX. Only an\n * event the delivery log saw for the first time counts, so a redelivered\n * webhook counts nothing twice. Answers how many outcomes it recorded.\n */\nexport async function recordDeliverabilityFromDeliveryEvents(\n events: readonly EmailDeliveryEvent[],\n outcomes: readonly EmailDeliveryEventOutcome[] | null,\n deps: MailDeliverabilityDeps = {},\n): Promise<number> {\n const first = new Set((outcomes ?? []).filter((outcome) => outcome.firstOfType).map(outcomeKey))\n const counted = events.filter((event) => {\n if (!first.has(outcomeKey(event))) return false\n if (!normalizeLedgerSendingDomain(bareSenderAddress(event.from))) return false\n if (event.type === 'delivered') return true\n return (\n event.type === 'bounced' &&\n event.bounceType === 'permanent' &&\n isMailGatewayBlock({ status: event.bounceStatus ?? null, diagnostic: event.detail })\n )\n })\n if (!counted.length) return 0\n const intel = await readMailDomains(\n counted.map((event) => event.to.slice(event.to.lastIndexOf('@') + 1)),\n deps,\n ).catch(() => new Map<string, MailDomainIntel | null>())\n let recorded = 0\n for (const event of counted) {\n const sender = bareSenderAddress(event.from) as string\n const sendingDomain = sender.slice(sender.lastIndexOf('@') + 1)\n const known = intel.get(event.to.slice(event.to.lastIndexOf('@') + 1))?.gateway ?? null\n const gateway =\n event.type === 'bounced'\n ? (mailGatewayOfHost(event.remoteMta) ?? readBounceText(event.detail).gateway ?? known)\n : known\n if (!gateway || gateway === 'none') continue\n // `other` is many unrelated exchanges and never holds, so a delivery\n // there would be a write that decides nothing; a refusal is still kept.\n if (event.type === 'delivered' && gateway === 'other') continue\n await recordMailGatewayOutcome(\n null,\n {\n sendingDomain,\n gateway,\n outcome: event.type === 'bounced' ? 'blocked' : 'delivered',\n atMs: event.at,\n detail: event.type === 'bounced' ? event.detail : null,\n },\n deps,\n )\n recorded += 1\n }\n return recorded\n}\n\n/*==========================================\n * THE STAFF READ\n *==========================================*/\n\n/** One row of the platform ledger as the staff console shows it. */\nexport interface MailGatewayLedgerRow extends MailGatewayLedger {\n id: string\n /** A pooled or platform sending domain: a block there hurts every tenant. */\n shared: boolean\n standing: MailGatewayStanding\n /** Whether bulk mail from this domain into this gateway is held today. */\n holds: boolean\n}\n\n/** One stored top-level ledger as a staff row; `null` for an unreadable one. */\nexport function mailGatewayLedgerRow(\n id: string,\n data: Record<string, unknown> | null | undefined,\n nowMs: number,\n): MailGatewayLedgerRow | null {\n const ledger = readStoredMailGatewayLedger(id, data)\n if (!ledger) return null\n const standing = mailGatewayStanding(ledger.gateway, ledger, nowMs)\n return {\n ...ledger,\n id,\n shared: data?.['shared'] === true || isSharedMailSendingDomain(ledger.sendingDomain),\n standing,\n holds: mailGatewayHolds(standing),\n }\n}\n\n/** The first instant of the hold's window: midnight UTC of its oldest day. */\nexport function mailGatewayWindowStartMs(nowMs: number): number {\n return Date.parse(`${mailGatewayDay(nowMs - (MAIL_GATEWAY_WINDOW_DAYS - 1) * 86_400_000)}T00:00:00.000Z`)\n}\n\n/** The most rows the refused-lately read returns; past it the answer says it was cut. */\nexport const MAIL_GATEWAY_REFUSED_READ_LIMIT = 500\n\n/**\n * Every top-level ledger with a refusal inside the hold's window, most\n * recent refusal first — the complete set a hold can come from, since a\n * hold needs refusals in that window and `lastBlockedAtMs` is the latest of\n * them. One range on one field, which the automatic index serves; nothing\n * is filtered after the read. `truncated` when there were more than `limit`.\n */\nexport async function listRecentlyRefusedMailGatewayLedgers(\n options: { limit?: number } = {},\n deps: MailDeliverabilityDeps = {},\n): Promise<{ rows: MailGatewayLedgerRow[]; sinceMs: number; truncated: boolean }> {\n const nowMs = deps.nowMs ?? Date.now()\n const firestore = deps.firestore ?? defaultFirestore()\n const limit = Math.min(Math.max(1, Math.floor(Number(options.limit) || MAIL_GATEWAY_REFUSED_READ_LIMIT)), MAIL_GATEWAY_REFUSED_READ_LIMIT)\n const sinceMs = mailGatewayWindowStartMs(nowMs)\n const snapshot = await firestore\n .collection(MAIL_GATEWAY_LEDGER_COLLECTION)\n .where('lastBlockedAtMs', '>=', sinceMs)\n .orderBy('lastBlockedAtMs', 'desc')\n .limit(limit + 1)\n .get()\n const rows: MailGatewayLedgerRow[] = []\n for (const doc of snapshot.docs.slice(0, limit)) {\n const row = mailGatewayLedgerRow(doc.id, doc.data() as Record<string, unknown>, nowMs)\n if (row) rows.push(row)\n }\n return { rows, sinceMs, truncated: snapshot.docs.length > limit }\n}\n\ninstallEmailDeliverabilityPreflight()\n"],"names":["isPublicMailboxDomain","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","promises","dns","isConclusiveDnsCode","lookupAddress","lookupMx","suppressEmail","firebaseAdmin","MAIL_DOMAINS_COLLECTION","MAIL_GATEWAY_LEDGER_COLLECTION","defaultFirestore","app","firestore","GET_ALL_CHUNK","LOOKUP_CONCURRENCY","unanswered","domain","what","Object","assign","Error","code","platformMailDnsResolver","resolveMx","pinned","answered","records","length","map","record","exchange","String","trim","toLowerCase","replace","priority","Number","error","resolveAddress","result","domainMemory","Map","DOMAIN_MEMORY_MAX","rememberDomain","intel","has","delete","size","oldest","keys","next","value","undefined","set","ledgerMemory","LEDGER_MEMORY_MS","LEDGER_MEMORY_MAX","rememberLedger","path","ledger","nowMs","readAtMs","resetMailDeliverabilityMemoryForTests","clear","eachLimited","items","limit","work","runners","Array","from","Math","min","item","Promise","all","readMailDomains","domains","deps","Date","now","answers","wanted","Set","filter","missing","remembered","get","push","collection","stored","start","chunk","slice","snapshots","getAll","doc","forEach","index","snapshot","exists","data","console","stale","answer","resolver","previous","fresh","warn","message","status","mx","gateway","resolvedAtMs","ledgerCollection","scope","orgId","ledgerPath","key","platformFromDomain","address","lastIndexOf","isSharedMailSendingDomain","name","readMailGatewayLedgers","sendingDomain","gateways","unread","recordMailGatewayOutcome","input","outcome","today","days","atMs","delivered","ref","written","runTransaction","transaction","current","count","detail","shared","standing","raiseOperatorAlert","dedupeKey","context","blocked30","readMailDeliverability","question","normalized","email","emails","values","Boolean","ledgers","entry","verdicts","given","purpose","publicMailbox","holdReleased","isNoMailServerVerdict","verdict","deciding","isGatewayHeldVerdict","filterDeliverableRecipients","deliverable","noMailServer","gatewayHeld","runEmailDeliverabilityPreflight","request","recipients","refused","held","reason","hostId","catch","installEmailDeliverabilityPreflight","outcomeKey","event","providerMessageId","to","type","recordDeliverabilityFromDeliveryEvents","events","outcomes","first","firstOfType","counted","bounceType","bounceStatus","diagnostic","recorded","sender","known","remoteMta","at","mailGatewayLedgerRow","id","holds","mailGatewayWindowStartMs","parse","MAIL_GATEWAY_REFUSED_READ_LIMIT","listRecentlyRefusedMailGatewayLedgers","options","max","floor","sinceMs","where","orderBy","rows","docs","row","truncated"],"mappings":"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"}
|
|
@@ -182,15 +182,43 @@ let configPending;
|
|
|
182
182
|
});
|
|
183
183
|
return decision;
|
|
184
184
|
});
|
|
185
|
-
|
|
185
|
+
const decided = verdict;
|
|
186
|
+
if (!decided.allowed) {
|
|
187
|
+
var _decided_used, _decided_ceiling;
|
|
188
|
+
// Refusable mail is being deferred for the rest of the hour. Told once
|
|
189
|
+
// per six hours (AGL-3377); the operator email itself is transactional,
|
|
190
|
+
// which this governor never refuses.
|
|
191
|
+
await raiseSendRateAlert('deliverability.sendRateSaturated', {
|
|
192
|
+
scope: 'platform',
|
|
193
|
+
detail: `${(_decided_used = decided.used) != null ? _decided_used : '?'}/${(_decided_ceiling = decided.ceiling) != null ? _decided_ceiling : config.perHour} this hour`
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return _extends({}, decided, {
|
|
186
197
|
windowStartMs,
|
|
187
198
|
degraded: false
|
|
188
199
|
});
|
|
189
200
|
} catch (error) {
|
|
190
201
|
console.error('[send-rate] counter unavailable — allowing', error);
|
|
202
|
+
await raiseSendRateAlert('deliverability.sendRateUnavailable', {
|
|
203
|
+
error: error instanceof Error ? error.message.slice(0, 200) : String(error)
|
|
204
|
+
});
|
|
191
205
|
return fallback();
|
|
192
206
|
}
|
|
193
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Raises a governor alert, loaded lazily so the hot path imports no alert
|
|
210
|
+
* pipeline until the governor actually has something to say. Never throws.
|
|
211
|
+
*/ async function raiseSendRateAlert(type, context) {
|
|
212
|
+
try {
|
|
213
|
+
const { raiseOperatorAlert } = await import("./operator-alerts.js");
|
|
214
|
+
await raiseOperatorAlert(type, {
|
|
215
|
+
dedupeKey: type,
|
|
216
|
+
context
|
|
217
|
+
});
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.error(`[send-rate] ${type} could not be raised`, error);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
194
222
|
/**
|
|
195
223
|
* What the current hour looks like. Read-only, for the staff console.
|
|
196
224
|
*
|
|
@@ -358,6 +386,9 @@ let configPending;
|
|
|
358
386
|
});
|
|
359
387
|
} catch (error) {
|
|
360
388
|
console.error('[send-rate] org window unavailable — allowing', error);
|
|
389
|
+
await raiseSendRateAlert('deliverability.sendRateUnavailable', {
|
|
390
|
+
error: error instanceof Error ? error.message.slice(0, 200) : String(error)
|
|
391
|
+
});
|
|
361
392
|
return granted(0, true);
|
|
362
393
|
}
|
|
363
394
|
}
|