@aglyn/tenant-data-admin 1.0.0-beta.203 → 1.0.0-beta.205

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/capture-email-check.d.ts +109 -0
  3. package/src/lib/server/capture-email-check.js +362 -0
  4. package/src/lib/server/capture-email-check.js.map +1 -0
  5. package/src/lib/server/card-payment-velocity.d.ts +11 -16
  6. package/src/lib/server/card-payment-velocity.js +24 -28
  7. package/src/lib/server/card-payment-velocity.js.map +1 -1
  8. package/src/lib/server/email-deliverability.d.ts +19 -3
  9. package/src/lib/server/email-deliverability.js +34 -16
  10. package/src/lib/server/email-deliverability.js.map +1 -1
  11. package/src/lib/server/held-page-subject.d.ts +93 -0
  12. package/src/lib/server/held-page-subject.js +206 -0
  13. package/src/lib/server/held-page-subject.js.map +1 -0
  14. package/src/lib/server/host-email-tokens.d.ts +42 -1
  15. package/src/lib/server/host-email-tokens.js +100 -12
  16. package/src/lib/server/host-email-tokens.js.map +1 -1
  17. package/src/lib/server/host-visitor-records.js +21 -5
  18. package/src/lib/server/host-visitor-records.js.map +1 -1
  19. package/src/lib/server/hosted-page-review.d.ts +16 -5
  20. package/src/lib/server/hosted-page-review.js +242 -61
  21. package/src/lib/server/hosted-page-review.js.map +1 -1
  22. package/src/lib/server/list-members.js +16 -0
  23. package/src/lib/server/list-members.js.map +1 -1
  24. package/src/lib/server/notifications.d.ts +7 -0
  25. package/src/lib/server/notifications.js +27 -4
  26. package/src/lib/server/notifications.js.map +1 -1
  27. package/src/lib/server/outbound-send-review.d.ts +22 -0
  28. package/src/lib/server/outbound-send-review.js +74 -6
  29. package/src/lib/server/outbound-send-review.js.map +1 -1
  30. package/src/lib/server/payment-fraud-signal.d.ts +20 -12
  31. package/src/lib/server/payment-fraud-signal.js +41 -11
  32. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  33. package/src/lib/server/payment-risk-record.d.ts +37 -13
  34. package/src/lib/server/payment-risk-record.js +23 -8
  35. package/src/lib/server/payment-risk-record.js.map +1 -1
  36. package/src/lib/server/render-system-email.d.ts +3 -0
  37. package/src/lib/server/render-system-email.js +39 -11
  38. package/src/lib/server/render-system-email.js.map +1 -1
  39. package/src/lib/server/risk-notice.d.ts +327 -0
  40. package/src/lib/server/risk-notice.js +1240 -0
  41. package/src/lib/server/risk-notice.js.map +1 -0
  42. package/src/lib/server/sender-readiness.d.ts +36 -0
  43. package/src/lib/server/sender-readiness.js +99 -0
  44. package/src/lib/server/sender-readiness.js.map +1 -0
  45. package/src/lib/server/staff-alert-email.d.ts +29 -15
  46. package/src/lib/server/staff-alert-email.js +131 -28
  47. package/src/lib/server/staff-alert-email.js.map +1 -1
  48. package/src/lib/server/test-firestore.js +1 -0
  49. package/src/lib/server/test-firestore.js.map +1 -1
  50. package/src/lib/server/upsert-contact.js +19 -3
  51. package/src/lib/server/upsert-contact.js.map +1 -1
  52. package/src/tenancy.d.ts +2 -0
  53. package/src/tenancy.js +3 -0
  54. package/src/tenancy.js.map +1 -1
@@ -34,8 +34,8 @@ import { CARD_PAYMENT_VELOCITY, cardPaymentAddressKey, cardPaymentAlarmDay, card
34
34
  import { NO_CLIENT_ADDRESS_BUCKET, readClientIp } from "@aglyn/aglyn/app-utils/request-ip";
35
35
  import { FieldValue } from "firebase-admin/firestore";
36
36
  import firebaseAdmin from "./firebase-admin.js";
37
- import { notifyHostManagers, notifyStaff } from "./notifications.js";
38
37
  import { consumeRateLimit } from "./rate-limit-store.js";
38
+ import { notifyRiskEvent } from "./risk-notice.js";
39
39
  function clientIp(request) {
40
40
  var _readClientIp;
41
41
  const headers = request == null ? void 0 : request.headers;
@@ -58,28 +58,25 @@ function refusal(resetMs, nowMs) {
58
58
  return createHash('sha256').update(`payment-velocity:${hostId}:${day}`).digest('hex').slice(0, 40);
59
59
  }
60
60
  /**
61
- * Tell staff and the site's managers, once per site per day, that its payment
62
- * doors crossed the card-testing alarm (AGL-3363). The one notifier for this
63
- * kind of signal, so a shared risk-notice seam can take it over whole.
64
- *
65
- * The merchant's sentence names the outcome and what to do, never the rule
66
- * or its numbers: a merchant who is the card tester must not learn how to
67
- * pace under it. Never throws.
61
+ * Tell staff and the site's managers and the workspace's owners that a site
62
+ * crossed the card-payment window (AGL-3363), through the risk notice seam
63
+ * (AGL-3368): the owners read the catalog's `card-testing` words — what was
64
+ * seen and what to do, never the window or its numbers; staff get the row.
65
+ * Never throws.
68
66
  */ export async function notifyCardTestingVelocity(input, deps) {
69
- await Promise.all([
70
- deps.notifyStaff({
71
- type: 'system.abuseReportUrgent',
72
- title: `Card-testing velocity — site ${input.hostId}`,
73
- body: 'An unusual number of card payments opened on one site' + (input.orgId ? ` (workspace ${input.orgId})` : '') + '. Nothing has been refused site-wide or refunded. Check the site and its ' + 'recent orders; if it is card testing, lock the workspace and pause the ' + `connected account's payouts. Reference ${input.reference}.`,
74
- link: '/admin/abuse-reports'
75
- }).catch(()=>undefined),
76
- deps.notifyManagers(input.hostId, {
77
- type: 'content.order',
78
- title: 'Unusual checkout activity on your site',
79
- body: 'Your checkout saw an unusual burst of payment attempts, which can be ' + 'someone testing stolen cards. Checkout is still open; repeated attempts ' + 'from one source are being slowed. Review recent orders before you ' + 'fulfill them, and refund any you do not recognize. If this was a launch ' + `or sale, no action is needed. Questions: contact support with reference ${input.reference}.`,
80
- link: `/${input.hostId}/orders`
81
- }).catch(()=>undefined)
82
- ]);
67
+ var _input_reviewId;
68
+ await deps.notifyRisk({
69
+ kind: 'card-testing',
70
+ orgId: input.orgId,
71
+ hostId: input.hostId,
72
+ reviewId: (_input_reviewId = input.reviewId) != null ? _input_reviewId : null,
73
+ reference: input.reference,
74
+ item: {
75
+ label: 'your site',
76
+ path: `/${input.hostId}/products/orders`
77
+ },
78
+ staffEvidence: 'An unusual number of card payments opened on one site.'
79
+ }).catch(()=>undefined);
83
80
  }
84
81
  /**
85
82
  * File (or count) the day's alarm row for a site whose payment doors crossed
@@ -133,10 +130,10 @@ function refusal(resetMs, nowMs) {
133
130
  await notifyCardTestingVelocity({
134
131
  hostId: input.hostId,
135
132
  orgId: input.orgId,
136
- reference
133
+ reference,
134
+ reviewId
137
135
  }, {
138
- notifyStaff: input.notify,
139
- notifyManagers: input.notifyManagers
136
+ notifyRisk: input.notifyRisk
140
137
  });
141
138
  }
142
139
  return {
@@ -179,7 +176,7 @@ function refusal(resetMs, nowMs) {
179
176
  const day = cardPaymentAlarmDay(nowMs);
180
177
  const filedKey = `${options.hostId}:${day}`;
181
178
  if (site && !site.allowed && !site.contended && options.hostId && !filedAlarms.has(filedKey)) {
182
- var _options_firestore, _options_orgId, _options_notify, _options_notifyManagers;
179
+ var _options_firestore, _options_orgId, _options_notifyRisk;
183
180
  filedAlarms.add(filedKey);
184
181
  const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
185
182
  await recordCardPaymentAlarm({
@@ -189,8 +186,7 @@ function refusal(resetMs, nowMs) {
189
186
  young: options.young === true,
190
187
  nowMs,
191
188
  firestore,
192
- notify: (_options_notify = options.notify) != null ? _options_notify : notifyStaff,
193
- notifyManagers: (_options_notifyManagers = options.notifyManagers) != null ? _options_notifyManagers : notifyHostManagers
189
+ notifyRisk: (_options_notifyRisk = options.notifyRisk) != null ? _options_notifyRisk : notifyRiskEvent
194
190
  });
195
191
  }
196
192
  if (!visitor.allowed) return refusal(visitor.resetMs, nowMs);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/card-payment-velocity.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 * CARD-TESTING VELOCITY, THE DURABLE HALF (AGL-3363).\n *\n * The policy — the three counters, their numbers and why — is\n * `@aglyn/aglyn/app-utils/card-payment-velocity`. This counts them in the\n * shared durable store (`consumeRateLimit`) and answers the dispatcher the\n * way the visitor-write limiter beside it does: a `Response` to return, or\n * null to carry on. The tenant dispatcher calls it for a route registered\n * with `{ cardPayment: true }`, so every plugin's payment door meets it by\n * declaring itself, and none carries its own copy.\n *\n * The site counter refuses nothing. Crossing it files ONE urgent row per\n * site per day in the abuse queue (`source: 'payment-velocity'`), beside the\n * seller-pattern row, and tells staff once.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport {\n CARD_PAYMENT_VELOCITY,\n cardPaymentAddressKey,\n cardPaymentAlarmDay,\n cardPaymentSiteKey,\n cardPaymentVisitorKey,\n} from '@aglyn/aglyn/app-utils/card-payment-velocity'\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyHostManagers, notifyStaff } from './notifications'\nimport { consumeRateLimit } from './rate-limit-store'\n\nexport interface CardPaymentVelocityOptions {\n /** Dispatcher path, for the staff row. */\n path: string\n /** The site the payment is for; `''` when none resolved (still counted). */\n hostId: string\n /** The workspace owning it, when the dispatcher read one. */\n orgId?: string | null\n /** True for a workspace younger than the screen's young-workspace age. */\n young?: boolean\n /** Read for its client-address headers. */\n request: { headers?: { get?: (name: string) => unknown } }\n nowMs?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: unknown\n /** Injectable for tests; defaults to `notifyStaff`. */\n notify?: typeof notifyStaff\n /** Injectable for tests; defaults to `notifyHostManagers`. */\n notifyManagers?: typeof notifyHostManagers\n}\n\nfunction clientIp(request: CardPaymentVelocityOptions['request']): string {\n const headers = request?.headers\n if (typeof headers?.get !== 'function') return NO_CLIENT_ADDRESS_BUCKET\n return (\n readClientIp(headers as { get(name: string): string | null }) ??\n NO_CLIENT_ADDRESS_BUCKET\n )\n}\n\nfunction refusal(resetMs: number, nowMs: number): Response {\n // The same bare 429 as the visitor-write limiter: no remaining-count\n // headers for a stranger to pace a script by.\n return Response.json(\n { error: 'Too many payment attempts. Please wait a few minutes and try again.' },\n {\n status: 429,\n headers: {\n 'Retry-After': String(Math.max(1, Math.ceil((resetMs - nowMs) / 1000))),\n },\n },\n )\n}\n\n/** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */\nexport function cardPaymentAlarmReviewId(hostId: string, day: string): string {\n return createHash('sha256')\n .update(`payment-velocity:${hostId}:${day}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/**\n * Tell staff and the site's managers, once per site per day, that its payment\n * doors crossed the card-testing alarm (AGL-3363). The one notifier for this\n * kind of signal, so a shared risk-notice seam can take it over whole.\n *\n * The merchant's sentence names the outcome and what to do, never the rule\n * or its numbers: a merchant who is the card tester must not learn how to\n * pace under it. Never throws.\n */\nexport async function notifyCardTestingVelocity(\n input: { hostId: string; orgId: string | null; reference: string },\n deps: {\n notifyStaff: typeof notifyStaff\n notifyManagers: typeof notifyHostManagers\n },\n): Promise<void> {\n await Promise.all([\n deps\n .notifyStaff({\n type: 'system.abuseReportUrgent',\n title: `Card-testing velocity — site ${input.hostId}`,\n body:\n 'An unusual number of card payments opened on one site' +\n (input.orgId ? ` (workspace ${input.orgId})` : '') +\n '. Nothing has been refused site-wide or refunded. Check the site and its ' +\n 'recent orders; if it is card testing, lock the workspace and pause the ' +\n `connected account's payouts. Reference ${input.reference}.`,\n link: '/admin/abuse-reports',\n })\n .catch(() => undefined),\n deps\n .notifyManagers(input.hostId, {\n type: 'content.order',\n title: 'Unusual checkout activity on your site',\n body:\n 'Your checkout saw an unusual burst of payment attempts, which can be ' +\n 'someone testing stolen cards. Checkout is still open; repeated attempts ' +\n 'from one source are being slowed. Review recent orders before you ' +\n 'fulfill them, and refund any you do not recognize. If this was a launch ' +\n `or sale, no action is needed. Questions: contact support with reference ${input.reference}.`,\n link: `/${input.hostId}/orders`,\n })\n .catch(() => undefined),\n ])\n}\n\n/**\n * File (or count) the day's alarm row for a site whose payment doors crossed\n * the site counter, and tell staff the first time. Never throws: an alarm\n * that fails must not turn a shopper's checkout into a 500.\n */\nexport async function recordCardPaymentAlarm(input: {\n hostId: string\n orgId: string | null\n path: string\n young: boolean\n nowMs: number\n firestore: any\n notify: typeof notifyStaff\n notifyManagers: typeof notifyHostManagers\n}): Promise<{ reviewId: string; first: boolean } | null> {\n try {\n const day = cardPaymentAlarmDay(input.nowMs)\n const reviewId = cardPaymentAlarmReviewId(input.hostId, day)\n const reference = `PV-${reviewId.slice(0, 10).toUpperCase()}`\n const ref = input.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n const window = CARD_PAYMENT_VELOCITY.perSite\n const minutes = Math.round(window.windowMs / 60_000)\n await ref.set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'payment-velocity',\n url: null,\n reportedHostname: null,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n `Site ${input.hostId} opened more than ${window.limit} card payments in ${minutes} minutes (first overage through ${input.path}).`,\n input.young\n ? 'The workspace is young. Each address on this site is held to the tighter per-visitor limit.'\n : 'Each address is held to the per-visitor limit; nothing site-wide was refused.',\n 'A script testing stolen cards leaves this shape, from one address or many; so does a merchant testing cards through their own shop, and so does a busy launch.',\n 'Nothing has been refunded, canceled or paused. Look at the site and its orders; if it is card testing, lock the workspace and pause the connected account\\'s payouts in Stripe, then close this row with what you did.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n paymentVelocity: {\n hostId: input.hostId,\n orgId: input.orgId,\n day,\n threshold: window.limit,\n windowMinutes: minutes,\n young: input.young,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n if (first) {\n await notifyCardTestingVelocity(\n { hostId: input.hostId, orgId: input.orgId, reference },\n { notifyStaff: input.notify, notifyManagers: input.notifyManagers },\n )\n }\n return { reviewId, first }\n } catch (error) {\n console.error('[card-payment-velocity] alarm row failed', error)\n return null\n }\n}\n\n/** The (site, day) alarms this process has filed. */\nconst filedAlarms = new Set<string>()\n\n/** Forget which alarms this process filed. */\nexport function resetCardPaymentAlarmsForTests(): void {\n filedAlarms.clear()\n}\n\n/**\n * Count one opened card payment and return a 429 once a visitor's or an\n * address's window is spent. See the policy module for the three counters.\n */\nexport async function cardPaymentVelocityRefusal(\n options: CardPaymentVelocityOptions,\n): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const ip = clientIp(options.request)\n const visitorWindow = options.young\n ? CARD_PAYMENT_VELOCITY.perVisitorYoung\n : CARD_PAYMENT_VELOCITY.perVisitor\n const count = (key: string, window: { limit: number; windowMs: number }) =>\n consumeRateLimit(key, {\n limit: window.limit,\n windowMs: window.windowMs,\n now: nowMs,\n firestore: options.firestore,\n })\n\n const siteKey = cardPaymentSiteKey(options.hostId)\n const [visitor, address, site] = await Promise.all([\n count(cardPaymentVisitorKey(options.hostId, ip), visitorWindow),\n count(cardPaymentAddressKey(ip), CARD_PAYMENT_VELOCITY.perAddress),\n siteKey ? count(siteKey, CARD_PAYMENT_VELOCITY.perSite) : Promise.resolve(null),\n ])\n\n // The site counter's overage files the day's alarm. Each process files a\n // (site, day) once — an attack is thousands of overages, and one row that\n // says so is the whole message; the row id makes a second process's filing\n // count on the same row rather than add one.\n const day = cardPaymentAlarmDay(nowMs)\n const filedKey = `${options.hostId}:${day}`\n if (site && !site.allowed && !site.contended && options.hostId && !filedAlarms.has(filedKey)) {\n filedAlarms.add(filedKey)\n const firestore =\n (options.firestore as any) ?? firebaseAdmin.app().firestore()\n await recordCardPaymentAlarm({\n hostId: options.hostId,\n orgId: options.orgId ?? null,\n path: options.path,\n young: options.young === true,\n nowMs,\n firestore,\n notify: options.notify ?? notifyStaff,\n notifyManagers: options.notifyManagers ?? notifyHostManagers,\n })\n }\n\n if (!visitor.allowed) return refusal(visitor.resetMs, nowMs)\n if (!address.allowed) return refusal(address.resetMs, nowMs)\n return null\n}\n\nexport default cardPaymentVelocityRefusal\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","CARD_PAYMENT_VELOCITY","cardPaymentAddressKey","cardPaymentAlarmDay","cardPaymentSiteKey","cardPaymentVisitorKey","NO_CLIENT_ADDRESS_BUCKET","readClientIp","FieldValue","firebaseAdmin","notifyHostManagers","notifyStaff","consumeRateLimit","clientIp","request","headers","get","refusal","resetMs","nowMs","Response","json","error","status","String","Math","max","ceil","cardPaymentAlarmReviewId","hostId","day","update","digest","slice","notifyCardTestingVelocity","input","deps","Promise","all","type","title","body","orgId","reference","link","catch","undefined","notifyManagers","recordCardPaymentAlarm","reviewId","toUpperCase","ref","firestore","collection","doc","first","exists","window","perSite","minutes","round","windowMs","set","category","severity","source","url","reportedHostname","details","limit","path","young","join","reporterEmail","reporterName","dmca","reportCount","increment","paymentVelocity","threshold","windowMinutes","updatedAt","serverTimestamp","createdAt","merge","notify","console","filedAlarms","Set","resetCardPaymentAlarmsForTests","clear","cardPaymentVelocityRefusal","options","Date","now","ip","visitorWindow","perVisitorYoung","perVisitor","count","key","siteKey","visitor","address","site","perAddress","resolve","filedKey","allowed","contended","has","add","app"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/card-payment-velocity.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 * CARD-TESTING VELOCITY, THE DURABLE HALF (AGL-3363).\n *\n * The policy — the three counters, their numbers and why — is\n * `@aglyn/aglyn/app-utils/card-payment-velocity`. This counts them in the\n * shared durable store (`consumeRateLimit`) and answers the dispatcher the\n * way the visitor-write limiter beside it does: a `Response` to return, or\n * null to carry on. The tenant dispatcher calls it for a route registered\n * with `{ cardPayment: true }`, so every plugin's payment door meets it by\n * declaring itself, and none carries its own copy.\n *\n * The site counter refuses nothing. Crossing it files ONE urgent row per\n * site per day in the abuse queue (`source: 'payment-velocity'`), beside the\n * seller-pattern row, and tells staff once.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport {\n CARD_PAYMENT_VELOCITY,\n cardPaymentAddressKey,\n cardPaymentAlarmDay,\n cardPaymentSiteKey,\n cardPaymentVisitorKey,\n} from '@aglyn/aglyn/app-utils/card-payment-velocity'\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { consumeRateLimit } from './rate-limit-store'\nimport { notifyRiskEvent, type RiskEventInput } from './risk-notice'\n\nexport interface CardPaymentVelocityOptions {\n /** Dispatcher path, for the staff row. */\n path: string\n /** The site the payment is for; `''` when none resolved (still counted). */\n hostId: string\n /** The workspace owning it, when the dispatcher read one. */\n orgId?: string | null\n /** True for a workspace younger than the screen's young-workspace age. */\n young?: boolean\n /** Read for its client-address headers. */\n request: { headers?: { get?: (name: string) => unknown } }\n nowMs?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: unknown\n /** Injectable for tests; defaults to `notifyRiskEvent` (AGL-3368). */\n notifyRisk?: (input: RiskEventInput) => Promise<unknown>\n}\n\nfunction clientIp(request: CardPaymentVelocityOptions['request']): string {\n const headers = request?.headers\n if (typeof headers?.get !== 'function') return NO_CLIENT_ADDRESS_BUCKET\n return (\n readClientIp(headers as { get(name: string): string | null }) ??\n NO_CLIENT_ADDRESS_BUCKET\n )\n}\n\nfunction refusal(resetMs: number, nowMs: number): Response {\n // The same bare 429 as the visitor-write limiter: no remaining-count\n // headers for a stranger to pace a script by.\n return Response.json(\n { error: 'Too many payment attempts. Please wait a few minutes and try again.' },\n {\n status: 429,\n headers: {\n 'Retry-After': String(Math.max(1, Math.ceil((resetMs - nowMs) / 1000))),\n },\n },\n )\n}\n\n/** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */\nexport function cardPaymentAlarmReviewId(hostId: string, day: string): string {\n return createHash('sha256')\n .update(`payment-velocity:${hostId}:${day}`)\n .digest('hex')\n .slice(0, 40)\n}\n\n/**\n * Tell staff and the site's managers and the workspace's owners that a site\n * crossed the card-payment window (AGL-3363), through the risk notice seam\n * (AGL-3368): the owners read the catalog's `card-testing` words — what was\n * seen and what to do, never the window or its numbers; staff get the row.\n * Never throws.\n */\nexport async function notifyCardTestingVelocity(\n input: { hostId: string; orgId: string | null; reference: string; reviewId?: string | null },\n deps: { notifyRisk: (input: RiskEventInput) => Promise<unknown> },\n): Promise<void> {\n await deps\n .notifyRisk({\n kind: 'card-testing',\n orgId: input.orgId,\n hostId: input.hostId,\n reviewId: input.reviewId ?? null,\n reference: input.reference,\n item: { label: 'your site', path: `/${input.hostId}/products/orders` },\n staffEvidence: 'An unusual number of card payments opened on one site.',\n })\n .catch(() => undefined)\n}\n\n/**\n * File (or count) the day's alarm row for a site whose payment doors crossed\n * the site counter, and tell staff the first time. Never throws: an alarm\n * that fails must not turn a shopper's checkout into a 500.\n */\nexport async function recordCardPaymentAlarm(input: {\n hostId: string\n orgId: string | null\n path: string\n young: boolean\n nowMs: number\n firestore: any\n notifyRisk: (input: RiskEventInput) => Promise<unknown>\n}): Promise<{ reviewId: string; first: boolean } | null> {\n try {\n const day = cardPaymentAlarmDay(input.nowMs)\n const reviewId = cardPaymentAlarmReviewId(input.hostId, day)\n const reference = `PV-${reviewId.slice(0, 10).toUpperCase()}`\n const ref = input.firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n const window = CARD_PAYMENT_VELOCITY.perSite\n const minutes = Math.round(window.windowMs / 60_000)\n await ref.set(\n {\n reference,\n // \"Phishing or fraud\" — the queue's urgent fraud category.\n category: 'phishing',\n severity: 'urgent',\n source: 'payment-velocity',\n url: null,\n reportedHostname: null,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n `Site ${input.hostId} opened more than ${window.limit} card payments in ${minutes} minutes (first overage through ${input.path}).`,\n input.young\n ? 'The workspace is young. Each address on this site is held to the tighter per-visitor limit.'\n : 'Each address is held to the per-visitor limit; nothing site-wide was refused.',\n 'A script testing stolen cards leaves this shape, from one address or many; so does a merchant testing cards through their own shop, and so does a busy launch.',\n 'Nothing has been refunded, canceled or paused. Look at the site and its orders; if it is card testing, lock the workspace and pause the connected account\\'s payouts in Stripe, then close this row with what you did.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n paymentVelocity: {\n hostId: input.hostId,\n orgId: input.orgId,\n day,\n threshold: window.limit,\n windowMinutes: minutes,\n young: input.young,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n if (first) {\n await notifyCardTestingVelocity(\n { hostId: input.hostId, orgId: input.orgId, reference, reviewId },\n { notifyRisk: input.notifyRisk },\n )\n }\n return { reviewId, first }\n } catch (error) {\n console.error('[card-payment-velocity] alarm row failed', error)\n return null\n }\n}\n\n/** The (site, day) alarms this process has filed. */\nconst filedAlarms = new Set<string>()\n\n/** Forget which alarms this process filed. */\nexport function resetCardPaymentAlarmsForTests(): void {\n filedAlarms.clear()\n}\n\n/**\n * Count one opened card payment and return a 429 once a visitor's or an\n * address's window is spent. See the policy module for the three counters.\n */\nexport async function cardPaymentVelocityRefusal(\n options: CardPaymentVelocityOptions,\n): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const ip = clientIp(options.request)\n const visitorWindow = options.young\n ? CARD_PAYMENT_VELOCITY.perVisitorYoung\n : CARD_PAYMENT_VELOCITY.perVisitor\n const count = (key: string, window: { limit: number; windowMs: number }) =>\n consumeRateLimit(key, {\n limit: window.limit,\n windowMs: window.windowMs,\n now: nowMs,\n firestore: options.firestore,\n })\n\n const siteKey = cardPaymentSiteKey(options.hostId)\n const [visitor, address, site] = await Promise.all([\n count(cardPaymentVisitorKey(options.hostId, ip), visitorWindow),\n count(cardPaymentAddressKey(ip), CARD_PAYMENT_VELOCITY.perAddress),\n siteKey ? count(siteKey, CARD_PAYMENT_VELOCITY.perSite) : Promise.resolve(null),\n ])\n\n // The site counter's overage files the day's alarm. Each process files a\n // (site, day) once — an attack is thousands of overages, and one row that\n // says so is the whole message; the row id makes a second process's filing\n // count on the same row rather than add one.\n const day = cardPaymentAlarmDay(nowMs)\n const filedKey = `${options.hostId}:${day}`\n if (site && !site.allowed && !site.contended && options.hostId && !filedAlarms.has(filedKey)) {\n filedAlarms.add(filedKey)\n const firestore =\n (options.firestore as any) ?? firebaseAdmin.app().firestore()\n await recordCardPaymentAlarm({\n hostId: options.hostId,\n orgId: options.orgId ?? null,\n path: options.path,\n young: options.young === true,\n nowMs,\n firestore,\n notifyRisk: options.notifyRisk ?? notifyRiskEvent,\n })\n }\n\n if (!visitor.allowed) return refusal(visitor.resetMs, nowMs)\n if (!address.allowed) return refusal(address.resetMs, nowMs)\n return null\n}\n\nexport default cardPaymentVelocityRefusal\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","CARD_PAYMENT_VELOCITY","cardPaymentAddressKey","cardPaymentAlarmDay","cardPaymentSiteKey","cardPaymentVisitorKey","NO_CLIENT_ADDRESS_BUCKET","readClientIp","FieldValue","firebaseAdmin","consumeRateLimit","notifyRiskEvent","clientIp","request","headers","get","refusal","resetMs","nowMs","Response","json","error","status","String","Math","max","ceil","cardPaymentAlarmReviewId","hostId","day","update","digest","slice","notifyCardTestingVelocity","input","deps","notifyRisk","kind","orgId","reviewId","reference","item","label","path","staffEvidence","catch","undefined","recordCardPaymentAlarm","toUpperCase","ref","firestore","collection","doc","first","exists","window","perSite","minutes","round","windowMs","set","category","severity","source","url","reportedHostname","details","limit","young","join","reporterEmail","reporterName","dmca","reportCount","increment","paymentVelocity","threshold","windowMinutes","updatedAt","serverTimestamp","createdAt","merge","console","filedAlarms","Set","resetCardPaymentAlarmsForTests","clear","cardPaymentVelocityRefusal","options","Date","now","ip","visitorWindow","perVisitorYoung","perVisitor","count","key","siteKey","visitor","address","site","Promise","all","perAddress","resolve","filedKey","allowed","contended","has","add","app"],"mappings":"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"}
@@ -143,8 +143,24 @@ export interface MailGatewayLedgerRow extends MailGatewayLedger {
143
143
  /** Whether bulk mail from this domain into this gateway is held today. */
144
144
  holds: boolean;
145
145
  }
146
- /** The platform ledger, most recently moved first. */
147
- export declare function listMailGatewayLedgers(options?: {
146
+ /** One stored top-level ledger as a staff row; `null` for an unreadable one. */
147
+ export declare function mailGatewayLedgerRow(id: string, data: Record<string, unknown> | null | undefined, nowMs: number): MailGatewayLedgerRow | null;
148
+ /** The first instant of the hold's window: midnight UTC of its oldest day. */
149
+ export declare function mailGatewayWindowStartMs(nowMs: number): number;
150
+ /** The most rows the refused-lately read returns; past it the answer says it was cut. */
151
+ export declare const MAIL_GATEWAY_REFUSED_READ_LIMIT = 500;
152
+ /**
153
+ * Every top-level ledger with a refusal inside the hold's window, most
154
+ * recent refusal first — the complete set a hold can come from, since a
155
+ * hold needs refusals in that window and `lastBlockedAtMs` is the latest of
156
+ * them. One range on one field, which the automatic index serves; nothing
157
+ * is filtered after the read. `truncated` when there were more than `limit`.
158
+ */
159
+ export declare function listRecentlyRefusedMailGatewayLedgers(options?: {
148
160
  limit?: number;
149
- }, deps?: MailDeliverabilityDeps): Promise<MailGatewayLedgerRow[]>;
161
+ }, deps?: MailDeliverabilityDeps): Promise<{
162
+ rows: MailGatewayLedgerRow[];
163
+ sinceMs: number;
164
+ truncated: boolean;
165
+ }>;
150
166
  export {};
@@ -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, 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, 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";
@@ -512,26 +512,44 @@ function ledgerPath(scope, key) {
512
512
  }
513
513
  return recorded;
514
514
  }
515
- /** The platform ledger, most recently moved first. */ export async function listMailGatewayLedgers(options = {}, deps = {}) {
515
+ /** One stored top-level ledger as a staff row; `null` for an unreadable one. */ export function mailGatewayLedgerRow(id, data, nowMs) {
516
+ const ledger = readStoredMailGatewayLedger(id, data);
517
+ if (!ledger) return null;
518
+ const standing = mailGatewayStanding(ledger.gateway, ledger, nowMs);
519
+ return _extends({}, ledger, {
520
+ id,
521
+ shared: (data == null ? void 0 : data['shared']) === true || isSharedMailSendingDomain(ledger.sendingDomain),
522
+ standing,
523
+ holds: mailGatewayHolds(standing)
524
+ });
525
+ }
526
+ /** The first instant of the hold's window: midnight UTC of its oldest day. */ export function mailGatewayWindowStartMs(nowMs) {
527
+ return Date.parse(`${mailGatewayDay(nowMs - (MAIL_GATEWAY_WINDOW_DAYS - 1) * 86400000)}T00:00:00.000Z`);
528
+ }
529
+ /** The most rows the refused-lately read returns; past it the answer says it was cut. */ export const MAIL_GATEWAY_REFUSED_READ_LIMIT = 500;
530
+ /**
531
+ * Every top-level ledger with a refusal inside the hold's window, most
532
+ * recent refusal first — the complete set a hold can come from, since a
533
+ * hold needs refusals in that window and `lastBlockedAtMs` is the latest of
534
+ * them. One range on one field, which the automatic index serves; nothing
535
+ * is filtered after the read. `truncated` when there were more than `limit`.
536
+ */ export async function listRecentlyRefusedMailGatewayLedgers(options = {}, deps = {}) {
516
537
  var _deps_nowMs, _deps_firestore;
517
538
  const nowMs = (_deps_nowMs = deps.nowMs) != null ? _deps_nowMs : Date.now();
518
539
  const firestore = (_deps_firestore = deps.firestore) != null ? _deps_firestore : defaultFirestore();
519
- const limit = Math.min(Math.max(1, Math.floor(Number(options.limit) || 100)), 500);
520
- const snapshot = await firestore.collection(MAIL_GATEWAY_LEDGER_COLLECTION).orderBy('updatedAtMs', 'desc').limit(limit).get();
540
+ const limit = Math.min(Math.max(1, Math.floor(Number(options.limit) || MAIL_GATEWAY_REFUSED_READ_LIMIT)), MAIL_GATEWAY_REFUSED_READ_LIMIT);
541
+ const sinceMs = mailGatewayWindowStartMs(nowMs);
542
+ const snapshot = await firestore.collection(MAIL_GATEWAY_LEDGER_COLLECTION).where('lastBlockedAtMs', '>=', sinceMs).orderBy('lastBlockedAtMs', 'desc').limit(limit + 1).get();
521
543
  const rows = [];
522
- for (const doc of snapshot.docs){
523
- const data = doc.data();
524
- const ledger = readStoredMailGatewayLedger(doc.id, data);
525
- if (!ledger) continue;
526
- const standing = mailGatewayStanding(ledger.gateway, ledger, nowMs);
527
- rows.push(_extends({}, ledger, {
528
- id: doc.id,
529
- shared: data['shared'] === true || isSharedMailSendingDomain(ledger.sendingDomain),
530
- standing,
531
- holds: mailGatewayHolds(standing)
532
- }));
544
+ for (const doc of snapshot.docs.slice(0, limit)){
545
+ const row = mailGatewayLedgerRow(doc.id, doc.data(), nowMs);
546
+ if (row) rows.push(row);
533
547
  }
534
- return rows;
548
+ return {
549
+ rows,
550
+ sinceMs,
551
+ truncated: snapshot.docs.length > limit
552
+ };
535
553
  }
536
554
  installEmailDeliverabilityPreflight();
537
555
 
@@ -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 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/** The platform ledger, most recently moved first. */\nexport async function listMailGatewayLedgers(\n options: { limit?: number } = {},\n deps: MailDeliverabilityDeps = {},\n): Promise<MailGatewayLedgerRow[]> {\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) || 100)), 500)\n const snapshot = await firestore\n .collection(MAIL_GATEWAY_LEDGER_COLLECTION)\n .orderBy('updatedAtMs', 'desc')\n .limit(limit)\n .get()\n const rows: MailGatewayLedgerRow[] = []\n for (const doc of snapshot.docs) {\n const data = doc.data() as Record<string, unknown>\n const ledger = readStoredMailGatewayLedger(doc.id, data)\n if (!ledger) continue\n const standing = mailGatewayStanding(ledger.gateway, ledger, nowMs)\n rows.push({\n ...ledger,\n id: doc.id,\n shared: data['shared'] === true || isSharedMailSendingDomain(ledger.sendingDomain),\n standing,\n holds: mailGatewayHolds(standing),\n })\n }\n return rows\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","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","listMailGatewayLedgers","options","max","floor","orderBy","rows","docs","id","holds"],"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 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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"}
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { HeldPageSubject } from '@aglyn/shared-util-email/held-page';
18
+ import type { PhishingScreenSignal } from '@aglyn/shared-util-email/outbound-phishing-screen';
19
+ /** One document that composes into the page, for attributing a signal. */
20
+ export interface HostedPagePart {
21
+ type: 'screen' | 'layout' | 'component';
22
+ id: string;
23
+ name?: string | null;
24
+ nodes: unknown;
25
+ }
26
+ /** What the composer knows about the page it is reviewing. */
27
+ export interface HostedPageContext {
28
+ /** The screen document it composed (its name and `kind`). */
29
+ screen?: {
30
+ displayName?: unknown;
31
+ name?: unknown;
32
+ kind?: unknown;
33
+ } | null;
34
+ /**
35
+ * A template composed against a routed listing or entry: its route pattern
36
+ * (`/videos`, `/videos/:slug`), what it renders, the address being shown,
37
+ * and what a visitor gets when the template has no clean version.
38
+ */
39
+ template?: {
40
+ role: 'list' | 'entry';
41
+ route: string;
42
+ collectionName?: string | null;
43
+ entryPath?: string | null;
44
+ fallback?: 'built-in-design' | 'not-found';
45
+ } | null;
46
+ /** An experiment variant's version of the page. */
47
+ variant?: {
48
+ experimentId: string;
49
+ variantId: string;
50
+ name?: string | null;
51
+ } | null;
52
+ /** The page's own nodes and those of each layout and component composed into it. */
53
+ parts?: () => Promise<HostedPagePart[]>;
54
+ }
55
+ /** The site's public origin, or null when it has no address yet. */
56
+ export declare function hostOrigin(host: Record<string, unknown> | null | undefined): string | null;
57
+ /**
58
+ * A screen's route from the host routing map: `/about`, `/` for the index.
59
+ * Null when the map does not route it — a template, an error screen, an
60
+ * unpublished page. Never the site root for a page the map does not name:
61
+ * that was the row that called a video template "https://aglyn.com/".
62
+ */
63
+ export declare function routedScreenPath(host: Record<string, unknown> | null | undefined, screenId: string): string | null;
64
+ /**
65
+ * Which part of the page carries the flagged content. The page's own nodes
66
+ * first — when they carry any of it, the page is the source and nothing else
67
+ * is named; otherwise the first layout, then the first component, that does.
68
+ */
69
+ export declare function attributeHeldSignals(parts: readonly HostedPagePart[], signals: readonly PhishingScreenSignal[], identity: {
70
+ ownNames?: readonly (string | null | undefined)[];
71
+ ownDomains?: readonly (string | null | undefined)[];
72
+ }): HostedPagePart | null;
73
+ /**
74
+ * Describe the reviewed page. Best effort throughout: a read that fails
75
+ * leaves its field empty, and the subject still names what it can — it is a
76
+ * label on a decision already made, never a reason to change it.
77
+ */
78
+ export declare function describeHeldPage(input: {
79
+ hostId: string;
80
+ screenId: string;
81
+ versionId: string;
82
+ host: Record<string, unknown> | null;
83
+ page?: HostedPageContext | null;
84
+ signals: readonly PhishingScreenSignal[];
85
+ identity: {
86
+ ownNames?: readonly (string | null | undefined)[];
87
+ ownDomains?: readonly (string | null | undefined)[];
88
+ };
89
+ /** The page was already live and keeps serving (the flag path). */
90
+ live: boolean;
91
+ /** The last version the page served clean, if any. */
92
+ previousVersionId: string | null;
93
+ }): Promise<HeldPageSubject>;