@aglyn/tenant-data-admin 1.0.0-beta.216 → 1.0.0-beta.218
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/src/lib/server/account-lock-mail.d.ts +74 -0
- package/src/lib/server/account-lock-mail.js +139 -0
- package/src/lib/server/account-lock-mail.js.map +1 -0
- package/src/lib/server/account-mail.d.ts +41 -0
- package/src/lib/server/account-mail.js +52 -0
- package/src/lib/server/account-mail.js.map +1 -0
- package/src/lib/server/email-deliverability.js +21 -6
- package/src/lib/server/email-deliverability.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +72 -3
- package/src/lib/server/email-suppression.js +194 -3
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/lockdown.js +2 -0
- package/src/lib/server/lockdown.js.map +1 -1
- package/src/lib/server/media-download-tokens.js +2 -1
- package/src/lib/server/media-download-tokens.js.map +1 -1
- package/src/lib/server/notifications.js +7 -1
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/org-member-notice.js +5 -1
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/organizations.js +4 -0
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/risk-notice.js +21 -3
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/serve-media-cdn.js +2 -2
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/serve-plugin-fetch.d.ts +17 -7
- package/src/lib/server/serve-plugin-fetch.js +19 -8
- package/src/lib/server/serve-plugin-fetch.js.map +1 -1
|
@@ -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 mailGatewayHoldSentence,\n MAIL_GATEWAY_LABELS,\n MAIL_GATEWAY_WINDOW_DAYS,\n normalizeDeliverabilityEmail,\n normalizeLedgerSendingDomain,\n readBounceText,\n readStoredMailDomainIntel,\n readStoredMailGatewayLedger,\n resolveMailDomain,\n scrubBounceDiagnostic,\n setEmailDeliverabilityPreflight,\n bareSenderAddress,\n} from '@aglyn/shared-util-email'\nimport { promises as dns } from 'dns'\nimport { isConclusiveDnsCode, lookupAddress, lookupMx } from './dns-probe'\nimport type { EmailDeliveryEventOutcome } from './email-delivery-log'\nimport { suppressEmail } from './email-suppression'\nimport { firebaseAdmin } from './firebase-admin'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The platform MX cache, keyed by recipient domain. */\nexport const MAIL_DOMAINS_COLLECTION = 'mailDomains'\n\n/** The gateway ledger, at the top level and under an organization — see the module note. */\nexport const MAIL_GATEWAY_LEDGER_COLLECTION = 'mailGatewayLedger'\n\n/**\n * Where a ledger lives: `null` for the top level (a Resend sending domain),\n * or the organization a connected mailbox belongs to.\n */\nexport type MailGatewayLedgerScope = { orgId: string } | null\n\n/** What the reads and writes here need beside their input; every field defaults to the platform's. */\nexport interface MailDeliverabilityDeps {\n firestore?: Firestore\n resolver?: MailDnsResolver\n nowMs?: number\n}\n\nconst defaultFirestore = (): Firestore => firebaseAdmin.app().firestore() as Firestore\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\n/** Domains looked up at once when a batch is cold. */\nconst LOOKUP_CONCURRENCY = 16\n\n/*==========================================\n * DNS\n *==========================================*/\n\n/** A resolver rejection that means nobody answered, which every caller reads as \"unknown\". */\nfunction unanswered(domain: string, what: string): Error {\n return Object.assign(new Error(`the ${what} lookup for ${domain} went unanswered`), { code: 'EUNANSWERED' })\n}\n\n/**\n * The platform's resolver: the pinned public resolvers first, and — for the\n * one answer that stops mail, \"no MX\" — the runtime's own as a second\n * opinion that must agree. See the module note.\n */\nexport function platformMailDnsResolver(): MailDnsResolver {\n return {\n async resolveMx(domain) {\n const pinned = await lookupMx(domain)\n if (!pinned.answered) throw unanswered(domain, 'MX')\n if (pinned.records.length) return pinned.records\n try {\n return (await dns.resolveMx(domain)).map((record) => ({\n exchange: String(record?.exchange ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.$/, ''),\n priority: Number(record?.priority) || 0,\n }))\n } catch (error) {\n if (isConclusiveDnsCode((error as NodeJS.ErrnoException)?.code)) return []\n throw unanswered(domain, 'MX')\n }\n },\n async resolveAddress(domain) {\n const result = await lookupAddress(domain)\n if (!result.answered) throw unanswered(domain, 'address')\n return result.records.length > 0\n },\n }\n}\n\n/*==========================================\n * THE MX CACHE\n *==========================================*/\n\n/**\n * A process-lifetime copy of the answers already read, so a campaign's\n * five hundred messages to one domain ask the store once. Bounded, oldest\n * out first; trusted exactly as long as the stored answer is.\n */\nconst domainMemory = new Map<string, MailDomainIntel>()\nconst DOMAIN_MEMORY_MAX = 5_000\n\nfunction rememberDomain(intel: MailDomainIntel): void {\n if (domainMemory.has(intel.domain)) domainMemory.delete(intel.domain)\n else if (domainMemory.size >= DOMAIN_MEMORY_MAX) {\n const oldest = domainMemory.keys().next().value\n if (oldest !== undefined) domainMemory.delete(oldest)\n }\n domainMemory.set(intel.domain, intel)\n}\n\n/** A ledger read, remembered for a minute so a batch does not re-read it per message. */\nconst ledgerMemory = new Map<string, { ledger: MailGatewayLedger | null; readAtMs: number }>()\nconst LEDGER_MEMORY_MS = 60_000\nconst LEDGER_MEMORY_MAX = 2_000\n\nfunction rememberLedger(path: string, ledger: MailGatewayLedger | null, nowMs: number): void {\n if (ledgerMemory.has(path)) ledgerMemory.delete(path)\n else if (ledgerMemory.size >= LEDGER_MEMORY_MAX) {\n const oldest = ledgerMemory.keys().next().value\n if (oldest !== undefined) ledgerMemory.delete(oldest)\n }\n ledgerMemory.set(path, { ledger, readAtMs: nowMs })\n}\n\n/** Test seam: forget everything remembered in this process. */\nexport function resetMailDeliverabilityMemoryForTests(): void {\n domainMemory.clear()\n ledgerMemory.clear()\n}\n\n/** Runs `work` over `items`, at most `limit` at a time. */\nasync function eachLimited<T>(items: readonly T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {\n let next = 0\n const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {\n while (next < items.length) {\n const item = items[next]\n next += 1\n await work(item)\n }\n })\n await Promise.all(runners)\n}\n\n/**\n * Each domain's MX answer, keyed by domain: remembered, stored and fresh, or\n * looked up and stored. `null` for a domain that could not be looked up and\n * has no answer that may stand — see the module note.\n */\nexport async function readMailDomains(\n domains: readonly string[],\n deps: MailDeliverabilityDeps = {},\n): Promise<Map<string, MailDomainIntel | null>> {\n const nowMs = deps.nowMs ?? Date.now()\n const answers = new Map<string, MailDomainIntel | null>()\n const wanted = [...new Set(domains.map((domain) => String(domain ?? '').trim().toLowerCase()))].filter(\n isDeliverabilityDomain,\n )\n const missing: string[] = []\n for (const domain of wanted) {\n const remembered = domainMemory.get(domain)\n if (remembered && isMailDomainIntelFresh(remembered, nowMs)) answers.set(domain, remembered)\n else missing.push(domain)\n }\n if (!missing.length) return answers\n\n const firestore = deps.firestore ?? defaultFirestore()\n const collection = firestore.collection(MAIL_DOMAINS_COLLECTION)\n const stored = new Map<string, MailDomainIntel | null>()\n try {\n for (let start = 0; start < missing.length; start += GET_ALL_CHUNK) {\n const chunk = missing.slice(start, start + GET_ALL_CHUNK)\n const snapshots = await firestore.getAll(...chunk.map((domain) => collection.doc(domain)))\n chunk.forEach((domain, index) => {\n const snapshot = snapshots[index]\n stored.set(domain, readStoredMailDomainIntel(domain, snapshot?.exists ? snapshot.data() : undefined))\n })\n }\n } catch (error) {\n console.error('[deliverability] the MX cache could not be read; looking the domains up', error)\n }\n\n const stale: string[] = []\n for (const domain of missing) {\n const answer = stored.get(domain) ?? null\n if (answer && isMailDomainIntelFresh(answer, nowMs)) {\n answers.set(domain, answer)\n rememberDomain(answer)\n } else {\n stale.push(domain)\n }\n }\n\n const resolver = deps.resolver ?? platformMailDnsResolver()\n await eachLimited(stale, LOOKUP_CONCURRENCY, async (domain) => {\n const previous = stored.get(domain) ?? null\n let fresh: MailDomainIntel\n try {\n fresh = await resolveMailDomain(resolver, domain, nowMs)\n } catch (error) {\n // Nobody answered. A stale \"takes mail\" still says the domain takes\n // mail; a stale \"takes none\" is not reused to stop a send.\n console.warn(`[deliverability] the MX of ${domain} could not be looked up`, (error as Error)?.message ?? error)\n answers.set(domain, previous && !mailDomainRefusesMail(previous.status) ? previous : null)\n return\n }\n answers.set(domain, fresh)\n rememberDomain(fresh)\n try {\n await collection.doc(domain).set({\n domain,\n status: fresh.status,\n mx: fresh.mx,\n gateway: fresh.gateway,\n resolvedAtMs: fresh.resolvedAtMs,\n })\n } catch (error) {\n // The cache is a convenience; the answer is already in hand.\n console.error(`[deliverability] the MX of ${domain} could not be cached`, error)\n }\n })\n return answers\n}\n\n/*==========================================\n * THE LEDGER\n *==========================================*/\n\nfunction ledgerCollection(firestore: Firestore, scope: MailGatewayLedgerScope) {\n return scope\n ? firestore.collection('orgs').doc(scope.orgId).collection(MAIL_GATEWAY_LEDGER_COLLECTION)\n : firestore.collection(MAIL_GATEWAY_LEDGER_COLLECTION)\n}\n\nfunction ledgerPath(scope: MailGatewayLedgerScope, key: string): string {\n return scope ? `orgs/${scope.orgId}/${MAIL_GATEWAY_LEDGER_COLLECTION}/${key}` : `${MAIL_GATEWAY_LEDGER_COLLECTION}/${key}`\n}\n\n/** The platform's own sending domain: `USAGE_EMAIL_FROM`'s. */\nfunction platformFromDomain(): string | null {\n const address = bareSenderAddress(getEmailConfig().from)\n return address ? address.slice(address.lastIndexOf('@') + 1) : null\n}\n\n/**\n * Whether a block on this sending domain hurts every tenant — Aglyn's own\n * domain or a shared pool member — which the staff console surfaces first.\n */\nexport function isSharedMailSendingDomain(domain: string | null | undefined): boolean {\n const name = normalizeLedgerSendingDomain(domain)\n if (!name) return false\n return isSharedSendingDomain(name) || name === platformFromDomain()\n}\n\n/** The ledger of each gateway named for one sending domain, keyed by gateway; `null` where none exists yet. */\nexport async function readMailGatewayLedgers(\n scope: MailGatewayLedgerScope,\n sendingDomain: string,\n gateways: readonly MailGateway[],\n deps: MailDeliverabilityDeps = {},\n): Promise<Map<MailGateway, MailGatewayLedger | null>> {\n const nowMs = deps.nowMs ?? Date.now()\n const domain = normalizeLedgerSendingDomain(sendingDomain)\n const answers = new Map<MailGateway, MailGatewayLedger | null>()\n const wanted = [...new Set(gateways)].filter((gateway) => gateway !== 'none')\n if (!domain || !wanted.length) return answers\n const unread: MailGateway[] = []\n for (const gateway of wanted) {\n const remembered = ledgerMemory.get(ledgerPath(scope, mailGatewayLedgerKey(domain, gateway)))\n if (remembered && remembered.readAtMs + LEDGER_MEMORY_MS > nowMs) answers.set(gateway, remembered.ledger)\n else unread.push(gateway)\n }\n if (!unread.length) return answers\n const firestore = deps.firestore ?? defaultFirestore()\n const collection = ledgerCollection(firestore, scope)\n const keys = unread.map((gateway) => mailGatewayLedgerKey(domain, gateway))\n const snapshots = await firestore.getAll(...keys.map((key) => collection.doc(key)))\n unread.forEach((gateway, index) => {\n const snapshot = snapshots[index]\n const ledger = readStoredMailGatewayLedger(keys[index], snapshot?.exists ? snapshot.data() : undefined)\n answers.set(gateway, ledger)\n rememberLedger(ledgerPath(scope, keys[index]), ledger, nowMs)\n })\n return answers\n}\n\n/**\n * Counts one outcome on a sending domain's ledger with a gateway. Never\n * throws: the send or the bounce it records has already happened, and a\n * lost count understates a gateway while a thrown one loses the step that\n * called. A delivery past the day's cap costs no write at all.\n */\nexport async function recordMailGatewayOutcome(\n scope: MailGatewayLedgerScope,\n input: {\n sendingDomain: string\n gateway: MailGateway\n outcome: MailGatewayOutcome\n count?: number\n atMs: number\n /** A refusal's diagnostic, scrubbed here of addresses before it is kept. */\n detail?: string | null\n },\n deps: MailDeliverabilityDeps = {},\n): Promise<void> {\n const sendingDomain = normalizeLedgerSendingDomain(input.sendingDomain)\n if (!sendingDomain || input.gateway === 'none') return\n const key = mailGatewayLedgerKey(sendingDomain, input.gateway)\n const path = ledgerPath(scope, key)\n if (input.outcome === 'delivered') {\n const remembered = ledgerMemory.get(path)?.ledger\n const today = remembered?.days?.[mailGatewayDay(input.atMs)]\n if ((today?.delivered ?? 0) >= MAIL_GATEWAY_DAILY_DELIVERY_CAP) return\n }\n try {\n const firestore = deps.firestore ?? defaultFirestore()\n const ref = ledgerCollection(firestore, scope).doc(key)\n const written = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const current = readStoredMailGatewayLedger(key, snapshot.exists ? snapshot.data() : undefined)\n const next = applyMailGatewayOutcome(current, {\n sendingDomain,\n gateway: input.gateway,\n outcome: input.outcome,\n count: input.count,\n atMs: input.atMs,\n detail: scrubBounceDiagnostic(input.detail),\n })\n if (!next) return current\n transaction.set(ref, {\n ...next,\n ...(scope ? { orgId: scope.orgId } : { shared: isSharedMailSendingDomain(sendingDomain) }),\n })\n return next\n })\n rememberLedger(path, written ?? null, input.atMs)\n // A named gateway that now holds a SHARED domain stops every site on it\n // from reaching everyone behind that gateway, and no single workspace\n // owns the problem to notice it (AGL-3377). Once a day per pair.\n if (!scope && input.outcome !== 'delivered' && written && isSharedMailSendingDomain(sendingDomain)) {\n const standing = mailGatewayStanding(input.gateway, written, input.atMs)\n if (mailGatewayHolds(standing)) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert('deliverability.gatewayBlocking', {\n dedupeKey: key,\n context: {\n gateway: MAIL_GATEWAY_LABELS[input.gateway] ?? input.gateway,\n domain: sendingDomain,\n detail: mailGatewayHoldSentence(input.gateway, standing.blocked30),\n },\n })\n }\n }\n } catch (error) {\n console.error(`[deliverability] the ledger could not count a ${input.outcome} for ${sendingDomain} at ${input.gateway}`, error)\n }\n}\n\n/*==========================================\n * THE VERDICTS\n *==========================================*/\n\n/** What a batch read needs to know about the mail being sent. */\nexport interface MailDeliverabilityQuestion {\n emails: readonly string[]\n purpose: EmailSendPurpose\n /** The domain the mail leaves from; without one, no ledger is read and nothing is held. */\n sendingDomain: string | null\n scope: MailGatewayLedgerScope\n /** Addresses a person already released the gateway hold for. */\n holdReleased?: (email: string) => boolean\n}\n\n/**\n * The deliverability verdict for every address, keyed by the address as\n * given: the MX answers read (and refreshed) above, joined to the sending\n * domain's ledger with each gateway they name. A ledger that cannot be read\n * reads as no history, which holds nothing.\n */\nexport async function readMailDeliverability(\n question: MailDeliverabilityQuestion,\n deps: MailDeliverabilityDeps = {},\n): Promise<Map<string, EmailDeliverabilityVerdict>> {\n const nowMs = deps.nowMs ?? Date.now()\n const normalized = new Map<string, string | null>()\n for (const email of question.emails) normalized.set(email, normalizeDeliverabilityEmail(email))\n const domains = [...normalized.values()]\n .filter((email): email is string => Boolean(email))\n .map((email) => email.slice(email.lastIndexOf('@') + 1))\n const intel = await readMailDomains(domains, { ...deps, nowMs })\n\n const sendingDomain = normalizeLedgerSendingDomain(question.sendingDomain)\n let ledgers = new Map<MailGateway, MailGatewayLedger | null>()\n if (sendingDomain) {\n const gateways = [...intel.values()]\n .filter((entry): entry is MailDomainIntel => Boolean(entry))\n .map((entry) => entry.gateway)\n try {\n ledgers = await readMailGatewayLedgers(question.scope, sendingDomain, gateways, { ...deps, nowMs })\n } catch (error) {\n console.error('[deliverability] the gateway ledger could not be read; reading as no history', error)\n }\n }\n\n const verdicts = new Map<string, EmailDeliverabilityVerdict>()\n for (const [given, email] of normalized) {\n const domain = email ? email.slice(email.lastIndexOf('@') + 1) : null\n const answer = domain ? (intel.get(domain) ?? null) : null\n const standing: MailGatewayStanding | null =\n answer && sendingDomain ? mailGatewayStanding(answer.gateway, ledgers.get(answer.gateway) ?? null, nowMs) : null\n verdicts.set(\n given,\n assessEmailDeliverability({\n email: given,\n purpose: question.purpose,\n intel: answer,\n gateway: standing,\n publicMailbox: domain ? isPublicMailboxDomain(domain) : false,\n holdReleased: email ? question.holdReleased?.(email) === true : false,\n }),\n )\n }\n return verdicts\n}\n\n/** Whether a verdict refuses because the domain takes no mail — the refusal that suppresses. */\nexport function isNoMailServerVerdict(verdict: EmailDeliverabilityVerdict | null | undefined): boolean {\n const deciding = verdict ? decidingDeliverabilityFinding(verdict) : null\n return deciding?.code === 'no_mx' || deciding?.code === 'null_mx'\n}\n\n/** Whether a verdict holds bulk mail for its gateway. */\nexport function isGatewayHeldVerdict(verdict: EmailDeliverabilityVerdict | null | undefined): boolean {\n const deciding = verdict ? decidingDeliverabilityFinding(verdict) : null\n return deciding?.code === 'gateway_held'\n}\n\n/** A campaign's audience after the checks, with who was excluded and why. */\nexport interface DeliverableRecipients {\n deliverable: string[]\n /** Addresses whose domain takes no mail. */\n noMailServer: string[]\n /** Addresses behind a gateway that refused this sending domain twice lately. */\n gatewayHeld: string[]\n}\n\n/**\n * A bulk audience with the addresses that would bounce, or that sit behind\n * a gateway holding this sending domain, taken out — the count a campaign's\n * review shows before Send, and the filter its batches run. Fails open: an\n * error keeps everyone.\n */\nexport async function filterDeliverableRecipients(\n input: { emails: readonly string[]; sendingDomain: string | null; purpose?: EmailSendPurpose },\n deps: MailDeliverabilityDeps = {},\n): Promise<DeliverableRecipients> {\n const answer: DeliverableRecipients = { deliverable: [], noMailServer: [], gatewayHeld: [] }\n let verdicts: Map<string, EmailDeliverabilityVerdict>\n try {\n verdicts = await readMailDeliverability(\n { emails: input.emails, purpose: input.purpose ?? 'bulk', sendingDomain: input.sendingDomain, scope: null },\n deps,\n )\n } catch (error) {\n console.error('[deliverability] the audience could not be checked; keeping everyone', error)\n return { ...answer, deliverable: [...input.emails] }\n }\n for (const email of input.emails) {\n const verdict = verdicts.get(email)\n if (isNoMailServerVerdict(verdict)) answer.noMailServer.push(email)\n else if (isGatewayHeldVerdict(verdict)) answer.gatewayHeld.push(email)\n else answer.deliverable.push(email)\n }\n return answer\n}\n\n/*==========================================\n * THE PREFLIGHT ON `sendEmail`\n *==========================================*/\n\n/**\n * What `sendEmail` asks before a message leaves — see the seam in\n * `@aglyn/shared-util-email`. Refuses every recipient whose domain takes no\n * mail, and files the address on the platform suppression list with its own\n * reason; holds bulk mail behind a gateway that refused this sending domain\n * twice. The Resend path's ledger is keyed by the sending domain alone.\n */\nexport async function runEmailDeliverabilityPreflight(\n request: EmailDeliverabilityPreflightRequest,\n deps: MailDeliverabilityDeps = {},\n): Promise<EmailDeliverabilityPreflightVerdict> {\n const verdicts = await readMailDeliverability(\n {\n emails: request.recipients,\n purpose: request.purpose,\n sendingDomain: request.sendingDomain,\n scope: null,\n },\n deps,\n )\n const answer: EmailDeliverabilityPreflightVerdict = { refused: [], held: [] }\n for (const [email, verdict] of verdicts) {\n const deciding = decidingDeliverabilityFinding(verdict)\n if (!deciding) continue\n if (isNoMailServerVerdict(verdict)) {\n answer.refused.push({ email, code: deciding.code, reason: deciding.message })\n await suppressEmail({\n email,\n reason: 'no_mail_server',\n context: request.context ?? null,\n hostId: request.hostId ?? null,\n ...(deps.firestore ? { firestore: deps.firestore } : {}),\n }).catch((error) => {\n console.error('[deliverability] a refused address could not be suppressed', error)\n })\n } else if (isGatewayHeldVerdict(verdict) && verdict.gateway) {\n answer.held.push({ email, gateway: verdict.gateway.gateway, reason: deciding.message })\n }\n }\n return answer\n}\n\n/**\n * Puts the preflight on `sendEmail`'s path. **Called at module load**, from\n * the bottom of this file, for the reason `installEmailSendGovernor` is.\n * Idempotent; the closure holds no state of its own.\n */\nexport function installEmailDeliverabilityPreflight(): void {\n // A harness that stands a partial email library in for the real one\n // still loads this module; the seam is simply not there to install on.\n if (typeof setEmailDeliverabilityPreflight !== 'function') return\n setEmailDeliverabilityPreflight((request) => runEmailDeliverabilityPreflight(request))\n}\n\n/*==========================================\n * WHAT THE PROVIDER'S EVENTS TEACH THE LEDGER\n *==========================================*/\n\n/** The events the ledger learns from, keyed the way the delivery log decides \"first\". */\nconst outcomeKey = (event: { providerMessageId: string; to: string; type: string }) =>\n `${event.providerMessageId}|${event.to}|${event.type}`\n\n/**\n * Counts what a Resend webhook reported on the sending domain's ledger: a\n * delivery credits the gateway in front of the recipient, and a permanent\n * bounce that reads as the gateway refusing the SENDER — not an unknown\n * address — counts as a block, filed under the gateway the bounce names\n * (its remote server, or its own words) or else the domain's MX. Only an\n * event the delivery log saw for the first time counts, so a redelivered\n * webhook counts nothing twice. Answers how many outcomes it recorded.\n */\nexport async function recordDeliverabilityFromDeliveryEvents(\n events: readonly EmailDeliveryEvent[],\n outcomes: readonly EmailDeliveryEventOutcome[] | null,\n deps: MailDeliverabilityDeps = {},\n): Promise<number> {\n const first = new Set((outcomes ?? []).filter((outcome) => outcome.firstOfType).map(outcomeKey))\n const counted = events.filter((event) => {\n if (!first.has(outcomeKey(event))) return false\n if (!normalizeLedgerSendingDomain(bareSenderAddress(event.from))) return false\n if (event.type === 'delivered') return true\n return (\n event.type === 'bounced' &&\n event.bounceType === 'permanent' &&\n isMailGatewayBlock({ status: event.bounceStatus ?? null, diagnostic: event.detail })\n )\n })\n if (!counted.length) return 0\n const intel = await readMailDomains(\n counted.map((event) => event.to.slice(event.to.lastIndexOf('@') + 1)),\n deps,\n ).catch(() => new Map<string, MailDomainIntel | null>())\n let recorded = 0\n for (const event of counted) {\n const sender = bareSenderAddress(event.from) as string\n const sendingDomain = sender.slice(sender.lastIndexOf('@') + 1)\n const known = intel.get(event.to.slice(event.to.lastIndexOf('@') + 1))?.gateway ?? null\n const gateway =\n event.type === 'bounced'\n ? (mailGatewayOfHost(event.remoteMta) ?? readBounceText(event.detail).gateway ?? known)\n : known\n if (!gateway || gateway === 'none') continue\n // `other` is many unrelated exchanges and never holds, so a delivery\n // there would be a write that decides nothing; a refusal is still kept.\n if (event.type === 'delivered' && gateway === 'other') continue\n await recordMailGatewayOutcome(\n null,\n {\n sendingDomain,\n gateway,\n outcome: event.type === 'bounced' ? 'blocked' : 'delivered',\n atMs: event.at,\n detail: event.type === 'bounced' ? event.detail : null,\n },\n deps,\n )\n recorded += 1\n }\n return recorded\n}\n\n/*==========================================\n * THE STAFF READ\n *==========================================*/\n\n/** One row of the platform ledger as the staff console shows it. */\nexport interface MailGatewayLedgerRow extends MailGatewayLedger {\n id: string\n /** A pooled or platform sending domain: a block there hurts every tenant. */\n shared: boolean\n standing: MailGatewayStanding\n /** Whether bulk mail from this domain into this gateway is held today. */\n holds: boolean\n}\n\n/** One stored top-level ledger as a staff row; `null` for an unreadable one. */\nexport function mailGatewayLedgerRow(\n id: string,\n data: Record<string, unknown> | null | undefined,\n nowMs: number,\n): MailGatewayLedgerRow | null {\n const ledger = readStoredMailGatewayLedger(id, data)\n if (!ledger) return null\n const standing = mailGatewayStanding(ledger.gateway, ledger, nowMs)\n return {\n ...ledger,\n id,\n shared: data?.['shared'] === true || isSharedMailSendingDomain(ledger.sendingDomain),\n standing,\n holds: mailGatewayHolds(standing),\n }\n}\n\n/** The first instant of the hold's window: midnight UTC of its oldest day. */\nexport function mailGatewayWindowStartMs(nowMs: number): number {\n return Date.parse(`${mailGatewayDay(nowMs - (MAIL_GATEWAY_WINDOW_DAYS - 1) * 86_400_000)}T00:00:00.000Z`)\n}\n\n/** The most rows the refused-lately read returns; past it the answer says it was cut. */\nexport const MAIL_GATEWAY_REFUSED_READ_LIMIT = 500\n\n/**\n * Every top-level ledger with a refusal inside the hold's window, most\n * recent refusal first — the complete set a hold can come from, since a\n * hold needs refusals in that window and `lastBlockedAtMs` is the latest of\n * them. One range on one field, which the automatic index serves; nothing\n * is filtered after the read. `truncated` when there were more than `limit`.\n */\nexport async function listRecentlyRefusedMailGatewayLedgers(\n options: { limit?: number } = {},\n deps: MailDeliverabilityDeps = {},\n): Promise<{ rows: MailGatewayLedgerRow[]; sinceMs: number; truncated: boolean }> {\n const nowMs = deps.nowMs ?? Date.now()\n const firestore = deps.firestore ?? defaultFirestore()\n const limit = Math.min(Math.max(1, Math.floor(Number(options.limit) || MAIL_GATEWAY_REFUSED_READ_LIMIT)), MAIL_GATEWAY_REFUSED_READ_LIMIT)\n const sinceMs = mailGatewayWindowStartMs(nowMs)\n const snapshot = await firestore\n .collection(MAIL_GATEWAY_LEDGER_COLLECTION)\n .where('lastBlockedAtMs', '>=', sinceMs)\n .orderBy('lastBlockedAtMs', 'desc')\n .limit(limit + 1)\n .get()\n const rows: MailGatewayLedgerRow[] = []\n for (const doc of snapshot.docs.slice(0, limit)) {\n const row = mailGatewayLedgerRow(doc.id, doc.data() as Record<string, unknown>, nowMs)\n if (row) rows.push(row)\n }\n return { rows, sinceMs, truncated: snapshot.docs.length > limit }\n}\n\ninstallEmailDeliverabilityPreflight()\n"],"names":["isPublicMailboxDomain","applyMailGatewayOutcome","assessEmailDeliverability","decidingDeliverabilityFinding","getEmailConfig","isDeliverabilityDomain","isMailDomainIntelFresh","isMailGatewayBlock","isSharedSendingDomain","mailGatewayLedgerKey","mailGatewayOfHost","mailGatewayStanding","mailDomainRefusesMail","MAIL_GATEWAY_DAILY_DELIVERY_CAP","mailGatewayDay","mailGatewayHolds","mailGatewayHoldSentence","MAIL_GATEWAY_LABELS","MAIL_GATEWAY_WINDOW_DAYS","normalizeDeliverabilityEmail","normalizeLedgerSendingDomain","readBounceText","readStoredMailDomainIntel","readStoredMailGatewayLedger","resolveMailDomain","scrubBounceDiagnostic","setEmailDeliverabilityPreflight","bareSenderAddress","promises","dns","isConclusiveDnsCode","lookupAddress","lookupMx","suppressEmail","firebaseAdmin","MAIL_DOMAINS_COLLECTION","MAIL_GATEWAY_LEDGER_COLLECTION","defaultFirestore","app","firestore","GET_ALL_CHUNK","LOOKUP_CONCURRENCY","unanswered","domain","what","Object","assign","Error","code","platformMailDnsResolver","resolveMx","pinned","answered","records","length","map","record","exchange","String","trim","toLowerCase","replace","priority","Number","error","resolveAddress","result","domainMemory","Map","DOMAIN_MEMORY_MAX","rememberDomain","intel","has","delete","size","oldest","keys","next","value","undefined","set","ledgerMemory","LEDGER_MEMORY_MS","LEDGER_MEMORY_MAX","rememberLedger","path","ledger","nowMs","readAtMs","resetMailDeliverabilityMemoryForTests","clear","eachLimited","items","limit","work","runners","Array","from","Math","min","item","Promise","all","readMailDomains","domains","deps","Date","now","answers","wanted","Set","filter","missing","remembered","get","push","collection","stored","start","chunk","slice","snapshots","getAll","doc","forEach","index","snapshot","exists","data","console","stale","answer","resolver","previous","fresh","warn","message","status","mx","gateway","resolvedAtMs","ledgerCollection","scope","orgId","ledgerPath","key","platformFromDomain","address","lastIndexOf","isSharedMailSendingDomain","name","readMailGatewayLedgers","sendingDomain","gateways","unread","recordMailGatewayOutcome","input","outcome","today","days","atMs","delivered","ref","written","runTransaction","transaction","current","count","detail","shared","standing","raiseOperatorAlert","dedupeKey","context","blocked30","readMailDeliverability","question","normalized","email","emails","values","Boolean","ledgers","entry","verdicts","given","purpose","publicMailbox","holdReleased","isNoMailServerVerdict","verdict","deciding","isGatewayHeldVerdict","filterDeliverableRecipients","deliverable","noMailServer","gatewayHeld","runEmailDeliverabilityPreflight","request","recipients","refused","held","reason","hostId","catch","installEmailDeliverabilityPreflight","outcomeKey","event","providerMessageId","to","type","recordDeliverabilityFromDeliveryEvents","events","outcomes","first","firstOfType","counted","bounceType","bounceStatus","diagnostic","recorded","sender","known","remoteMta","at","mailGatewayLedgerRow","id","holds","mailGatewayWindowStartMs","parse","MAIL_GATEWAY_REFUSED_READ_LIMIT","listRecentlyRefusedMailGatewayLedgers","options","max","floor","sinceMs","where","orderBy","rows","docs","row","truncated"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-deliverability.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE PLATFORM'S DELIVERABILITY STORE (AGL-3328).\n *\n * The durable half of `@aglyn/shared-util-email`'s deliverability engine:\n * the DNS lookups, the cache and the ledger, and the preflight this module\n * installs on `sendEmail` when it loads — the way `email-send-rate` installs\n * the governor, so every server surface that imports this library's barrel\n * gets it without a call at each entrypoint.\n *\n * ## Two collections at the top level, one under an organization\n *\n * - `mailDomains/{domain}` — a recipient domain's MX answer. A fact about\n * the world, not about an organization, so it is cached ONCE for the whole\n * platform: the first organization to write to a domain warms it for every\n * other. A week for a domain that takes mail, a day for one that does not.\n * - `mailGatewayLedger/{sendingDomain}~{gateway}` — what a gateway did with\n * one sending domain's mail: the Resend path's ledger. A Resend sending\n * domain is one name on one provider account — the platform's own, a\n * shared pool member, or one site's verified domain — so the domain alone\n * is the key, and a pooled or platform domain is flagged `shared`: a block\n * there hurts every tenant, and the staff console shows it.\n * - `orgs/{orgId}/mailGatewayLedger/{sendingDomain}~{gateway}` — the same\n * ledger for a connected mailbox (Sequences). A mailbox's domain is not\n * one organization's — two workspaces can each connect a `gmail.com`\n * mailbox — so its record stays with the organization that sends from it.\n *\n * All three are server-written; clients read them only as the rules allow,\n * staff for the top level and members for their organization's.\n *\n * ## Failing open, and asking twice before saying no\n *\n * Every read here fails OPEN: a resolver that cannot answer, a store that\n * cannot be read, is \"unknown\", and unknown sends. The one answer that stops\n * mail — \"this domain has no MX\" — is asked of two resolvers, the pinned\n * public ones and the runtime's own, and stands only when both agree; and a\n * stale \"no MX\" is never reused when a fresh lookup cannot be made.\n *==========================================*/\n\nimport { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport {\n applyMailGatewayOutcome,\n assessEmailDeliverability,\n decidingDeliverabilityFinding,\n type EmailDeliverabilityPreflightRequest,\n type EmailDeliverabilityPreflightVerdict,\n type EmailDeliverabilityVerdict,\n type EmailDeliveryEvent,\n type EmailSendPurpose,\n getEmailConfig,\n isDeliverabilityDomain,\n isMailDomainIntelFresh,\n isMailGatewayBlock,\n isSharedSendingDomain,\n type MailDnsResolver,\n type MailDomainIntel,\n type MailGateway,\n type MailGatewayLedger,\n mailGatewayLedgerKey,\n mailGatewayOfHost,\n type MailGatewayOutcome,\n mailGatewayStanding,\n type MailGatewayStanding,\n mailDomainRefusesMail,\n MAIL_GATEWAY_DAILY_DELIVERY_CAP,\n mailGatewayDay,\n mailGatewayHolds,\n mailGatewayHoldSentence,\n MAIL_GATEWAY_LABELS,\n MAIL_GATEWAY_WINDOW_DAYS,\n normalizeDeliverabilityEmail,\n normalizeLedgerSendingDomain,\n readBounceText,\n readStoredMailDomainIntel,\n readStoredMailGatewayLedger,\n resolveMailDomain,\n scrubBounceDiagnostic,\n setEmailDeliverabilityPreflight,\n bareSenderAddress,\n} from '@aglyn/shared-util-email'\nimport { promises as dns } from 'dns'\nimport { isConclusiveDnsCode, lookupAddress, lookupMx } from './dns-probe'\nimport type { EmailDeliveryEventOutcome } from './email-delivery-log'\nimport { accountBannedRecipients, suppressEmail } from './email-suppression'\nimport { firebaseAdmin } from './firebase-admin'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The platform MX cache, keyed by recipient domain. */\nexport const MAIL_DOMAINS_COLLECTION = 'mailDomains'\n\n/** The gateway ledger, at the top level and under an organization — see the module note. */\nexport const MAIL_GATEWAY_LEDGER_COLLECTION = 'mailGatewayLedger'\n\n/**\n * Where a ledger lives: `null` for the top level (a Resend sending domain),\n * or the organization a connected mailbox belongs to.\n */\nexport type MailGatewayLedgerScope = { orgId: string } | null\n\n/** What the reads and writes here need beside their input; every field defaults to the platform's. */\nexport interface MailDeliverabilityDeps {\n firestore?: Firestore\n resolver?: MailDnsResolver\n nowMs?: number\n}\n\nconst defaultFirestore = (): Firestore => firebaseAdmin.app().firestore() as Firestore\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\n/** Domains looked up at once when a batch is cold. */\nconst LOOKUP_CONCURRENCY = 16\n\n/*==========================================\n * DNS\n *==========================================*/\n\n/** A resolver rejection that means nobody answered, which every caller reads as \"unknown\". */\nfunction unanswered(domain: string, what: string): Error {\n return Object.assign(new Error(`the ${what} lookup for ${domain} went unanswered`), { code: 'EUNANSWERED' })\n}\n\n/**\n * The platform's resolver: the pinned public resolvers first, and — for the\n * one answer that stops mail, \"no MX\" — the runtime's own as a second\n * opinion that must agree. See the module note.\n */\nexport function platformMailDnsResolver(): MailDnsResolver {\n return {\n async resolveMx(domain) {\n const pinned = await lookupMx(domain)\n if (!pinned.answered) throw unanswered(domain, 'MX')\n if (pinned.records.length) return pinned.records\n try {\n return (await dns.resolveMx(domain)).map((record) => ({\n exchange: String(record?.exchange ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.$/, ''),\n priority: Number(record?.priority) || 0,\n }))\n } catch (error) {\n if (isConclusiveDnsCode((error as NodeJS.ErrnoException)?.code)) return []\n throw unanswered(domain, 'MX')\n }\n },\n async resolveAddress(domain) {\n const result = await lookupAddress(domain)\n if (!result.answered) throw unanswered(domain, 'address')\n return result.records.length > 0\n },\n }\n}\n\n/*==========================================\n * THE MX CACHE\n *==========================================*/\n\n/**\n * A process-lifetime copy of the answers already read, so a campaign's\n * five hundred messages to one domain ask the store once. Bounded, oldest\n * out first; trusted exactly as long as the stored answer is.\n */\nconst domainMemory = new Map<string, MailDomainIntel>()\nconst DOMAIN_MEMORY_MAX = 5_000\n\nfunction rememberDomain(intel: MailDomainIntel): void {\n if (domainMemory.has(intel.domain)) domainMemory.delete(intel.domain)\n else if (domainMemory.size >= DOMAIN_MEMORY_MAX) {\n const oldest = domainMemory.keys().next().value\n if (oldest !== undefined) domainMemory.delete(oldest)\n }\n domainMemory.set(intel.domain, intel)\n}\n\n/** A ledger read, remembered for a minute so a batch does not re-read it per message. */\nconst ledgerMemory = new Map<string, { ledger: MailGatewayLedger | null; readAtMs: number }>()\nconst LEDGER_MEMORY_MS = 60_000\nconst LEDGER_MEMORY_MAX = 2_000\n\nfunction rememberLedger(path: string, ledger: MailGatewayLedger | null, nowMs: number): void {\n if (ledgerMemory.has(path)) ledgerMemory.delete(path)\n else if (ledgerMemory.size >= LEDGER_MEMORY_MAX) {\n const oldest = ledgerMemory.keys().next().value\n if (oldest !== undefined) ledgerMemory.delete(oldest)\n }\n ledgerMemory.set(path, { ledger, readAtMs: nowMs })\n}\n\n/** Test seam: forget everything remembered in this process. */\nexport function resetMailDeliverabilityMemoryForTests(): void {\n domainMemory.clear()\n ledgerMemory.clear()\n}\n\n/** Runs `work` over `items`, at most `limit` at a time. */\nasync function eachLimited<T>(items: readonly T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {\n let next = 0\n const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {\n while (next < items.length) {\n const item = items[next]\n next += 1\n await work(item)\n }\n })\n await Promise.all(runners)\n}\n\n/**\n * Each domain's MX answer, keyed by domain: remembered, stored and fresh, or\n * looked up and stored. `null` for a domain that could not be looked up and\n * has no answer that may stand — see the module note.\n */\nexport async function readMailDomains(\n domains: readonly string[],\n deps: MailDeliverabilityDeps = {},\n): Promise<Map<string, MailDomainIntel | null>> {\n const nowMs = deps.nowMs ?? Date.now()\n const answers = new Map<string, MailDomainIntel | null>()\n const wanted = [...new Set(domains.map((domain) => String(domain ?? '').trim().toLowerCase()))].filter(\n isDeliverabilityDomain,\n )\n const missing: string[] = []\n for (const domain of wanted) {\n const remembered = domainMemory.get(domain)\n if (remembered && isMailDomainIntelFresh(remembered, nowMs)) answers.set(domain, remembered)\n else missing.push(domain)\n }\n if (!missing.length) return answers\n\n const firestore = deps.firestore ?? defaultFirestore()\n const collection = firestore.collection(MAIL_DOMAINS_COLLECTION)\n const stored = new Map<string, MailDomainIntel | null>()\n try {\n for (let start = 0; start < missing.length; start += GET_ALL_CHUNK) {\n const chunk = missing.slice(start, start + GET_ALL_CHUNK)\n const snapshots = await firestore.getAll(...chunk.map((domain) => collection.doc(domain)))\n chunk.forEach((domain, index) => {\n const snapshot = snapshots[index]\n stored.set(domain, readStoredMailDomainIntel(domain, snapshot?.exists ? snapshot.data() : undefined))\n })\n }\n } catch (error) {\n console.error('[deliverability] the MX cache could not be read; looking the domains up', error)\n }\n\n const stale: string[] = []\n for (const domain of missing) {\n const answer = stored.get(domain) ?? null\n if (answer && isMailDomainIntelFresh(answer, nowMs)) {\n answers.set(domain, answer)\n rememberDomain(answer)\n } else {\n stale.push(domain)\n }\n }\n\n const resolver = deps.resolver ?? platformMailDnsResolver()\n await eachLimited(stale, LOOKUP_CONCURRENCY, async (domain) => {\n const previous = stored.get(domain) ?? null\n let fresh: MailDomainIntel\n try {\n fresh = await resolveMailDomain(resolver, domain, nowMs)\n } catch (error) {\n // Nobody answered. A stale \"takes mail\" still says the domain takes\n // mail; a stale \"takes none\" is not reused to stop a send.\n console.warn(`[deliverability] the MX of ${domain} could not be looked up`, (error as Error)?.message ?? error)\n answers.set(domain, previous && !mailDomainRefusesMail(previous.status) ? previous : null)\n return\n }\n answers.set(domain, fresh)\n rememberDomain(fresh)\n try {\n await collection.doc(domain).set({\n domain,\n status: fresh.status,\n mx: fresh.mx,\n gateway: fresh.gateway,\n resolvedAtMs: fresh.resolvedAtMs,\n })\n } catch (error) {\n // The cache is a convenience; the answer is already in hand.\n console.error(`[deliverability] the MX of ${domain} could not be cached`, error)\n }\n })\n return answers\n}\n\n/*==========================================\n * THE LEDGER\n *==========================================*/\n\nfunction ledgerCollection(firestore: Firestore, scope: MailGatewayLedgerScope) {\n return scope\n ? firestore.collection('orgs').doc(scope.orgId).collection(MAIL_GATEWAY_LEDGER_COLLECTION)\n : firestore.collection(MAIL_GATEWAY_LEDGER_COLLECTION)\n}\n\nfunction ledgerPath(scope: MailGatewayLedgerScope, key: string): string {\n return scope ? `orgs/${scope.orgId}/${MAIL_GATEWAY_LEDGER_COLLECTION}/${key}` : `${MAIL_GATEWAY_LEDGER_COLLECTION}/${key}`\n}\n\n/** The platform's own sending domain: `USAGE_EMAIL_FROM`'s. */\nfunction platformFromDomain(): string | null {\n const address = bareSenderAddress(getEmailConfig().from)\n return address ? address.slice(address.lastIndexOf('@') + 1) : null\n}\n\n/**\n * Whether a block on this sending domain hurts every tenant — Aglyn's own\n * domain or a shared pool member — which the staff console surfaces first.\n */\nexport function isSharedMailSendingDomain(domain: string | null | undefined): boolean {\n const name = normalizeLedgerSendingDomain(domain)\n if (!name) return false\n return isSharedSendingDomain(name) || name === platformFromDomain()\n}\n\n/** The ledger of each gateway named for one sending domain, keyed by gateway; `null` where none exists yet. */\nexport async function readMailGatewayLedgers(\n scope: MailGatewayLedgerScope,\n sendingDomain: string,\n gateways: readonly MailGateway[],\n deps: MailDeliverabilityDeps = {},\n): Promise<Map<MailGateway, MailGatewayLedger | null>> {\n const nowMs = deps.nowMs ?? Date.now()\n const domain = normalizeLedgerSendingDomain(sendingDomain)\n const answers = new Map<MailGateway, MailGatewayLedger | null>()\n const wanted = [...new Set(gateways)].filter((gateway) => gateway !== 'none')\n if (!domain || !wanted.length) return answers\n const unread: MailGateway[] = []\n for (const gateway of wanted) {\n const remembered = ledgerMemory.get(ledgerPath(scope, mailGatewayLedgerKey(domain, gateway)))\n if (remembered && remembered.readAtMs + LEDGER_MEMORY_MS > nowMs) answers.set(gateway, remembered.ledger)\n else unread.push(gateway)\n }\n if (!unread.length) return answers\n const firestore = deps.firestore ?? defaultFirestore()\n const collection = ledgerCollection(firestore, scope)\n const keys = unread.map((gateway) => mailGatewayLedgerKey(domain, gateway))\n const snapshots = await firestore.getAll(...keys.map((key) => collection.doc(key)))\n unread.forEach((gateway, index) => {\n const snapshot = snapshots[index]\n const ledger = readStoredMailGatewayLedger(keys[index], snapshot?.exists ? snapshot.data() : undefined)\n answers.set(gateway, ledger)\n rememberLedger(ledgerPath(scope, keys[index]), ledger, nowMs)\n })\n return answers\n}\n\n/**\n * Counts one outcome on a sending domain's ledger with a gateway. Never\n * throws: the send or the bounce it records has already happened, and a\n * lost count understates a gateway while a thrown one loses the step that\n * called. A delivery past the day's cap costs no write at all.\n */\nexport async function recordMailGatewayOutcome(\n scope: MailGatewayLedgerScope,\n input: {\n sendingDomain: string\n gateway: MailGateway\n outcome: MailGatewayOutcome\n count?: number\n atMs: number\n /** A refusal's diagnostic, scrubbed here of addresses before it is kept. */\n detail?: string | null\n },\n deps: MailDeliverabilityDeps = {},\n): Promise<void> {\n const sendingDomain = normalizeLedgerSendingDomain(input.sendingDomain)\n if (!sendingDomain || input.gateway === 'none') return\n const key = mailGatewayLedgerKey(sendingDomain, input.gateway)\n const path = ledgerPath(scope, key)\n if (input.outcome === 'delivered') {\n const remembered = ledgerMemory.get(path)?.ledger\n const today = remembered?.days?.[mailGatewayDay(input.atMs)]\n if ((today?.delivered ?? 0) >= MAIL_GATEWAY_DAILY_DELIVERY_CAP) return\n }\n try {\n const firestore = deps.firestore ?? defaultFirestore()\n const ref = ledgerCollection(firestore, scope).doc(key)\n const written = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const current = readStoredMailGatewayLedger(key, snapshot.exists ? snapshot.data() : undefined)\n const next = applyMailGatewayOutcome(current, {\n sendingDomain,\n gateway: input.gateway,\n outcome: input.outcome,\n count: input.count,\n atMs: input.atMs,\n detail: scrubBounceDiagnostic(input.detail),\n })\n if (!next) return current\n transaction.set(ref, {\n ...next,\n ...(scope ? { orgId: scope.orgId } : { shared: isSharedMailSendingDomain(sendingDomain) }),\n })\n return next\n })\n rememberLedger(path, written ?? null, input.atMs)\n // A named gateway that now holds a SHARED domain stops every site on it\n // from reaching everyone behind that gateway, and no single workspace\n // owns the problem to notice it (AGL-3377). Once a day per pair.\n if (!scope && input.outcome !== 'delivered' && written && isSharedMailSendingDomain(sendingDomain)) {\n const standing = mailGatewayStanding(input.gateway, written, input.atMs)\n if (mailGatewayHolds(standing)) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert('deliverability.gatewayBlocking', {\n dedupeKey: key,\n context: {\n gateway: MAIL_GATEWAY_LABELS[input.gateway] ?? input.gateway,\n domain: sendingDomain,\n detail: mailGatewayHoldSentence(input.gateway, standing.blocked30),\n },\n })\n }\n }\n } catch (error) {\n console.error(`[deliverability] the ledger could not count a ${input.outcome} for ${sendingDomain} at ${input.gateway}`, error)\n }\n}\n\n/*==========================================\n * THE VERDICTS\n *==========================================*/\n\n/** What a batch read needs to know about the mail being sent. */\nexport interface MailDeliverabilityQuestion {\n emails: readonly string[]\n purpose: EmailSendPurpose\n /** The domain the mail leaves from; without one, no ledger is read and nothing is held. */\n sendingDomain: string | null\n scope: MailGatewayLedgerScope\n /** Addresses a person already released the gateway hold for. */\n holdReleased?: (email: string) => boolean\n}\n\n/**\n * The deliverability verdict for every address, keyed by the address as\n * given: the MX answers read (and refreshed) above, joined to the sending\n * domain's ledger with each gateway they name. A ledger that cannot be read\n * reads as no history, which holds nothing.\n */\nexport async function readMailDeliverability(\n question: MailDeliverabilityQuestion,\n deps: MailDeliverabilityDeps = {},\n): Promise<Map<string, EmailDeliverabilityVerdict>> {\n const nowMs = deps.nowMs ?? Date.now()\n const normalized = new Map<string, string | null>()\n for (const email of question.emails) normalized.set(email, normalizeDeliverabilityEmail(email))\n const domains = [...normalized.values()]\n .filter((email): email is string => Boolean(email))\n .map((email) => email.slice(email.lastIndexOf('@') + 1))\n const intel = await readMailDomains(domains, { ...deps, nowMs })\n\n const sendingDomain = normalizeLedgerSendingDomain(question.sendingDomain)\n let ledgers = new Map<MailGateway, MailGatewayLedger | null>()\n if (sendingDomain) {\n const gateways = [...intel.values()]\n .filter((entry): entry is MailDomainIntel => Boolean(entry))\n .map((entry) => entry.gateway)\n try {\n ledgers = await readMailGatewayLedgers(question.scope, sendingDomain, gateways, { ...deps, nowMs })\n } catch (error) {\n console.error('[deliverability] the gateway ledger could not be read; reading as no history', error)\n }\n }\n\n const verdicts = new Map<string, EmailDeliverabilityVerdict>()\n for (const [given, email] of normalized) {\n const domain = email ? email.slice(email.lastIndexOf('@') + 1) : null\n const answer = domain ? (intel.get(domain) ?? null) : null\n const standing: MailGatewayStanding | null =\n answer && sendingDomain ? mailGatewayStanding(answer.gateway, ledgers.get(answer.gateway) ?? null, nowMs) : null\n verdicts.set(\n given,\n assessEmailDeliverability({\n email: given,\n purpose: question.purpose,\n intel: answer,\n gateway: standing,\n publicMailbox: domain ? isPublicMailboxDomain(domain) : false,\n holdReleased: email ? question.holdReleased?.(email) === true : false,\n }),\n )\n }\n return verdicts\n}\n\n/** Whether a verdict refuses because the domain takes no mail — the refusal that suppresses. */\nexport function isNoMailServerVerdict(verdict: EmailDeliverabilityVerdict | null | undefined): boolean {\n const deciding = verdict ? decidingDeliverabilityFinding(verdict) : null\n return deciding?.code === 'no_mx' || deciding?.code === 'null_mx'\n}\n\n/** Whether a verdict holds bulk mail for its gateway. */\nexport function isGatewayHeldVerdict(verdict: EmailDeliverabilityVerdict | null | undefined): boolean {\n const deciding = verdict ? decidingDeliverabilityFinding(verdict) : null\n return deciding?.code === 'gateway_held'\n}\n\n/** A campaign's audience after the checks, with who was excluded and why. */\nexport interface DeliverableRecipients {\n deliverable: string[]\n /** Addresses whose domain takes no mail. */\n noMailServer: string[]\n /** Addresses behind a gateway that refused this sending domain twice lately. */\n gatewayHeld: string[]\n}\n\n/**\n * A bulk audience with the addresses that would bounce, or that sit behind\n * a gateway holding this sending domain, taken out — the count a campaign's\n * review shows before Send, and the filter its batches run. Fails open: an\n * error keeps everyone.\n */\nexport async function filterDeliverableRecipients(\n input: { emails: readonly string[]; sendingDomain: string | null; purpose?: EmailSendPurpose },\n deps: MailDeliverabilityDeps = {},\n): Promise<DeliverableRecipients> {\n const answer: DeliverableRecipients = { deliverable: [], noMailServer: [], gatewayHeld: [] }\n let verdicts: Map<string, EmailDeliverabilityVerdict>\n try {\n verdicts = await readMailDeliverability(\n { emails: input.emails, purpose: input.purpose ?? 'bulk', sendingDomain: input.sendingDomain, scope: null },\n deps,\n )\n } catch (error) {\n console.error('[deliverability] the audience could not be checked; keeping everyone', error)\n return { ...answer, deliverable: [...input.emails] }\n }\n for (const email of input.emails) {\n const verdict = verdicts.get(email)\n if (isNoMailServerVerdict(verdict)) answer.noMailServer.push(email)\n else if (isGatewayHeldVerdict(verdict)) answer.gatewayHeld.push(email)\n else answer.deliverable.push(email)\n }\n return answer\n}\n\n/*==========================================\n * THE PREFLIGHT ON `sendEmail`\n *==========================================*/\n\n/**\n * What `sendEmail` asks before a message leaves — see the seam in\n * `@aglyn/shared-util-email`. Refuses every recipient whose domain takes no\n * mail, and files the address on the platform suppression list with its own\n * reason; holds bulk mail behind a gateway that refused this sending domain\n * twice. The Resend path's ledger is keyed by the sending domain alone.\n */\nexport async function runEmailDeliverabilityPreflight(\n request: EmailDeliverabilityPreflightRequest,\n deps: MailDeliverabilityDeps = {},\n): Promise<EmailDeliverabilityPreflightVerdict> {\n const answer: EmailDeliverabilityPreflightVerdict = { refused: [], held: [] }\n /*\n * A BANNED ACCOUNT FIRST (AGL-3420), and for every purpose: after its ban\n * notice it is sent nothing — no receipt, no reset, no site's mail —\n * which is the one refusal on this path that transactional mail does not\n * get past. Only a lock or lift notice says it is exempt.\n */\n const banned = request.accountBanExempt\n ? new Set<string>()\n : await accountBannedRecipients(request.recipients, deps.firestore)\n for (const email of banned) {\n answer.refused.push({\n email,\n code: 'account_banned',\n reason: 'This address belongs to a closed account and is not sent mail.',\n })\n }\n const recipients = request.recipients.filter((email) => !banned.has(email))\n if (!recipients.length) return answer\n const verdicts = await readMailDeliverability(\n {\n emails: recipients,\n purpose: request.purpose,\n sendingDomain: request.sendingDomain,\n scope: null,\n },\n deps,\n )\n for (const [email, verdict] of verdicts) {\n const deciding = decidingDeliverabilityFinding(verdict)\n if (!deciding) continue\n if (isNoMailServerVerdict(verdict)) {\n answer.refused.push({ email, code: deciding.code, reason: deciding.message })\n await suppressEmail({\n email,\n reason: 'no_mail_server',\n context: request.context ?? null,\n hostId: request.hostId ?? null,\n ...(deps.firestore ? { firestore: deps.firestore } : {}),\n }).catch((error) => {\n console.error('[deliverability] a refused address could not be suppressed', error)\n })\n } else if (isGatewayHeldVerdict(verdict) && verdict.gateway) {\n answer.held.push({ email, gateway: verdict.gateway.gateway, reason: deciding.message })\n }\n }\n return answer\n}\n\n/**\n * Puts the preflight on `sendEmail`'s path. **Called at module load**, from\n * the bottom of this file, for the reason `installEmailSendGovernor` is.\n * Idempotent; the closure holds no state of its own.\n */\nexport function installEmailDeliverabilityPreflight(): void {\n // A harness that stands a partial email library in for the real one\n // still loads this module; the seam is simply not there to install on.\n if (typeof setEmailDeliverabilityPreflight !== 'function') return\n setEmailDeliverabilityPreflight((request) => runEmailDeliverabilityPreflight(request))\n}\n\n/*==========================================\n * WHAT THE PROVIDER'S EVENTS TEACH THE LEDGER\n *==========================================*/\n\n/** The events the ledger learns from, keyed the way the delivery log decides \"first\". */\nconst outcomeKey = (event: { providerMessageId: string; to: string; type: string }) =>\n `${event.providerMessageId}|${event.to}|${event.type}`\n\n/**\n * Counts what a Resend webhook reported on the sending domain's ledger: a\n * delivery credits the gateway in front of the recipient, and a permanent\n * bounce that reads as the gateway refusing the SENDER — not an unknown\n * address — counts as a block, filed under the gateway the bounce names\n * (its remote server, or its own words) or else the domain's MX. Only an\n * event the delivery log saw for the first time counts, so a redelivered\n * webhook counts nothing twice. Answers how many outcomes it recorded.\n */\nexport async function recordDeliverabilityFromDeliveryEvents(\n events: readonly EmailDeliveryEvent[],\n outcomes: readonly EmailDeliveryEventOutcome[] | null,\n deps: MailDeliverabilityDeps = {},\n): Promise<number> {\n const first = new Set((outcomes ?? []).filter((outcome) => outcome.firstOfType).map(outcomeKey))\n const counted = events.filter((event) => {\n if (!first.has(outcomeKey(event))) return false\n if (!normalizeLedgerSendingDomain(bareSenderAddress(event.from))) return false\n if (event.type === 'delivered') return true\n return (\n event.type === 'bounced' &&\n event.bounceType === 'permanent' &&\n isMailGatewayBlock({ status: event.bounceStatus ?? null, diagnostic: event.detail })\n )\n })\n if (!counted.length) return 0\n const intel = await readMailDomains(\n counted.map((event) => event.to.slice(event.to.lastIndexOf('@') + 1)),\n deps,\n ).catch(() => new Map<string, MailDomainIntel | null>())\n let recorded = 0\n for (const event of counted) {\n const sender = bareSenderAddress(event.from) as string\n const sendingDomain = sender.slice(sender.lastIndexOf('@') + 1)\n const known = intel.get(event.to.slice(event.to.lastIndexOf('@') + 1))?.gateway ?? null\n const gateway =\n event.type === 'bounced'\n ? (mailGatewayOfHost(event.remoteMta) ?? readBounceText(event.detail).gateway ?? known)\n : known\n if (!gateway || gateway === 'none') continue\n // `other` is many unrelated exchanges and never holds, so a delivery\n // there would be a write that decides nothing; a refusal is still kept.\n if (event.type === 'delivered' && gateway === 'other') continue\n await recordMailGatewayOutcome(\n null,\n {\n sendingDomain,\n gateway,\n outcome: event.type === 'bounced' ? 'blocked' : 'delivered',\n atMs: event.at,\n detail: event.type === 'bounced' ? event.detail : null,\n },\n deps,\n )\n recorded += 1\n }\n return recorded\n}\n\n/*==========================================\n * THE STAFF READ\n *==========================================*/\n\n/** One row of the platform ledger as the staff console shows it. */\nexport interface MailGatewayLedgerRow extends MailGatewayLedger {\n id: string\n /** A pooled or platform sending domain: a block there hurts every tenant. */\n shared: boolean\n standing: MailGatewayStanding\n /** Whether bulk mail from this domain into this gateway is held today. */\n holds: boolean\n}\n\n/** One stored top-level ledger as a staff row; `null` for an unreadable one. */\nexport function mailGatewayLedgerRow(\n id: string,\n data: Record<string, unknown> | null | undefined,\n nowMs: number,\n): MailGatewayLedgerRow | null {\n const ledger = readStoredMailGatewayLedger(id, data)\n if (!ledger) return null\n const standing = mailGatewayStanding(ledger.gateway, ledger, nowMs)\n return {\n ...ledger,\n id,\n shared: data?.['shared'] === true || isSharedMailSendingDomain(ledger.sendingDomain),\n standing,\n holds: mailGatewayHolds(standing),\n }\n}\n\n/** The first instant of the hold's window: midnight UTC of its oldest day. */\nexport function mailGatewayWindowStartMs(nowMs: number): number {\n return Date.parse(`${mailGatewayDay(nowMs - (MAIL_GATEWAY_WINDOW_DAYS - 1) * 86_400_000)}T00:00:00.000Z`)\n}\n\n/** The most rows the refused-lately read returns; past it the answer says it was cut. */\nexport const MAIL_GATEWAY_REFUSED_READ_LIMIT = 500\n\n/**\n * Every top-level ledger with a refusal inside the hold's window, most\n * recent refusal first — the complete set a hold can come from, since a\n * hold needs refusals in that window and `lastBlockedAtMs` is the latest of\n * them. One range on one field, which the automatic index serves; nothing\n * is filtered after the read. `truncated` when there were more than `limit`.\n */\nexport async function listRecentlyRefusedMailGatewayLedgers(\n options: { limit?: number } = {},\n deps: MailDeliverabilityDeps = {},\n): Promise<{ rows: MailGatewayLedgerRow[]; sinceMs: number; truncated: boolean }> {\n const nowMs = deps.nowMs ?? Date.now()\n const firestore = deps.firestore ?? defaultFirestore()\n const limit = Math.min(Math.max(1, Math.floor(Number(options.limit) || MAIL_GATEWAY_REFUSED_READ_LIMIT)), MAIL_GATEWAY_REFUSED_READ_LIMIT)\n const sinceMs = mailGatewayWindowStartMs(nowMs)\n const snapshot = await firestore\n .collection(MAIL_GATEWAY_LEDGER_COLLECTION)\n .where('lastBlockedAtMs', '>=', sinceMs)\n .orderBy('lastBlockedAtMs', 'desc')\n .limit(limit + 1)\n .get()\n const rows: MailGatewayLedgerRow[] = []\n for (const doc of snapshot.docs.slice(0, limit)) {\n const row = mailGatewayLedgerRow(doc.id, doc.data() as Record<string, unknown>, nowMs)\n if (row) rows.push(row)\n }\n return { rows, sinceMs, truncated: snapshot.docs.length > limit }\n}\n\ninstallEmailDeliverabilityPreflight()\n"],"names":["isPublicMailboxDomain","applyMailGatewayOutcome","assessEmailDeliverability","decidingDeliverabilityFinding","getEmailConfig","isDeliverabilityDomain","isMailDomainIntelFresh","isMailGatewayBlock","isSharedSendingDomain","mailGatewayLedgerKey","mailGatewayOfHost","mailGatewayStanding","mailDomainRefusesMail","MAIL_GATEWAY_DAILY_DELIVERY_CAP","mailGatewayDay","mailGatewayHolds","mailGatewayHoldSentence","MAIL_GATEWAY_LABELS","MAIL_GATEWAY_WINDOW_DAYS","normalizeDeliverabilityEmail","normalizeLedgerSendingDomain","readBounceText","readStoredMailDomainIntel","readStoredMailGatewayLedger","resolveMailDomain","scrubBounceDiagnostic","setEmailDeliverabilityPreflight","bareSenderAddress","promises","dns","isConclusiveDnsCode","lookupAddress","lookupMx","accountBannedRecipients","suppressEmail","firebaseAdmin","MAIL_DOMAINS_COLLECTION","MAIL_GATEWAY_LEDGER_COLLECTION","defaultFirestore","app","firestore","GET_ALL_CHUNK","LOOKUP_CONCURRENCY","unanswered","domain","what","Object","assign","Error","code","platformMailDnsResolver","resolveMx","pinned","answered","records","length","map","record","exchange","String","trim","toLowerCase","replace","priority","Number","error","resolveAddress","result","domainMemory","Map","DOMAIN_MEMORY_MAX","rememberDomain","intel","has","delete","size","oldest","keys","next","value","undefined","set","ledgerMemory","LEDGER_MEMORY_MS","LEDGER_MEMORY_MAX","rememberLedger","path","ledger","nowMs","readAtMs","resetMailDeliverabilityMemoryForTests","clear","eachLimited","items","limit","work","runners","Array","from","Math","min","item","Promise","all","readMailDomains","domains","deps","Date","now","answers","wanted","Set","filter","missing","remembered","get","push","collection","stored","start","chunk","slice","snapshots","getAll","doc","forEach","index","snapshot","exists","data","console","stale","answer","resolver","previous","fresh","warn","message","status","mx","gateway","resolvedAtMs","ledgerCollection","scope","orgId","ledgerPath","key","platformFromDomain","address","lastIndexOf","isSharedMailSendingDomain","name","readMailGatewayLedgers","sendingDomain","gateways","unread","recordMailGatewayOutcome","input","outcome","today","days","atMs","delivered","ref","written","runTransaction","transaction","current","count","detail","shared","standing","raiseOperatorAlert","dedupeKey","context","blocked30","readMailDeliverability","question","normalized","email","emails","values","Boolean","ledgers","entry","verdicts","given","purpose","publicMailbox","holdReleased","isNoMailServerVerdict","verdict","deciding","isGatewayHeldVerdict","filterDeliverableRecipients","deliverable","noMailServer","gatewayHeld","runEmailDeliverabilityPreflight","request","refused","held","banned","accountBanExempt","recipients","reason","hostId","catch","installEmailDeliverabilityPreflight","outcomeKey","event","providerMessageId","to","type","recordDeliverabilityFromDeliveryEvents","events","outcomes","first","firstOfType","counted","bounceType","bounceStatus","diagnostic","recorded","sender","known","remoteMta","at","mailGatewayLedgerRow","id","holds","mailGatewayWindowStartMs","parse","MAIL_GATEWAY_REFUSED_READ_LIMIT","listRecentlyRefusedMailGatewayLedgers","options","max","floor","sinceMs","where","orderBy","rows","docs","row","truncated"],"mappings":"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"}
|
|
@@ -125,7 +125,17 @@ export type EmailSuppressionReason =
|
|
|
125
125
|
* like a bounce; a staff release lifts it, and the preflight files it
|
|
126
126
|
* again only if the domain still takes no mail.
|
|
127
127
|
*/
|
|
128
|
-
| 'no_mail_server'
|
|
128
|
+
| 'no_mail_server'
|
|
129
|
+
/**
|
|
130
|
+
* The account holding this address is BANNED — locked for phishing, fraud
|
|
131
|
+
* or malicious content (AGL-3420). Not a mailing preference and not a
|
|
132
|
+
* deliverability fact: the platform's own decision that this address is
|
|
133
|
+
* sent nothing, by any sender, for any purpose, once the ban notice has
|
|
134
|
+
* gone. Unlike every other reason it is also read on TRANSACTIONAL mail,
|
|
135
|
+
* by the deliverability preflight on `sendEmail`. Only lifting the ban
|
|
136
|
+
* releases it — see {@link ACCOUNT_BAN_SUPPRESSION_REASON}.
|
|
137
|
+
*/
|
|
138
|
+
| 'account_ban';
|
|
129
139
|
/**
|
|
130
140
|
* The reasons that may be filed PLATFORM-WIDE, as a runtime set.
|
|
131
141
|
*
|
|
@@ -155,6 +165,14 @@ export type EmailSuppressionReason =
|
|
|
155
165
|
* suppression list does not mention it.
|
|
156
166
|
*/
|
|
157
167
|
export declare const PLATFORM_SUPPRESSION_REASONS: readonly EmailSuppressionReason[];
|
|
168
|
+
/**
|
|
169
|
+
* The ban reason, named because four rules hang on it: a later failure never
|
|
170
|
+
* rewrites it ({@link suppressEmail}), no staff release or opt-in lifts it
|
|
171
|
+
* ({@link releaseEmail}, {@link releaseEmailForConfirmedOptIn}), only
|
|
172
|
+
* {@link releaseAccountBanSuppressions} does, and the preflight refuses it on
|
|
173
|
+
* every send ({@link accountBannedRecipients}).
|
|
174
|
+
*/
|
|
175
|
+
export declare const ACCOUNT_BAN_SUPPRESSION_REASON = "account_ban";
|
|
158
176
|
/**
|
|
159
177
|
* The reason a self-service opt-out is filed under, on the PER-SITE list.
|
|
160
178
|
*
|
|
@@ -231,6 +249,11 @@ export interface SuppressEmailInput {
|
|
|
231
249
|
* A sender that stamps a richer verdict of its own passes `false`.
|
|
232
250
|
*/
|
|
233
251
|
stampRecord?: boolean;
|
|
252
|
+
/**
|
|
253
|
+
* The account the address belongs to, on a ban row, so lifting that
|
|
254
|
+
* account's ban releases exactly its rows.
|
|
255
|
+
*/
|
|
256
|
+
subjectUid?: string | null;
|
|
234
257
|
/** Injectable for tests; defaults to the admin app's Firestore. */
|
|
235
258
|
firestore?: any;
|
|
236
259
|
}
|
|
@@ -264,7 +287,9 @@ export type EmailReleaseChannel =
|
|
|
264
287
|
/** A staff correction through the admin suppressions surface. */
|
|
265
288
|
'staff'
|
|
266
289
|
/** A recipient completed a double opt-in from this address. */
|
|
267
|
-
| 'double-opt-in'
|
|
290
|
+
| 'double-opt-in'
|
|
291
|
+
/** Staff lifted the ban on the account holding it (AGL-3420). */
|
|
292
|
+
| 'ban-lifted';
|
|
268
293
|
/**
|
|
269
294
|
* Put an address back in circulation (a staff correction, or the person asking
|
|
270
295
|
* to be re-added). The record is kept and marked released, never deleted —
|
|
@@ -277,7 +302,13 @@ export type EmailReleaseChannel =
|
|
|
277
302
|
* a report filed against the wrong message, an address entered by mistake.
|
|
278
303
|
* Nothing automatic gets that latitude.
|
|
279
304
|
*
|
|
280
|
-
*
|
|
305
|
+
* Except a ban (AGL-3420): an `account_ban` row belongs to the account's
|
|
306
|
+
* lock, and is lifted by lifting the ban, never from this list — releasing
|
|
307
|
+
* one here would put a banned account back on every sender while the
|
|
308
|
+
* account itself stays locked.
|
|
309
|
+
*
|
|
310
|
+
* @returns false when there was no live record to release, or the record is
|
|
311
|
+
* a ban.
|
|
281
312
|
*/
|
|
282
313
|
export declare function releaseEmail(input: {
|
|
283
314
|
email: string;
|
|
@@ -285,6 +316,22 @@ export declare function releaseEmail(input: {
|
|
|
285
316
|
note?: string | null;
|
|
286
317
|
firestore?: any;
|
|
287
318
|
}): Promise<boolean>;
|
|
319
|
+
/** Whether a live ban row holds this address. Fails open, logging. */
|
|
320
|
+
export declare function isAccountBanSuppression(email: string | null | undefined, injectedFirestore?: any): Promise<boolean>;
|
|
321
|
+
/**
|
|
322
|
+
* Lift a ban's rows: every live `account_ban` row filed for `uid`, and only
|
|
323
|
+
* those. Kept and marked released, like every other release. Answers how
|
|
324
|
+
* many it released.
|
|
325
|
+
*/
|
|
326
|
+
export declare function releaseAccountBanSuppressions(input: {
|
|
327
|
+
uid: string;
|
|
328
|
+
releasedByUid?: string | null;
|
|
329
|
+
firestore?: any;
|
|
330
|
+
}): Promise<number>;
|
|
331
|
+
/** Drop the held set, so the next send reads the list again. */
|
|
332
|
+
export declare function forgetAccountBans(): void;
|
|
333
|
+
/** The addresses among `emails` whose account is banned, lowercased. */
|
|
334
|
+
export declare function accountBannedRecipients(emails: readonly string[], injectedFirestore?: any): Promise<Set<string>>;
|
|
288
335
|
/**
|
|
289
336
|
* The ONLY reason a completed round trip may lift, as a runtime set.
|
|
290
337
|
*
|
|
@@ -584,3 +631,25 @@ export declare function suppressEmailForHostErasure(input: {
|
|
|
584
631
|
* recreated record by the same address.
|
|
585
632
|
*/
|
|
586
633
|
export declare function hostRefusesCaptureForErasure(hostId: string, email: string | null | undefined, injectedFirestore?: any): Promise<boolean>;
|
|
634
|
+
/** The `reason` a lock's per-site row carries. */
|
|
635
|
+
export declare const HOST_ACCOUNT_LOCK_SUPPRESSION_REASON = "account_lock";
|
|
636
|
+
/**
|
|
637
|
+
* Suppress an address on one site because the account holding it is
|
|
638
|
+
* locked. Answers whether it wrote a row: false when one already stood.
|
|
639
|
+
*/
|
|
640
|
+
export declare function suppressEmailForAccountLock(input: {
|
|
641
|
+
hostId: string;
|
|
642
|
+
email: string;
|
|
643
|
+
uid: string;
|
|
644
|
+
firestore?: any;
|
|
645
|
+
}): Promise<boolean>;
|
|
646
|
+
/**
|
|
647
|
+
* Lift a lock's row on one site: deleted only while it is still this
|
|
648
|
+
* account's lock row. Answers whether it deleted one.
|
|
649
|
+
*/
|
|
650
|
+
export declare function releaseAccountLockSuppression(input: {
|
|
651
|
+
hostId: string;
|
|
652
|
+
email: string;
|
|
653
|
+
uid: string;
|
|
654
|
+
firestore?: any;
|
|
655
|
+
}): Promise<boolean>;
|
|
@@ -139,8 +139,16 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
139
139
|
'bounce',
|
|
140
140
|
'complaint',
|
|
141
141
|
'staff',
|
|
142
|
-
'no_mail_server'
|
|
142
|
+
'no_mail_server',
|
|
143
|
+
'account_ban'
|
|
143
144
|
];
|
|
145
|
+
/**
|
|
146
|
+
* The ban reason, named because four rules hang on it: a later failure never
|
|
147
|
+
* rewrites it ({@link suppressEmail}), no staff release or opt-in lifts it
|
|
148
|
+
* ({@link releaseEmail}, {@link releaseEmailForConfirmedOptIn}), only
|
|
149
|
+
* {@link releaseAccountBanSuppressions} does, and the preflight refuses it on
|
|
150
|
+
* every send ({@link accountBannedRecipients}).
|
|
151
|
+
*/ export const ACCOUNT_BAN_SUPPRESSION_REASON = 'account_ban';
|
|
144
152
|
/**
|
|
145
153
|
* The reason a self-service opt-out is filed under, on the PER-SITE list.
|
|
146
154
|
*
|
|
@@ -178,6 +186,7 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
178
186
|
* `tools/scripts/lib/email-search-tokens.mjs`; the spec and the twin's test
|
|
179
187
|
* both assert `email-search-tokens.fixtures.json`.
|
|
180
188
|
*/ export { emailSearchTokens };
|
|
189
|
+
/** A live ban row: nothing but lifting the ban may change or release it. */ const isLiveBanRow = (snapshot)=>Boolean(snapshot == null ? void 0 : snapshot.exists) && !snapshot.get('releasedAt') && snapshot.get('reason') === ACCOUNT_BAN_SUPPRESSION_REASON;
|
|
181
190
|
/**
|
|
182
191
|
* Record a platform-wide suppression. Idempotent by document id.
|
|
183
192
|
*
|
|
@@ -202,13 +211,30 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
202
211
|
const db = (_input_firestore = input.firestore) != null ? _input_firestore : defaultFirestore();
|
|
203
212
|
const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key);
|
|
204
213
|
const snapshot = await ref.get();
|
|
214
|
+
// A bounce or complaint arriving for a banned address must not rewrite the
|
|
215
|
+
// ban as a reason an opt-in or a staff release could lift.
|
|
216
|
+
if (input.reason !== ACCOUNT_BAN_SUPPRESSION_REASON && isLiveBanRow(snapshot)) {
|
|
217
|
+
return {
|
|
218
|
+
key,
|
|
219
|
+
created: false
|
|
220
|
+
};
|
|
221
|
+
}
|
|
205
222
|
const email = String(input.email).trim().toLowerCase();
|
|
223
|
+
// A ban filed over a live suppression of another kind keeps that reason,
|
|
224
|
+
// so lifting the ban puts the complaint or bounce back rather than
|
|
225
|
+
// releasing it with the ban.
|
|
226
|
+
const priorReason = input.reason === ACCOUNT_BAN_SUPPRESSION_REASON && snapshot.exists && !snapshot.get('releasedAt') && snapshot.get('reason') !== ACCOUNT_BAN_SUPPRESSION_REASON ? snapshot.get('reason') : null;
|
|
206
227
|
await ref.set(_extends({
|
|
207
228
|
email,
|
|
208
229
|
emailTokens: emailSearchTokens(email),
|
|
209
230
|
reason: input.reason,
|
|
210
231
|
context: (_input_context = input.context) != null ? _input_context : null,
|
|
211
|
-
hostId: (_input_hostId = input.hostId) != null ? _input_hostId : null
|
|
232
|
+
hostId: (_input_hostId = input.hostId) != null ? _input_hostId : null
|
|
233
|
+
}, input.subjectUid ? {
|
|
234
|
+
subjectUid: input.subjectUid
|
|
235
|
+
} : {}, priorReason ? {
|
|
236
|
+
priorReason
|
|
237
|
+
} : {}, {
|
|
212
238
|
releasedAt: null,
|
|
213
239
|
released: false,
|
|
214
240
|
suppressedAt: FieldValue.serverTimestamp()
|
|
@@ -253,7 +279,13 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
253
279
|
* a report filed against the wrong message, an address entered by mistake.
|
|
254
280
|
* Nothing automatic gets that latitude.
|
|
255
281
|
*
|
|
256
|
-
*
|
|
282
|
+
* Except a ban (AGL-3420): an `account_ban` row belongs to the account's
|
|
283
|
+
* lock, and is lifted by lifting the ban, never from this list — releasing
|
|
284
|
+
* one here would put a banned account back on every sender while the
|
|
285
|
+
* account itself stays locked.
|
|
286
|
+
*
|
|
287
|
+
* @returns false when there was no live record to release, or the record is
|
|
288
|
+
* a ban.
|
|
257
289
|
*/ export async function releaseEmail(input) {
|
|
258
290
|
var _input_firestore, _input_releasedByUid, _input_note;
|
|
259
291
|
const key = emailSuppressionKey(input.email);
|
|
@@ -262,6 +294,7 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
262
294
|
const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key);
|
|
263
295
|
const snapshot = await ref.get();
|
|
264
296
|
if (!snapshot.exists || snapshot.get('releasedAt')) return false;
|
|
297
|
+
if (isLiveBanRow(snapshot)) return false;
|
|
265
298
|
await ref.set({
|
|
266
299
|
releasedAt: FieldValue.serverTimestamp(),
|
|
267
300
|
released: true,
|
|
@@ -273,6 +306,103 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
273
306
|
});
|
|
274
307
|
return true;
|
|
275
308
|
}
|
|
309
|
+
/** Whether a live ban row holds this address. Fails open, logging. */ export async function isAccountBanSuppression(email, injectedFirestore) {
|
|
310
|
+
const key = emailSuppressionKey(email);
|
|
311
|
+
if (!key) return false;
|
|
312
|
+
try {
|
|
313
|
+
const db = injectedFirestore != null ? injectedFirestore : defaultFirestore();
|
|
314
|
+
return isLiveBanRow(await db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key).get());
|
|
315
|
+
} catch (error) {
|
|
316
|
+
console.error('[email-suppression] ban lookup failed', error);
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Lift a ban's rows: every live `account_ban` row filed for `uid`, and only
|
|
322
|
+
* those. Kept and marked released, like every other release. Answers how
|
|
323
|
+
* many it released.
|
|
324
|
+
*/ export async function releaseAccountBanSuppressions(input) {
|
|
325
|
+
var _input_firestore;
|
|
326
|
+
if (!input.uid) return 0;
|
|
327
|
+
const db = (_input_firestore = input.firestore) != null ? _input_firestore : defaultFirestore();
|
|
328
|
+
const snapshot = await db.collection(EMAIL_SUPPRESSIONS_COLLECTION).where('subjectUid', '==', input.uid).get();
|
|
329
|
+
let released = 0;
|
|
330
|
+
for (const doc of snapshot.docs){
|
|
331
|
+
var _input_releasedByUid;
|
|
332
|
+
if (!isLiveBanRow(doc)) continue;
|
|
333
|
+
const priorReason = doc.get('priorReason');
|
|
334
|
+
await doc.ref.set(PLATFORM_SUPPRESSION_REASONS.includes(priorReason) ? {
|
|
335
|
+
reason: priorReason,
|
|
336
|
+
priorReason: FieldValue.delete(),
|
|
337
|
+
subjectUid: FieldValue.delete()
|
|
338
|
+
} : {
|
|
339
|
+
releasedAt: FieldValue.serverTimestamp(),
|
|
340
|
+
released: true,
|
|
341
|
+
releasedByUid: (_input_releasedByUid = input.releasedByUid) != null ? _input_releasedByUid : null,
|
|
342
|
+
releasedNote: null,
|
|
343
|
+
releasedVia: 'ban-lifted'
|
|
344
|
+
}, {
|
|
345
|
+
merge: true
|
|
346
|
+
});
|
|
347
|
+
released += 1;
|
|
348
|
+
}
|
|
349
|
+
forgetAccountBans();
|
|
350
|
+
return released;
|
|
351
|
+
}
|
|
352
|
+
/*==========================================
|
|
353
|
+
* THE BAN LIST, AS THE PREFLIGHT READS IT (AGL-3420)
|
|
354
|
+
*
|
|
355
|
+
* Every message `sendEmail` sends asks whether a recipient is banned, so the
|
|
356
|
+
* answer cannot cost a read per recipient per send. Bans are few and rarely
|
|
357
|
+
* change, so the live set is read whole — one equality query — and held for
|
|
358
|
+
* a minute. A failed read keeps the last set it had (a ban is not lifted by
|
|
359
|
+
* an outage) and, with none, answers "nobody": transactional mail must not
|
|
360
|
+
* stop because the list is unreadable.
|
|
361
|
+
*=========================================*/ const ACCOUNT_BAN_CACHE_MS = 60000;
|
|
362
|
+
let accountBanCache = null;
|
|
363
|
+
let accountBanPending = null;
|
|
364
|
+
/** Drop the held set, so the next send reads the list again. */ export function forgetAccountBans() {
|
|
365
|
+
accountBanCache = null;
|
|
366
|
+
accountBanPending = null;
|
|
367
|
+
}
|
|
368
|
+
async function accountBanKeys(injectedFirestore) {
|
|
369
|
+
if (accountBanCache && Date.now() - accountBanCache.at < ACCOUNT_BAN_CACHE_MS) {
|
|
370
|
+
return accountBanCache.keys;
|
|
371
|
+
}
|
|
372
|
+
accountBanPending != null ? accountBanPending : accountBanPending = (async ()=>{
|
|
373
|
+
try {
|
|
374
|
+
const db = injectedFirestore != null ? injectedFirestore : defaultFirestore();
|
|
375
|
+
const snapshot = await db.collection(EMAIL_SUPPRESSIONS_COLLECTION).where('reason', '==', ACCOUNT_BAN_SUPPRESSION_REASON).get();
|
|
376
|
+
const keys = new Set();
|
|
377
|
+
for (const doc of snapshot.docs){
|
|
378
|
+
if (!doc.get('releasedAt')) keys.add(doc.id);
|
|
379
|
+
}
|
|
380
|
+
accountBanCache = {
|
|
381
|
+
at: Date.now(),
|
|
382
|
+
keys
|
|
383
|
+
};
|
|
384
|
+
return keys;
|
|
385
|
+
} catch (error) {
|
|
386
|
+
var _ref;
|
|
387
|
+
console.error('[email-suppression] ban list read failed', error);
|
|
388
|
+
return (_ref = accountBanCache == null ? void 0 : accountBanCache.keys) != null ? _ref : new Set();
|
|
389
|
+
} finally{
|
|
390
|
+
accountBanPending = null;
|
|
391
|
+
}
|
|
392
|
+
})();
|
|
393
|
+
return accountBanPending;
|
|
394
|
+
}
|
|
395
|
+
/** The addresses among `emails` whose account is banned, lowercased. */ export async function accountBannedRecipients(emails, injectedFirestore) {
|
|
396
|
+
const banned = new Set();
|
|
397
|
+
if (!emails.length) return banned;
|
|
398
|
+
const keys = await accountBanKeys(injectedFirestore);
|
|
399
|
+
if (!keys.size) return banned;
|
|
400
|
+
for (const email of emails){
|
|
401
|
+
const key = emailSuppressionKey(email);
|
|
402
|
+
if (key && keys.has(key)) banned.add(String(email).trim().toLowerCase());
|
|
403
|
+
}
|
|
404
|
+
return banned;
|
|
405
|
+
}
|
|
276
406
|
/**
|
|
277
407
|
* The ONLY reason a completed round trip may lift, as a runtime set.
|
|
278
408
|
*
|
|
@@ -781,5 +911,66 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
781
911
|
return false;
|
|
782
912
|
}
|
|
783
913
|
}
|
|
914
|
+
/*==========================================
|
|
915
|
+
* A LOCKED ACCOUNT, ON THE HOUSE WORKSPACE'S SITES (AGL-3420)
|
|
916
|
+
*
|
|
917
|
+
* Aglyn's own campaigns and sequences go to the house workspace's contacts,
|
|
918
|
+
* which are keyed by address and know nothing about accounts. A locked
|
|
919
|
+
* account's addresses are therefore suppressed on each of the house sites
|
|
920
|
+
* for as long as the lock stands, so every sender that honors a site's list
|
|
921
|
+
* — campaigns, sequences, workflow and CRM mail — leaves them alone.
|
|
922
|
+
*
|
|
923
|
+
* Per SITE and only on the house workspace's: the lock is our relationship
|
|
924
|
+
* with the person, and another workspace that knows the same address has one
|
|
925
|
+
* of its own that the lock has no claim on. A ban reaches everyone through
|
|
926
|
+
* the platform list instead.
|
|
927
|
+
*
|
|
928
|
+
* Written only where no row exists, and lifted only where this row still
|
|
929
|
+
* stands for this account. So an unsubscribe, bounce or erasure already on
|
|
930
|
+
* the list is never rewritten or deleted by a lock or its lift.
|
|
931
|
+
*=========================================*/ /** The `reason` a lock's per-site row carries. */ export const HOST_ACCOUNT_LOCK_SUPPRESSION_REASON = 'account_lock';
|
|
932
|
+
/**
|
|
933
|
+
* Suppress an address on one site because the account holding it is
|
|
934
|
+
* locked. Answers whether it wrote a row: false when one already stood.
|
|
935
|
+
*/ export async function suppressEmailForAccountLock(input) {
|
|
936
|
+
var _input_firestore;
|
|
937
|
+
const key = emailSuppressionKey(input.email);
|
|
938
|
+
if (!key) return false;
|
|
939
|
+
const db = (_input_firestore = input.firestore) != null ? _input_firestore : defaultFirestore();
|
|
940
|
+
const ref = db.collection('hosts').doc(input.hostId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key);
|
|
941
|
+
const email = String(input.email).trim().toLowerCase();
|
|
942
|
+
let created = false;
|
|
943
|
+
await db.runTransaction(async (transaction)=>{
|
|
944
|
+
const existing = await transaction.get(ref);
|
|
945
|
+
created = !existing.exists;
|
|
946
|
+
if (!created) return;
|
|
947
|
+
transaction.set(ref, {
|
|
948
|
+
email,
|
|
949
|
+
emailTokens: emailSearchTokens(email),
|
|
950
|
+
reason: HOST_ACCOUNT_LOCK_SUPPRESSION_REASON,
|
|
951
|
+
subjectUid: input.uid,
|
|
952
|
+
suppressedAt: FieldValue.serverTimestamp(),
|
|
953
|
+
createdAt: FieldValue.serverTimestamp()
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
return created;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Lift a lock's row on one site: deleted only while it is still this
|
|
960
|
+
* account's lock row. Answers whether it deleted one.
|
|
961
|
+
*/ export async function releaseAccountLockSuppression(input) {
|
|
962
|
+
var _input_firestore;
|
|
963
|
+
const key = emailSuppressionKey(input.email);
|
|
964
|
+
if (!key) return false;
|
|
965
|
+
const db = (_input_firestore = input.firestore) != null ? _input_firestore : defaultFirestore();
|
|
966
|
+
const ref = db.collection('hosts').doc(input.hostId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key);
|
|
967
|
+
let deleted = false;
|
|
968
|
+
await db.runTransaction(async (transaction)=>{
|
|
969
|
+
const existing = await transaction.get(ref);
|
|
970
|
+
deleted = Boolean(existing.exists) && existing.get('reason') === HOST_ACCOUNT_LOCK_SUPPRESSION_REASON && existing.get('subjectUid') === input.uid;
|
|
971
|
+
if (deleted) transaction.delete(ref);
|
|
972
|
+
});
|
|
973
|
+
return deleted;
|
|
974
|
+
}
|
|
784
975
|
|
|
785
976
|
//# sourceMappingURL=email-suppression.js.map
|