@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.219
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +156 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +144 -0
- package/src/lib/server/api-v1-resources.js +126 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +41 -16
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-inbound-email.d.ts +23 -0
- package/src/lib/server/crm-inbound-email.js +1 -16
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +4 -1
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -0
- package/src/lib/server/email-suppression.js +36 -1
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +1 -1
- package/src/lib/server/member-email-aliases.js +1 -1
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +8 -2
- package/src/lib/server/notifications.js +11 -3
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.js.map +1 -1
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +85 -18
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +4 -5
- package/src/tenancy.js +11 -6
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-suppression.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-WIDE email suppression list, `emailSuppressions/{emailKey}`\n * (AGL-2407).\n *\n * ## What was missing\n *\n * AGL-1918 (`7a8f3cd68`) made `email.bounced` (permanent) and\n * `email.complained` write a suppression — into `hosts/{hostId}/suppressions`,\n * found from a Resend tag that only `campaign-send.ts` stamped. That closed\n * the campaign half and could close no other, because there was nowhere to\n * file the rest:\n *\n * Every other sender in the product goes through the shared `sendEmail` —\n * invites,\n * password resets, verification, receipts, booking confirmations, the monthly\n * usage summary, the usage-alert fan-out, restock and abandoned-cart mail, the\n * merchant-authored workflow `sendEmail` step. A bounce on any of those\n * arrived at the webhook with no site to place it against and was answered\n * `200 {ignored:true}`. A dead address was re-mailed on every subsequent send,\n * forever, and a spam complaint had no effect on anything.\n *\n * That matters most exactly when it is hardest to fix: on Sept 1 the signup\n * door opens, and verification and invite mail — addressed by strangers typing\n * addresses — is the highest-bounce-rate mail we send, on the same Resend key\n * and the same From address as everything else.\n *\n * ## Why a sibling of `contactSuppressions` and not a channel on it\n *\n * `contact-suppression.ts` is the same idea for phones and every argument it\n * makes holds here: keyed on the identifier because that is what the sender\n * holds at send time; one `get()` by document id, no query and no composite\n * index that could go missing and fail the lookup open; a revocation is a\n * FIELD and not a delete, because the record is the evidence that the\n * suppression was honoured while it was in force.\n *\n * It is a separate collection for one concrete reason: the document id. That\n * list's key is the E.164 digits, and its docblock argues at length that a\n * phone number must NOT be hashed (NANP is ~10^10 values, so a hash buys the\n * appearance of de-identification and none of the substance). An email address\n * is not enumerable, `hosts/{hostId}/suppressions` already keys on\n * `sha256(email)`, and one collection holding two incompatible key spaces is\n * how a lookup comes to be performed against the wrong derivation. The\n * address is stored in the document in the clear regardless, exactly as the\n * per-host list stores it, so a staff reader still shows a human something\n * they can act on.\n *\n * ## Who reads it, and who deliberately does NOT\n *\n * AGL-1438's rule is that a QUOTA may only ever refuse a campaign, because\n * refusing a receipt or a password reset converts a billing event into an\n * outage on a customer's business. A suppression is not a quota, but the same\n * proportionality applies and lands in the same place:\n *\n * - **Bulk mail consults this list.** The monthly usage summary, the\n * usage-alert fan-out and the marketplace review fan-out to a publisher's\n * owners and admins go to a LIST of people who did not ask for that\n * particular message, on a schedule or on every submission, forever. They\n * are the sends that re-hit a dead mailbox every month and teach a mailbox\n * provider that `aglyn.com` does not listen.\n * - **Transactional mail does NOT.** A password reset, a verification, an\n * invite, a receipt or a booking confirmation answers something the human\n * just did. Refusing one because an address bounced or because somebody\n * once pressed \"report spam\" on an unrelated message would lock a real\n * customer out of their own account. The shared sender is therefore left\n * unconditional on purpose, and this is the note that says so.\n *\n * (Written throughout without a literal call expression, on purpose:\n * `email-send-metering-coverage.spec.ts` enumerates senders by grepping the\n * tree for one, and a prose mention here would enrol a module that sends\n * nothing into the AGL-1438 cost-meter sweep.)\n */\n\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\n// The leaf entry, not the `@aglyn/aglyn` barrel, for the reason\n// `document-id.ts` gives at length: a barrel import resolves to whatever a\n// spec's `jest.mock` happens to contain, and this module's neighbours are\n// mocked in nearly every spec that touches them.\nimport {\n consentGroupOptOutHosts,\n consentGroupTopicState,\n type ConsentGroup,\n} from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport { emailSearchTokens } from '@aglyn/aglyn/app-utils/email-search'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport firebaseAdmin from './firebase-admin'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\nexport const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions'\n\n/**\n * The PER-SITE list, `hosts/{hostId}/suppressions/{emailKey}` — unsubscribes\n * from one site's campaigns, plus the bounces and complaints that arrived\n * carrying that site's tag.\n *\n * Named here rather than at each call site because the two lists are read\n * together by {@link filterSendableForHost}, and a sender that knows one path\n * as a string literal and the other through this module is one rename away\n * from consulting a collection that does not exist and finding nobody\n * suppressed.\n */\nexport const HOST_SUPPRESSIONS_SUBCOLLECTION = 'suppressions'\n\n/**\n * Why an address is suppressed.\n *\n * `bounce` means PERMANENT only. Resend reports `data.bounce.type` as\n * `Permanent` or `Transient`, and a transient bounce is a full mailbox or a\n * greylisting server — recording one here would suppress a real recipient over\n * a temporary condition at their provider. The webhook makes that distinction;\n * this type only names the outcome.\n */\nexport type EmailSuppressionReason =\n /** Permanent bounce — the mailbox does not exist. */\n | 'bounce'\n /** The recipient pressed \"report spam\". */\n | 'complaint'\n /** Recorded by staff (a written request, a court order, a correction). */\n | 'staff'\n /**\n * The address's domain takes no mail — no MX and no address record, or a\n * null MX — as the deliverability preflight found before a send\n * (AGL-3328). Filed without a bounce ever happening, which is the point:\n * the bounce is what it prevents. A fact about the address for everyone,\n * like a bounce; a staff release lifts it, and the preflight files it\n * again only if the domain still takes no mail.\n */\n | 'no_mail_server'\n /**\n * The account holding this address is BANNED — locked for phishing, fraud\n * or malicious content (AGL-3420). Not a mailing preference and not a\n * deliverability fact: the platform's own decision that this address is\n * sent nothing, by any sender, for any purpose, once the ban notice has\n * gone. Unlike every other reason it is also read on TRANSACTIONAL mail,\n * by the deliverability preflight on `sendEmail`. Only lifting the ban\n * releases it — see {@link ACCOUNT_BAN_SUPPRESSION_REASON}.\n */\n | 'account_ban'\n\n/**\n * The reasons that may be filed PLATFORM-WIDE, as a runtime set.\n *\n * ## The split this constant enforces\n *\n * A bounce and a complaint are facts about a MAILBOX and about the sending\n * domain every tenant's mail leaves by: the address does not exist for\n * anybody, and somebody who pressed \"report spam\" on `noreply@aglyn.com` has\n * told every sender behind that domain at once. Those belong everywhere,\n * immediately.\n *\n * An UNSUBSCRIBE is not that. It is a preference a person expressed to ONE\n * brand — the sender line they read, the newsletter they joined — and an\n * agency running twelve unrelated clients out of one account would, on a\n * platform-wide entry, stop mailing that person on behalf of eleven brands\n * they never heard from. So an unsubscribe lives only in\n * `hosts/{hostId}/suppressions`, and {@link suppressEmail} refuses it.\n *\n * ## Why a runtime refusal and not just the type\n *\n * {@link EmailSuppressionReason} already excludes it, and a type is not a\n * guard: every caller here builds its reason from a webhook payload or a\n * ternary, `as` casts exist, and the plugin API surface is untyped at the\n * boundary. The cost of the type being wrong once is an opt-out from one\n * brand silently applied to every brand in the account, which is invisible\n * from the console — the mail simply never arrives, and the merchant's own\n * suppression list does not mention it.\n */\nexport const PLATFORM_SUPPRESSION_REASONS: readonly EmailSuppressionReason[] = [\n 'bounce',\n 'complaint',\n 'staff',\n 'no_mail_server',\n 'account_ban',\n]\n\n/**\n * The ban reason, named because four rules hang on it: a later failure never\n * rewrites it ({@link suppressEmail}), no staff release or opt-in lifts it\n * ({@link releaseEmail}, {@link releaseEmailForConfirmedOptIn}), only\n * {@link releaseAccountBanSuppressions} does, and the preflight refuses it on\n * every send ({@link accountBannedRecipients}).\n */\nexport const ACCOUNT_BAN_SUPPRESSION_REASON = 'account_ban' satisfies EmailSuppressionReason\n\n/**\n * The reason a self-service opt-out is filed under, on the PER-SITE list.\n *\n * Named because three call sites compare against it — the unsubscribe writes\n * it, the resubscribe link refuses to reverse anything else, and the\n * preference page reads it to decide whether an address may opt back in. A\n * literal in three places is a literal that can be changed in two.\n */\nexport const UNSUBSCRIBE_SUPPRESSION_REASON = 'unsubscribe'\n\nexport interface EmailSuppressionRecord {\n /** The address, in the clear, lowercased. The id is its hash. */\n email: string\n reason: EmailSuppressionReason\n /**\n * The `context` tag the send carried (`'invite'`, `'usage-summary'`,\n * `'campaign'`, …), so a human reading the list can tell which of our\n * senders produced the address that died. Null when the send carried none.\n */\n context: string | null\n /** The site the failure was attributed to, when the send named one. */\n hostId: string | null\n /** Non-null once released. A released record does not suppress. */\n releasedAt: unknown | null\n /**\n * `releasedAt` as a boolean, which the staff list filters by EQUALITY\n * beneath its `suppressedAt` cursor: \"released\" read from `releasedAt`\n * would be `!= null`, an inequality Firestore makes the first sort.\n * Absent on records written before it existed until\n * `tools/scripts/backfill-email-suppression-filters.mjs` stamps them.\n */\n released?: boolean\n /** Search tokens for the address; see {@link emailSearchTokens}. */\n emailTokens?: string[]\n /** What lifted it. Absent on records written before releases were typed. */\n releasedVia?: EmailReleaseChannel\n /** The site whose confirmed double opt-in lifted it, when one did. */\n releasedHostId?: string | null\n /** The stream that was confirmed, likewise. */\n releasedTopicId?: string | null\n}\n\n/**\n * Document id for an address: `sha256` of the lowercased, trimmed form.\n *\n * The SAME derivation `campaign-send.ts` uses for `hosts/{hostId}/suppressions`\n * — deliberately, so the two lists can never disagree about which document\n * describes which person.\n *\n * @returns the id, or `null` for anything that is not an address. Never a\n * best-guess id: suppressing the wrong person and believing you\n * suppressed the right one is worse than refusing.\n */\nexport function emailSuppressionKey(\n email: string | null | undefined,\n): string | null {\n /*\n * Delegated, not reimplemented. A second hash of the same address is how\n * this area got two derivations that agreed only by accident; `personKey`\n * is the one that normalizes and the one the unsubscribe route already\n * hashes through, so a suppression filed by either is found by both.\n */\n return personKey(email)\n}\n\n/**\n * The `array-contains` search tokens for an address — shared with every list\n * that searches one (`@aglyn/aglyn/app-utils/email-search`), re-exported here\n * for the callers that have always imported it from this module.\n *\n * Mirrored for the backfills that stamp the rows written before the field\n * (`backfill-email-suppression-filters.mjs`, `backfill-host-suppression-filters.mjs`,\n * `backfill-email-list-filters.mjs`) by ONE script-side twin,\n * `tools/scripts/lib/email-search-tokens.mjs`; the spec and the twin's test\n * both assert `email-search-tokens.fixtures.json`.\n */\nexport { emailSearchTokens }\n\nexport interface SuppressEmailInput {\n email: string\n reason: EmailSuppressionReason\n /** The `context` tag the failed send carried, when it carried one. */\n context?: string | null\n /** The site the failed send was attributed to, when it named one. */\n hostId?: string | null\n /**\n * Whether to say so on the person's record too (AGL-3245); on by default.\n * A sender that stamps a richer verdict of its own passes `false`.\n */\n stampRecord?: boolean\n /**\n * The account the address belongs to, on a ban row, so lifting that\n * account's ban releases exactly its rows.\n */\n subjectUid?: string | null\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n}\n\n/** A live ban row: nothing but lifting the ban may change or release it. */\nconst isLiveBanRow = (snapshot: any): boolean =>\n Boolean(snapshot?.exists) &&\n !snapshot.get('releasedAt') &&\n snapshot.get('reason') === ACCOUNT_BAN_SUPPRESSION_REASON\n\n/**\n * Record a platform-wide suppression. Idempotent by document id.\n *\n * `createdAt` is stamped only when the document is new, matching the per-host\n * list: a second bounce must not overwrite the moment the address first went\n * bad, because that is the date a human is told when they ask. `suppressedAt`\n * moves every time, so \"when did we last see this fail\" stays answerable.\n *\n * A previously released record is un-released — a fresh failure is a fresh\n * failure.\n *\n * @returns `created: false` when the address was already on the list.\n */\nexport async function suppressEmail(input: SuppressEmailInput): Promise<{\n key: string\n created: boolean\n}> {\n const key = emailSuppressionKey(input.email)\n if (!key) {\n throw new Error('[email-suppression] cannot key a suppression for that value')\n }\n if (!PLATFORM_SUPPRESSION_REASONS.includes(input.reason)) {\n throw new Error(\n `[email-suppression] ${input.reason} is a per-site preference and ` +\n 'cannot be filed platform-wide',\n )\n }\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n // A bounce or complaint arriving for a banned address must not rewrite the\n // ban as a reason an opt-in or a staff release could lift.\n if (input.reason !== ACCOUNT_BAN_SUPPRESSION_REASON && isLiveBanRow(snapshot)) {\n return { key, created: false }\n }\n const email = String(input.email).trim().toLowerCase()\n // A ban filed over a live suppression of another kind keeps that reason,\n // so lifting the ban puts the complaint or bounce back rather than\n // releasing it with the ban.\n const priorReason =\n input.reason === ACCOUNT_BAN_SUPPRESSION_REASON &&\n snapshot.exists &&\n !snapshot.get('releasedAt') &&\n snapshot.get('reason') !== ACCOUNT_BAN_SUPPRESSION_REASON\n ? (snapshot.get('reason') as EmailSuppressionReason)\n : null\n await ref.set(\n {\n email,\n emailTokens: emailSearchTokens(email),\n reason: input.reason,\n context: input.context ?? null,\n hostId: input.hostId ?? null,\n ...(input.subjectUid ? { subjectUid: input.subjectUid } : {}),\n ...(priorReason ? { priorReason } : {}),\n releasedAt: null,\n released: false,\n suppressedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n /*\n * The same verdict on the record the person reads (AGL-3245), through\n * whichever plugin keeps the workspace's records: a bounce or a complaint\n * on a send that named a site stamps the site's records. A send that\n * named none has no records to find, and a caller that stamps its own,\n * richer verdict — the sequence runtime, with the domain block and the\n * enrollment — says `stampRecord: false` and is left to it.\n */\n if (input.hostId && input.stampRecord !== false && input.reason !== 'staff') {\n await stampRecordEmailState({\n hostId: input.hostId,\n email: input.email,\n state: {\n status:\n input.reason === 'complaint'\n ? 'complained'\n : input.reason === 'no_mail_server'\n ? 'undeliverable'\n : 'bounced',\n atMs: Date.now(),\n source: input.reason === 'no_mail_server' ? 'check' : 'campaign',\n detail: input.context ? `Reported by the ${input.context} send.` : null,\n },\n })\n }\n return { key, created: !snapshot.exists }\n}\n\n/**\n * What lifted a suppression, recorded on the record it lifted.\n *\n * Written explicitly by both paths rather than left to \"absent means staff\".\n * The two are held to different rules — a staff release may lift any reason,\n * a confirmed opt-in may lift exactly one — so the audit answer to \"how did\n * this address get back on the domain\" has to be a stored fact and not an\n * inference from which field happens to be missing.\n */\nexport type EmailReleaseChannel =\n /** A staff correction through the admin suppressions surface. */\n | 'staff'\n /** A recipient completed a double opt-in from this address. */\n | 'double-opt-in'\n /** Staff lifted the ban on the account holding it (AGL-3420). */\n | 'ban-lifted'\n\n/**\n * Put an address back in circulation (a staff correction, or the person asking\n * to be re-added). The record is kept and marked released, never deleted —\n * same reasoning as `releasePhoneContact`: a deleted record cannot show that\n * the suppression was honored while it stood.\n *\n * Unconditional on the reason, and that is what separates it from\n * {@link releaseEmailForConfirmedOptIn}: a human with the staff role has read\n * the record and is accountable for the row, so they may lift a complaint —\n * a report filed against the wrong message, an address entered by mistake.\n * Nothing automatic gets that latitude.\n *\n * Except a ban (AGL-3420): an `account_ban` row belongs to the account's\n * lock, and is lifted by lifting the ban, never from this list — releasing\n * one here would put a banned account back on every sender while the\n * account itself stays locked.\n *\n * @returns false when there was no live record to release, or the record is\n * a ban.\n */\nexport async function releaseEmail(input: {\n email: string\n releasedByUid?: string | null\n note?: string | null\n firestore?: any\n}): Promise<boolean> {\n const key = emailSuppressionKey(input.email)\n if (!key) return false\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n if (!snapshot.exists || snapshot.get('releasedAt')) return false\n if (isLiveBanRow(snapshot)) return false\n await ref.set(\n {\n releasedAt: FieldValue.serverTimestamp(),\n released: true,\n releasedByUid: input.releasedByUid ?? null,\n releasedNote: input.note ?? null,\n releasedVia: 'staff' satisfies EmailReleaseChannel,\n },\n { merge: true },\n )\n return true\n}\n\n/** Whether a live ban row holds this address. Fails open, logging. */\nexport async function isAccountBanSuppression(\n email: string | null | undefined,\n injectedFirestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(email)\n if (!key) return false\n try {\n const db = injectedFirestore ?? defaultFirestore()\n return isLiveBanRow(\n await db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key).get(),\n )\n } catch (error) {\n console.error('[email-suppression] ban lookup failed', error)\n return false\n }\n}\n\n/**\n * Lift a ban's rows: every live `account_ban` row filed for `uid`, and only\n * those. Kept and marked released, like every other release. Answers how\n * many it released.\n */\nexport async function releaseAccountBanSuppressions(input: {\n uid: string\n releasedByUid?: string | null\n firestore?: any\n}): Promise<number> {\n if (!input.uid) return 0\n const db = input.firestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_SUPPRESSIONS_COLLECTION)\n .where('subjectUid', '==', input.uid)\n .get()\n let released = 0\n for (const doc of snapshot.docs) {\n if (!isLiveBanRow(doc)) continue\n const priorReason = doc.get('priorReason')\n await doc.ref.set(\n PLATFORM_SUPPRESSION_REASONS.includes(priorReason)\n ? // The suppression the ban was filed over stands again.\n {\n reason: priorReason,\n priorReason: FieldValue.delete(),\n subjectUid: FieldValue.delete(),\n }\n : {\n releasedAt: FieldValue.serverTimestamp(),\n released: true,\n releasedByUid: input.releasedByUid ?? null,\n releasedNote: null,\n releasedVia: 'ban-lifted' satisfies EmailReleaseChannel,\n },\n { merge: true },\n )\n released += 1\n }\n forgetAccountBans()\n return released\n}\n\n/*==========================================\n * THE BAN LIST, AS THE PREFLIGHT READS IT (AGL-3420)\n *\n * Every message `sendEmail` sends asks whether a recipient is banned, so the\n * answer cannot cost a read per recipient per send. Bans are few and rarely\n * change, so the live set is read whole — one equality query — and held for\n * a minute. A failed read keeps the last set it had (a ban is not lifted by\n * an outage) and, with none, answers \"nobody\": transactional mail must not\n * stop because the list is unreadable.\n *=========================================*/\n\nconst ACCOUNT_BAN_CACHE_MS = 60_000\nlet accountBanCache: { at: number; keys: Set<string> } | null = null\nlet accountBanPending: Promise<Set<string>> | null = null\n\n/** Drop the held set, so the next send reads the list again. */\nexport function forgetAccountBans(): void {\n accountBanCache = null\n accountBanPending = null\n}\n\nasync function accountBanKeys(injectedFirestore?: any): Promise<Set<string>> {\n if (accountBanCache && Date.now() - accountBanCache.at < ACCOUNT_BAN_CACHE_MS) {\n return accountBanCache.keys\n }\n accountBanPending ??= (async () => {\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_SUPPRESSIONS_COLLECTION)\n .where('reason', '==', ACCOUNT_BAN_SUPPRESSION_REASON)\n .get()\n const keys = new Set<string>()\n for (const doc of snapshot.docs) {\n if (!doc.get('releasedAt')) keys.add(doc.id)\n }\n accountBanCache = { at: Date.now(), keys }\n return keys\n } catch (error) {\n console.error('[email-suppression] ban list read failed', error)\n return accountBanCache?.keys ?? new Set<string>()\n } finally {\n accountBanPending = null\n }\n })()\n return accountBanPending\n}\n\n/** The addresses among `emails` whose account is banned, lowercased. */\nexport async function accountBannedRecipients(\n emails: readonly string[],\n injectedFirestore?: any,\n): Promise<Set<string>> {\n const banned = new Set<string>()\n if (!emails.length) return banned\n const keys = await accountBanKeys(injectedFirestore)\n if (!keys.size) return banned\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key && keys.has(key)) banned.add(String(email).trim().toLowerCase())\n }\n return banned\n}\n\n/**\n * The ONLY reason a completed round trip may lift, as a runtime set.\n *\n * ## Why a bounce is releasable\n *\n * A `bounce` record is a claim about a MAILBOX at a moment: mail addressed\n * here was permanently refused. Somebody clicking a confirmation link that\n * was delivered to that mailbox has refuted the claim with the only evidence\n * that could refute it — the message arrived, a human read it, and the round\n * trip closed. Leaving the record standing after that makes one transient\n * failure recorded as permanent — a mailbox that was full, a receiving server\n * that answered 550 while it was misconfigured — a life sentence no route can\n * undo.\n *\n * ## Why a complaint is NOT, and never will be\n *\n * `complaint` is not a deliverability fact. It is a person stating they do not\n * want this mail, and a round trip proves nothing about that statement — it\n * proves the mailbox works, which nobody doubted. Releasing one here would\n * make a public signup form into a laundry for spam complaints: submit the\n * complainant's address, they receive a confirmation (the shared sender is\n * transactional and consults no list), and one click anywhere in the chain\n * puts them back on the sending domain that the complaint was filed against.\n * The same reasoning refuses `staff`, which is a written request, a\n * correction, or a legal instruction, and is nobody's to reverse from a link.\n *\n * A runtime set and not just the type, for the reason\n * {@link PLATFORM_SUPPRESSION_REASONS} gives: reasons arrive from webhook\n * payloads through an untyped boundary, and the cost of the type being wrong\n * once here is a complaint silently laundered.\n */\nexport const OPT_IN_RELEASABLE_REASONS: readonly EmailSuppressionReason[] = [\n 'bounce',\n]\n\n/** What a completed double opt-in did to a platform suppression. */\nexport type ConfirmedOptInRelease =\n /** A live `bounce` record was lifted. The address is mailable again. */\n | 'released'\n /** Nothing was suppressed, or it was already released. */\n | 'nothing-to-release'\n /** A record stands that a round trip may not lift — see the constant. */\n | 'refused'\n /** The read or the write failed. The record, whatever it is, still stands. */\n | 'failed'\n\n/**\n * Lift a platform suppression that a completed double opt-in has disproved.\n *\n * THE ONE PLACE THE AUTOMATIC RULE IS STATED, mirroring\n * `releaseSiteSuppression` for the per-site list: every path that puts an\n * address back in circulation without a human deciding goes through here, so\n * there is one line to read and one line to change.\n *\n * ## Platform-wide, from one site's round trip\n *\n * The record being lifted asserts something host-independent — this mailbox\n * does not exist, learned anywhere in the product, including on transactional\n * mail that carried no site tag. A confirmation delivered to that mailbox\n * disproves it just as host-independently, so scoping the release to the\n * confirming site would leave a record standing that says the mailbox is dead\n * while we hold proof that it is not. The site and the topic are written onto\n * the record instead, which is what makes the release reviewable: an operator\n * reading the row sees which site's round trip lifted it and when.\n *\n * ## The per-site list is NOT touched\n *\n * `hosts/{hostId}/suppressions` mixes a deliverability fact with a stated\n * preference — an unsubscribe from that one site lives there too — and the\n * email plugin's `releaseSiteSuppression` is the single guarded path that\n * lifts one. Reaching around it from here would put a second, differently\n * reasoned releaser on a list whose whole protection is that there is one.\n *\n * Never throws: it runs inside a recipient's confirmation click, and a\n * Firestore failure must degrade to a suppression that stays in force, not to\n * a person told their confirmation failed.\n */\nexport async function releaseEmailForConfirmedOptIn(input: {\n email: string\n /** The site whose confirmation link was clicked, for the audit trail. */\n hostId: string\n /** The stream that was confirmed, likewise. */\n topicId: string\n firestore?: any\n}): Promise<ConfirmedOptInRelease> {\n const key = emailSuppressionKey(input.email)\n if (!key) return 'nothing-to-release'\n try {\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n if (!snapshot.exists || snapshot.get('releasedAt')) {\n return 'nothing-to-release'\n }\n if (!OPT_IN_RELEASABLE_REASONS.includes(snapshot.get('reason'))) {\n return 'refused'\n }\n await ref.set(\n {\n releasedAt: FieldValue.serverTimestamp(),\n released: true,\n // No human released this, so the field says so rather than naming\n // whoever last touched the record.\n releasedByUid: null,\n releasedVia: 'double-opt-in' satisfies EmailReleaseChannel,\n releasedHostId: input.hostId || null,\n releasedTopicId: input.topicId || null,\n },\n { merge: true },\n )\n return 'released'\n } catch (error) {\n console.error(\n '[email-suppression] opt-in release failed; suppression stands',\n error,\n )\n return 'failed'\n }\n}\n\n/**\n * The BULK-SEND gate. See the module note for which senders must call this and\n * which must not.\n *\n * FAILS CLOSED, like `isPhoneContactSuppressed`: a read that throws answers\n * `true`, meaning \"treat as suppressed\". The cost of failing closed here is\n * one delayed informational email — every caller is a cron that runs again —\n * and it is nearly free besides, because every one of them is already deep\n * inside a Firestore-backed sweep that a Firestore outage has stopped anyway.\n * The cost of failing open is another delivery attempt at a mailbox that has\n * already told us permanently that it does not exist, which is precisely the\n * behaviour a provider scores a sending domain on. Never \"fix\" a flaky read\n * here by returning false.\n */\nexport async function isEmailSuppressed(\n email: string | null | undefined,\n injectedFirestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(email)\n // An unusable value is one we cannot check against the list, so it is one we\n // must not send to. Same fail-closed rule.\n if (!key) return true\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_SUPPRESSIONS_COLLECTION)\n .doc(key)\n .get()\n if (!snapshot.exists) return false\n return !snapshot.get('releasedAt')\n } catch (error) {\n console.error('[email-suppression] lookup failed; failing closed', error)\n return true\n }\n}\n\n/**\n * The sendable subset of a recipient list, for the bulk senders.\n *\n * One `get()` per DISTINCT address rather than a query, so the check needs no\n * index and cannot fail open on a missing one. The list is deduplicated first\n * because the callers build it by fanning out over an org's owners and admins,\n * which routinely names the same person twice.\n */\nexport async function filterSuppressedEmails(\n emails: readonly string[],\n injectedFirestore?: any,\n): Promise<string[]> {\n const seen = new Set<string>()\n const candidates: string[] = []\n for (const email of emails) {\n const normalized = String(email ?? '')\n .trim()\n .toLowerCase()\n if (!normalized || seen.has(normalized)) continue\n seen.add(normalized)\n candidates.push(normalized)\n }\n const verdicts = await Promise.all(\n candidates.map((email) => isEmailSuppressed(email, injectedFirestore)),\n )\n return candidates.filter((_email, index) => !verdicts[index])\n}\n\n/*==========================================\n * THE SENDER A PERSON LEAVES IS THE CONSENT GROUP, NOT THE SITE.\n *\n * `consent-groups.ts` lets an org declare several sites ONE sender — a shop,\n * a booking page and a blog under one name, disclosed as one on every\n * capture form. To the person on the other end those are one sender, so\n * leaving any of them is leaving it: an unsubscribe filed by site A has to\n * withhold site B's mail too, or the recipient is made to unsubscribe once\n * per site of a sender they only ever knew as one.\n *\n * Every opt-out is written against the ONE site the person acted on and read\n * across the sending site's CURRENT group. Reading rather than copying on\n * write is what makes a site that joins a group inherit every refusal already\n * standing against its siblings, and it reaches every row already stored and\n * every link already sitting in an inbox — none of which a write-time fan-out\n * could.\n *\n * All three per-site facts are read this way: the site suppression list (an\n * unsubscribe, a hand-added opt-out, an erasure, and the bounces and\n * complaints the platform list already carries), the topic opt-outs, and the\n * recipient's cadence. A sibling's pending confirmation joins them only when\n * the org turned its group's confirmation switch on — see\n * {@link filterTopicSendable}. The group of one — every site of an org that\n * declared nothing — reads exactly the documents these filters always read,\n * in the same round trips.\n *=========================================*/\n\n/**\n * The sites whose opt-out lists answer for a send made BY `hostId`, the\n * sending site first.\n *\n * The sending site's consent group: every site it names, because a refusal\n * standing on any of them is a refusal of the sender. Absent, or a group of\n * one, is the site alone.\n *\n * The group is the one `consentGroupForHost` resolved — never a set a caller\n * built from `org.hosts` or a shared domain, which would be inferring a\n * controller (see `consent-groups.ts`).\n *\n * Throws on a group resolved for a DIFFERENT site. That is a wiring defect no\n * runtime value produces, and the failure it would hide is one site's send\n * decided against another site's lists — the same refusal\n * `splitByMarketingConsent` makes over a consent map read for the wrong host.\n */\nexport function optOutHostIds(\n hostId: string,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null,\n): string[] {\n if (!group) return [hostId]\n if (group.hostId !== hostId) {\n throw new Error(\n `[email-suppression] a consent group resolved for ${group.hostId} ` +\n `cannot decide a send from ${hostId}`,\n )\n }\n return consentGroupOptOutHosts(group)\n}\n\n/**\n * One keyed `getAll` per site in `hostIds`, positionally aligned with `keys`.\n *\n * Per site rather than one call over every (site, address) pair, so each\n * round trip is exactly the size the single-site read always was — a batch\n * of a campaign — and a group multiplies the number of calls, run in\n * parallel, rather than the size of one. A group of one is one call, over\n * the same references in the same order.\n *\n * Rejects when any read does; each caller keeps its own posture on that.\n */\nexport async function getAllAcrossSites(\n db: any,\n hostIds: readonly string[],\n subcollection: string,\n keys: readonly string[],\n): Promise<any[][]> {\n return Promise.all(\n hostIds.map((id) => {\n const list = db.collection('hosts').doc(id).collection(subcollection)\n return db.getAll(...keys.map((key) => list.doc(key)))\n }),\n )\n}\n\n/**\n * The sendable subset for a send made in ONE SITE's name — BOTH lists.\n *\n * A per-site suppression says \"not from this site\". A platform suppression\n * says the address hard-bounced or somebody pressed \"report spam\", learned\n * anywhere in the product — including on transactional mail that carried no\n * site tag at all and could therefore never have reached the per-site list.\n * Consulting only the site's own list mails a known-dead or complaining\n * address from the one shared sending domain every tenant's mail leaves by,\n * which makes it every tenant's deliverability problem rather than one\n * merchant's.\n *\n * Composed from {@link filterSuppressedEmails} rather than reimplementing the\n * platform half: normalization, de-duplication and the fail-closed posture\n * live there, and a second copy of them is a second set of rules for two\n * senders to disagree about.\n *\n * ## \"This site\" means its consent group\n *\n * `group` is the sending site's consent group, and an entry on ANY of its\n * sites' lists withholds — see the section above. Every entry counts, whatever\n * its reason: an unsubscribe and a hand-added opt-out are the person leaving\n * the sender, an erasure is already written on every site of the workspace,\n * and a bounce or a complaint is on the platform list besides. Omitted, the\n * site alone, which is also the whole answer for an org that declared no\n * group.\n *\n * ## Both halves fail CLOSED\n *\n * A read that throws answers \"suppressed\". The platform half already does; the\n * per-site half matches it, because a list we could not read is not a list\n * that said this address is safe to mail. The cost of the other choice is a\n * message delivered to somebody who asked us to stop.\n *\n * ## One `getAll`, keyed, rather than a scan of the collection\n *\n * The per-site half looks up exactly the addresses being mailed, by document\n * id, in one round trip per site. Reading the whole collection instead —\n * which is what the campaign sender did — is bounded by however large the\n * collection has grown, so a site with more suppressions than the read window\n * fails OPEN on the remainder: the people most certain not to want the mail\n * are the ones a truncated read drops.\n */\nexport async function filterSendableForHost(\n hostId: string,\n emails: readonly string[],\n injectedFirestore?: any,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null,\n): Promise<string[]> {\n const hostIds = optOutHostIds(hostId, group)\n const platformSendable = await filterSuppressedEmails(\n emails,\n injectedFirestore,\n )\n // `getAll` rejects an empty reference list, and there is nothing to ask.\n if (!platformSendable.length) return []\n const db = injectedFirestore ?? defaultFirestore()\n // Every survivor of the platform half is keyable — `isEmailSuppressed`\n // answers `true` for an address it cannot key — so this narrows the type\n // rather than dropping anybody.\n const keyed: Array<{ email: string; key: string }> = []\n for (const email of platformSendable) {\n const key = emailSuppressionKey(email)\n if (key) keyed.push({ email, key })\n }\n if (!keyed.length) return []\n try {\n const bySite = await getAllAcrossSites(\n db,\n hostIds,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n keyed.map((entry) => entry.key),\n )\n return keyed\n .filter(\n (_entry, index) =>\n !bySite.some((snapshots) => snapshots[index]?.exists),\n )\n .map((entry) => entry.email)\n } catch (error) {\n console.error(\n '[email-suppression] per-site lookup failed; failing closed',\n error,\n )\n return []\n }\n}\n\n/**\n * The subset of `emails` that may be mailed about `topicId` on this site.\n *\n * The third filter a campaign passes, after the platform list and the site's\n * own: the two suppression lists answer \"may we mail this person at all\", and\n * this answers \"may we mail them about THIS\". A recipient who unticked\n * \"Promotions and offers\" on the preference page is not suppressed — they\n * still get the newsletter — so the fact cannot live on either suppression\n * list without meaning something it does not mean.\n *\n * ## Two ways to be excluded, and both are held here\n *\n * Somebody who LEFT the stream, and somebody who has been asked to confirm\n * joining it and has not. The second is what makes a double opt-in worth\n * recording: an unconfirmed subscriber has to be a real quarantine, not a\n * field the send path never looks at. Both come back from\n * `readTopicSubscriptionState`, which is the only place the three states are\n * decided.\n *\n * Keyed by {@link emailSuppressionKey} and read with one `getAll`, which is\n * both halves of the point: the same derivation as the two lists it runs\n * beside, so one person is one document id everywhere; and one round trip\n * bounded by the size of the send, so adding topics does not make a campaign\n * cost more to resolve.\n *\n * ## This one fails OPEN, and that is the opposite of its neighbors\n *\n * Every other filter in this module answers \"suppressed\" when a read throws,\n * because the cost of guessing wrong is mailing somebody who told us to stop.\n * Here the cost of guessing wrong in that direction is refusing to send a\n * newsletter somebody asked for, on a read that failed for an unrelated\n * reason — and the campaign has already passed both suppression lists, so\n * nobody who asked us to stop entirely can reach this line. A topic\n * preference is a narrower fact than a suppression and it gets the treatment\n * that matches. A caller that wants the strict posture already has it one\n * layer up.\n *\n * An empty `topicId` is a campaign from before topics existed, or one whose\n * topic was never resolved. It filters nobody: there is no stream to have\n * left.\n *\n * ## Leaving a stream is leaving it for the whole consent group\n *\n * The topic catalog is the org's, so \"Newsletter\" on one site of a declared\n * group is the same stream on its siblings, and a person who left it on one\n * has left it from the sender. `group` extends the read to every site in it,\n * exactly as {@link filterSendableForHost} does for the suppression list.\n *\n * A sibling's REFUSAL always holds. A sibling's pending confirmation holds\n * only when the group `awaitsConfirmation` — the org's switch, resolved into\n * the group, so asking it reads nothing the refusal read did not already\n * read. Off, the question stays the concern of the site whose form asked it.\n */\nexport async function filterTopicSendable(\n hostId: string,\n topicId: string | null | undefined,\n emails: readonly string[],\n injectedFirestore?: any,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds' | 'awaitsConfirmation'> | null,\n): Promise<string[]> {\n const hostIds = optOutHostIds(hostId, group)\n const topic = String(topicId ?? '').trim()\n if (!topic || !emails.length) return [...emails]\n // An unkeyable address cannot carry an opt-out record, so it cannot have\n // left this topic. It is dropped from the LOOKUP and kept in the answer —\n // the suppression filters above have already refused it on their own\n // stricter rule, so this one has no business refusing it a second time.\n const lookups: Array<{ email: string; key: string }> = []\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key) lookups.push({ email, key })\n }\n if (!lookups.length) return [...emails]\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const bySite = await getAllAcrossSites(\n db,\n hostIds,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n lookups.map((entry) => entry.key),\n )\n /*\n * The shared state reader, never a field test written out here.\n *\n * An entry means one of three things and only that function knows all\n * three. The shorthand this replaced — \"an entry with no\n * `resubscribedAt` is a live opt-out\" — reads a CONFIRMED double\n * opt-in, which carries `pendingAt` and `confirmedAt` and no\n * `resubscribedAt`, as somebody who left.\n */\n const stateIn = (snapshot: any) =>\n snapshot?.exists\n ? readTopicSubscriptionState((snapshot.get('topics') ?? {})[topic])\n : 'subscribed'\n // The sending site first, as `optOutHostIds` ordered the reads — which\n // is how the fold tells its own pending question from a sibling's.\n const awaits = { awaitsConfirmation: group?.awaitsConfirmation === true }\n const gone = new Set<string>()\n lookups.forEach((entry, index) => {\n const states = bySite.map((snapshots) => stateIn(snapshots[index]))\n if (consentGroupTopicState(awaits, states) !== 'subscribed') {\n gone.add(entry.email)\n }\n })\n return emails.filter((email) => !gone.has(email))\n } catch (error) {\n console.error(\n '[email-suppression] topic opt-out lookup failed; failing open',\n error,\n )\n return [...emails]\n }\n}\n\n/*\n * THE FOURTH FILTER IS NOT IN THIS FILE, and where it is is forced.\n *\n * `filterCadenceSendable` — the subset that has not asked this site for mail\n * less often than right now — lives in `email-marketing-gate.ts`, because it\n * reads the per-recipient counter document that module owns and names. That\n * module already imports this one for the two suppression lists, so putting\n * the cadence filter here would close a cycle. A campaign's subtraction chain\n * therefore reads: this file's two lists, this file's topic opt-outs, then\n * that file's cadence.\n */\n\n/**\n * The staff queue, newest first, ordered by `suppressedAt` so a re-recorded or\n * released entry surfaces again — the operator's question is \"what changed\",\n * not \"what was first written\". Mirrors `listContactSuppressions`.\n */\nexport async function listEmailSuppressions(options?: {\n limit?: number\n /**\n * Where the NEXT page starts: the `suppressedAt` of the last row already\n * shown, as `seconds.nanoseconds`.\n *\n * The full timestamp rather than milliseconds, because `startAfter` skips\n * exactly the value it is given: a millisecond-truncated cursor sits BEFORE\n * the record it names, so that record would arrive again at the top of the\n * following page. Repeating a row is a smaller fault than skipping one and\n * neither is necessary.\n */\n startAfter?: string | null\n /**\n * Predicates the caller adds beneath the ordering — equalities, one\n * `array-contains`, and ranges over `suppressedAt` itself, which are the\n * only shapes that keep this cursor valid. It must not add an `orderBy`.\n */\n narrow?: (query: any) => any\n firestore?: any\n}): Promise<Array<EmailSuppressionRecord & { $id: string }>> {\n const db = options?.firestore ?? defaultFirestore()\n const collection = db.collection(EMAIL_SUPPRESSIONS_COLLECTION)\n let query = (options?.narrow ? options.narrow(collection) : collection).orderBy(\n 'suppressedAt',\n 'desc',\n )\n const cursor = suppressionCursorTimestamp(options?.startAfter)\n if (cursor) query = query.startAfter(cursor)\n const snapshot = await query\n .limit(Math.min(Math.max(options?.limit ?? 100, 1), 500))\n .get()\n return snapshot.docs.map((doc: any) => ({\n $id: doc.id,\n ...(doc.data() as EmailSuppressionRecord),\n }))\n}\n\n/**\n * The cursor a page hands back, from the last row on it.\n *\n * Null when the row carries no `suppressedAt` — every entry written since\n * AGL-1918 does, and `orderBy` has already excluded any that does not, so\n * this is the type narrowing rather than a case that occurs.\n */\nexport function suppressionCursorFrom(\n record: Record<string, any> | null | undefined,\n): string | null {\n const at = record?.['suppressedAt'] as\n | { seconds?: number; _seconds?: number; nanoseconds?: number; _nanoseconds?: number }\n | undefined\n const seconds = at?.seconds ?? at?._seconds\n if (typeof seconds !== 'number') return null\n const nanoseconds = at?.nanoseconds ?? at?._nanoseconds ?? 0\n return `${seconds}.${nanoseconds}`\n}\n\n/** The inverse, for the query. Invalid input yields no cursor, never a guess. */\nexport function suppressionCursorTimestamp(\n cursor: string | null | undefined,\n): Timestamp | null {\n const [rawSeconds, rawNanos] = String(cursor ?? '').split('.')\n const seconds = Number(rawSeconds)\n const nanoseconds = Number(rawNanos ?? 0)\n if (!Number.isFinite(seconds) || !seconds) return null\n return new Timestamp(seconds, Number.isFinite(nanoseconds) ? nanoseconds : 0)\n}\n\n/*==========================================\n * THE ERASURE ROW ON THE PER-SITE LIST (AGL-2623).\n *\n * A person erased from a workspace must not be quietly rebuilt by the next\n * form they fill in or the next order they place, and must not be mailed by\n * a campaign either. Both gates already read `hosts/{hostId}/suppressions`\n * — every campaign filter refuses an address with a row there — so the\n * erasure writes one row per site of the workspace rather than inventing a\n * third list, and the capture door reads the same row.\n *\n * The row carries NO address. Every other writer of this list stores the\n * email in the clear beside the hash, because a hub admin releasing an\n * unsubscribe needs to see who it was; an erasure row is the one whose\n * whole purpose is that the address is no longer held, so the id — the\n * hash — is the entire record, and a row that already held the address in\n * the clear has it removed. The hub's list renders such a row as an entry\n * with no address, which it already knows how to do.\n *\n * Per SITE, not platform-wide, on purpose: the erasure was asked of one\n * workspace, and a second workspace that knows the same person has a\n * relationship this request has no claim on. The platform list is for\n * bounces and complaints, which are facts about the address everywhere.\n *=========================================*/\n\n/** The `reason` an erasure row carries. Read by the capture door, below. */\nexport const HOST_ERASURE_SUPPRESSION_REASON = 'erasure'\n\n/**\n * Suppress an address on one site because the person was erased. Idempotent:\n * a second erasure of the same person merges onto the same row.\n */\nexport async function suppressEmailForHostErasure(input: {\n hostId: string\n email: string\n firestore?: any\n}): Promise<{ key: string; created: boolean } | null> {\n const key = emailSuppressionKey(input.email)\n if (!key) return null\n const db = input.firestore ?? defaultFirestore()\n const ref = db\n .collection('hosts')\n .doc(input.hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n const snapshot = await ref.get()\n await ref.set(\n {\n email: null,\n // The address's search tokens are the address in pieces, so an erasure\n // clears them with it (AGL-3321).\n emailTokens: [],\n reason: HOST_ERASURE_SUPPRESSION_REASON,\n suppressedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n return { key, created: !snapshot.exists }\n}\n\n/**\n * Whether a site has erased this address, so a capture must not create a\n * contact for it. Only an ERASURE row refuses: an ordinary unsubscribe is a\n * mailing preference, and a person who unsubscribed and then bought\n * something is still a customer whose order the CRM should know about.\n *\n * Fails OPEN. A read that throws answers `false` and logs: the alternative\n * refuses every capture on the site for as long as the list is unreadable,\n * which turns a transient read failure into a silent drop of the site's\n * leads and orders. The window this leaves — a recreate during an outage\n * of the suppression read — is logged, and the next erasure run finds the\n * recreated record by the same address.\n */\nexport async function hostRefusesCaptureForErasure(\n hostId: string,\n email: string | null | undefined,\n injectedFirestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(email)\n if (!key) return false\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const snapshot = await db\n .collection('hosts')\n .doc(hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n .get()\n return (\n Boolean(snapshot?.exists) &&\n snapshot.get('reason') === HOST_ERASURE_SUPPRESSION_REASON\n )\n } catch (error) {\n console.error(\n '[email-suppression] erasure lookup failed; the capture proceeds',\n error,\n )\n return false\n }\n}\n\n/*==========================================\n * A LOCKED ACCOUNT, ON THE HOUSE WORKSPACE'S SITES (AGL-3420)\n *\n * Aglyn's own campaigns and sequences go to the house workspace's contacts,\n * which are keyed by address and know nothing about accounts. A locked\n * account's addresses are therefore suppressed on each of the house sites\n * for as long as the lock stands, so every sender that honors a site's list\n * — campaigns, sequences, workflow and CRM mail — leaves them alone.\n *\n * Per SITE and only on the house workspace's: the lock is our relationship\n * with the person, and another workspace that knows the same address has one\n * of its own that the lock has no claim on. A ban reaches everyone through\n * the platform list instead.\n *\n * Written only where no row exists, and lifted only where this row still\n * stands for this account. So an unsubscribe, bounce or erasure already on\n * the list is never rewritten or deleted by a lock or its lift.\n *=========================================*/\n\n/** The `reason` a lock's per-site row carries. */\nexport const HOST_ACCOUNT_LOCK_SUPPRESSION_REASON = 'account_lock'\n\n/**\n * Suppress an address on one site because the account holding it is\n * locked. Answers whether it wrote a row: false when one already stood.\n */\nexport async function suppressEmailForAccountLock(input: {\n hostId: string\n email: string\n uid: string\n firestore?: any\n}): Promise<boolean> {\n const key = emailSuppressionKey(input.email)\n if (!key) return false\n const db = input.firestore ?? defaultFirestore()\n const ref = db\n .collection('hosts')\n .doc(input.hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n const email = String(input.email).trim().toLowerCase()\n let created = false\n await db.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n created = !existing.exists\n if (!created) return\n transaction.set(ref, {\n email,\n emailTokens: emailSearchTokens(email),\n reason: HOST_ACCOUNT_LOCK_SUPPRESSION_REASON,\n subjectUid: input.uid,\n suppressedAt: FieldValue.serverTimestamp(),\n createdAt: FieldValue.serverTimestamp(),\n })\n })\n return created\n}\n\n/**\n * Lift a lock's row on one site: deleted only while it is still this\n * account's lock row. Answers whether it deleted one.\n */\nexport async function releaseAccountLockSuppression(input: {\n hostId: string\n email: string\n uid: string\n firestore?: any\n}): Promise<boolean> {\n const key = emailSuppressionKey(input.email)\n if (!key) return false\n const db = input.firestore ?? defaultFirestore()\n const ref = db\n .collection('hosts')\n .doc(input.hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n let deleted = false\n await db.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n deleted =\n Boolean(existing.exists) &&\n existing.get('reason') === HOST_ACCOUNT_LOCK_SUPPRESSION_REASON &&\n existing.get('subjectUid') === input.uid\n if (deleted) transaction.delete(ref)\n })\n return deleted\n}\n"],"names":["FieldValue","Timestamp","consentGroupOptOutHosts","consentGroupTopicState","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","emailSearchTokens","personKey","stampRecordEmailState","firebaseAdmin","defaultFirestore","app","firestore","EMAIL_SUPPRESSIONS_COLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","PLATFORM_SUPPRESSION_REASONS","ACCOUNT_BAN_SUPPRESSION_REASON","UNSUBSCRIBE_SUPPRESSION_REASON","emailSuppressionKey","email","isLiveBanRow","snapshot","Boolean","exists","get","suppressEmail","input","key","Error","includes","reason","db","ref","collection","doc","created","String","trim","toLowerCase","priorReason","set","emailTokens","context","hostId","subjectUid","releasedAt","released","suppressedAt","serverTimestamp","createdAt","merge","stampRecord","state","status","atMs","Date","now","source","detail","releaseEmail","releasedByUid","releasedNote","note","releasedVia","isAccountBanSuppression","injectedFirestore","error","console","releaseAccountBanSuppressions","uid","where","docs","delete","forgetAccountBans","ACCOUNT_BAN_CACHE_MS","accountBanCache","accountBanPending","accountBanKeys","at","keys","Set","add","id","accountBannedRecipients","emails","banned","length","size","has","OPT_IN_RELEASABLE_REASONS","releaseEmailForConfirmedOptIn","releasedHostId","releasedTopicId","topicId","isEmailSuppressed","filterSuppressedEmails","seen","candidates","normalized","push","verdicts","Promise","all","map","filter","_email","index","optOutHostIds","group","getAllAcrossSites","hostIds","subcollection","list","getAll","filterSendableForHost","platformSendable","keyed","bySite","entry","_entry","some","snapshots","filterTopicSendable","topic","lookups","stateIn","awaits","awaitsConfirmation","gone","forEach","states","listEmailSuppressions","options","query","narrow","orderBy","cursor","suppressionCursorTimestamp","startAfter","limit","Math","min","max","$id","data","suppressionCursorFrom","record","seconds","_seconds","nanoseconds","_nanoseconds","rawSeconds","rawNanos","split","Number","isFinite","HOST_ERASURE_SUPPRESSION_REASON","suppressEmailForHostErasure","hostRefusesCaptureForErasure","HOST_ACCOUNT_LOCK_SUPPRESSION_REASON","suppressEmailForAccountLock","runTransaction","transaction","existing","releaseAccountLockSuppression","deleted"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-suppression.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-WIDE email suppression list, `emailSuppressions/{emailKey}`\n * (AGL-2407).\n *\n * ## What was missing\n *\n * AGL-1918 (`7a8f3cd68`) made `email.bounced` (permanent) and\n * `email.complained` write a suppression — into `hosts/{hostId}/suppressions`,\n * found from a Resend tag that only `campaign-send.ts` stamped. That closed\n * the campaign half and could close no other, because there was nowhere to\n * file the rest:\n *\n * Every other sender in the product goes through the shared `sendEmail` —\n * invites,\n * password resets, verification, receipts, booking confirmations, the monthly\n * usage summary, the usage-alert fan-out, restock and abandoned-cart mail, the\n * merchant-authored workflow `sendEmail` step. A bounce on any of those\n * arrived at the webhook with no site to place it against and was answered\n * `200 {ignored:true}`. A dead address was re-mailed on every subsequent send,\n * forever, and a spam complaint had no effect on anything.\n *\n * That matters most exactly when it is hardest to fix: on Sept 1 the signup\n * door opens, and verification and invite mail — addressed by strangers typing\n * addresses — is the highest-bounce-rate mail we send, on the same Resend key\n * and the same From address as everything else.\n *\n * ## Why a sibling of `contactSuppressions` and not a channel on it\n *\n * `contact-suppression.ts` is the same idea for phones and every argument it\n * makes holds here: keyed on the identifier because that is what the sender\n * holds at send time; one `get()` by document id, no query and no composite\n * index that could go missing and fail the lookup open; a revocation is a\n * FIELD and not a delete, because the record is the evidence that the\n * suppression was honoured while it was in force.\n *\n * It is a separate collection for one concrete reason: the document id. That\n * list's key is the E.164 digits, and its docblock argues at length that a\n * phone number must NOT be hashed (NANP is ~10^10 values, so a hash buys the\n * appearance of de-identification and none of the substance). An email address\n * is not enumerable, `hosts/{hostId}/suppressions` already keys on\n * `sha256(email)`, and one collection holding two incompatible key spaces is\n * how a lookup comes to be performed against the wrong derivation. The\n * address is stored in the document in the clear regardless, exactly as the\n * per-host list stores it, so a staff reader still shows a human something\n * they can act on.\n *\n * ## Who reads it, and who deliberately does NOT\n *\n * AGL-1438's rule is that a QUOTA may only ever refuse a campaign, because\n * refusing a receipt or a password reset converts a billing event into an\n * outage on a customer's business. A suppression is not a quota, but the same\n * proportionality applies and lands in the same place:\n *\n * - **Bulk mail consults this list.** The monthly usage summary, the\n * usage-alert fan-out and the marketplace review fan-out to a publisher's\n * owners and admins go to a LIST of people who did not ask for that\n * particular message, on a schedule or on every submission, forever. They\n * are the sends that re-hit a dead mailbox every month and teach a mailbox\n * provider that `aglyn.com` does not listen.\n * - **Transactional mail does NOT.** A password reset, a verification, an\n * invite, a receipt or a booking confirmation answers something the human\n * just did. Refusing one because an address bounced or because somebody\n * once pressed \"report spam\" on an unrelated message would lock a real\n * customer out of their own account. The shared sender is therefore left\n * unconditional on purpose, and this is the note that says so.\n *\n * (Written throughout without a literal call expression, on purpose:\n * `email-send-metering-coverage.spec.ts` enumerates senders by grepping the\n * tree for one, and a prose mention here would enrol a module that sends\n * nothing into the AGL-1438 cost-meter sweep.)\n */\n\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\n// The leaf entry, not the `@aglyn/aglyn` barrel, for the reason\n// `document-id.ts` gives at length: a barrel import resolves to whatever a\n// spec's `jest.mock` happens to contain, and this module's neighbours are\n// mocked in nearly every spec that touches them.\nimport {\n consentGroupOptOutHosts,\n consentGroupTopicState,\n type ConsentGroup,\n} from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport { emailSearchTokens } from '@aglyn/aglyn/app-utils/email-search'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport firebaseAdmin from './firebase-admin'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\nexport const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions'\n\n/**\n * The PER-SITE list, `hosts/{hostId}/suppressions/{emailKey}` — unsubscribes\n * from one site's campaigns, plus the bounces and complaints that arrived\n * carrying that site's tag.\n *\n * Named here rather than at each call site because the two lists are read\n * together by {@link filterSendableForHost}, and a sender that knows one path\n * as a string literal and the other through this module is one rename away\n * from consulting a collection that does not exist and finding nobody\n * suppressed.\n */\nexport const HOST_SUPPRESSIONS_SUBCOLLECTION = 'suppressions'\n\n/**\n * Why an address is suppressed.\n *\n * `bounce` means PERMANENT only. Resend reports `data.bounce.type` as\n * `Permanent` or `Transient`, and a transient bounce is a full mailbox or a\n * greylisting server — recording one here would suppress a real recipient over\n * a temporary condition at their provider. The webhook makes that distinction;\n * this type only names the outcome.\n */\nexport type EmailSuppressionReason =\n /** Permanent bounce — the mailbox does not exist. */\n | 'bounce'\n /** The recipient pressed \"report spam\". */\n | 'complaint'\n /** Recorded by staff (a written request, a court order, a correction). */\n | 'staff'\n /**\n * The address's domain takes no mail — no MX and no address record, or a\n * null MX — as the deliverability preflight found before a send\n * (AGL-3328). Filed without a bounce ever happening, which is the point:\n * the bounce is what it prevents. A fact about the address for everyone,\n * like a bounce; a staff release lifts it, and the preflight files it\n * again only if the domain still takes no mail.\n */\n | 'no_mail_server'\n /**\n * The account holding this address is BANNED — locked for phishing, fraud\n * or malicious content (AGL-3420). Not a mailing preference and not a\n * deliverability fact: the platform's own decision that this address is\n * sent nothing, by any sender, for any purpose, once the ban notice has\n * gone. Unlike every other reason it is also read on TRANSACTIONAL mail,\n * by the deliverability preflight on `sendEmail`. Only lifting the ban\n * releases it — see {@link ACCOUNT_BAN_SUPPRESSION_REASON}.\n */\n | 'account_ban'\n\n/**\n * The reasons that may be filed PLATFORM-WIDE, as a runtime set.\n *\n * ## The split this constant enforces\n *\n * A bounce and a complaint are facts about a MAILBOX and about the sending\n * domain every tenant's mail leaves by: the address does not exist for\n * anybody, and somebody who pressed \"report spam\" on `noreply@aglyn.com` has\n * told every sender behind that domain at once. Those belong everywhere,\n * immediately.\n *\n * An UNSUBSCRIBE is not that. It is a preference a person expressed to ONE\n * brand — the sender line they read, the newsletter they joined — and an\n * agency running twelve unrelated clients out of one account would, on a\n * platform-wide entry, stop mailing that person on behalf of eleven brands\n * they never heard from. So an unsubscribe lives only in\n * `hosts/{hostId}/suppressions`, and {@link suppressEmail} refuses it.\n *\n * ## Why a runtime refusal and not just the type\n *\n * {@link EmailSuppressionReason} already excludes it, and a type is not a\n * guard: every caller here builds its reason from a webhook payload or a\n * ternary, `as` casts exist, and the plugin API surface is untyped at the\n * boundary. The cost of the type being wrong once is an opt-out from one\n * brand silently applied to every brand in the account, which is invisible\n * from the console — the mail simply never arrives, and the merchant's own\n * suppression list does not mention it.\n */\nexport const PLATFORM_SUPPRESSION_REASONS: readonly EmailSuppressionReason[] = [\n 'bounce',\n 'complaint',\n 'staff',\n 'no_mail_server',\n 'account_ban',\n]\n\n/**\n * The ban reason, named because four rules hang on it: a later failure never\n * rewrites it ({@link suppressEmail}), no staff release or opt-in lifts it\n * ({@link releaseEmail}, {@link releaseEmailForConfirmedOptIn}), only\n * {@link releaseAccountBanSuppressions} does, and the preflight refuses it on\n * every send ({@link accountBannedRecipients}).\n */\nexport const ACCOUNT_BAN_SUPPRESSION_REASON = 'account_ban' satisfies EmailSuppressionReason\n\n/**\n * The reason a self-service opt-out is filed under, on the PER-SITE list.\n *\n * Named because three call sites compare against it — the unsubscribe writes\n * it, the resubscribe link refuses to reverse anything else, and the\n * preference page reads it to decide whether an address may opt back in. A\n * literal in three places is a literal that can be changed in two.\n */\nexport const UNSUBSCRIBE_SUPPRESSION_REASON = 'unsubscribe'\n\nexport interface EmailSuppressionRecord {\n /** The address, in the clear, lowercased. The id is its hash. */\n email: string\n reason: EmailSuppressionReason\n /**\n * The `context` tag the send carried (`'invite'`, `'usage-summary'`,\n * `'campaign'`, …), so a human reading the list can tell which of our\n * senders produced the address that died. Null when the send carried none.\n */\n context: string | null\n /** The site the failure was attributed to, when the send named one. */\n hostId: string | null\n /** Non-null once released. A released record does not suppress. */\n releasedAt: unknown | null\n /**\n * `releasedAt` as a boolean, which the staff list filters by EQUALITY\n * beneath its `suppressedAt` cursor: \"released\" read from `releasedAt`\n * would be `!= null`, an inequality Firestore makes the first sort.\n * Absent on records written before it existed until\n * `tools/scripts/backfill-email-suppression-filters.mjs` stamps them.\n */\n released?: boolean\n /** Search tokens for the address; see {@link emailSearchTokens}. */\n emailTokens?: string[]\n /** What lifted it. Absent on records written before releases were typed. */\n releasedVia?: EmailReleaseChannel\n /** The site whose confirmed double opt-in lifted it, when one did. */\n releasedHostId?: string | null\n /** The stream that was confirmed, likewise. */\n releasedTopicId?: string | null\n}\n\n/**\n * Document id for an address: `sha256` of the lowercased, trimmed form.\n *\n * The SAME derivation `campaign-send.ts` uses for `hosts/{hostId}/suppressions`\n * — deliberately, so the two lists can never disagree about which document\n * describes which person.\n *\n * @returns the id, or `null` for anything that is not an address. Never a\n * best-guess id: suppressing the wrong person and believing you\n * suppressed the right one is worse than refusing.\n */\nexport function emailSuppressionKey(\n email: string | null | undefined,\n): string | null {\n /*\n * Delegated, not reimplemented. A second hash of the same address is how\n * this area got two derivations that agreed only by accident; `personKey`\n * is the one that normalizes and the one the unsubscribe route already\n * hashes through, so a suppression filed by either is found by both.\n */\n return personKey(email)\n}\n\n/**\n * The `array-contains` search tokens for an address — shared with every list\n * that searches one (`@aglyn/aglyn/app-utils/email-search`), re-exported here\n * for the callers that have always imported it from this module.\n *\n * Mirrored for the backfills that stamp the rows written before the field\n * (`backfill-email-suppression-filters.mjs`, `backfill-host-suppression-filters.mjs`,\n * `backfill-email-list-filters.mjs`) by ONE script-side twin,\n * `tools/scripts/lib/email-search-tokens.mjs`; the spec and the twin's test\n * both assert `email-search-tokens.fixtures.json`.\n */\nexport { emailSearchTokens }\n\nexport interface SuppressEmailInput {\n email: string\n reason: EmailSuppressionReason\n /** The `context` tag the failed send carried, when it carried one. */\n context?: string | null\n /** The site the failed send was attributed to, when it named one. */\n hostId?: string | null\n /**\n * Whether to say so on the person's record too (AGL-3245); on by default.\n * A sender that stamps a richer verdict of its own passes `false`.\n */\n stampRecord?: boolean\n /**\n * The account the address belongs to, on a ban row, so lifting that\n * account's ban releases exactly its rows.\n */\n subjectUid?: string | null\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n}\n\n/** A live ban row: nothing but lifting the ban may change or release it. */\nconst isLiveBanRow = (snapshot: any): boolean =>\n Boolean(snapshot?.exists) &&\n !snapshot.get('releasedAt') &&\n snapshot.get('reason') === ACCOUNT_BAN_SUPPRESSION_REASON\n\n/**\n * Record a platform-wide suppression. Idempotent by document id.\n *\n * `createdAt` is stamped only when the document is new, matching the per-host\n * list: a second bounce must not overwrite the moment the address first went\n * bad, because that is the date a human is told when they ask. `suppressedAt`\n * moves every time, so \"when did we last see this fail\" stays answerable.\n *\n * A previously released record is un-released — a fresh failure is a fresh\n * failure.\n *\n * @returns `created: false` when the address was already on the list.\n */\nexport async function suppressEmail(input: SuppressEmailInput): Promise<{\n key: string\n created: boolean\n}> {\n const key = emailSuppressionKey(input.email)\n if (!key) {\n throw new Error('[email-suppression] cannot key a suppression for that value')\n }\n if (!PLATFORM_SUPPRESSION_REASONS.includes(input.reason)) {\n throw new Error(\n `[email-suppression] ${input.reason} is a per-site preference and ` +\n 'cannot be filed platform-wide',\n )\n }\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n // A bounce or complaint arriving for a banned address must not rewrite the\n // ban as a reason an opt-in or a staff release could lift.\n if (input.reason !== ACCOUNT_BAN_SUPPRESSION_REASON && isLiveBanRow(snapshot)) {\n return { key, created: false }\n }\n const email = String(input.email).trim().toLowerCase()\n // A ban filed over a live suppression of another kind keeps that reason,\n // so lifting the ban puts the complaint or bounce back rather than\n // releasing it with the ban.\n const priorReason =\n input.reason === ACCOUNT_BAN_SUPPRESSION_REASON &&\n snapshot.exists &&\n !snapshot.get('releasedAt') &&\n snapshot.get('reason') !== ACCOUNT_BAN_SUPPRESSION_REASON\n ? (snapshot.get('reason') as EmailSuppressionReason)\n : null\n await ref.set(\n {\n email,\n emailTokens: emailSearchTokens(email),\n reason: input.reason,\n context: input.context ?? null,\n hostId: input.hostId ?? null,\n ...(input.subjectUid ? { subjectUid: input.subjectUid } : {}),\n ...(priorReason ? { priorReason } : {}),\n releasedAt: null,\n released: false,\n suppressedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n /*\n * The same verdict on the record the person reads (AGL-3245), through\n * whichever plugin keeps the workspace's records: a bounce or a complaint\n * on a send that named a site stamps the site's records. A send that\n * named none has no records to find, and a caller that stamps its own,\n * richer verdict — the sequence runtime, with the domain block and the\n * enrollment — says `stampRecord: false` and is left to it.\n */\n if (input.hostId && input.stampRecord !== false && input.reason !== 'staff') {\n await stampRecordEmailState({\n hostId: input.hostId,\n email: input.email,\n state: {\n status:\n input.reason === 'complaint'\n ? 'complained'\n : input.reason === 'no_mail_server'\n ? 'undeliverable'\n : 'bounced',\n atMs: Date.now(),\n source: input.reason === 'no_mail_server' ? 'check' : 'campaign',\n detail: input.context ? `Reported by the ${input.context} send.` : null,\n },\n })\n }\n return { key, created: !snapshot.exists }\n}\n\n/**\n * What lifted a suppression, recorded on the record it lifted.\n *\n * Written explicitly by both paths rather than left to \"absent means staff\".\n * The two are held to different rules — a staff release may lift any reason,\n * a confirmed opt-in may lift exactly one — so the audit answer to \"how did\n * this address get back on the domain\" has to be a stored fact and not an\n * inference from which field happens to be missing.\n */\nexport type EmailReleaseChannel =\n /** A staff correction through the admin suppressions surface. */\n | 'staff'\n /** A recipient completed a double opt-in from this address. */\n | 'double-opt-in'\n /** Staff lifted the ban on the account holding it (AGL-3420). */\n | 'ban-lifted'\n\n/**\n * Put an address back in circulation (a staff correction, or the person asking\n * to be re-added). The record is kept and marked released, never deleted —\n * same reasoning as `releasePhoneContact`: a deleted record cannot show that\n * the suppression was honored while it stood.\n *\n * Unconditional on the reason, and that is what separates it from\n * {@link releaseEmailForConfirmedOptIn}: a human with the staff role has read\n * the record and is accountable for the row, so they may lift a complaint —\n * a report filed against the wrong message, an address entered by mistake.\n * Nothing automatic gets that latitude.\n *\n * Except a ban (AGL-3420): an `account_ban` row belongs to the account's\n * lock, and is lifted by lifting the ban, never from this list — releasing\n * one here would put a banned account back on every sender while the\n * account itself stays locked.\n *\n * @returns false when there was no live record to release, or the record is\n * a ban.\n */\nexport async function releaseEmail(input: {\n email: string\n releasedByUid?: string | null\n note?: string | null\n firestore?: any\n}): Promise<boolean> {\n const key = emailSuppressionKey(input.email)\n if (!key) return false\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n if (!snapshot.exists || snapshot.get('releasedAt')) return false\n if (isLiveBanRow(snapshot)) return false\n await ref.set(\n {\n releasedAt: FieldValue.serverTimestamp(),\n released: true,\n releasedByUid: input.releasedByUid ?? null,\n releasedNote: input.note ?? null,\n releasedVia: 'staff' satisfies EmailReleaseChannel,\n },\n { merge: true },\n )\n return true\n}\n\n/** Whether a live ban row holds this address. Fails open, logging. */\nexport async function isAccountBanSuppression(\n email: string | null | undefined,\n injectedFirestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(email)\n if (!key) return false\n try {\n const db = injectedFirestore ?? defaultFirestore()\n return isLiveBanRow(\n await db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key).get(),\n )\n } catch (error) {\n console.error('[email-suppression] ban lookup failed', error)\n return false\n }\n}\n\n/**\n * Lift a ban's rows: every live `account_ban` row filed for `uid`, and only\n * those. Kept and marked released, like every other release. Answers how\n * many it released.\n */\nexport async function releaseAccountBanSuppressions(input: {\n uid: string\n releasedByUid?: string | null\n firestore?: any\n}): Promise<number> {\n if (!input.uid) return 0\n const db = input.firestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_SUPPRESSIONS_COLLECTION)\n .where('subjectUid', '==', input.uid)\n .get()\n let released = 0\n for (const doc of snapshot.docs) {\n if (!isLiveBanRow(doc)) continue\n const priorReason = doc.get('priorReason')\n await doc.ref.set(\n PLATFORM_SUPPRESSION_REASONS.includes(priorReason)\n ? // The suppression the ban was filed over stands again.\n {\n reason: priorReason,\n priorReason: FieldValue.delete(),\n subjectUid: FieldValue.delete(),\n }\n : {\n releasedAt: FieldValue.serverTimestamp(),\n released: true,\n releasedByUid: input.releasedByUid ?? null,\n releasedNote: null,\n releasedVia: 'ban-lifted' satisfies EmailReleaseChannel,\n },\n { merge: true },\n )\n released += 1\n }\n forgetAccountBans()\n return released\n}\n\n/*==========================================\n * THE BAN LIST, AS THE PREFLIGHT READS IT (AGL-3420)\n *\n * Every message `sendEmail` sends asks whether a recipient is banned, so the\n * answer cannot cost a read per recipient per send. Bans are few and rarely\n * change, so the live set is read whole — one equality query — and held for\n * a minute. A failed read keeps the last set it had (a ban is not lifted by\n * an outage) and, with none, answers \"nobody\": transactional mail must not\n * stop because the list is unreadable.\n *=========================================*/\n\nconst ACCOUNT_BAN_CACHE_MS = 60_000\nlet accountBanCache: { at: number; keys: Set<string> } | null = null\nlet accountBanPending: Promise<Set<string>> | null = null\n\n/** Drop the held set, so the next send reads the list again. */\nexport function forgetAccountBans(): void {\n accountBanCache = null\n accountBanPending = null\n}\n\nasync function accountBanKeys(injectedFirestore?: any): Promise<Set<string>> {\n if (accountBanCache && Date.now() - accountBanCache.at < ACCOUNT_BAN_CACHE_MS) {\n return accountBanCache.keys\n }\n accountBanPending ??= (async () => {\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_SUPPRESSIONS_COLLECTION)\n .where('reason', '==', ACCOUNT_BAN_SUPPRESSION_REASON)\n .get()\n const keys = new Set<string>()\n for (const doc of snapshot.docs) {\n if (!doc.get('releasedAt')) keys.add(doc.id)\n }\n accountBanCache = { at: Date.now(), keys }\n return keys\n } catch (error) {\n console.error('[email-suppression] ban list read failed', error)\n return accountBanCache?.keys ?? new Set<string>()\n } finally {\n accountBanPending = null\n }\n })()\n return accountBanPending\n}\n\n/** The addresses among `emails` whose account is banned, lowercased. */\nexport async function accountBannedRecipients(\n emails: readonly string[],\n injectedFirestore?: any,\n): Promise<Set<string>> {\n const banned = new Set<string>()\n if (!emails.length) return banned\n const keys = await accountBanKeys(injectedFirestore)\n if (!keys.size) return banned\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key && keys.has(key)) banned.add(String(email).trim().toLowerCase())\n }\n return banned\n}\n\n/**\n * The ONLY reason a completed round trip may lift, as a runtime set.\n *\n * ## Why a bounce is releasable\n *\n * A `bounce` record is a claim about a MAILBOX at a moment: mail addressed\n * here was permanently refused. Somebody clicking a confirmation link that\n * was delivered to that mailbox has refuted the claim with the only evidence\n * that could refute it — the message arrived, a human read it, and the round\n * trip closed. Leaving the record standing after that makes one transient\n * failure recorded as permanent — a mailbox that was full, a receiving server\n * that answered 550 while it was misconfigured — a life sentence no route can\n * undo.\n *\n * ## Why a complaint is NOT, and never will be\n *\n * `complaint` is not a deliverability fact. It is a person stating they do not\n * want this mail, and a round trip proves nothing about that statement — it\n * proves the mailbox works, which nobody doubted. Releasing one here would\n * make a public signup form into a laundry for spam complaints: submit the\n * complainant's address, they receive a confirmation (the shared sender is\n * transactional and consults no list), and one click anywhere in the chain\n * puts them back on the sending domain that the complaint was filed against.\n * The same reasoning refuses `staff`, which is a written request, a\n * correction, or a legal instruction, and is nobody's to reverse from a link.\n *\n * A runtime set and not just the type, for the reason\n * {@link PLATFORM_SUPPRESSION_REASONS} gives: reasons arrive from webhook\n * payloads through an untyped boundary, and the cost of the type being wrong\n * once here is a complaint silently laundered.\n */\nexport const OPT_IN_RELEASABLE_REASONS: readonly EmailSuppressionReason[] = [\n 'bounce',\n]\n\n/** What a completed double opt-in did to a platform suppression. */\nexport type ConfirmedOptInRelease =\n /** A live `bounce` record was lifted. The address is mailable again. */\n | 'released'\n /** Nothing was suppressed, or it was already released. */\n | 'nothing-to-release'\n /** A record stands that a round trip may not lift — see the constant. */\n | 'refused'\n /** The read or the write failed. The record, whatever it is, still stands. */\n | 'failed'\n\n/**\n * Lift a platform suppression that a completed double opt-in has disproved.\n *\n * THE ONE PLACE THE AUTOMATIC RULE IS STATED, mirroring\n * `releaseSiteSuppression` for the per-site list: every path that puts an\n * address back in circulation without a human deciding goes through here, so\n * there is one line to read and one line to change.\n *\n * ## Platform-wide, from one site's round trip\n *\n * The record being lifted asserts something host-independent — this mailbox\n * does not exist, learned anywhere in the product, including on transactional\n * mail that carried no site tag. A confirmation delivered to that mailbox\n * disproves it just as host-independently, so scoping the release to the\n * confirming site would leave a record standing that says the mailbox is dead\n * while we hold proof that it is not. The site and the topic are written onto\n * the record instead, which is what makes the release reviewable: an operator\n * reading the row sees which site's round trip lifted it and when.\n *\n * ## The per-site list is NOT touched\n *\n * `hosts/{hostId}/suppressions` mixes a deliverability fact with a stated\n * preference — an unsubscribe from that one site lives there too — and the\n * email plugin's `releaseSiteSuppression` is the single guarded path that\n * lifts one. Reaching around it from here would put a second, differently\n * reasoned releaser on a list whose whole protection is that there is one.\n *\n * Never throws: it runs inside a recipient's confirmation click, and a\n * Firestore failure must degrade to a suppression that stays in force, not to\n * a person told their confirmation failed.\n */\nexport async function releaseEmailForConfirmedOptIn(input: {\n email: string\n /** The site whose confirmation link was clicked, for the audit trail. */\n hostId: string\n /** The stream that was confirmed, likewise. */\n topicId: string\n firestore?: any\n}): Promise<ConfirmedOptInRelease> {\n const key = emailSuppressionKey(input.email)\n if (!key) return 'nothing-to-release'\n try {\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n if (!snapshot.exists || snapshot.get('releasedAt')) {\n return 'nothing-to-release'\n }\n if (!OPT_IN_RELEASABLE_REASONS.includes(snapshot.get('reason'))) {\n return 'refused'\n }\n await ref.set(\n {\n releasedAt: FieldValue.serverTimestamp(),\n released: true,\n // No human released this, so the field says so rather than naming\n // whoever last touched the record.\n releasedByUid: null,\n releasedVia: 'double-opt-in' satisfies EmailReleaseChannel,\n releasedHostId: input.hostId || null,\n releasedTopicId: input.topicId || null,\n },\n { merge: true },\n )\n return 'released'\n } catch (error) {\n console.error(\n '[email-suppression] opt-in release failed; suppression stands',\n error,\n )\n return 'failed'\n }\n}\n\n/**\n * The BULK-SEND gate. See the module note for which senders must call this and\n * which must not.\n *\n * FAILS CLOSED, like `isPhoneContactSuppressed`: a read that throws answers\n * `true`, meaning \"treat as suppressed\". The cost of failing closed here is\n * one delayed informational email — every caller is a cron that runs again —\n * and it is nearly free besides, because every one of them is already deep\n * inside a Firestore-backed sweep that a Firestore outage has stopped anyway.\n * The cost of failing open is another delivery attempt at a mailbox that has\n * already told us permanently that it does not exist, which is precisely the\n * behaviour a provider scores a sending domain on. Never \"fix\" a flaky read\n * here by returning false.\n */\nexport async function isEmailSuppressed(\n email: string | null | undefined,\n injectedFirestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(email)\n // An unusable value is one we cannot check against the list, so it is one we\n // must not send to. Same fail-closed rule.\n if (!key) return true\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_SUPPRESSIONS_COLLECTION)\n .doc(key)\n .get()\n if (!snapshot.exists) return false\n return !snapshot.get('releasedAt')\n } catch (error) {\n console.error('[email-suppression] lookup failed; failing closed', error)\n return true\n }\n}\n\n/**\n * The sendable subset of a recipient list, for the bulk senders.\n *\n * One `get()` per DISTINCT address rather than a query, so the check needs no\n * index and cannot fail open on a missing one. The list is deduplicated first\n * because the callers build it by fanning out over an org's owners and admins,\n * which routinely names the same person twice.\n */\nexport async function filterSuppressedEmails(\n emails: readonly string[],\n injectedFirestore?: any,\n): Promise<string[]> {\n const seen = new Set<string>()\n const candidates: string[] = []\n for (const email of emails) {\n const normalized = String(email ?? '')\n .trim()\n .toLowerCase()\n if (!normalized || seen.has(normalized)) continue\n seen.add(normalized)\n candidates.push(normalized)\n }\n const verdicts = await Promise.all(\n candidates.map((email) => isEmailSuppressed(email, injectedFirestore)),\n )\n return candidates.filter((_email, index) => !verdicts[index])\n}\n\n/*==========================================\n * THE SENDER A PERSON LEAVES IS THE CONSENT GROUP, NOT THE SITE.\n *\n * `consent-groups.ts` lets an org declare several sites ONE sender — a shop,\n * a booking page and a blog under one name, disclosed as one on every\n * capture form. To the person on the other end those are one sender, so\n * leaving any of them is leaving it: an unsubscribe filed by site A has to\n * withhold site B's mail too, or the recipient is made to unsubscribe once\n * per site of a sender they only ever knew as one.\n *\n * Every opt-out is written against the ONE site the person acted on and read\n * across the sending site's CURRENT group. Reading rather than copying on\n * write is what makes a site that joins a group inherit every refusal already\n * standing against its siblings, and it reaches every row already stored and\n * every link already sitting in an inbox — none of which a write-time fan-out\n * could.\n *\n * All three per-site facts are read this way: the site suppression list (an\n * unsubscribe, a hand-added opt-out, an erasure, and the bounces and\n * complaints the platform list already carries), the topic opt-outs, and the\n * recipient's cadence. A sibling's pending confirmation joins them only when\n * the org turned its group's confirmation switch on — see\n * {@link filterTopicSendable}. The group of one — every site of an org that\n * declared nothing — reads exactly the documents these filters always read,\n * in the same round trips.\n *=========================================*/\n\n/**\n * The sites whose opt-out lists answer for a send made BY `hostId`, the\n * sending site first.\n *\n * The sending site's consent group: every site it names, because a refusal\n * standing on any of them is a refusal of the sender. Absent, or a group of\n * one, is the site alone.\n *\n * The group is the one `consentGroupForHost` resolved — never a set a caller\n * built from `org.hosts` or a shared domain, which would be inferring a\n * controller (see `consent-groups.ts`).\n *\n * Throws on a group resolved for a DIFFERENT site. That is a wiring defect no\n * runtime value produces, and the failure it would hide is one site's send\n * decided against another site's lists — the same refusal\n * `splitByMarketingConsent` makes over a consent map read for the wrong host.\n */\nexport function optOutHostIds(\n hostId: string,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null,\n): string[] {\n if (!group) return [hostId]\n if (group.hostId !== hostId) {\n throw new Error(\n `[email-suppression] a consent group resolved for ${group.hostId} ` +\n `cannot decide a send from ${hostId}`,\n )\n }\n return consentGroupOptOutHosts(group)\n}\n\n/**\n * One keyed `getAll` per site in `hostIds`, positionally aligned with `keys`.\n *\n * Per site rather than one call over every (site, address) pair, so each\n * round trip is exactly the size the single-site read always was — a batch\n * of a campaign — and a group multiplies the number of calls, run in\n * parallel, rather than the size of one. A group of one is one call, over\n * the same references in the same order.\n *\n * Rejects when any read does; each caller keeps its own posture on that.\n */\nexport async function getAllAcrossSites(\n db: any,\n hostIds: readonly string[],\n subcollection: string,\n keys: readonly string[],\n): Promise<any[][]> {\n return Promise.all(\n hostIds.map((id) => {\n const list = db.collection('hosts').doc(id).collection(subcollection)\n return db.getAll(...keys.map((key) => list.doc(key)))\n }),\n )\n}\n\n/**\n * The sendable subset for a send made in ONE SITE's name — BOTH lists.\n *\n * A per-site suppression says \"not from this site\". A platform suppression\n * says the address hard-bounced or somebody pressed \"report spam\", learned\n * anywhere in the product — including on transactional mail that carried no\n * site tag at all and could therefore never have reached the per-site list.\n * Consulting only the site's own list mails a known-dead or complaining\n * address from the one shared sending domain every tenant's mail leaves by,\n * which makes it every tenant's deliverability problem rather than one\n * merchant's.\n *\n * Composed from {@link filterSuppressedEmails} rather than reimplementing the\n * platform half: normalization, de-duplication and the fail-closed posture\n * live there, and a second copy of them is a second set of rules for two\n * senders to disagree about.\n *\n * ## \"This site\" means its consent group\n *\n * `group` is the sending site's consent group, and an entry on ANY of its\n * sites' lists withholds — see the section above. Every entry counts, whatever\n * its reason: an unsubscribe and a hand-added opt-out are the person leaving\n * the sender, an erasure is already written on every site of the workspace,\n * and a bounce or a complaint is on the platform list besides. Omitted, the\n * site alone, which is also the whole answer for an org that declared no\n * group.\n *\n * ## Both halves fail CLOSED\n *\n * A read that throws answers \"suppressed\". The platform half already does; the\n * per-site half matches it, because a list we could not read is not a list\n * that said this address is safe to mail. The cost of the other choice is a\n * message delivered to somebody who asked us to stop.\n *\n * ## One `getAll`, keyed, rather than a scan of the collection\n *\n * The per-site half looks up exactly the addresses being mailed, by document\n * id, in one round trip per site. Reading the whole collection instead —\n * which is what the campaign sender did — is bounded by however large the\n * collection has grown, so a site with more suppressions than the read window\n * fails OPEN on the remainder: the people most certain not to want the mail\n * are the ones a truncated read drops.\n */\nexport async function filterSendableForHost(\n hostId: string,\n emails: readonly string[],\n injectedFirestore?: any,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null,\n): Promise<string[]> {\n const hostIds = optOutHostIds(hostId, group)\n const platformSendable = await filterSuppressedEmails(\n emails,\n injectedFirestore,\n )\n // `getAll` rejects an empty reference list, and there is nothing to ask.\n if (!platformSendable.length) return []\n const db = injectedFirestore ?? defaultFirestore()\n // Every survivor of the platform half is keyable — `isEmailSuppressed`\n // answers `true` for an address it cannot key — so this narrows the type\n // rather than dropping anybody.\n const keyed: Array<{ email: string; key: string }> = []\n for (const email of platformSendable) {\n const key = emailSuppressionKey(email)\n if (key) keyed.push({ email, key })\n }\n if (!keyed.length) return []\n try {\n const bySite = await getAllAcrossSites(\n db,\n hostIds,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n keyed.map((entry) => entry.key),\n )\n return keyed\n .filter(\n (_entry, index) =>\n !bySite.some((snapshots) => snapshots[index]?.exists),\n )\n .map((entry) => entry.email)\n } catch (error) {\n console.error(\n '[email-suppression] per-site lookup failed; failing closed',\n error,\n )\n return []\n }\n}\n\n/**\n * The subset of `emails` that may be mailed about `topicId` on this site.\n *\n * The third filter a campaign passes, after the platform list and the site's\n * own: the two suppression lists answer \"may we mail this person at all\", and\n * this answers \"may we mail them about THIS\". A recipient who unticked\n * \"Promotions and offers\" on the preference page is not suppressed — they\n * still get the newsletter — so the fact cannot live on either suppression\n * list without meaning something it does not mean.\n *\n * ## Two ways to be excluded, and both are held here\n *\n * Somebody who LEFT the stream, and somebody who has been asked to confirm\n * joining it and has not. The second is what makes a double opt-in worth\n * recording: an unconfirmed subscriber has to be a real quarantine, not a\n * field the send path never looks at. Both come back from\n * `readTopicSubscriptionState`, which is the only place the three states are\n * decided.\n *\n * Keyed by {@link emailSuppressionKey} and read with one `getAll`, which is\n * both halves of the point: the same derivation as the two lists it runs\n * beside, so one person is one document id everywhere; and one round trip\n * bounded by the size of the send, so adding topics does not make a campaign\n * cost more to resolve.\n *\n * ## This one fails OPEN, and that is the opposite of its neighbors\n *\n * Every other filter in this module answers \"suppressed\" when a read throws,\n * because the cost of guessing wrong is mailing somebody who told us to stop.\n * Here the cost of guessing wrong in that direction is refusing to send a\n * newsletter somebody asked for, on a read that failed for an unrelated\n * reason — and the campaign has already passed both suppression lists, so\n * nobody who asked us to stop entirely can reach this line. A topic\n * preference is a narrower fact than a suppression and it gets the treatment\n * that matches. A caller that wants the strict posture already has it one\n * layer up.\n *\n * An empty `topicId` is a campaign from before topics existed, or one whose\n * topic was never resolved. It filters nobody: there is no stream to have\n * left.\n *\n * ## Leaving a stream is leaving it for the whole consent group\n *\n * The topic catalog is the org's, so \"Newsletter\" on one site of a declared\n * group is the same stream on its siblings, and a person who left it on one\n * has left it from the sender. `group` extends the read to every site in it,\n * exactly as {@link filterSendableForHost} does for the suppression list.\n *\n * A sibling's REFUSAL always holds. A sibling's pending confirmation holds\n * only when the group `awaitsConfirmation` — the org's switch, resolved into\n * the group, so asking it reads nothing the refusal read did not already\n * read. Off, the question stays the concern of the site whose form asked it.\n */\nexport async function filterTopicSendable(\n hostId: string,\n topicId: string | null | undefined,\n emails: readonly string[],\n injectedFirestore?: any,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds' | 'awaitsConfirmation'> | null,\n): Promise<string[]> {\n const hostIds = optOutHostIds(hostId, group)\n const topic = String(topicId ?? '').trim()\n if (!topic || !emails.length) return [...emails]\n // An unkeyable address cannot carry an opt-out record, so it cannot have\n // left this topic. It is dropped from the LOOKUP and kept in the answer —\n // the suppression filters above have already refused it on their own\n // stricter rule, so this one has no business refusing it a second time.\n const lookups: Array<{ email: string; key: string }> = []\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key) lookups.push({ email, key })\n }\n if (!lookups.length) return [...emails]\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const bySite = await getAllAcrossSites(\n db,\n hostIds,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n lookups.map((entry) => entry.key),\n )\n /*\n * The shared state reader, never a field test written out here.\n *\n * An entry means one of three things and only that function knows all\n * three. The shorthand this replaced — \"an entry with no\n * `resubscribedAt` is a live opt-out\" — reads a CONFIRMED double\n * opt-in, which carries `pendingAt` and `confirmedAt` and no\n * `resubscribedAt`, as somebody who left.\n */\n const stateIn = (snapshot: any) =>\n snapshot?.exists\n ? readTopicSubscriptionState((snapshot.get('topics') ?? {})[topic])\n : 'subscribed'\n // The sending site first, as `optOutHostIds` ordered the reads — which\n // is how the fold tells its own pending question from a sibling's.\n const awaits = { awaitsConfirmation: group?.awaitsConfirmation === true }\n const gone = new Set<string>()\n lookups.forEach((entry, index) => {\n const states = bySite.map((snapshots) => stateIn(snapshots[index]))\n if (consentGroupTopicState(awaits, states) !== 'subscribed') {\n gone.add(entry.email)\n }\n })\n return emails.filter((email) => !gone.has(email))\n } catch (error) {\n console.error(\n '[email-suppression] topic opt-out lookup failed; failing open',\n error,\n )\n return [...emails]\n }\n}\n\n/*\n * THE FOURTH FILTER IS NOT IN THIS FILE, and where it is is forced.\n *\n * `filterCadenceSendable` — the subset that has not asked this site for mail\n * less often than right now — lives in `email-marketing-gate.ts`, because it\n * reads the per-recipient counter document that module owns and names. That\n * module already imports this one for the two suppression lists, so putting\n * the cadence filter here would close a cycle. A campaign's subtraction chain\n * therefore reads: this file's two lists, this file's topic opt-outs, then\n * that file's cadence.\n */\n\n/**\n * The staff queue, newest first, ordered by `suppressedAt` so a re-recorded or\n * released entry surfaces again — the operator's question is \"what changed\",\n * not \"what was first written\". Mirrors `listContactSuppressions`.\n */\nexport async function listEmailSuppressions(options?: {\n limit?: number\n /**\n * Where the NEXT page starts: the `suppressedAt` of the last row already\n * shown, as `seconds.nanoseconds`.\n *\n * The full timestamp rather than milliseconds, because `startAfter` skips\n * exactly the value it is given: a millisecond-truncated cursor sits BEFORE\n * the record it names, so that record would arrive again at the top of the\n * following page. Repeating a row is a smaller fault than skipping one and\n * neither is necessary.\n */\n startAfter?: string | null\n /**\n * Predicates the caller adds beneath the ordering — equalities, one\n * `array-contains`, and ranges over `suppressedAt` itself, which are the\n * only shapes that keep this cursor valid. It must not add an `orderBy`.\n */\n narrow?: (query: any) => any\n firestore?: any\n}): Promise<Array<EmailSuppressionRecord & { $id: string }>> {\n const db = options?.firestore ?? defaultFirestore()\n const collection = db.collection(EMAIL_SUPPRESSIONS_COLLECTION)\n let query = (options?.narrow ? options.narrow(collection) : collection).orderBy(\n 'suppressedAt',\n 'desc',\n )\n const cursor = suppressionCursorTimestamp(options?.startAfter)\n if (cursor) query = query.startAfter(cursor)\n const snapshot = await query\n .limit(Math.min(Math.max(options?.limit ?? 100, 1), 500))\n .get()\n return snapshot.docs.map((doc: any) => ({\n $id: doc.id,\n ...(doc.data() as EmailSuppressionRecord),\n }))\n}\n\n/**\n * The cursor a page hands back, from the last row on it.\n *\n * Null when the row carries no `suppressedAt` — every entry written since\n * AGL-1918 does, and `orderBy` has already excluded any that does not, so\n * this is the type narrowing rather than a case that occurs.\n */\nexport function suppressionCursorFrom(\n record: Record<string, any> | null | undefined,\n): string | null {\n const at = record?.['suppressedAt'] as\n | { seconds?: number; _seconds?: number; nanoseconds?: number; _nanoseconds?: number }\n | undefined\n const seconds = at?.seconds ?? at?._seconds\n if (typeof seconds !== 'number') return null\n const nanoseconds = at?.nanoseconds ?? at?._nanoseconds ?? 0\n return `${seconds}.${nanoseconds}`\n}\n\n/** The inverse, for the query. Invalid input yields no cursor, never a guess. */\nexport function suppressionCursorTimestamp(\n cursor: string | null | undefined,\n): Timestamp | null {\n const [rawSeconds, rawNanos] = String(cursor ?? '').split('.')\n const seconds = Number(rawSeconds)\n const nanoseconds = Number(rawNanos ?? 0)\n if (!Number.isFinite(seconds) || !seconds) return null\n return new Timestamp(seconds, Number.isFinite(nanoseconds) ? nanoseconds : 0)\n}\n\n/*==========================================\n * THE ERASURE ROW ON THE PER-SITE LIST (AGL-2623).\n *\n * A person erased from a workspace must not be quietly rebuilt by the next\n * form they fill in or the next order they place, and must not be mailed by\n * a campaign either. Both gates already read `hosts/{hostId}/suppressions`\n * — every campaign filter refuses an address with a row there — so the\n * erasure writes one row per site of the workspace rather than inventing a\n * third list, and the capture door reads the same row.\n *\n * The row carries NO address. Every other writer of this list stores the\n * email in the clear beside the hash, because a hub admin releasing an\n * unsubscribe needs to see who it was; an erasure row is the one whose\n * whole purpose is that the address is no longer held, so the id — the\n * hash — is the entire record, and a row that already held the address in\n * the clear has it removed. The hub's list renders such a row as an entry\n * with no address, which it already knows how to do.\n *\n * Per SITE, not platform-wide, on purpose: the erasure was asked of one\n * workspace, and a second workspace that knows the same person has a\n * relationship this request has no claim on. The platform list is for\n * bounces and complaints, which are facts about the address everywhere.\n *=========================================*/\n\n/** The `reason` an erasure row carries. Read by the capture door, below. */\nexport const HOST_ERASURE_SUPPRESSION_REASON = 'erasure'\n\n/**\n * Suppress an address on one site because the person was erased. Idempotent:\n * a second erasure of the same person merges onto the same row.\n */\nexport async function suppressEmailForHostErasure(input: {\n hostId: string\n email: string\n firestore?: any\n}): Promise<{ key: string; created: boolean } | null> {\n const key = emailSuppressionKey(input.email)\n if (!key) return null\n const db = input.firestore ?? defaultFirestore()\n const ref = db\n .collection('hosts')\n .doc(input.hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n const snapshot = await ref.get()\n await ref.set(\n {\n email: null,\n // The address's search tokens are the address in pieces, so an erasure\n // clears them with it (AGL-3321).\n emailTokens: [],\n reason: HOST_ERASURE_SUPPRESSION_REASON,\n suppressedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n return { key, created: !snapshot.exists }\n}\n\n/**\n * Whether a site has erased this address, so a capture must not create a\n * contact for it. Only an ERASURE row refuses: an ordinary unsubscribe is a\n * mailing preference, and a person who unsubscribed and then bought\n * something is still a customer whose order the CRM should know about.\n *\n * Fails OPEN. A read that throws answers `false` and logs: the alternative\n * refuses every capture on the site for as long as the list is unreadable,\n * which turns a transient read failure into a silent drop of the site's\n * leads and orders. The window this leaves — a recreate during an outage\n * of the suppression read — is logged, and the next erasure run finds the\n * recreated record by the same address.\n */\nexport async function hostRefusesCaptureForErasure(\n hostId: string,\n email: string | null | undefined,\n injectedFirestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(email)\n if (!key) return false\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const snapshot = await db\n .collection('hosts')\n .doc(hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n .get()\n return (\n Boolean(snapshot?.exists) &&\n snapshot.get('reason') === HOST_ERASURE_SUPPRESSION_REASON\n )\n } catch (error) {\n console.error(\n '[email-suppression] erasure lookup failed; the capture proceeds',\n error,\n )\n return false\n }\n}\n\n/*==========================================\n * A LOCKED ACCOUNT, ON THE HOUSE WORKSPACE'S SITES (AGL-3420)\n *\n * Aglyn's own campaigns and sequences go to the house workspace's contacts,\n * which are keyed by address and know nothing about accounts. A locked\n * account's addresses are therefore suppressed on each of the house sites\n * for as long as the lock stands, so every sender that honors a site's list\n * — campaigns, sequences, workflow and CRM mail — leaves them alone.\n *\n * Per SITE and only on the house workspace's: the lock is our relationship\n * with the person, and another workspace that knows the same address has one\n * of its own that the lock has no claim on. A ban reaches everyone through\n * the platform list instead.\n *\n * Written only where no row exists, and lifted only where this row still\n * stands for this account. So an unsubscribe, bounce or erasure already on\n * the list is never rewritten or deleted by a lock or its lift.\n *=========================================*/\n\n/** The `reason` a lock's per-site row carries. */\n/**\n * {@link hostRefusesCaptureForErasure} for many addresses in one read.\n *\n * The addresses a site has erased, among the ones given — one `getAll` of\n * their suppression rows rather than one read per address, for a door that\n * captures a whole file at once. Answers `null` when the read throws, which\n * a caller takes as \"not looked up\" and asks per address instead, so the\n * fail-open rule above is the single-address function's and not a second\n * copy of it.\n */\nexport async function hostErasedEmails(\n hostId: string,\n emails: readonly (string | null | undefined)[],\n injectedFirestore?: any,\n): Promise<Set<string> | null> {\n const keyed = new Map<string, string>()\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key && email && !keyed.has(key)) keyed.set(key, email)\n }\n if (!keyed.size) return new Set()\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const suppressions = db\n .collection('hosts')\n .doc(hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n const keys = [...keyed.keys()]\n const snapshots = await db.getAll(...keys.map((key) => suppressions.doc(key)))\n const erased = new Set<string>()\n snapshots.forEach((snapshot: any, at: number) => {\n if (snapshot?.exists && snapshot.get('reason') === HOST_ERASURE_SUPPRESSION_REASON) {\n erased.add(keyed.get(keys[at]) as string)\n }\n })\n return erased\n } catch (error) {\n console.error('[email-suppression] bulk erasure lookup failed', error)\n return null\n }\n}\n\nexport const HOST_ACCOUNT_LOCK_SUPPRESSION_REASON = 'account_lock'\n\n/**\n * Suppress an address on one site because the account holding it is\n * locked. Answers whether it wrote a row: false when one already stood.\n */\nexport async function suppressEmailForAccountLock(input: {\n hostId: string\n email: string\n uid: string\n firestore?: any\n}): Promise<boolean> {\n const key = emailSuppressionKey(input.email)\n if (!key) return false\n const db = input.firestore ?? defaultFirestore()\n const ref = db\n .collection('hosts')\n .doc(input.hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n const email = String(input.email).trim().toLowerCase()\n let created = false\n await db.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n created = !existing.exists\n if (!created) return\n transaction.set(ref, {\n email,\n emailTokens: emailSearchTokens(email),\n reason: HOST_ACCOUNT_LOCK_SUPPRESSION_REASON,\n subjectUid: input.uid,\n suppressedAt: FieldValue.serverTimestamp(),\n createdAt: FieldValue.serverTimestamp(),\n })\n })\n return created\n}\n\n/**\n * Lift a lock's row on one site: deleted only while it is still this\n * account's lock row. Answers whether it deleted one.\n */\nexport async function releaseAccountLockSuppression(input: {\n hostId: string\n email: string\n uid: string\n firestore?: any\n}): Promise<boolean> {\n const key = emailSuppressionKey(input.email)\n if (!key) return false\n const db = input.firestore ?? defaultFirestore()\n const ref = db\n .collection('hosts')\n .doc(input.hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n let deleted = false\n await db.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n deleted =\n Boolean(existing.exists) &&\n existing.get('reason') === HOST_ACCOUNT_LOCK_SUPPRESSION_REASON &&\n existing.get('subjectUid') === input.uid\n if (deleted) transaction.delete(ref)\n })\n return deleted\n}\n"],"names":["FieldValue","Timestamp","consentGroupOptOutHosts","consentGroupTopicState","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","emailSearchTokens","personKey","stampRecordEmailState","firebaseAdmin","defaultFirestore","app","firestore","EMAIL_SUPPRESSIONS_COLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","PLATFORM_SUPPRESSION_REASONS","ACCOUNT_BAN_SUPPRESSION_REASON","UNSUBSCRIBE_SUPPRESSION_REASON","emailSuppressionKey","email","isLiveBanRow","snapshot","Boolean","exists","get","suppressEmail","input","key","Error","includes","reason","db","ref","collection","doc","created","String","trim","toLowerCase","priorReason","set","emailTokens","context","hostId","subjectUid","releasedAt","released","suppressedAt","serverTimestamp","createdAt","merge","stampRecord","state","status","atMs","Date","now","source","detail","releaseEmail","releasedByUid","releasedNote","note","releasedVia","isAccountBanSuppression","injectedFirestore","error","console","releaseAccountBanSuppressions","uid","where","docs","delete","forgetAccountBans","ACCOUNT_BAN_CACHE_MS","accountBanCache","accountBanPending","accountBanKeys","at","keys","Set","add","id","accountBannedRecipients","emails","banned","length","size","has","OPT_IN_RELEASABLE_REASONS","releaseEmailForConfirmedOptIn","releasedHostId","releasedTopicId","topicId","isEmailSuppressed","filterSuppressedEmails","seen","candidates","normalized","push","verdicts","Promise","all","map","filter","_email","index","optOutHostIds","group","getAllAcrossSites","hostIds","subcollection","list","getAll","filterSendableForHost","platformSendable","keyed","bySite","entry","_entry","some","snapshots","filterTopicSendable","topic","lookups","stateIn","awaits","awaitsConfirmation","gone","forEach","states","listEmailSuppressions","options","query","narrow","orderBy","cursor","suppressionCursorTimestamp","startAfter","limit","Math","min","max","$id","data","suppressionCursorFrom","record","seconds","_seconds","nanoseconds","_nanoseconds","rawSeconds","rawNanos","split","Number","isFinite","HOST_ERASURE_SUPPRESSION_REASON","suppressEmailForHostErasure","hostRefusesCaptureForErasure","hostErasedEmails","Map","suppressions","erased","HOST_ACCOUNT_LOCK_SUPPRESSION_REASON","suppressEmailForAccountLock","runTransaction","transaction","existing","releaseAccountLockSuppression","deleted"],"mappings":"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"}
|
|
@@ -39,9 +39,9 @@ export declare function unsubscribeLinkSecret(): string;
|
|
|
39
39
|
* liked — a small forgery, and a completely gratuitous one, since the
|
|
40
40
|
* campaign is already known at the moment the link is minted.
|
|
41
41
|
*/
|
|
42
|
-
export declare function unsubscribeSignatureSubject(hostId: string, email: string,
|
|
42
|
+
export declare function unsubscribeSignatureSubject(hostId: string, email: string, sendId?: string, topicId?: string): string;
|
|
43
43
|
/** HMAC for unsubscribe links; env-gated on the shared secret. */
|
|
44
|
-
export declare function unsubscribeSignature(hostId: string, email: string, secret: string,
|
|
44
|
+
export declare function unsubscribeSignature(hostId: string, email: string, secret: string, sendId?: string, topicId?: string): string;
|
|
45
45
|
/**
|
|
46
46
|
* Whether a signature is this link's.
|
|
47
47
|
*
|
|
@@ -60,7 +60,7 @@ export declare function unsubscribeSignature(hostId: string, email: string, secr
|
|
|
60
60
|
export declare function unsubscribeSignatureMatches(args: {
|
|
61
61
|
hostId: string;
|
|
62
62
|
email: string;
|
|
63
|
-
|
|
63
|
+
sendId?: string;
|
|
64
64
|
signature: string;
|
|
65
65
|
secret: string;
|
|
66
66
|
}): boolean;
|
|
@@ -138,8 +138,11 @@ export declare function buildUnsubscribeUrl(input: {
|
|
|
138
138
|
siteBase: string;
|
|
139
139
|
hostId: string;
|
|
140
140
|
email: string;
|
|
141
|
-
/**
|
|
142
|
-
|
|
141
|
+
/**
|
|
142
|
+
* The bulk send this link rides in, when there is one (`cid`): the id of
|
|
143
|
+
* the send document an unsubscribe is counted against.
|
|
144
|
+
*/
|
|
145
|
+
sendId?: string;
|
|
143
146
|
/** The topic the message belonged to, when it belonged to one. */
|
|
144
147
|
topicId?: string;
|
|
145
148
|
/**
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
* attribution anybody holding one valid link could point at any campaign they
|
|
64
64
|
* liked — a small forgery, and a completely gratuitous one, since the
|
|
65
65
|
* campaign is already known at the moment the link is minted.
|
|
66
|
-
*/ export function unsubscribeSignatureSubject(hostId, email,
|
|
66
|
+
*/ export function unsubscribeSignatureSubject(hostId, email, sendId, topicId) {
|
|
67
67
|
const address = String(email != null ? email : '').trim().toLowerCase();
|
|
68
68
|
/*
|
|
69
69
|
* A colon in either id is refused outright, because the forms are joined
|
|
@@ -77,15 +77,15 @@
|
|
|
77
77
|
* four-part subject with an empty middle, which would make
|
|
78
78
|
* `host:email::t` and a campaign id of `:t` the same string. The sender
|
|
79
79
|
* always has a campaign, so this is a guard rather than a path.
|
|
80
|
-
*/ const campaign = String(
|
|
80
|
+
*/ const campaign = String(sendId != null ? sendId : '');
|
|
81
81
|
const topic = String(topicId != null ? topicId : '');
|
|
82
82
|
if (campaign.includes(':') || topic.includes(':')) return '';
|
|
83
83
|
if (topic && campaign) return `${hostId}:${address}:${campaign}:${topic}`;
|
|
84
84
|
if (campaign) return `${hostId}:${address}:${campaign}`;
|
|
85
85
|
return `${hostId}:${address}`;
|
|
86
86
|
}
|
|
87
|
-
/** HMAC for unsubscribe links; env-gated on the shared secret. */ export function unsubscribeSignature(hostId, email, secret,
|
|
88
|
-
const subject = unsubscribeSignatureSubject(hostId, email,
|
|
87
|
+
/** HMAC for unsubscribe links; env-gated on the shared secret. */ export function unsubscribeSignature(hostId, email, secret, sendId, topicId) {
|
|
88
|
+
const subject = unsubscribeSignatureSubject(hostId, email, sendId, topicId);
|
|
89
89
|
// An unsignable subject yields no signature rather than a signature over the
|
|
90
90
|
// empty string, which would verify for every caller that also passed one.
|
|
91
91
|
if (!subject) return '';
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
* is not a secret, both digests are fixed-width hex, and the call throws on a
|
|
107
107
|
* mismatch rather than returning false.
|
|
108
108
|
*/ export function unsubscribeSignatureMatches(args) {
|
|
109
|
-
const expected = unsubscribeSignature(args.hostId, args.email, args.secret, args.
|
|
109
|
+
const expected = unsubscribeSignature(args.hostId, args.email, args.secret, args.sendId || undefined);
|
|
110
110
|
return expected.length === args.signature.length && timingSafeEqual(new Uint8Array(Buffer.from(expected)), new Uint8Array(Buffer.from(args.signature)));
|
|
111
111
|
}
|
|
112
112
|
/**
|
|
@@ -193,10 +193,10 @@
|
|
|
193
193
|
const siteBase = String((_input_siteBase = input.siteBase) != null ? _input_siteBase : '').replace(/\/+$/, '');
|
|
194
194
|
const address = String((_input_email = input.email) != null ? _input_email : '').trim().toLowerCase();
|
|
195
195
|
if (!secret || !siteBase || !input.hostId || !address) return '';
|
|
196
|
-
const signature = unsubscribeSignature(input.hostId, address, secret, input.
|
|
196
|
+
const signature = unsubscribeSignature(input.hostId, address, secret, input.sendId, input.topicId);
|
|
197
197
|
if (!signature) return '';
|
|
198
198
|
const route = input.surface === 'preferences' ? '/api/email/preferences' : '/api/email/unsubscribe';
|
|
199
|
-
return `${siteBase}${route}` + `?hostId=${encodeURIComponent(input.hostId)}` + `&email=${encodeURIComponent(address)}` + `&sig=${signature}` + (input.
|
|
199
|
+
return `${siteBase}${route}` + `?hostId=${encodeURIComponent(input.hostId)}` + `&email=${encodeURIComponent(address)}` + `&sig=${signature}` + (input.sendId ? `&cid=${encodeURIComponent(input.sendId)}` : '') + (input.topicId ? `&tid=${encodeURIComponent(input.topicId)}` : '');
|
|
200
200
|
}
|
|
201
201
|
/*==========================================
|
|
202
202
|
* A SIGNED LINK OF A SENDER'S OWN (AGL-2981).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-unsubscribe-link.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 UNSUBSCRIBE LINK — one signer, one URL shape, one verifier.\n *\n * A signed `/api/email/unsubscribe` URL was minted in exactly one place, the\n * campaign sender, and verified in another, the unsubscribe handler. Two\n * copies of an HMAC subject is a shape that only stays correct while nobody\n * adds a third caller — and the marketing gate is the third caller, so the\n * subject moved here and both existing sides now read it from one place.\n *\n * ## The address is lowercased in the subject AND in the URL\n *\n * This is the property that makes one derivation work rather than two that\n * agree by luck. The campaign sender lowercases every address far upstream,\n * so its links have always carried a lowercase address and its signature has\n * always covered one; the handler's verifier does not lowercase, and did not\n * need to. Marketing mail reaches addresses that no upstream step\n * normalized — a checkout's `customerEmail`, a form payload's `email` — so a\n * link minted over `Bob@Example.com` would sign the lowercase form and put\n * the mixed-case form on the URL, and the verifier would answer 403 to a\n * recipient pressing Unsubscribe.\n *\n * So {@link buildUnsubscribeUrl} writes the lowercased address into the query\n * as well as into the subject, and the two agree by construction rather than\n * by every caller remembering.\n */\n\nimport { createHmac, timingSafeEqual } from 'crypto'\n\n/**\n * The shared secret, or empty when the deployment has none.\n *\n * `CRON_SECRET` is the documented fallback, matching what the campaign sender\n * and the unsubscribe handler already resolve. Read per call rather than at\n * module load: these run in serverless handlers where the module may be\n * evaluated during a build, long before the runtime env exists.\n */\nexport function unsubscribeLinkSecret(): string {\n return process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.CRON_SECRET || ''\n}\n\n/**\n * The signed subject for an unsubscribe link.\n *\n * ## Two signed forms, and why the campaign id is additive\n *\n * Every email already sitting in an inbox carries a two-part signature over\n * `hostId:email`, and those links must go on working forever — an unsubscribe\n * link that stops honoring itself is the one bug in this area with a legal\n * edge on it. So the campaign is appended only when it is present, and the\n * verifier chooses which form to check by whether the link carries a `cid`.\n *\n * SIGNED rather than passed alongside. An unsigned `cid` would be an\n * attribution anybody holding one valid link could point at any campaign they\n * liked — a small forgery, and a completely gratuitous one, since the\n * campaign is already known at the moment the link is minted.\n */\nexport function unsubscribeSignatureSubject(\n hostId: string,\n email: string,\n campaignId?: string,\n topicId?: string,\n): string {\n const address = String(email ?? '')\n .trim()\n .toLowerCase()\n /*\n * A colon in either id is refused outright, because the forms are joined\n * with one. A four-part `host:email:c:t` is byte-identical to a three-part\n * subject whose campaign id is `c:t`, so one signature would verify two\n * different parameter tuples and a topic link could be re-presented as a\n * campaign link with the topic spliced in. It costs nothing real: ids come\n * from `createResourceUid()`, whose alphabet has no colon.\n *\n * A topic with no campaign DROPS the topic rather than producing a\n * four-part subject with an empty middle, which would make\n * `host:email::t` and a campaign id of `:t` the same string. The sender\n * always has a campaign, so this is a guard rather than a path.\n */\n const campaign = String(campaignId ?? '')\n const topic = String(topicId ?? '')\n if (campaign.includes(':') || topic.includes(':')) return ''\n if (topic && campaign) return `${hostId}:${address}:${campaign}:${topic}`\n if (campaign) return `${hostId}:${address}:${campaign}`\n return `${hostId}:${address}`\n}\n\n/** HMAC for unsubscribe links; env-gated on the shared secret. */\nexport function unsubscribeSignature(\n hostId: string,\n email: string,\n secret: string,\n campaignId?: string,\n topicId?: string,\n): string {\n const subject = unsubscribeSignatureSubject(hostId, email, campaignId, topicId)\n // An unsignable subject yields no signature rather than a signature over the\n // empty string, which would verify for every caller that also passed one.\n if (!subject) return ''\n return createHmac('sha256', secret).update(subject).digest('hex')\n}\n\n/**\n * Whether a signature is this link's.\n *\n * Which form is checked is decided by the LINK, not by the signature: a link\n * carrying no `cid` is checked against the two-part form and a link carrying\n * one against the three-part form. There is no fallback between them, and\n * that is what stops this being a downgrade — an attacker cannot take a\n * three-part link, drop the `cid` and have it verify, because the two-part\n * check over the same `hostId:email` produces a different digest. Nor can\n * they bolt a `cid` onto a two-part link: the three-part check then fails.\n *\n * `timingSafeEqual` needs equal lengths, so the length is compared first — it\n * is not a secret, both digests are fixed-width hex, and the call throws on a\n * mismatch rather than returning false.\n */\nexport function unsubscribeSignatureMatches(args: {\n hostId: string\n email: string\n campaignId?: string\n signature: string\n secret: string\n}): boolean {\n const expected = unsubscribeSignature(\n args.hostId,\n args.email,\n args.secret,\n args.campaignId || undefined,\n )\n return (\n expected.length === args.signature.length &&\n timingSafeEqual(\n new Uint8Array(Buffer.from(expected)),\n new Uint8Array(Buffer.from(args.signature)),\n )\n )\n}\n\n/**\n * The purpose component that distinguishes a CONFIRMATION subject.\n *\n * See {@link confirmSignatureSubject}. Exported because the plugin's verifier\n * imports it rather than restating the literal.\n */\nexport const CONFIRM_SUBJECT_PREFIX = 'confirm'\n\n/**\n * The signed subject for a double opt-in confirmation link.\n *\n * ## Why it is not one of the three forms above\n *\n * A confirmation names a host, an address and a topic, and it has NO\n * campaign — nobody is unsubscribing from a message, they are joining a\n * stream. The forms above refuse exactly that combination: a topic with no\n * campaign would leave an empty middle component, and `host:email::t` is the\n * same string as a three-part subject whose campaign id is `:t`.\n *\n * So it gets a leading PURPOSE component. That is a fourth form of the same\n * scheme, not a second scheme: the digest, the secret and the comparison are\n * unchanged.\n *\n * ## The one collision, and the guard for it\n *\n * `confirm:H:E:T` is byte-identical to the four-part unsubscribe subject\n * `A:B:C:D` when the site's id is literally `confirm`, which would let one\n * signature verify as both. Ids are not ours to constrain after the fact, so\n * the subject is refused for that host rather than the collision being\n * reasoned about: the cost is one unusable document id, and the alternative\n * is a signature that means two things.\n *\n * @returns the subject, or `''` for a combination it cannot sign\n * unambiguously. Empty rather than a partial subject, so\n * {@link confirmSignature} yields no signature rather than one over\n * the empty string — which would verify for every other caller that\n * also produced one.\n */\nexport function confirmSignatureSubject(\n hostId: string,\n email: string,\n topicId: string,\n): string {\n const address = String(email ?? '')\n .trim()\n .toLowerCase()\n const host = String(hostId ?? '')\n const topic = String(topicId ?? '')\n if (!host || !address || !topic) return ''\n if (host === CONFIRM_SUBJECT_PREFIX) return ''\n if (host.includes(':') || topic.includes(':')) return ''\n return `${CONFIRM_SUBJECT_PREFIX}:${host}:${address}:${topic}`\n}\n\n/** HMAC for a confirmation link; empty for an unsignable subject. */\nexport function confirmSignature(\n hostId: string,\n email: string,\n topicId: string,\n secret: string,\n): string {\n const subject = confirmSignatureSubject(hostId, email, topicId)\n if (!subject || !secret) return ''\n return createHmac('sha256', secret).update(subject).digest('hex')\n}\n\n/**\n * The absolute confirmation URL for one address and one topic.\n *\n * Minted where the signup happens, because the message carrying it is sent\n * from there — and that message is TRANSACTIONAL, not marketing: the person\n * just asked for this, so asking them to confirm it is the transaction they\n * started. It carries no unsubscribe header for the same reason a receipt\n * does not.\n *\n * Empty when there is no secret or no origin, for the reason\n * {@link buildUnsubscribeUrl} gives: a link pointing at nothing is worse than\n * no link, because the recipient believes they have confirmed.\n */\nexport function buildConfirmUrl(input: {\n siteBase: string\n hostId: string\n email: string\n topicId: string\n /** Defaults to {@link unsubscribeLinkSecret} — the same signing secret. */\n secret?: string\n}): string {\n const secret = input.secret ?? unsubscribeLinkSecret()\n const siteBase = String(input.siteBase ?? '').replace(/\\/+$/, '')\n const address = String(input.email ?? '')\n .trim()\n .toLowerCase()\n const signature = confirmSignature(\n input.hostId,\n address,\n input.topicId,\n secret,\n )\n if (!siteBase || !signature) return ''\n return (\n `${siteBase}/api/email/confirm` +\n `?hostId=${encodeURIComponent(input.hostId)}` +\n `&email=${encodeURIComponent(address)}` +\n `&tid=${encodeURIComponent(input.topicId)}` +\n `&sig=${signature}`\n )\n}\n\n/**\n * The absolute unsubscribe URL for one recipient of one site's mail.\n *\n * @returns the URL, or empty string when there is no secret to sign with or\n * no origin to resolve against. Empty rather than a half-built URL:\n * a caller can tell it does not have a link, and a link pointing at\n * nothing is worse than an absent header, because the recipient\n * believes they have unsubscribed.\n */\nexport function buildUnsubscribeUrl(input: {\n siteBase: string\n hostId: string\n email: string\n /** The campaign this link rides in, when there is one. */\n campaignId?: string\n /** The topic the message belonged to, when it belonged to one. */\n topicId?: string\n /**\n * Which of the two URLs over this one signature to build.\n *\n * `one-click` is what the `List-Unsubscribe` header names: a mailbox\n * provider POSTs it with no human present and expects the act to have\n * happened when it reads the 200, so it points at the route whose POST\n * writes immediately and must never point at a page somebody has to submit.\n * `preferences` is the link a PERSON clicks in the footer, where the topic\n * this message belonged to is one of the things they can stop instead of\n * all of it. Defaults to `one-click`, which is what a sender with no topic\n * — every non-campaign marketing path — wants.\n */\n surface?: 'one-click' | 'preferences'\n /** Defaults to {@link unsubscribeLinkSecret}. */\n secret?: string\n}): string {\n const secret = input.secret ?? unsubscribeLinkSecret()\n const siteBase = String(input.siteBase ?? '').replace(/\\/+$/, '')\n const address = String(input.email ?? '')\n .trim()\n .toLowerCase()\n if (!secret || !siteBase || !input.hostId || !address) return ''\n const signature = unsubscribeSignature(\n input.hostId,\n address,\n secret,\n input.campaignId,\n input.topicId,\n )\n if (!signature) return ''\n const route =\n input.surface === 'preferences'\n ? '/api/email/preferences'\n : '/api/email/unsubscribe'\n return (\n `${siteBase}${route}` +\n `?hostId=${encodeURIComponent(input.hostId)}` +\n `&email=${encodeURIComponent(address)}` +\n `&sig=${signature}` +\n (input.campaignId ? `&cid=${encodeURIComponent(input.campaignId)}` : '') +\n (input.topicId ? `&tid=${encodeURIComponent(input.topicId)}` : '')\n )\n}\n\n/*==========================================\n * A SIGNED LINK OF A SENDER'S OWN (AGL-2981).\n *\n * The forms above are the platform's own unsubscribe and confirmation\n * links, each a subject over a site and an address. A sender that mails\n * from somewhere else — a plugin sending from a person's own mailbox, whose\n * link carries its own facts — signs its link here instead, over a payload\n * of its own and under a PURPOSE of its own.\n *\n * The purpose is not a prefix of the subject, which is how the confirmation\n * form had to guard a collision by hand. It selects the KEY: every purpose's\n * links are signed with an HMAC key derived from the shared secret and the\n * purpose, so a signature minted for one purpose verifies for no other and\n * for none of the platform's own forms above, whatever the payloads spell.\n * The secret, the digest and the constant-time comparison are the same.\n *==========================================*/\n\n/** The key one purpose's links are signed with: derived from the secret, bound to the purpose. */\nfunction signedLinkKey(purpose: string, secret: string): Buffer {\n return createHmac('sha256', secret).update(`aglyn-signed-link:${purpose}`).digest()\n}\n\n/**\n * The signature of a payload for one purpose, base64url — or `''` when there\n * is no secret, no purpose or no payload to sign, so a caller can tell it has\n * no link rather than one that verifies for anybody.\n */\nexport function signedLinkSignature(\n purpose: string,\n payload: string,\n secret: string = unsubscribeLinkSecret(),\n): string {\n const name = String(purpose ?? '').trim()\n const body = String(payload ?? '')\n if (!secret || !name || !body) return ''\n return createHmac('sha256', signedLinkKey(name, secret)).update(body).digest('base64url')\n}\n\n/** Whether a signature is this payload's, for this purpose, in constant time. */\nexport function signedLinkSignatureMatches(args: {\n purpose: string\n payload: string\n signature: string\n /** Defaults to {@link unsubscribeLinkSecret}. */\n secret?: string\n}): boolean {\n const expected = signedLinkSignature(args.purpose, args.payload, args.secret ?? unsubscribeLinkSecret())\n const presented = String(args.signature ?? '')\n return (\n expected.length > 0 &&\n expected.length === presented.length &&\n timingSafeEqual(new Uint8Array(Buffer.from(expected)), new Uint8Array(Buffer.from(presented)))\n )\n}\n"],"names":["createHmac","timingSafeEqual","unsubscribeLinkSecret","process","env","EMAIL_UNSUBSCRIBE_SECRET","CRON_SECRET","unsubscribeSignatureSubject","hostId","email","campaignId","topicId","address","String","trim","toLowerCase","campaign","topic","includes","unsubscribeSignature","secret","subject","update","digest","unsubscribeSignatureMatches","args","expected","undefined","length","signature","Uint8Array","Buffer","from","CONFIRM_SUBJECT_PREFIX","confirmSignatureSubject","host","confirmSignature","buildConfirmUrl","input","siteBase","replace","encodeURIComponent","buildUnsubscribeUrl","route","surface","signedLinkKey","purpose","signedLinkSignature","payload","name","body","signedLinkSignatureMatches","presented"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-unsubscribe-link.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 UNSUBSCRIBE LINK — one signer, one URL shape, one verifier.\n *\n * A signed `/api/email/unsubscribe` URL was minted in exactly one place, the\n * campaign sender, and verified in another, the unsubscribe handler. Two\n * copies of an HMAC subject is a shape that only stays correct while nobody\n * adds a third caller — and the marketing gate is the third caller, so the\n * subject moved here and both existing sides now read it from one place.\n *\n * ## The address is lowercased in the subject AND in the URL\n *\n * This is the property that makes one derivation work rather than two that\n * agree by luck. The campaign sender lowercases every address far upstream,\n * so its links have always carried a lowercase address and its signature has\n * always covered one; the handler's verifier does not lowercase, and did not\n * need to. Marketing mail reaches addresses that no upstream step\n * normalized — a checkout's `customerEmail`, a form payload's `email` — so a\n * link minted over `Bob@Example.com` would sign the lowercase form and put\n * the mixed-case form on the URL, and the verifier would answer 403 to a\n * recipient pressing Unsubscribe.\n *\n * So {@link buildUnsubscribeUrl} writes the lowercased address into the query\n * as well as into the subject, and the two agree by construction rather than\n * by every caller remembering.\n */\n\nimport { createHmac, timingSafeEqual } from 'crypto'\n\n/**\n * The shared secret, or empty when the deployment has none.\n *\n * `CRON_SECRET` is the documented fallback, matching what the campaign sender\n * and the unsubscribe handler already resolve. Read per call rather than at\n * module load: these run in serverless handlers where the module may be\n * evaluated during a build, long before the runtime env exists.\n */\nexport function unsubscribeLinkSecret(): string {\n return process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.CRON_SECRET || ''\n}\n\n/**\n * The signed subject for an unsubscribe link.\n *\n * ## Two signed forms, and why the campaign id is additive\n *\n * Every email already sitting in an inbox carries a two-part signature over\n * `hostId:email`, and those links must go on working forever — an unsubscribe\n * link that stops honoring itself is the one bug in this area with a legal\n * edge on it. So the campaign is appended only when it is present, and the\n * verifier chooses which form to check by whether the link carries a `cid`.\n *\n * SIGNED rather than passed alongside. An unsigned `cid` would be an\n * attribution anybody holding one valid link could point at any campaign they\n * liked — a small forgery, and a completely gratuitous one, since the\n * campaign is already known at the moment the link is minted.\n */\nexport function unsubscribeSignatureSubject(\n hostId: string,\n email: string,\n sendId?: string,\n topicId?: string,\n): string {\n const address = String(email ?? '')\n .trim()\n .toLowerCase()\n /*\n * A colon in either id is refused outright, because the forms are joined\n * with one. A four-part `host:email:c:t` is byte-identical to a three-part\n * subject whose campaign id is `c:t`, so one signature would verify two\n * different parameter tuples and a topic link could be re-presented as a\n * campaign link with the topic spliced in. It costs nothing real: ids come\n * from `createResourceUid()`, whose alphabet has no colon.\n *\n * A topic with no campaign DROPS the topic rather than producing a\n * four-part subject with an empty middle, which would make\n * `host:email::t` and a campaign id of `:t` the same string. The sender\n * always has a campaign, so this is a guard rather than a path.\n */\n const campaign = String(sendId ?? '')\n const topic = String(topicId ?? '')\n if (campaign.includes(':') || topic.includes(':')) return ''\n if (topic && campaign) return `${hostId}:${address}:${campaign}:${topic}`\n if (campaign) return `${hostId}:${address}:${campaign}`\n return `${hostId}:${address}`\n}\n\n/** HMAC for unsubscribe links; env-gated on the shared secret. */\nexport function unsubscribeSignature(\n hostId: string,\n email: string,\n secret: string,\n sendId?: string,\n topicId?: string,\n): string {\n const subject = unsubscribeSignatureSubject(hostId, email, sendId, topicId)\n // An unsignable subject yields no signature rather than a signature over the\n // empty string, which would verify for every caller that also passed one.\n if (!subject) return ''\n return createHmac('sha256', secret).update(subject).digest('hex')\n}\n\n/**\n * Whether a signature is this link's.\n *\n * Which form is checked is decided by the LINK, not by the signature: a link\n * carrying no `cid` is checked against the two-part form and a link carrying\n * one against the three-part form. There is no fallback between them, and\n * that is what stops this being a downgrade — an attacker cannot take a\n * three-part link, drop the `cid` and have it verify, because the two-part\n * check over the same `hostId:email` produces a different digest. Nor can\n * they bolt a `cid` onto a two-part link: the three-part check then fails.\n *\n * `timingSafeEqual` needs equal lengths, so the length is compared first — it\n * is not a secret, both digests are fixed-width hex, and the call throws on a\n * mismatch rather than returning false.\n */\nexport function unsubscribeSignatureMatches(args: {\n hostId: string\n email: string\n sendId?: string\n signature: string\n secret: string\n}): boolean {\n const expected = unsubscribeSignature(\n args.hostId,\n args.email,\n args.secret,\n args.sendId || undefined,\n )\n return (\n expected.length === args.signature.length &&\n timingSafeEqual(\n new Uint8Array(Buffer.from(expected)),\n new Uint8Array(Buffer.from(args.signature)),\n )\n )\n}\n\n/**\n * The purpose component that distinguishes a CONFIRMATION subject.\n *\n * See {@link confirmSignatureSubject}. Exported because the plugin's verifier\n * imports it rather than restating the literal.\n */\nexport const CONFIRM_SUBJECT_PREFIX = 'confirm'\n\n/**\n * The signed subject for a double opt-in confirmation link.\n *\n * ## Why it is not one of the three forms above\n *\n * A confirmation names a host, an address and a topic, and it has NO\n * campaign — nobody is unsubscribing from a message, they are joining a\n * stream. The forms above refuse exactly that combination: a topic with no\n * campaign would leave an empty middle component, and `host:email::t` is the\n * same string as a three-part subject whose campaign id is `:t`.\n *\n * So it gets a leading PURPOSE component. That is a fourth form of the same\n * scheme, not a second scheme: the digest, the secret and the comparison are\n * unchanged.\n *\n * ## The one collision, and the guard for it\n *\n * `confirm:H:E:T` is byte-identical to the four-part unsubscribe subject\n * `A:B:C:D` when the site's id is literally `confirm`, which would let one\n * signature verify as both. Ids are not ours to constrain after the fact, so\n * the subject is refused for that host rather than the collision being\n * reasoned about: the cost is one unusable document id, and the alternative\n * is a signature that means two things.\n *\n * @returns the subject, or `''` for a combination it cannot sign\n * unambiguously. Empty rather than a partial subject, so\n * {@link confirmSignature} yields no signature rather than one over\n * the empty string — which would verify for every other caller that\n * also produced one.\n */\nexport function confirmSignatureSubject(\n hostId: string,\n email: string,\n topicId: string,\n): string {\n const address = String(email ?? '')\n .trim()\n .toLowerCase()\n const host = String(hostId ?? '')\n const topic = String(topicId ?? '')\n if (!host || !address || !topic) return ''\n if (host === CONFIRM_SUBJECT_PREFIX) return ''\n if (host.includes(':') || topic.includes(':')) return ''\n return `${CONFIRM_SUBJECT_PREFIX}:${host}:${address}:${topic}`\n}\n\n/** HMAC for a confirmation link; empty for an unsignable subject. */\nexport function confirmSignature(\n hostId: string,\n email: string,\n topicId: string,\n secret: string,\n): string {\n const subject = confirmSignatureSubject(hostId, email, topicId)\n if (!subject || !secret) return ''\n return createHmac('sha256', secret).update(subject).digest('hex')\n}\n\n/**\n * The absolute confirmation URL for one address and one topic.\n *\n * Minted where the signup happens, because the message carrying it is sent\n * from there — and that message is TRANSACTIONAL, not marketing: the person\n * just asked for this, so asking them to confirm it is the transaction they\n * started. It carries no unsubscribe header for the same reason a receipt\n * does not.\n *\n * Empty when there is no secret or no origin, for the reason\n * {@link buildUnsubscribeUrl} gives: a link pointing at nothing is worse than\n * no link, because the recipient believes they have confirmed.\n */\nexport function buildConfirmUrl(input: {\n siteBase: string\n hostId: string\n email: string\n topicId: string\n /** Defaults to {@link unsubscribeLinkSecret} — the same signing secret. */\n secret?: string\n}): string {\n const secret = input.secret ?? unsubscribeLinkSecret()\n const siteBase = String(input.siteBase ?? '').replace(/\\/+$/, '')\n const address = String(input.email ?? '')\n .trim()\n .toLowerCase()\n const signature = confirmSignature(\n input.hostId,\n address,\n input.topicId,\n secret,\n )\n if (!siteBase || !signature) return ''\n return (\n `${siteBase}/api/email/confirm` +\n `?hostId=${encodeURIComponent(input.hostId)}` +\n `&email=${encodeURIComponent(address)}` +\n `&tid=${encodeURIComponent(input.topicId)}` +\n `&sig=${signature}`\n )\n}\n\n/**\n * The absolute unsubscribe URL for one recipient of one site's mail.\n *\n * @returns the URL, or empty string when there is no secret to sign with or\n * no origin to resolve against. Empty rather than a half-built URL:\n * a caller can tell it does not have a link, and a link pointing at\n * nothing is worse than an absent header, because the recipient\n * believes they have unsubscribed.\n */\nexport function buildUnsubscribeUrl(input: {\n siteBase: string\n hostId: string\n email: string\n /**\n * The bulk send this link rides in, when there is one (`cid`): the id of\n * the send document an unsubscribe is counted against.\n */\n sendId?: string\n /** The topic the message belonged to, when it belonged to one. */\n topicId?: string\n /**\n * Which of the two URLs over this one signature to build.\n *\n * `one-click` is what the `List-Unsubscribe` header names: a mailbox\n * provider POSTs it with no human present and expects the act to have\n * happened when it reads the 200, so it points at the route whose POST\n * writes immediately and must never point at a page somebody has to submit.\n * `preferences` is the link a PERSON clicks in the footer, where the topic\n * this message belonged to is one of the things they can stop instead of\n * all of it. Defaults to `one-click`, which is what a sender with no topic\n * — every non-campaign marketing path — wants.\n */\n surface?: 'one-click' | 'preferences'\n /** Defaults to {@link unsubscribeLinkSecret}. */\n secret?: string\n}): string {\n const secret = input.secret ?? unsubscribeLinkSecret()\n const siteBase = String(input.siteBase ?? '').replace(/\\/+$/, '')\n const address = String(input.email ?? '')\n .trim()\n .toLowerCase()\n if (!secret || !siteBase || !input.hostId || !address) return ''\n const signature = unsubscribeSignature(\n input.hostId,\n address,\n secret,\n input.sendId,\n input.topicId,\n )\n if (!signature) return ''\n const route =\n input.surface === 'preferences'\n ? '/api/email/preferences'\n : '/api/email/unsubscribe'\n return (\n `${siteBase}${route}` +\n `?hostId=${encodeURIComponent(input.hostId)}` +\n `&email=${encodeURIComponent(address)}` +\n `&sig=${signature}` +\n (input.sendId ? `&cid=${encodeURIComponent(input.sendId)}` : '') +\n (input.topicId ? `&tid=${encodeURIComponent(input.topicId)}` : '')\n )\n}\n\n/*==========================================\n * A SIGNED LINK OF A SENDER'S OWN (AGL-2981).\n *\n * The forms above are the platform's own unsubscribe and confirmation\n * links, each a subject over a site and an address. A sender that mails\n * from somewhere else — a plugin sending from a person's own mailbox, whose\n * link carries its own facts — signs its link here instead, over a payload\n * of its own and under a PURPOSE of its own.\n *\n * The purpose is not a prefix of the subject, which is how the confirmation\n * form had to guard a collision by hand. It selects the KEY: every purpose's\n * links are signed with an HMAC key derived from the shared secret and the\n * purpose, so a signature minted for one purpose verifies for no other and\n * for none of the platform's own forms above, whatever the payloads spell.\n * The secret, the digest and the constant-time comparison are the same.\n *==========================================*/\n\n/** The key one purpose's links are signed with: derived from the secret, bound to the purpose. */\nfunction signedLinkKey(purpose: string, secret: string): Buffer {\n return createHmac('sha256', secret).update(`aglyn-signed-link:${purpose}`).digest()\n}\n\n/**\n * The signature of a payload for one purpose, base64url — or `''` when there\n * is no secret, no purpose or no payload to sign, so a caller can tell it has\n * no link rather than one that verifies for anybody.\n */\nexport function signedLinkSignature(\n purpose: string,\n payload: string,\n secret: string = unsubscribeLinkSecret(),\n): string {\n const name = String(purpose ?? '').trim()\n const body = String(payload ?? '')\n if (!secret || !name || !body) return ''\n return createHmac('sha256', signedLinkKey(name, secret)).update(body).digest('base64url')\n}\n\n/** Whether a signature is this payload's, for this purpose, in constant time. */\nexport function signedLinkSignatureMatches(args: {\n purpose: string\n payload: string\n signature: string\n /** Defaults to {@link unsubscribeLinkSecret}. */\n secret?: string\n}): boolean {\n const expected = signedLinkSignature(args.purpose, args.payload, args.secret ?? unsubscribeLinkSecret())\n const presented = String(args.signature ?? '')\n return (\n expected.length > 0 &&\n expected.length === presented.length &&\n timingSafeEqual(new Uint8Array(Buffer.from(expected)), new Uint8Array(Buffer.from(presented)))\n )\n}\n"],"names":["createHmac","timingSafeEqual","unsubscribeLinkSecret","process","env","EMAIL_UNSUBSCRIBE_SECRET","CRON_SECRET","unsubscribeSignatureSubject","hostId","email","sendId","topicId","address","String","trim","toLowerCase","campaign","topic","includes","unsubscribeSignature","secret","subject","update","digest","unsubscribeSignatureMatches","args","expected","undefined","length","signature","Uint8Array","Buffer","from","CONFIRM_SUBJECT_PREFIX","confirmSignatureSubject","host","confirmSignature","buildConfirmUrl","input","siteBase","replace","encodeURIComponent","buildUnsubscribeUrl","route","surface","signedLinkKey","purpose","signedLinkSignature","payload","name","body","signedLinkSignatureMatches","presented"],"mappings":"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"}
|