@aglyn/tenant-data-admin 1.0.0-beta.231 → 1.0.0-beta.233
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/account-acquisition.d.ts +30 -0
- package/src/lib/server/account-acquisition.js +37 -2
- package/src/lib/server/account-acquisition.js.map +1 -1
- package/src/lib/server/account-lock-mail.d.ts +17 -0
- package/src/lib/server/account-lock-mail.js +28 -1
- package/src/lib/server/account-lock-mail.js.map +1 -1
- package/src/lib/server/api-keys.d.ts +14 -1
- package/src/lib/server/api-keys.js +28 -0
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/content-entry-drafts.d.ts +131 -0
- package/src/lib/server/content-entry-drafts.js +307 -0
- package/src/lib/server/content-entry-drafts.js.map +1 -0
- package/src/lib/server/email-delivery-log.js +18 -2
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/list-members.js +7 -2
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/operator-alerts-log.d.ts +31 -0
- package/src/lib/server/operator-alerts-log.js +87 -0
- package/src/lib/server/operator-alerts-log.js.map +1 -0
- package/src/lib/server/operator-alerts.js +3 -0
- package/src/lib/server/operator-alerts.js.map +1 -1
- package/src/lib/server/personal-data-export.js +123 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-delivery-log.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 PER-RECIPIENT DELIVERY LOG,\n * `emailDeliveries/{emailKey}/messages/{providerMessageId}`.\n *\n * ## What it answers\n *\n * \"Did this person get their invite, and did they open it?\" — the question\n * every support conversation about a missing email starts with, and the one\n * that until now could only be answered by signing into the sending provider\n * and searching a list that is not scoped to the account being discussed.\n *\n * ## Why a store, and where the provider still comes in\n *\n * The READ is always local. Fanning out to the ESP on render would put a\n * vendor at the centre of a staff screen, at three specific costs: lock-in to\n * a per-vendor list shape, a rolling retention window our own record outlives,\n * and a third-party round trip on every page view. Resend's list endpoint also\n * has no recipient filter at all, so a per-person lookup would mean paging the\n * whole account's history on each render.\n *\n * The WRITE has two sources, and the second exists because the first is not\n * enough on its own:\n *\n * - **The event feed** ({@link recordEmailDeliveryEvent}) — live, complete,\n * and the only source of open and click counts. It knows nothing about\n * mail sent before it was connected.\n * - **A history import** ({@link importEmailDeliveryHistory}) — a one-off\n * (and re-runnable) sweep of the provider's own list, through the same\n * neutral vocabulary. Without it the log is empty for every message that\n * predates the webhook, which is exactly the mail a support question is\n * about. A card that shows nothing for a person we demonstrably emailed is\n * the failure this whole file exists to remove.\n *\n * ## Shape\n *\n * A subcollection per recipient rather than one flat collection with a `to`\n * field. The read is then a single ordered query inside one small collection\n * — no composite index to go missing, and no `where` clause whose absent\n * field would silently drop documents. The parent id is\n * {@link emailSuppressionKey}'s `sha256`, deliberately the SAME derivation the\n * suppression lists use, so the two can never disagree about which document\n * describes which person.\n *\n * One document per MESSAGE, not per event: `sent`, `delivered`, `opened` and\n * three `clicked`s are one row in the staff view, and an append-only event\n * collection would make the common read six documents instead of one. Opens\n * and clicks are counted rather than listed, because the count is the fact a\n * staffer uses and an unbounded array is how a document reaches the 1 MiB\n * limit on a mailing nobody was watching.\n *\n * ## Never throws\n *\n * Every function here is best-effort, on the same reasoning as the rest of the\n * mail path: a webhook must acknowledge the provider, and a staff page must\n * render, whatever Firestore is doing. A failed write loses a row from a log;\n * a thrown one loses the delivery event AND teaches the provider to retry.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type EmailDeliveryEvent,\n type EmailDeliveryEventType,\n type EmailDeliveryHistorySource,\n type EmailDeliverySnapshot,\n worstDeliveryStatus,\n} from '@aglyn/shared-util-email'\nimport { eraseConversionCredits } from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { emailSearchTokens, emailSuppressionKey } from './email-suppression'\nimport firebaseAdmin from './firebase-admin'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\nexport const EMAIL_DELIVERIES_COLLECTION = 'emailDeliveries'\nexport const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages'\n\n/** The most messages one staff read will return. */\nexport const EMAIL_DELIVERY_READ_LIMIT = 50\n\n/**\n * The most distinct links one message records.\n *\n * A newsletter with forty links clicked by one reader must not grow the\n * document without bound; the first few tell a staffer what they need.\n */\nexport const EMAIL_DELIVERY_MAX_LINKS = 10\n\n/**\n * The field a message's search tokens are stored in (AGL-3321): the staff\n * account page's delivery table searches it with `array-contains` on one\n * typed word, and its Message filter's \"contains\" reads it too.\n */\nexport const EMAIL_DELIVERY_SEARCH_FIELD = 'searchTokens'\n\n/** The most tokens one message stores; a long subject loses reach past it. */\nexport const EMAIL_DELIVERY_SEARCH_TOKEN_LIMIT = 200\n\n/**\n * A message's search tokens: its recipient the way a suppression's address\n * is tokenized (`emailSearchTokens` — the whole address, its domain and each\n * piece of it), then the word prefixes of its subject and of its sender tag.\n * In that order, deduplicated, capped.\n *\n * Every writer stamps it from the message's merged state, so a subject that\n * arrives on a later event than the first still becomes searchable, and\n * `tools/scripts/backfill-email-delivery-search.mjs` stamps the messages\n * written before the field. Both are held to\n * `tools/scripts/lib/email-search-tokens.fixtures.json`.\n */\nexport function emailDeliverySearchTokens(message: {\n to?: unknown\n subject?: unknown\n context?: unknown\n}): string[] {\n const text = (value: unknown) => (typeof value === 'string' ? value : '')\n const tokens = new Set<string>([\n ...emailSearchTokens(text(message.to)),\n ...nameSearchTokens(text(message.subject)),\n ...nameSearchTokens(text(message.context)),\n ])\n return [...tokens].slice(0, EMAIL_DELIVERY_SEARCH_TOKEN_LIMIT)\n}\n\n/** One message as the staff view reads it. */\nexport interface EmailDeliveryRecord {\n /** The provider's message id — also the document id. */\n messageId: string\n provider: string\n to: string\n subject: string | null\n /** The sender label, e.g. `'invite'`. Null for a send that carried none. */\n context: string | null\n /** Furthest-along (worst) lifecycle state seen. */\n status: EmailDeliveryEventType\n /** Epoch ms per state, absent for states that never happened. */\n timestamps: Partial<Record<EmailDeliveryEventType, number>>\n /** First event we saw for this message. Always present — the sort key. */\n firstSeenAtMs: number\n openCount: number\n clickCount: number\n /** Distinct destinations followed, capped. */\n clickedLinks: string[]\n bounceType: string | null\n detail: string | null\n hostId: string | null\n campaignId: string | null\n}\n\n/**\n * What one {@link recordEmailDeliveryEvent} call did.\n *\n * `firstOfType` exists so a CAMPAIGN counter can be incremented once per\n * recipient without buying a read of its own. This transaction already holds\n * the message's prior state, and \"has this message ever been opened before\"\n * is the fact a distinct-openers count needs — deriving it here costs\n * nothing, and deriving it anywhere else costs a document read per event.\n *\n * It is also what makes those counters idempotent, on the same reasoning the\n * webhook's replay guard rests on: a redelivered or replayed event finds the\n * state already recorded and reports `false`, so the counter cannot be\n * incremented twice for one message's first open.\n */\nexport interface EmailDeliveryEventOutcome {\n /**\n * No event of this TYPE had been recorded against this message before.\n *\n * Read off `timestamps`, which is written for every event type, rather than\n * off `openCount`/`clickCount`, which exist for two of them.\n */\n firstOfType: boolean\n /** The message this event was recorded against. */\n providerMessageId: string\n /** The recipient, lowercased — the person the event is about. */\n to: string\n /** Which event this was. */\n type: EmailDeliveryEventType\n /** When it happened, epoch ms. */\n at: number\n}\n\n/**\n * Records one normalized event against its message.\n *\n * A transaction rather than a merge-set, for one property that matters to the\n * reader: `firstSeenAtMs` must be written exactly once and must never be\n * absent. Events arrive out of order — an `opened` can beat its own `sent`\n * through the queue — so \"create with the first event's time, then leave it\n * alone\" needs a read in the same atomic step as the write. A document missing\n * that field would be dropped from the `orderBy` read entirely and the message\n * would simply not appear, which is the failure mode a delivery log can least\n * afford.\n *\n * @returns the outcome, or `null` when nothing was written. `null` is the\n * ordinary answer for an address that is not an address; it is never\n * an error.\n */\nexport async function recordEmailDeliveryEvent(\n event: EmailDeliveryEvent,\n firestore?: any,\n): Promise<EmailDeliveryEventOutcome | null> {\n const key = emailSuppressionKey(event.to)\n if (!key || !event.providerMessageId) return null\n\n let firstOfType = false\n try {\n const db = firestore ?? defaultFirestore()\n const ref = db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .collection(EMAIL_DELIVERY_MESSAGES_COLLECTION)\n .doc(event.providerMessageId)\n\n await db.runTransaction(async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const existing = (snapshot.exists ? snapshot.data() : null) ?? {}\n\n /*\n * Set INSIDE the transaction body, which may run more than once: a\n * Firestore transaction retries on contention, and a value computed\n * before the retry would describe the state that lost the race. This\n * assignment (not `||=`) makes the last attempt — the one whose write\n * committed — the one whose reading is reported.\n */\n firstOfType = !(\n existing.timestamps && existing.timestamps[event.type] !== undefined\n )\n\n const update: Record<string, unknown> = {\n messageId: event.providerMessageId,\n provider: event.provider,\n to: event.to,\n status: worstDeliveryStatus(existing.status, event.type),\n /*\n * A NESTED MAP, not a dotted key.\n *\n * `set({merge:true})` treats `'timestamps.sent'` as a field whose\n * NAME contains a dot — only `update()` reads a dot as a path. So the\n * dotted form wrote a top-level field nothing reads and left\n * `timestamps` empty, which the staff card rendered as a message with\n * no send date. What merge DOES do is merge nested maps at depth, so\n * this form keeps every sibling state rather than replacing them.\n */\n timestamps: { [event.type]: event.at },\n lastEventAtMs: event.at,\n updatedAt: FieldValue.serverTimestamp(),\n }\n\n // Written once. A later event for the same message carries the same\n // subject, but an `email.opened` payload may carry none at all — and\n // overwriting a known subject with null is how a staff row loses the\n // only thing that identifies it.\n if (!snapshot.exists) update.firstSeenAtMs = event.at\n if (event.subject && !existing.subject) update.subject = event.subject\n if (event.context && !existing.context) update.context = event.context\n if (event.tags?.hostId && !existing.hostId)\n update.hostId = event.tags.hostId\n if (event.tags?.campaignId && !existing.campaignId)\n update.campaignId = event.tags.campaignId\n if (event.bounceType) update.bounceType = event.bounceType\n if (event.detail) update.detail = event.detail\n // Searchable by what the message now says, merged — the subject may\n // arrive on a later event than the first (AGL-3321).\n update[EMAIL_DELIVERY_SEARCH_FIELD] = emailDeliverySearchTokens({\n to: event.to,\n subject: existing.subject || event.subject,\n context: existing.context || event.context,\n })\n\n if (event.type === 'opened') update.openCount = FieldValue.increment(1)\n if (event.type === 'clicked') {\n update.clickCount = FieldValue.increment(1)\n if (event.link) {\n const links: string[] = Array.isArray(existing.clickedLinks)\n ? existing.clickedLinks.map(String)\n : []\n if (\n !links.includes(event.link) &&\n links.length < EMAIL_DELIVERY_MAX_LINKS\n ) {\n update.clickedLinks = [...links, event.link]\n }\n }\n }\n\n transaction.set(ref, update, { merge: true })\n })\n return {\n firstOfType,\n providerMessageId: event.providerMessageId,\n to: event.to,\n type: event.type,\n at: event.at,\n }\n } catch (error) {\n console.error(\n '[email-delivery-log] write failed',\n event.providerMessageId,\n error,\n )\n return null\n }\n}\n\n/**\n * Records one message the PROVIDER already knows about — the history import.\n *\n * ## Why this is not just `recordEmailDeliveryEvent` with a made-up event\n *\n * A snapshot is weaker evidence than an event, in two specific ways, and\n * writing it as an event would silently promote it:\n *\n * - **It carries no counts.** A provider's list reports one `last_event` per\n * message and no engagement detail, so `opened` means \"at least once\" and\n * can never mean \"three times\". Incrementing `openCount` from a snapshot\n * would invent a number, and re-running the import would invent it again.\n * - **It can be STALER than what we already hold.** The event feed is live;\n * an import is a page of results fetched some time ago. So the status is\n * merged with {@link worstDeliveryStatus} rather than assigned, and a row\n * the webhook has already advanced is never walked backwards.\n *\n * Everything else it fills is a gap-fill only: `subject` and `sentAt` are\n * written when absent and left alone when present. The net effect is that\n * importing history is idempotent and can be run as often as you like, and a\n * message the event feed has covered is untouched by it.\n *\n * `context` is deliberately NOT recoverable here. It comes from a send tag,\n * and the list endpoint does not return tags — so an imported row shows the\n * subject and the status but cannot say which of our senders produced it. The\n * card renders that absence rather than guessing.\n *\n * @returns whether a row was written or updated.\n */\nexport async function recordEmailDeliverySnapshot(\n snapshot: EmailDeliverySnapshot,\n firestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(snapshot.to)\n if (!key || !snapshot.providerMessageId || !snapshot.sentAt) return false\n\n try {\n const db = firestore ?? defaultFirestore()\n const ref = db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .collection(EMAIL_DELIVERY_MESSAGES_COLLECTION)\n .doc(snapshot.providerMessageId)\n\n await db.runTransaction(async (transaction: any) => {\n const stored = await transaction.get(ref)\n const existing = (stored.exists ? stored.data() : null) ?? {}\n\n const update: Record<string, unknown> = {\n messageId: snapshot.providerMessageId,\n provider: snapshot.provider,\n to: snapshot.to,\n status: worstDeliveryStatus(existing.status, snapshot.status),\n importedAtMs: Date.now(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n if (!stored.exists) update.firstSeenAtMs = snapshot.sentAt\n if (snapshot.subject && !existing.subject) update.subject = snapshot.subject\n // Only when the event feed has not already dated the send itself. An\n // imported `created_at` is the provider's, and so is the webhook's, but\n // the webhook's arrived with the rest of that message's truth. Nested\n // map rather than a dotted key, for the reason recorded above.\n if (!existing.timestamps?.sent) {\n update.timestamps = { sent: snapshot.sentAt }\n }\n update[EMAIL_DELIVERY_SEARCH_FIELD] = emailDeliverySearchTokens({\n to: snapshot.to,\n subject: existing.subject || snapshot.subject,\n context: existing.context,\n })\n\n transaction.set(ref, update, { merge: true })\n })\n return true\n } catch (error) {\n console.error(\n '[email-delivery-log] snapshot write failed',\n snapshot.providerMessageId,\n error,\n )\n return false\n }\n}\n\n/**\n * Records a batch, independently — one bad event must not lose the others.\n *\n * @returns one outcome per event that was WRITTEN; events that wrote nothing\n * are absent, so the length is still the count the old return value\n * reported.\n */\nexport async function recordEmailDeliveryEvents(\n events: EmailDeliveryEvent[],\n firestore?: any,\n): Promise<EmailDeliveryEventOutcome[]> {\n const results = await Promise.all(\n events.map((event) => recordEmailDeliveryEvent(event, firestore)),\n )\n return results.filter((one): one is EmailDeliveryEventOutcome => one !== null)\n}\n\n/*==========================================\n * THE PER-PERSON ENGAGEMENT ROLLUP.\n *\n * The message rows above answer \"what did we send this person\". They cannot\n * answer \"has this person engaged with anything lately\" without reading every\n * row in their `messages` subcollection, which is the expensive-read shape\n * this codebase refuses — and that single absence is what made an audience\n * rule like \"opened in the last 30 days\" unanswerable and engagement-based\n * sunsetting unbuildable.\n *\n * So the rollup lands on the PARENT of the messages, `emailDeliveries/{key}`,\n * which already exists as the erasure tombstone's home. One document per\n * person, read by key, no query and therefore no index.\n *\n * ## Address-global, not per site\n *\n * The store keys on an address, the erasure path treats it as an address, and\n * the deliverability problem the rollup exists to serve is domain-wide: every\n * tenant's mail leaves on one domain under one DKIM `d=`, so the engagement\n * that moves the platform's spam rate is engagement with ANY of it. A\n * per-site map would also have to be capped, and capping a map needs a read\n * of it on every write.\n *\n * The cost of that choice is stated rather than hidden: a person who engages\n * with one site's mail reads as engaged when a second site asks. That is the\n * lenient direction for a control whose only power is to REFUSE a send.\n *\n * ## What one webhook event costs\n *\n * A rollup that wrote on every event would be a write per event per person,\n * which is a bill — a single reader opening a newsletter six times, plus\n * mailbox-provider prefetches, is one fact and six writes. So the rollup\n * moves only on an event that is the FIRST of its type for its message, which\n * {@link recordEmailDeliveryEvent}'s transaction already decided at no extra\n * cost. `delivered`, `bounced`, `complained`, `sent` and `delayed` move\n * nothing here at all.\n *\n * That bound is also what makes it replay-proof for free, by the same\n * reasoning the campaign counters rest on: a redelivered or replayed event\n * finds its type already recorded, reports `firstOfType: false`, and\n * contributes nothing.\n *\n * ⚠️ The bound has one consequence worth naming. A reader who opens only mail\n * they have already opened does not advance their own stamp, so a person can\n * read a year-old message and still measure as cold. Every message we send\n * them afterwards is a fresh first-open, so the stamp advances the moment\n * they engage with anything new — which is the population any sunset rule is\n * actually about.\n *=========================================*/\n\n/** The event types that count as a person engaging. */\nconst ENGAGEMENT_TYPES: readonly EmailDeliveryEventType[] = ['opened', 'clicked']\n\n/** What one person's mail says about whether they are still listening. */\nexport interface EmailPersonEngagement {\n /** The later of {@link lastOpenedAtMs} and {@link lastClickedAtMs}. */\n lastEngagedAtMs: number | null\n lastOpenedAtMs: number | null\n /**\n * Clicks are the metric to lean on. Apple's Mail Privacy Protection\n * prefetches images, so an open is partly a statement about the recipient's\n * mail client; a click is a statement about the recipient.\n */\n lastClickedAtMs: number | null\n}\n\n/** The empty answer, so a caller never has to invent one. */\nexport const NO_PERSON_ENGAGEMENT: EmailPersonEngagement = {\n lastEngagedAtMs: null,\n lastOpenedAtMs: null,\n lastClickedAtMs: null,\n}\n\n/** Reads the three stamps off a parent document's data. */\nfunction engagementFrom(data: Record<string, unknown> | null | undefined) {\n const number = (value: unknown): number | null => {\n const parsed = Number(value ?? 0)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : null\n }\n const opened = number(data?.['lastOpenedAtMs'])\n const clicked = number(data?.['lastClickedAtMs'])\n const engaged = number(data?.['lastEngagedAtMs'])\n return {\n lastEngagedAtMs:\n engaged ?? (opened || clicked ? Math.max(opened ?? 0, clicked ?? 0) : null),\n lastOpenedAtMs: opened,\n lastClickedAtMs: clicked,\n }\n}\n\n/**\n * Advances the engagement stamps for the people these outcomes are about.\n *\n * A transaction, and it buys exactly one property: the stamps only ever move\n * FORWARD. Provider events are not ordered, and a replay of an event whose\n * first delivery never landed can carry an instant from months ago — a blind\n * merge-set would let that overwrite a fresh stamp and quietly make an active\n * subscriber look cold to a control whose whole job is refusing to mail cold\n * people. Reading before writing is a cheaper unit than the write beside it,\n * and it happens at most once per message per event type.\n *\n * Never throws, for the same reason nothing else in this file does: a rollup\n * that failed loses a stamp, and a rollup that threw would lose the webhook's\n * acknowledgement and teach the provider to retry the whole event.\n *\n * @returns how many person documents were written.\n */\nexport async function recordPersonEngagement(\n outcomes: readonly EmailDeliveryEventOutcome[],\n firestore?: any,\n): Promise<number> {\n /** Person key → the newest instant seen per engagement type in this batch. */\n const byPerson = new Map<\n string,\n { openedAtMs: number; clickedAtMs: number }\n >()\n for (const outcome of outcomes) {\n if (!outcome.firstOfType) continue\n if (!ENGAGEMENT_TYPES.includes(outcome.type)) continue\n const key = emailSuppressionKey(outcome.to)\n const at = Number(outcome.at)\n if (!key || !Number.isFinite(at) || at <= 0) continue\n const held = byPerson.get(key) ?? { openedAtMs: 0, clickedAtMs: 0 }\n if (outcome.type === 'opened') {\n held.openedAtMs = Math.max(held.openedAtMs, at)\n } else {\n held.clickedAtMs = Math.max(held.clickedAtMs, at)\n }\n byPerson.set(key, held)\n }\n if (!byPerson.size) return 0\n\n const db = firestore ?? defaultFirestore()\n let written = 0\n for (const [key, seen] of byPerson) {\n try {\n const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key)\n await db.runTransaction(async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const stored = engagementFrom(\n (snapshot.exists ? snapshot.data() : null) ?? {},\n )\n const opened = Math.max(stored.lastOpenedAtMs ?? 0, seen.openedAtMs)\n const clicked = Math.max(stored.lastClickedAtMs ?? 0, seen.clickedAtMs)\n const engaged = Math.max(stored.lastEngagedAtMs ?? 0, opened, clicked)\n // Nothing moved forward, so nothing is written. An out-of-order event\n // is the ordinary case this skips, and skipping it costs a write\n // rather than losing a fact.\n if (\n opened === (stored.lastOpenedAtMs ?? 0) &&\n clicked === (stored.lastClickedAtMs ?? 0) &&\n engaged === (stored.lastEngagedAtMs ?? 0)\n ) {\n return\n }\n /*\n * A merge-set that CREATES. Unlike the campaign counters, there is no\n * document here to resurrect: `emailDeliveries/{key}` is a container\n * this store owns, its only other content is the erasure tombstone,\n * and a person's first recorded open is exactly when it should come\n * into existence.\n */\n transaction.set(\n ref,\n {\n ...(opened ? { lastOpenedAtMs: opened } : {}),\n ...(clicked ? { lastClickedAtMs: clicked } : {}),\n lastEngagedAtMs: engaged,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n written += 1\n })\n } catch (error) {\n console.error('[email-delivery-log] engagement rollup failed', key, error)\n }\n }\n return written\n}\n\n/**\n * One person's engagement, by address. Never throws.\n *\n * Returns {@link NO_PERSON_ENGAGEMENT} for an address we hold nothing about,\n * AND for a read that failed. The two are deliberately the same answer here:\n * every caller uses this to decide whether to REFUSE something, and both\n * readings must resolve to \"we have no evidence this person is cold\", which\n * is the only safe direction for a control that stops mail.\n */\nexport async function readPersonEngagement(\n email: string | null | undefined,\n firestore?: any,\n): Promise<EmailPersonEngagement> {\n const key = emailSuppressionKey(email)\n if (!key) return NO_PERSON_ENGAGEMENT\n try {\n const db = firestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .get()\n // No `exists` branch: a missing document has no data, and `engagementFrom`\n // already answers an absent field with null. A second gate saying the same\n // thing would be a line no test can distinguish from its own removal.\n return engagementFrom(snapshot.data() ?? {})\n } catch (error) {\n console.error('[email-delivery-log] engagement read failed', error)\n return NO_PERSON_ENGAGEMENT\n }\n}\n\n/**\n * Engagement for many people at once, keyed by their person key.\n *\n * A `getAll` rather than a query: these are keyed document reads, so this\n * needs no index, cannot be truncated by a `limit`, and cannot drop somebody\n * for missing a field the way an `orderBy` would. The audience materializer\n * calls it a page at a time and counts every read against its scan budget.\n *\n * A key with no document is present in the result with\n * {@link NO_PERSON_ENGAGEMENT}, so a caller never has to tell \"absent\" from\n * \"not read\" — and a failure returns every requested key that way for the\n * same reason {@link readPersonEngagement} does.\n */\nexport async function readPersonEngagementByKeys(\n keys: readonly string[],\n firestore?: any,\n): Promise<Map<string, EmailPersonEngagement>> {\n const wanted = [...new Set(keys.filter(Boolean))]\n const found = new Map<string, EmailPersonEngagement>()\n for (const key of wanted) found.set(key, NO_PERSON_ENGAGEMENT)\n if (!wanted.length) return found\n try {\n const db = firestore ?? defaultFirestore()\n const collection = db.collection(EMAIL_DELIVERIES_COLLECTION)\n const snapshots = await db.getAll(\n ...wanted.map((key: string) => collection.doc(key)),\n )\n for (const snapshot of snapshots) {\n if (!snapshot?.exists) continue\n found.set(snapshot.id, engagementFrom(snapshot.data() ?? {}))\n }\n } catch (error) {\n console.error('[email-delivery-log] engagement batch read failed', error)\n }\n return found\n}\n\n/** What one {@link importEmailDeliveryHistory} run did. */\nexport interface EmailDeliveryImportResult {\n /** Provider messages read. */\n scanned: number\n /** Per-recipient rows written or refreshed. */\n recorded: number\n pages: number\n /** Cursor to resume from, or null when the history was exhausted. */\n nextCursor: string | null\n /** True when the page budget ran out before the history did. */\n truncated: boolean\n}\n\n/** Default page budget for one import run. 100 messages per page. */\nexport const EMAIL_DELIVERY_IMPORT_MAX_PAGES = 20\n\n/**\n * Imports already-sent mail from a provider into the log.\n *\n * Bounded by PAGES rather than run to completion: this is called from a\n * request handler, and an account with a large history would otherwise hold\n * one open until it timed out — losing every page it had already written,\n * because a partial import that reports nothing is indistinguishable from one\n * that did nothing. Instead it stops at the budget, returns `nextCursor`, and\n * the caller resumes. Every page is written before the next is fetched, so an\n * interrupted run keeps its work.\n *\n * Idempotent by construction — see {@link recordEmailDeliverySnapshot}: a\n * message the event feed already covered is not walked backwards, and\n * re-running invents no counts.\n *\n * The `source` is injected rather than constructed here. This module may not\n * know which provider is in use, and a test must be able to run the whole\n * loop — pagination, cursor handling, the stop condition — without a network.\n */\nexport async function importEmailDeliveryHistory(options: {\n source: EmailDeliveryHistorySource\n cursor?: string | null\n maxPages?: number\n firestore?: any\n}): Promise<EmailDeliveryImportResult> {\n const maxPages = Math.max(1, options.maxPages ?? EMAIL_DELIVERY_IMPORT_MAX_PAGES)\n let cursor = options.cursor ?? null\n let scanned = 0\n let recorded = 0\n let pages = 0\n\n while (pages < maxPages) {\n const page = await options.source({ cursor })\n pages += 1\n scanned += page.snapshots.length\n for (const snapshot of page.snapshots) {\n if (await recordEmailDeliverySnapshot(snapshot, options.firestore)) {\n recorded += 1\n }\n }\n cursor = page.nextCursor\n if (!cursor) break\n }\n\n return {\n scanned,\n recorded,\n pages,\n nextCursor: cursor,\n truncated: Boolean(cursor),\n }\n}\n\n/**\n * The messages sent to one address, newest first.\n *\n * Ordered on `firstSeenAtMs`, which the writer guarantees on creation, rather\n * than on a per-state timestamp that only some rows carry: `orderBy` drops\n * every document missing the field, so ordering on `timestamps.sent` would\n * silently hide any message whose `sent` webhook never arrived — exactly the\n * message a staffer is looking for.\n *\n * @returns the rows, or an empty array. The caller distinguishes \"none\" from\n * \"could not read\" through {@link readEmailDeliveryHistory}.\n */\nexport async function readEmailDeliveries(\n email: string | null | undefined,\n options?: { limit?: number; firestore?: any },\n): Promise<EmailDeliveryRecord[]> {\n const key = emailSuppressionKey(email)\n if (!key) return []\n const db = options?.firestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .collection(EMAIL_DELIVERY_MESSAGES_COLLECTION)\n .orderBy('firstSeenAtMs', 'desc')\n .limit(Math.max(1, options?.limit ?? EMAIL_DELIVERY_READ_LIMIT))\n .get()\n\n return snapshot.docs.map(deliveryRecordFrom)\n}\n\n/**\n * One stored message document as {@link EmailDeliveryRecord}.\n *\n * Shared by every reader in this file so the defaults are decided once. A\n * second copy would be a second answer to \"what does an absent `openCount`\n * mean\", and the two would drift the first time a field is added.\n */\nexport function deliveryRecordFrom(doc: any): EmailDeliveryRecord {\n const data = doc.data() ?? {}\n return {\n messageId: String(data.messageId ?? doc.id),\n provider: String(data.provider ?? 'unknown'),\n to: String(data.to ?? ''),\n subject: data.subject ?? null,\n context: data.context ?? null,\n status: (data.status ?? 'sent') as EmailDeliveryEventType,\n timestamps: (data.timestamps ?? {}) as EmailDeliveryRecord['timestamps'],\n firstSeenAtMs: Number(data.firstSeenAtMs ?? 0),\n openCount: Number(data.openCount ?? 0),\n clickCount: Number(data.clickCount ?? 0),\n clickedLinks: Array.isArray(data.clickedLinks)\n ? data.clickedLinks.map(String)\n : [],\n bounceType: data.bounceType ?? null,\n detail: data.detail ?? null,\n hostId: data.hostId ?? null,\n campaignId: data.campaignId ?? null,\n }\n}\n\n/**\n * {@link readEmailDeliveries} with the read failure kept separate from an\n * empty result.\n *\n * The same shape `devices` uses on the staff detail route, for the same\n * reason: \"we have no record of any email to this person\" and \"we could not\n * reach the log\" lead a staffer to opposite next actions, and a card that\n * renders both as an empty table sends them down the wrong one.\n */\nexport async function readEmailDeliveryHistory(\n email: string | null | undefined,\n options?: { limit?: number; firestore?: any },\n): Promise<{ lookupFailed: boolean; rows: EmailDeliveryRecord[] }> {\n try {\n return { lookupFailed: false, rows: await readEmailDeliveries(email, options) }\n } catch (error) {\n console.error('[email-delivery-log] read failed', error)\n return { lookupFailed: true, rows: [] }\n }\n}\n\n/*==========================================\n * ACROSS THE BULK SENDS OF ONE SITE.\n *\n * The readers above answer \"what did we send this person\". This one answers\n * the other direction — \"who did this send reach, and which of them opened\n * it\" — and it is the SAME store, queried across the recipient documents\n * instead of down one of them. A send is whatever the sender tagged its\n * messages with when they went out: the mail rail stamps every bulk message\n * with the site and the send's id (`send-email.ts`), under the field name\n * `campaignId` it has always been stored as, whichever plugin sent it.\n *\n * That direction is a collection-group query, and it is the one shape this\n * file's header says the per-address layout avoids. It is worth the index\n * here for the reason the index exists at all: the alternative is a second\n * per-recipient store keyed by send, written by the same webhook, which\n * would be two records of the same fact and one of them eventually wrong.\n *\n * ⚠️ EVERY caller must be authorised on `hostId` before calling. The rows\n * carry recipient addresses, and the `hostId` filter below is a query\n * predicate, not a permission — it narrows the read to one site's mail and\n * says nothing about who is asking.\n *=========================================*/\n\n/** The most recipient rows one send-engagement read returns. */\nexport const EMAIL_SEND_ENGAGEMENT_PAGE_SIZE = 25\n\n/**\n * How many sends one engagement read can span.\n *\n * Firestore's `in` operator takes at most 30 values, and the query below runs\n * as a merge of one sub-query per value — so this is a hard limit of the\n * store rather than a number worth tuning. A design used by more sends than\n * this reads its most recent 30, and the caller is told so.\n */\nexport const EMAIL_SEND_ENGAGEMENT_MAX_SENDS = 30\n\n/** Which recipients a send-engagement read returns. */\nexport type EmailEngagementFilter = 'all' | 'opened' | 'clicked'\n\n/** One page of recipient rows. */\nexport interface EmailSendEngagementPage {\n rows: EmailDeliveryRecord[]\n /**\n * Cursor for the next page, or null at the end.\n *\n * The full document PATH of the last row, which is\n * `emailDeliveries/{sha256(address)}/messages/{messageId}`. It is re-read\n * as a snapshot to resume the query, rather than resuming from the ordered\n * VALUE: a value cursor positions after every document sharing it, so two\n * messages recorded in the same millisecond would lose one of them between\n * pages — silently, and only under load.\n */\n cursor: string | null\n /** The read failed, as distinct from finding nothing. */\n lookupFailed: boolean\n /** Sends past {@link EMAIL_SEND_ENGAGEMENT_MAX_SENDS}. */\n sendsOmitted: number\n}\n\n/**\n * The recipients of one site's bulk sends, newest message first.\n *\n * ## What each filter orders on, and why it is not one query with a flag\n *\n * `all` orders on `firstSeenAtMs`, which {@link recordEmailDeliveryEvent}\n * guarantees on creation. `opened` and `clicked` carry an inequality —\n * `openCount > 0` — and Firestore requires the first ordering to be on the\n * inequality's own field, so those two order on the count and then on the\n * time. That is not a workaround: a message never opened has no `openCount`\n * field at all, so the inequality is also what excludes it, and the ordering\n * puts the most engaged recipient first, which is the order a merchant reads\n * such a table in.\n *\n * ## Never throws\n *\n * Same contract as the rest of this file: `lookupFailed` distinguishes a read\n * that could not run — a missing index is the likely one — from a send\n * nobody opened. Rendering those two the same way is how a merchant concludes\n * their mail reached nobody.\n */\nexport async function readSendEngagement(options: {\n /** The site whose mail this is. The caller must already have proven it. */\n hostId: string\n /** Send ids to read, most recent first. */\n sendIds: readonly string[]\n filter?: EmailEngagementFilter\n limit?: number\n /** A `cursor` from a previous page. */\n cursor?: string | null\n firestore?: any\n}): Promise<EmailSendEngagementPage> {\n const {\n hostId,\n sendIds,\n filter = 'all',\n cursor = null,\n firestore,\n } = options\n const pageSize = Math.max(\n 1,\n Math.min(\n EMAIL_SEND_ENGAGEMENT_PAGE_SIZE,\n options.limit ?? EMAIL_SEND_ENGAGEMENT_PAGE_SIZE,\n ),\n )\n const ids = sendIds\n .filter(Boolean)\n .slice(0, EMAIL_SEND_ENGAGEMENT_MAX_SENDS)\n const sendsOmitted = Math.max(\n 0,\n sendIds.filter(Boolean).length - ids.length,\n )\n const empty: EmailSendEngagementPage = {\n rows: [],\n cursor: null,\n lookupFailed: false,\n sendsOmitted,\n }\n if (!hostId || !ids.length) return empty\n\n try {\n const db = firestore ?? defaultFirestore()\n let query = db\n .collectionGroup(EMAIL_DELIVERY_MESSAGES_COLLECTION)\n // `hostId` first so the read is provably one site's mail even if a\n // caller ever passes a send id belonging to another.\n .where('hostId', '==', hostId)\n // The send tag's stored name (see the section header).\n .where('campaignId', 'in', ids)\n if (filter === 'opened') {\n query = query.where('openCount', '>', 0).orderBy('openCount', 'desc')\n } else if (filter === 'clicked') {\n query = query.where('clickCount', '>', 0).orderBy('clickCount', 'desc')\n }\n query = query.orderBy('firstSeenAtMs', 'desc')\n\n if (cursor) {\n const anchor = await db.doc(cursor).get()\n // A cursor whose document has been erased resumes nothing rather than\n // silently restarting at page one, which would loop the reader through\n // the same rows forever.\n if (!anchor.exists) return empty\n query = query.startAfter(anchor)\n }\n\n const snapshot = await query.limit(pageSize).get()\n const rows = snapshot.docs.map(deliveryRecordFrom)\n return {\n rows,\n // Null on a short page: a full page is the only state from which more\n // rows can exist, and offering a cursor that returns nothing makes a\n // finished table look unfinished.\n cursor:\n rows.length === pageSize\n ? String(snapshot.docs[snapshot.docs.length - 1].ref.path)\n : null,\n lookupFailed: false,\n sendsOmitted,\n }\n } catch (error) {\n console.error('[email-delivery-log] send engagement read failed', error)\n return { ...empty, lookupFailed: true }\n }\n}\n\n/*==========================================\n * ACROSS EVERY ADDRESS AN ACCOUNT HOLDS.\n *\n * The single-address functions above are the primitive and stay exactly as\n * they were — one address, one document. What was wrong was never the\n * primitive; it was that every CALLER passed the Auth record's current\n * primary and nothing else, so a changed address orphaned the history and an\n * erasure missed the mail sitting under the other addresses.\n *\n * The address list is resolved ONCE, by `account-addresses.ts`, and passed\n * in. This module deliberately does not resolve it: a store keyed by a hash\n * should not also own the rule for which hashes describe a person, and a copy\n * of that rule here is the second copy the whole change exists to prevent.\n *=========================================*/\n\n/**\n * A record that delivery data WAS held for an address and has been erased.\n *\n * Written into the parent `emailDeliveries/{emailKey}` document, which the\n * messages subcollection otherwise leaves empty.\n *\n * ⚠️ It carries no address, no subject, no message id and no uid — nothing\n * the erasure was performed to destroy. `count` is a magnitude, which is what\n * makes the row honest without reconstituting anything: it says data existed\n * and is gone, and nothing about what it was.\n */\nexport interface EmailDeliveryErasure {\n /** Epoch ms. */\n at: number\n /** How many messages were removed. */\n count: number\n}\n\n/** One account's mail, gathered from every address it holds. */\nexport interface EmailDeliveryHistory {\n lookupFailed: boolean\n rows: EmailDeliveryRecord[]\n /**\n * The addresses actually read, in the order they were given.\n *\n * The card names them. A staffer looking at mail sent to an address that is\n * no longer this account's primary has to be able to see that that is what\n * they are looking at.\n */\n addressesRead: string[]\n /**\n * Erasure tombstones found, keyed by address.\n *\n * An address whose records were erased under somebody's request reads as an\n * empty table otherwise — which is the precise failure this card's copy\n * warns about, recreated by the fix for it.\n */\n erasures: Record<string, EmailDeliveryErasure>\n}\n\n/** The tombstone on one address, or null. Never throws. */\nexport async function readEmailDeliveryErasure(\n email: string | null | undefined,\n firestore?: any,\n): Promise<EmailDeliveryErasure | null> {\n const key = emailSuppressionKey(email)\n if (!key) return null\n try {\n const db = firestore ?? defaultFirestore()\n const doc = await db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key).get()\n if (!doc.exists) return null\n const at = Number(doc.get('erasedAtMs') ?? 0)\n if (!at) return null\n return { at, count: Number(doc.get('erasedCount') ?? 0) }\n } catch {\n return null\n }\n}\n\n/**\n * Every message sent to any address this account holds, newest first.\n *\n * Merged and re-sorted rather than concatenated: the rows are one person's\n * mail and a staffer reads them as a timeline, so grouping them by which\n * address happened to receive them would put the answer in two places and\n * make \"what was the last thing we sent them\" a question about two tables.\n * Each row keeps its own `to`, so the card can still say which address.\n *\n * `lookupFailed` is true when ANY address failed. A partial read of a\n * delivery log is the same hazard as an empty one — it under-reports mail we\n * sent — and reporting it as a clean result is how a staffer comes to tell a\n * customer something untrue.\n */\nexport async function readEmailDeliveryHistoryForAddresses(\n addresses: readonly string[],\n options?: { limit?: number; firestore?: any },\n): Promise<EmailDeliveryHistory> {\n const limit = Math.max(1, options?.limit ?? EMAIL_DELIVERY_READ_LIMIT)\n const addressesRead: string[] = []\n const erasures: Record<string, EmailDeliveryErasure> = {}\n const rows: EmailDeliveryRecord[] = []\n let lookupFailed = false\n\n for (const address of addresses) {\n const key = emailSuppressionKey(address)\n if (!key) continue\n addressesRead.push(address)\n try {\n rows.push(...(await readEmailDeliveries(address, { limit, ...options })))\n } catch (error) {\n console.error('[email-delivery-log] read failed', error)\n lookupFailed = true\n }\n const erasure = await readEmailDeliveryErasure(address, options?.firestore)\n if (erasure) erasures[address] = erasure\n }\n\n rows.sort((a, b) => b.firstSeenAtMs - a.firstSeenAtMs)\n return { lookupFailed, rows: rows.slice(0, limit), addressesRead, erasures }\n}\n\n/** What one multi-address erasure did. */\nexport interface EmailDeliveryErasureResult {\n /** Messages removed, across every address that was erased. */\n removed: number\n /** The addresses actually erased. Tombstoned, one document each. */\n addresses: string[]\n /**\n * Addresses left INTACT because another account is also known to hold them.\n *\n * Never empty and ignorable: a caller erasing an account has to treat a\n * non-empty list as an erasure it did not finish. See\n * {@link eraseEmailDeliveriesForAddresses}.\n */\n contestedAddresses: string[]\n}\n\n/**\n * Erase the delivery log for every address an account holds, except the ones\n * a second account also holds.\n *\n * ## The shared-address decision\n *\n * The log describes an ADDRESS, not an account. Where one account holds an\n * address, erasing it is simply erasing the subject's mail, and this sweeps\n * it.\n *\n * Where TWO accounts hold one address, the same rows are two people's answer\n * to \"what did you send me\", and the two readings are incompatible:\n *\n * - **One human, two accounts** — the ordinary live shape, an account whose\n * federated provider address is another account's primary. Erasing is\n * right; the mail is the requester's.\n * - **A genuinely shared mailbox** — `billing@`, `support@`, a role account\n * two different people hold. Erasing destroys the second person's delivery\n * history for an address they legitimately hold, and they asked for\n * nothing.\n *\n * ⛔ **Nothing here can tell those apart.** The difference is a fact about the\n * humans, and the data holds no fact about the humans — only that two account\n * records name one address. So this function does not choose. It erases what\n * it can decide about and reports the rest as CONTESTED, and `eraseUser`\n * refuses the whole erasure rather than half-perform one: destroying the\n * second party's mail has no remedy, and quietly leaving it while reporting\n * the erasure complete is the gap this area exists to close. Refusing is the\n * only outcome that is neither, and it is reversible — a human decides which\n * reading applies, detaches the address or confirms the account, and the\n * erasure runs.\n *\n * ⚠️ A contested address is not tombstoned. The tombstone means \"the records\n * here were removed under an erasure request\", and writing one over rows that\n * are still present would tell the second holder their mail is gone while it\n * sits underneath — a worse misreading than the blank table, because it is\n * confidently wrong rather than merely empty. Nothing was removed, so their\n * card renders their mail exactly as before.\n *\n * ⚠️ `shared` is one-directional evidence. True proves a second holder; false\n * only means none was found, because there is no lookup for an account\n * holding an address through a federated provider (see\n * `account-addresses.ts`). So the tombstone is still written for EVERY\n * address that IS erased, not only ones believed unshared — it costs one\n * small document and closes the case where a second holder exists behind the\n * gap in the probe and would otherwise meet a blank table.\n *\n * ⛔ Only addresses the account HOLDS, resolved through the one resolver. An\n * address arriving here that the account does not hold erases a stranger's\n * mail, which no erasure request authorises.\n */\nexport async function eraseEmailDeliveriesForAddresses(\n addresses: readonly { address: string; shared?: boolean }[],\n firestore?: any,\n): Promise<EmailDeliveryErasureResult> {\n const db = firestore ?? defaultFirestore()\n const erased: string[] = []\n const contestedAddresses: string[] = []\n let removed = 0\n\n for (const entry of addresses) {\n const key = emailSuppressionKey(entry.address)\n if (!key) continue\n\n // Before any write for this address, so a contested one is untouched\n // rather than erased-then-regretted. There is no undo below this line.\n if (entry.shared === true) {\n contestedAddresses.push(entry.address)\n continue\n }\n\n erased.push(entry.address)\n const count = await eraseEmailDeliveries(entry.address, db).catch(() => 0)\n removed += count\n\n // The tombstone lands whether or not anything was removed: an address we\n // erased and found empty is still an address whose records this request\n // covered, and a later import must not be able to refill it silently.\n try {\n await db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .set(\n {\n erasedAtMs: Date.now(),\n erasedCount: FieldValue.increment(count),\n /*\n * The engagement rollup goes with the messages it was summarised\n * from. \"This person read our mail on the 3rd\" is the same\n * personal fact as the row it was derived from, and a summary\n * that outlived its source would leave an erasure that removed\n * the evidence and kept the conclusion.\n */\n lastEngagedAtMs: FieldValue.delete(),\n lastOpenedAtMs: FieldValue.delete(),\n lastClickedAtMs: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n } catch (error) {\n console.error('[email-delivery-log] tombstone write failed', error)\n }\n\n /*\n * And what was CONCLUDED from this person's clicks, on every site.\n *\n * The plugin that credits outcomes (`plugin-conversion-credit.ts`) keeps\n * which campaign a person last clicked, on this same document, and which\n * campaign each of their sign-ups, leads and bookings was credited to —\n * \"this person clicked THIS campaign on the 3rd\" names both the person\n * and what they were reading, the strongest personal fact here, and a\n * credit drawn from it is a stronger statement still. Deleting the\n * engagement above and keeping either would be an erasure that removed\n * the evidence and kept the conclusion. The orders already credited keep\n * their own record — a commercial fact about a sale, held under the\n * order's id rather than the person's.\n *\n * Asked by the same key — `personKey` is `emailSuppressionKey`, one\n * derivation — so it covers exactly the person this loop is erasing, and\n * per address rather than per site, because an erasure request names an\n * address and knows nothing about which sites it ever visited.\n */\n await eraseConversionCredits(key)\n }\n\n return { removed, addresses: erased, contestedAddresses }\n}\n\n/**\n * Deletes everything recorded for one address.\n *\n * The log holds an address, the subjects sent to it and when they were opened\n * — personal data by any reading — so the erasure path has to be able to reach\n * it. Batched because a long-lived account can hold hundreds of rows and a\n * single `delete()` per document would be one round trip each.\n */\nexport async function eraseEmailDeliveries(\n email: string | null | undefined,\n firestore?: any,\n): Promise<number> {\n const key = emailSuppressionKey(email)\n if (!key) return 0\n const db = firestore ?? defaultFirestore()\n const parent = db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .collection(EMAIL_DELIVERY_MESSAGES_COLLECTION)\n\n let removed = 0\n // Bounded loop rather than `while (true)`: a pathological collection must\n // not be able to hold an erasure request open indefinitely.\n for (let pass = 0; pass < 20; pass += 1) {\n const snapshot = await parent.limit(400).get()\n if (snapshot.empty) break\n const batch = db.batch()\n snapshot.docs.forEach((doc: any) => batch.delete(doc.ref))\n await batch.commit()\n removed += snapshot.size\n if (snapshot.size < 400) break\n }\n return removed\n}\n"],"names":["FieldValue","worstDeliveryStatus","eraseConversionCredits","nameSearchTokens","emailSearchTokens","emailSuppressionKey","firebaseAdmin","defaultFirestore","app","firestore","EMAIL_DELIVERIES_COLLECTION","EMAIL_DELIVERY_MESSAGES_COLLECTION","EMAIL_DELIVERY_READ_LIMIT","EMAIL_DELIVERY_MAX_LINKS","EMAIL_DELIVERY_SEARCH_FIELD","EMAIL_DELIVERY_SEARCH_TOKEN_LIMIT","emailDeliverySearchTokens","message","text","value","tokens","Set","to","subject","context","slice","recordEmailDeliveryEvent","event","key","providerMessageId","firstOfType","db","ref","collection","doc","runTransaction","transaction","snapshot","get","existing","exists","data","timestamps","type","undefined","update","messageId","provider","status","at","lastEventAtMs","updatedAt","serverTimestamp","firstSeenAtMs","tags","hostId","campaignId","bounceType","detail","openCount","increment","clickCount","link","links","Array","isArray","clickedLinks","map","String","includes","length","set","merge","error","console","recordEmailDeliverySnapshot","sentAt","stored","importedAtMs","Date","now","sent","recordEmailDeliveryEvents","events","results","Promise","all","filter","one","ENGAGEMENT_TYPES","NO_PERSON_ENGAGEMENT","lastEngagedAtMs","lastOpenedAtMs","lastClickedAtMs","engagementFrom","number","parsed","Number","isFinite","opened","clicked","engaged","Math","max","recordPersonEngagement","outcomes","byPerson","Map","outcome","held","openedAtMs","clickedAtMs","size","written","seen","readPersonEngagement","email","readPersonEngagementByKeys","keys","wanted","Boolean","found","snapshots","getAll","id","EMAIL_DELIVERY_IMPORT_MAX_PAGES","importEmailDeliveryHistory","options","maxPages","cursor","scanned","recorded","pages","page","source","nextCursor","truncated","readEmailDeliveries","orderBy","limit","docs","deliveryRecordFrom","readEmailDeliveryHistory","lookupFailed","rows","EMAIL_SEND_ENGAGEMENT_PAGE_SIZE","EMAIL_SEND_ENGAGEMENT_MAX_SENDS","readSendEngagement","sendIds","pageSize","min","ids","sendsOmitted","empty","query","collectionGroup","where","anchor","startAfter","path","readEmailDeliveryErasure","count","readEmailDeliveryHistoryForAddresses","addresses","addressesRead","erasures","address","push","erasure","sort","a","b","eraseEmailDeliveriesForAddresses","erased","contestedAddresses","removed","entry","shared","eraseEmailDeliveries","catch","erasedAtMs","erasedCount","delete","parent","pass","batch","forEach","commit"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwDC,GAED,SAASA,UAAU,QAAQ,2BAA0B;AACrD,SAKEC,mBAAmB,QACd,2BAA0B;AACjC,SAASC,sBAAsB,QAAQ,uDAAsD;AAC7F,SAASC,gBAAgB,QAAQ,qCAAoC;AACrE,SAASC,iBAAiB,EAAEC,mBAAmB,QAAQ,yBAAqB;AAC5E,OAAOC,mBAAmB,sBAAkB;AAE5C,MAAMC,mBAAmB,IAAMD,cAAcE,GAAG,GAAGC,SAAS;AAE5D,OAAO,MAAMC,8BAA8B,kBAAiB;AAC5D,OAAO,MAAMC,qCAAqC,WAAU;AAE5D,kDAAkD,GAClD,OAAO,MAAMC,4BAA4B,GAAE;AAE3C;;;;;CAKC,GACD,OAAO,MAAMC,2BAA2B,GAAE;AAE1C;;;;CAIC,GACD,OAAO,MAAMC,8BAA8B,eAAc;AAEzD,4EAA4E,GAC5E,OAAO,MAAMC,oCAAoC,IAAG;AAEpD;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,0BAA0BC,OAIzC;IACC,MAAMC,OAAO,CAACC,QAAoB,OAAOA,UAAU,WAAWA,QAAQ;IACtE,MAAMC,SAAS,IAAIC,IAAY;WAC1BjB,kBAAkBc,KAAKD,QAAQK,EAAE;WACjCnB,iBAAiBe,KAAKD,QAAQM,OAAO;WACrCpB,iBAAiBe,KAAKD,QAAQO,OAAO;KACzC;IACD,OAAO;WAAIJ;KAAO,CAACK,KAAK,CAAC,GAAGV;AAC9B;AA2DA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,eAAeW,yBACpBC,KAAyB,EACzBlB,SAAe;IAEf,MAAMmB,MAAMvB,oBAAoBsB,MAAML,EAAE;IACxC,IAAI,CAACM,OAAO,CAACD,MAAME,iBAAiB,EAAE,OAAO;IAE7C,IAAIC,cAAc;IAClB,IAAI;QACF,MAAMC,KAAKtB,oBAAAA,YAAaF;QACxB,MAAMyB,MAAMD,GACTE,UAAU,CAACvB,6BACXwB,GAAG,CAACN,KACJK,UAAU,CAACtB,oCACXuB,GAAG,CAACP,MAAME,iBAAiB;QAE9B,MAAME,GAAGI,cAAc,CAAC,OAAOC;gBAEXC;gBAwCdV,aAEAA;YA3CJ,MAAMU,WAAW,MAAMD,YAAYE,GAAG,CAACN;YACvC,MAAMO,YAAYF,OAAAA,SAASG,MAAM,GAAGH,SAASI,IAAI,KAAK,gBAApCJ,OAA6C,CAAC;YAEhE;;;;;;OAMC,GACDP,cAAc,CACZS,CAAAA,SAASG,UAAU,IAAIH,SAASG,UAAU,CAACf,MAAMgB,IAAI,CAAC,KAAKC,SAAQ;YAGrE,MAAMC,SAAkC;gBACtCC,WAAWnB,MAAME,iBAAiB;gBAClCkB,UAAUpB,MAAMoB,QAAQ;gBACxBzB,IAAIK,MAAML,EAAE;gBACZ0B,QAAQ/C,oBAAoBsC,SAASS,MAAM,EAAErB,MAAMgB,IAAI;gBACvD;;;;;;;;;SASC,GACDD,YAAY;oBAAE,CAACf,MAAMgB,IAAI,CAAC,EAAEhB,MAAMsB,EAAE;gBAAC;gBACrCC,eAAevB,MAAMsB,EAAE;gBACvBE,WAAWnD,WAAWoD,eAAe;YACvC;YAEA,oEAAoE;YACpE,qEAAqE;YACrE,qEAAqE;YACrE,iCAAiC;YACjC,IAAI,CAACf,SAASG,MAAM,EAAEK,OAAOQ,aAAa,GAAG1B,MAAMsB,EAAE;YACrD,IAAItB,MAAMJ,OAAO,IAAI,CAACgB,SAAShB,OAAO,EAAEsB,OAAOtB,OAAO,GAAGI,MAAMJ,OAAO;YACtE,IAAII,MAAMH,OAAO,IAAI,CAACe,SAASf,OAAO,EAAEqB,OAAOrB,OAAO,GAAGG,MAAMH,OAAO;YACtE,IAAIG,EAAAA,cAAAA,MAAM2B,IAAI,qBAAV3B,YAAY4B,MAAM,KAAI,CAAChB,SAASgB,MAAM,EACxCV,OAAOU,MAAM,GAAG5B,MAAM2B,IAAI,CAACC,MAAM;YACnC,IAAI5B,EAAAA,eAAAA,MAAM2B,IAAI,qBAAV3B,aAAY6B,UAAU,KAAI,CAACjB,SAASiB,UAAU,EAChDX,OAAOW,UAAU,GAAG7B,MAAM2B,IAAI,CAACE,UAAU;YAC3C,IAAI7B,MAAM8B,UAAU,EAAEZ,OAAOY,UAAU,GAAG9B,MAAM8B,UAAU;YAC1D,IAAI9B,MAAM+B,MAAM,EAAEb,OAAOa,MAAM,GAAG/B,MAAM+B,MAAM;YAC9C,oEAAoE;YACpE,qDAAqD;YACrDb,MAAM,CAAC/B,4BAA4B,GAAGE,0BAA0B;gBAC9DM,IAAIK,MAAML,EAAE;gBACZC,SAASgB,SAAShB,OAAO,IAAII,MAAMJ,OAAO;gBAC1CC,SAASe,SAASf,OAAO,IAAIG,MAAMH,OAAO;YAC5C;YAEA,IAAIG,MAAMgB,IAAI,KAAK,UAAUE,OAAOc,SAAS,GAAG3D,WAAW4D,SAAS,CAAC;YACrE,IAAIjC,MAAMgB,IAAI,KAAK,WAAW;gBAC5BE,OAAOgB,UAAU,GAAG7D,WAAW4D,SAAS,CAAC;gBACzC,IAAIjC,MAAMmC,IAAI,EAAE;oBACd,MAAMC,QAAkBC,MAAMC,OAAO,CAAC1B,SAAS2B,YAAY,IACvD3B,SAAS2B,YAAY,CAACC,GAAG,CAACC,UAC1B,EAAE;oBACN,IACE,CAACL,MAAMM,QAAQ,CAAC1C,MAAMmC,IAAI,KAC1BC,MAAMO,MAAM,GAAGzD,0BACf;wBACAgC,OAAOqB,YAAY,GAAG;+BAAIH;4BAAOpC,MAAMmC,IAAI;yBAAC;oBAC9C;gBACF;YACF;YAEA1B,YAAYmC,GAAG,CAACvC,KAAKa,QAAQ;gBAAE2B,OAAO;YAAK;QAC7C;QACA,OAAO;YACL1C;YACAD,mBAAmBF,MAAME,iBAAiB;YAC1CP,IAAIK,MAAML,EAAE;YACZqB,MAAMhB,MAAMgB,IAAI;YAChBM,IAAItB,MAAMsB,EAAE;QACd;IACF,EAAE,OAAOwB,OAAO;QACdC,QAAQD,KAAK,CACX,qCACA9C,MAAME,iBAAiB,EACvB4C;QAEF,OAAO;IACT;AACF;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,eAAeE,4BACpBtC,QAA+B,EAC/B5B,SAAe;IAEf,MAAMmB,MAAMvB,oBAAoBgC,SAASf,EAAE;IAC3C,IAAI,CAACM,OAAO,CAACS,SAASR,iBAAiB,IAAI,CAACQ,SAASuC,MAAM,EAAE,OAAO;IAEpE,IAAI;QACF,MAAM7C,KAAKtB,oBAAAA,YAAaF;QACxB,MAAMyB,MAAMD,GACTE,UAAU,CAACvB,6BACXwB,GAAG,CAACN,KACJK,UAAU,CAACtB,oCACXuB,GAAG,CAACG,SAASR,iBAAiB;QAEjC,MAAME,GAAGI,cAAc,CAAC,OAAOC;gBAEXyC;gBAgBbtC;YAjBL,MAAMsC,SAAS,MAAMzC,YAAYE,GAAG,CAACN;YACrC,MAAMO,YAAYsC,OAAAA,OAAOrC,MAAM,GAAGqC,OAAOpC,IAAI,KAAK,gBAAhCoC,OAAyC,CAAC;YAE5D,MAAMhC,SAAkC;gBACtCC,WAAWT,SAASR,iBAAiB;gBACrCkB,UAAUV,SAASU,QAAQ;gBAC3BzB,IAAIe,SAASf,EAAE;gBACf0B,QAAQ/C,oBAAoBsC,SAASS,MAAM,EAAEX,SAASW,MAAM;gBAC5D8B,cAAcC,KAAKC,GAAG;gBACtB7B,WAAWnD,WAAWoD,eAAe;YACvC;YACA,IAAI,CAACyB,OAAOrC,MAAM,EAAEK,OAAOQ,aAAa,GAAGhB,SAASuC,MAAM;YAC1D,IAAIvC,SAASd,OAAO,IAAI,CAACgB,SAAShB,OAAO,EAAEsB,OAAOtB,OAAO,GAAGc,SAASd,OAAO;YAC5E,qEAAqE;YACrE,wEAAwE;YACxE,sEAAsE;YACtE,+DAA+D;YAC/D,IAAI,GAACgB,uBAAAA,SAASG,UAAU,qBAAnBH,qBAAqB0C,IAAI,GAAE;gBAC9BpC,OAAOH,UAAU,GAAG;oBAAEuC,MAAM5C,SAASuC,MAAM;gBAAC;YAC9C;YACA/B,MAAM,CAAC/B,4BAA4B,GAAGE,0BAA0B;gBAC9DM,IAAIe,SAASf,EAAE;gBACfC,SAASgB,SAAShB,OAAO,IAAIc,SAASd,OAAO;gBAC7CC,SAASe,SAASf,OAAO;YAC3B;YAEAY,YAAYmC,GAAG,CAACvC,KAAKa,QAAQ;gBAAE2B,OAAO;YAAK;QAC7C;QACA,OAAO;IACT,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CACX,8CACApC,SAASR,iBAAiB,EAC1B4C;QAEF,OAAO;IACT;AACF;AAEA;;;;;;CAMC,GACD,OAAO,eAAeS,0BACpBC,MAA4B,EAC5B1E,SAAe;IAEf,MAAM2E,UAAU,MAAMC,QAAQC,GAAG,CAC/BH,OAAOhB,GAAG,CAAC,CAACxC,QAAUD,yBAAyBC,OAAOlB;IAExD,OAAO2E,QAAQG,MAAM,CAAC,CAACC,MAA0CA,QAAQ;AAC3E;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CAgD2C,GAE3C,qDAAqD,GACrD,MAAMC,mBAAsD;IAAC;IAAU;CAAU;AAejF,2DAA2D,GAC3D,OAAO,MAAMC,uBAA8C;IACzDC,iBAAiB;IACjBC,gBAAgB;IAChBC,iBAAiB;AACnB,EAAC;AAED,yDAAyD,GACzD,SAASC,eAAerD,IAAgD;IACtE,MAAMsD,SAAS,CAAC5E;QACd,MAAM6E,SAASC,OAAO9E,gBAAAA,QAAS;QAC/B,OAAO8E,OAAOC,QAAQ,CAACF,WAAWA,SAAS,IAAIA,SAAS;IAC1D;IACA,MAAMG,SAASJ,OAAOtD,wBAAAA,IAAM,CAAC,iBAAiB;IAC9C,MAAM2D,UAAUL,OAAOtD,wBAAAA,IAAM,CAAC,kBAAkB;IAChD,MAAM4D,UAAUN,OAAOtD,wBAAAA,IAAM,CAAC,kBAAkB;IAChD,OAAO;QACLkD,eAAe,EACbU,kBAAAA,UAAYF,UAAUC,UAAUE,KAAKC,GAAG,CAACJ,iBAAAA,SAAU,GAAGC,kBAAAA,UAAW,KAAK;QACxER,gBAAgBO;QAChBN,iBAAiBO;IACnB;AACF;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,eAAeI,uBACpBC,QAA8C,EAC9ChG,SAAe;IAEf,4EAA4E,GAC5E,MAAMiG,WAAW,IAAIC;IAIrB,KAAK,MAAMC,WAAWH,SAAU;YAMjBC;QALb,IAAI,CAACE,QAAQ9E,WAAW,EAAE;QAC1B,IAAI,CAAC2D,iBAAiBpB,QAAQ,CAACuC,QAAQjE,IAAI,GAAG;QAC9C,MAAMf,MAAMvB,oBAAoBuG,QAAQtF,EAAE;QAC1C,MAAM2B,KAAKgD,OAAOW,QAAQ3D,EAAE;QAC5B,IAAI,CAACrB,OAAO,CAACqE,OAAOC,QAAQ,CAACjD,OAAOA,MAAM,GAAG;QAC7C,MAAM4D,QAAOH,gBAAAA,SAASpE,GAAG,CAACV,gBAAb8E,gBAAqB;YAAEI,YAAY;YAAGC,aAAa;QAAE;QAClE,IAAIH,QAAQjE,IAAI,KAAK,UAAU;YAC7BkE,KAAKC,UAAU,GAAGR,KAAKC,GAAG,CAACM,KAAKC,UAAU,EAAE7D;QAC9C,OAAO;YACL4D,KAAKE,WAAW,GAAGT,KAAKC,GAAG,CAACM,KAAKE,WAAW,EAAE9D;QAChD;QACAyD,SAASnC,GAAG,CAAC3C,KAAKiF;IACpB;IACA,IAAI,CAACH,SAASM,IAAI,EAAE,OAAO;IAE3B,MAAMjF,KAAKtB,oBAAAA,YAAaF;IACxB,IAAI0G,UAAU;IACd,KAAK,MAAM,CAACrF,KAAKsF,KAAK,IAAIR,SAAU;QAClC,IAAI;YACF,MAAM1E,MAAMD,GAAGE,UAAU,CAACvB,6BAA6BwB,GAAG,CAACN;YAC3D,MAAMG,GAAGI,cAAc,CAAC,OAAOC;oBAG1BC,MAEqBwC,wBACCA,yBACAA,yBAKXA,yBACCA,0BACAA;gBAbf,MAAMxC,WAAW,MAAMD,YAAYE,GAAG,CAACN;gBACvC,MAAM6C,SAASiB,gBACZzD,OAAAA,SAASG,MAAM,GAAGH,SAASI,IAAI,KAAK,gBAApCJ,OAA6C,CAAC;gBAEjD,MAAM8D,SAASG,KAAKC,GAAG,EAAC1B,yBAAAA,OAAOe,cAAc,YAArBf,yBAAyB,GAAGqC,KAAKJ,UAAU;gBACnE,MAAMV,UAAUE,KAAKC,GAAG,EAAC1B,0BAAAA,OAAOgB,eAAe,YAAtBhB,0BAA0B,GAAGqC,KAAKH,WAAW;gBACtE,MAAMV,UAAUC,KAAKC,GAAG,EAAC1B,0BAAAA,OAAOc,eAAe,YAAtBd,0BAA0B,GAAGsB,QAAQC;gBAC9D,sEAAsE;gBACtE,iEAAiE;gBACjE,6BAA6B;gBAC7B,IACED,aAAYtB,0BAAAA,OAAOe,cAAc,YAArBf,0BAAyB,MACrCuB,cAAavB,2BAAAA,OAAOgB,eAAe,YAAtBhB,2BAA0B,MACvCwB,cAAaxB,2BAAAA,OAAOc,eAAe,YAAtBd,2BAA0B,IACvC;oBACA;gBACF;gBACA;;;;;;SAMC,GACDzC,YAAYmC,GAAG,CACbvC,KACA,aACMmE,SAAS;oBAAEP,gBAAgBO;gBAAO,IAAI,CAAC,GACvCC,UAAU;oBAAEP,iBAAiBO;gBAAQ,IAAI,CAAC;oBAC9CT,iBAAiBU;oBACjBlD,WAAWnD,WAAWoD,eAAe;oBAEvC;oBAAEoB,OAAO;gBAAK;gBAEhByC,WAAW;YACb;QACF,EAAE,OAAOxC,OAAO;YACdC,QAAQD,KAAK,CAAC,iDAAiD7C,KAAK6C;QACtE;IACF;IACA,OAAOwC;AACT;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeE,qBACpBC,KAAgC,EAChC3G,SAAe;IAEf,MAAMmB,MAAMvB,oBAAoB+G;IAChC,IAAI,CAACxF,KAAK,OAAO8D;IACjB,IAAI;YASoBrD;QARtB,MAAMN,KAAKtB,oBAAAA,YAAaF;QACxB,MAAM8B,WAAW,MAAMN,GACpBE,UAAU,CAACvB,6BACXwB,GAAG,CAACN,KACJU,GAAG;QACN,2EAA2E;QAC3E,2EAA2E;QAC3E,sEAAsE;QACtE,OAAOwD,gBAAezD,iBAAAA,SAASI,IAAI,cAAbJ,iBAAmB,CAAC;IAC5C,EAAE,OAAOoC,OAAO;QACdC,QAAQD,KAAK,CAAC,+CAA+CA;QAC7D,OAAOiB;IACT;AACF;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,eAAe2B,2BACpBC,IAAuB,EACvB7G,SAAe;IAEf,MAAM8G,SAAS;WAAI,IAAIlG,IAAIiG,KAAK/B,MAAM,CAACiC;KAAU;IACjD,MAAMC,QAAQ,IAAId;IAClB,KAAK,MAAM/E,OAAO2F,OAAQE,MAAMlD,GAAG,CAAC3C,KAAK8D;IACzC,IAAI,CAAC6B,OAAOjD,MAAM,EAAE,OAAOmD;IAC3B,IAAI;QACF,MAAM1F,KAAKtB,oBAAAA,YAAaF;QACxB,MAAM0B,aAAaF,GAAGE,UAAU,CAACvB;QACjC,MAAMgH,YAAY,MAAM3F,GAAG4F,MAAM,IAC5BJ,OAAOpD,GAAG,CAAC,CAACvC,MAAgBK,WAAWC,GAAG,CAACN;QAEhD,KAAK,MAAMS,YAAYqF,UAAW;gBAEMrF;YADtC,IAAI,EAACA,4BAAAA,SAAUG,MAAM,GAAE;YACvBiF,MAAMlD,GAAG,CAAClC,SAASuF,EAAE,EAAE9B,gBAAezD,iBAAAA,SAASI,IAAI,cAAbJ,iBAAmB,CAAC;QAC5D;IACF,EAAE,OAAOoC,OAAO;QACdC,QAAQD,KAAK,CAAC,qDAAqDA;IACrE;IACA,OAAOgD;AACT;AAeA,mEAAmE,GACnE,OAAO,MAAMI,kCAAkC,GAAE;AAEjD;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,eAAeC,2BAA2BC,OAKhD;QAC8BA,mBAChBA;IADb,MAAMC,WAAW1B,KAAKC,GAAG,CAAC,IAAGwB,oBAAAA,QAAQC,QAAQ,YAAhBD,oBAAoBF;IACjD,IAAII,UAASF,kBAAAA,QAAQE,MAAM,YAAdF,kBAAkB;IAC/B,IAAIG,UAAU;IACd,IAAIC,WAAW;IACf,IAAIC,QAAQ;IAEZ,MAAOA,QAAQJ,SAAU;QACvB,MAAMK,OAAO,MAAMN,QAAQO,MAAM,CAAC;YAAEL;QAAO;QAC3CG,SAAS;QACTF,WAAWG,KAAKX,SAAS,CAACpD,MAAM;QAChC,KAAK,MAAMjC,YAAYgG,KAAKX,SAAS,CAAE;YACrC,IAAI,MAAM/C,4BAA4BtC,UAAU0F,QAAQtH,SAAS,GAAG;gBAClE0H,YAAY;YACd;QACF;QACAF,SAASI,KAAKE,UAAU;QACxB,IAAI,CAACN,QAAQ;IACf;IAEA,OAAO;QACLC;QACAC;QACAC;QACAG,YAAYN;QACZO,WAAWhB,QAAQS;IACrB;AACF;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,eAAeQ,oBACpBrB,KAAgC,EAChCW,OAA6C;;IAE7C,MAAMnG,MAAMvB,oBAAoB+G;IAChC,IAAI,CAACxF,KAAK,OAAO,EAAE;IACnB,MAAMG,aAAKgG,2BAAAA,QAAStH,SAAS,mBAAIF;IACjC,MAAM8B,WAAW,MAAMN,GACpBE,UAAU,CAACvB,6BACXwB,GAAG,CAACN,KACJK,UAAU,CAACtB,oCACX+H,OAAO,CAAC,iBAAiB,QACzBC,KAAK,CAACrC,KAAKC,GAAG,CAAC,YAAGwB,2BAAAA,QAASY,KAAK,oBAAI/H,4BACpC0B,GAAG;IAEN,OAAOD,SAASuG,IAAI,CAACzE,GAAG,CAAC0E;AAC3B;AAEA;;;;;;CAMC,GACD,OAAO,SAASA,mBAAmB3G,GAAQ;QAC5BA,WAEOO,iBACDA,gBACNA,UACFA,eACAA,eACAA,cACIA,kBACSA,qBACJA,iBACCA,kBAIPA,kBACJA,cACAA,cACIA;IAlBd,MAAMA,QAAOP,YAAAA,IAAIO,IAAI,cAARP,YAAc,CAAC;IAC5B,OAAO;QACLY,WAAWsB,QAAO3B,kBAAAA,KAAKK,SAAS,YAAdL,kBAAkBP,IAAI0F,EAAE;QAC1C7E,UAAUqB,QAAO3B,iBAAAA,KAAKM,QAAQ,YAAbN,iBAAiB;QAClCnB,IAAI8C,QAAO3B,WAAAA,KAAKnB,EAAE,YAAPmB,WAAW;QACtBlB,OAAO,GAAEkB,gBAAAA,KAAKlB,OAAO,YAAZkB,gBAAgB;QACzBjB,OAAO,GAAEiB,gBAAAA,KAAKjB,OAAO,YAAZiB,gBAAgB;QACzBO,MAAM,GAAGP,eAAAA,KAAKO,MAAM,YAAXP,eAAe;QACxBC,UAAU,GAAGD,mBAAAA,KAAKC,UAAU,YAAfD,mBAAmB,CAAC;QACjCY,eAAe4C,QAAOxD,sBAAAA,KAAKY,aAAa,YAAlBZ,sBAAsB;QAC5CkB,WAAWsC,QAAOxD,kBAAAA,KAAKkB,SAAS,YAAdlB,kBAAkB;QACpCoB,YAAYoC,QAAOxD,mBAAAA,KAAKoB,UAAU,YAAfpB,mBAAmB;QACtCyB,cAAcF,MAAMC,OAAO,CAACxB,KAAKyB,YAAY,IACzCzB,KAAKyB,YAAY,CAACC,GAAG,CAACC,UACtB,EAAE;QACNX,UAAU,GAAEhB,mBAAAA,KAAKgB,UAAU,YAAfhB,mBAAmB;QAC/BiB,MAAM,GAAEjB,eAAAA,KAAKiB,MAAM,YAAXjB,eAAe;QACvBc,MAAM,GAAEd,eAAAA,KAAKc,MAAM,YAAXd,eAAe;QACvBe,UAAU,GAAEf,mBAAAA,KAAKe,UAAU,YAAff,mBAAmB;IACjC;AACF;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeqG,yBACpB1B,KAAgC,EAChCW,OAA6C;IAE7C,IAAI;QACF,OAAO;YAAEgB,cAAc;YAAOC,MAAM,MAAMP,oBAAoBrB,OAAOW;QAAS;IAChF,EAAE,OAAOtD,OAAO;QACdC,QAAQD,KAAK,CAAC,oCAAoCA;QAClD,OAAO;YAAEsE,cAAc;YAAMC,MAAM,EAAE;QAAC;IACxC;AACF;AAEA;;;;;;;;;;;;;;;;;;;;;2CAqB2C,GAE3C,8DAA8D,GAC9D,OAAO,MAAMC,kCAAkC,GAAE;AAEjD;;;;;;;CAOC,GACD,OAAO,MAAMC,kCAAkC,GAAE;AAyBjD;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,eAAeC,mBAAmBpB,OAUxC;QAYKA;IAXJ,MAAM,EACJxE,MAAM,EACN6F,OAAO,EACP7D,SAAS,KAAK,EACd0C,SAAS,IAAI,EACbxH,SAAS,EACV,GAAGsH;IACJ,MAAMsB,WAAW/C,KAAKC,GAAG,CACvB,GACAD,KAAKgD,GAAG,CACNL,kCACAlB,iBAAAA,QAAQY,KAAK,YAAbZ,iBAAiBkB;IAGrB,MAAMM,MAAMH,QACT7D,MAAM,CAACiC,SACP/F,KAAK,CAAC,GAAGyH;IACZ,MAAMM,eAAelD,KAAKC,GAAG,CAC3B,GACA6C,QAAQ7D,MAAM,CAACiC,SAASlD,MAAM,GAAGiF,IAAIjF,MAAM;IAE7C,MAAMmF,QAAiC;QACrCT,MAAM,EAAE;QACRf,QAAQ;QACRc,cAAc;QACdS;IACF;IACA,IAAI,CAACjG,UAAU,CAACgG,IAAIjF,MAAM,EAAE,OAAOmF;IAEnC,IAAI;QACF,MAAM1H,KAAKtB,oBAAAA,YAAaF;QACxB,IAAImJ,QAAQ3H,GACT4H,eAAe,CAAChJ,mCACjB,mEAAmE;QACnE,qDAAqD;SACpDiJ,KAAK,CAAC,UAAU,MAAMrG,OACvB,uDAAuD;SACtDqG,KAAK,CAAC,cAAc,MAAML;QAC7B,IAAIhE,WAAW,UAAU;YACvBmE,QAAQA,MAAME,KAAK,CAAC,aAAa,KAAK,GAAGlB,OAAO,CAAC,aAAa;QAChE,OAAO,IAAInD,WAAW,WAAW;YAC/BmE,QAAQA,MAAME,KAAK,CAAC,cAAc,KAAK,GAAGlB,OAAO,CAAC,cAAc;QAClE;QACAgB,QAAQA,MAAMhB,OAAO,CAAC,iBAAiB;QAEvC,IAAIT,QAAQ;YACV,MAAM4B,SAAS,MAAM9H,GAAGG,GAAG,CAAC+F,QAAQ3F,GAAG;YACvC,sEAAsE;YACtE,uEAAuE;YACvE,yBAAyB;YACzB,IAAI,CAACuH,OAAOrH,MAAM,EAAE,OAAOiH;YAC3BC,QAAQA,MAAMI,UAAU,CAACD;QAC3B;QAEA,MAAMxH,WAAW,MAAMqH,MAAMf,KAAK,CAACU,UAAU/G,GAAG;QAChD,MAAM0G,OAAO3G,SAASuG,IAAI,CAACzE,GAAG,CAAC0E;QAC/B,OAAO;YACLG;YACA,sEAAsE;YACtE,qEAAqE;YACrE,kCAAkC;YAClCf,QACEe,KAAK1E,MAAM,KAAK+E,WACZjF,OAAO/B,SAASuG,IAAI,CAACvG,SAASuG,IAAI,CAACtE,MAAM,GAAG,EAAE,CAACtC,GAAG,CAAC+H,IAAI,IACvD;YACNhB,cAAc;YACdS;QACF;IACF,EAAE,OAAO/E,OAAO;QACdC,QAAQD,KAAK,CAAC,oDAAoDA;QAClE,OAAO,aAAKgF;YAAOV,cAAc;;IACnC;AACF;AAyDA,yDAAyD,GACzD,OAAO,eAAeiB,yBACpB5C,KAAgC,EAChC3G,SAAe;IAEf,MAAMmB,MAAMvB,oBAAoB+G;IAChC,IAAI,CAACxF,KAAK,OAAO;IACjB,IAAI;YAIgBM,UAESA;QAL3B,MAAMH,KAAKtB,oBAAAA,YAAaF;QACxB,MAAM2B,MAAM,MAAMH,GAAGE,UAAU,CAACvB,6BAA6BwB,GAAG,CAACN,KAAKU,GAAG;QACzE,IAAI,CAACJ,IAAIM,MAAM,EAAE,OAAO;QACxB,MAAMS,KAAKgD,QAAO/D,WAAAA,IAAII,GAAG,CAAC,yBAARJ,WAAyB;QAC3C,IAAI,CAACe,IAAI,OAAO;QAChB,OAAO;YAAEA;YAAIgH,OAAOhE,QAAO/D,YAAAA,IAAII,GAAG,CAAC,0BAARJ,YAA0B;QAAG;IAC1D,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAegI,qCACpBC,SAA4B,EAC5BpC,OAA6C;;IAE7C,MAAMY,QAAQrC,KAAKC,GAAG,CAAC,WAAGwB,2BAAAA,QAASY,KAAK,mBAAI/H;IAC5C,MAAMwJ,gBAA0B,EAAE;IAClC,MAAMC,WAAiD,CAAC;IACxD,MAAMrB,OAA8B,EAAE;IACtC,IAAID,eAAe;IAEnB,KAAK,MAAMuB,WAAWH,UAAW;QAC/B,MAAMvI,MAAMvB,oBAAoBiK;QAChC,IAAI,CAAC1I,KAAK;QACVwI,cAAcG,IAAI,CAACD;QACnB,IAAI;YACFtB,KAAKuB,IAAI,IAAK,MAAM9B,oBAAoB6B,SAAS;gBAAE3B;eAAUZ;QAC/D,EAAE,OAAOtD,OAAO;YACdC,QAAQD,KAAK,CAAC,oCAAoCA;YAClDsE,eAAe;QACjB;QACA,MAAMyB,UAAU,MAAMR,yBAAyBM,SAASvC,2BAAAA,QAAStH,SAAS;QAC1E,IAAI+J,SAASH,QAAQ,CAACC,QAAQ,GAAGE;IACnC;IAEAxB,KAAKyB,IAAI,CAAC,CAACC,GAAGC,IAAMA,EAAEtH,aAAa,GAAGqH,EAAErH,aAAa;IACrD,OAAO;QAAE0F;QAAcC,MAAMA,KAAKvH,KAAK,CAAC,GAAGkH;QAAQyB;QAAeC;IAAS;AAC7E;AAkBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC,GACD,OAAO,eAAeO,iCACpBT,SAA2D,EAC3D1J,SAAe;IAEf,MAAMsB,KAAKtB,oBAAAA,YAAaF;IACxB,MAAMsK,SAAmB,EAAE;IAC3B,MAAMC,qBAA+B,EAAE;IACvC,IAAIC,UAAU;IAEd,KAAK,MAAMC,SAASb,UAAW;QAC7B,MAAMvI,MAAMvB,oBAAoB2K,MAAMV,OAAO;QAC7C,IAAI,CAAC1I,KAAK;QAEV,qEAAqE;QACrE,uEAAuE;QACvE,IAAIoJ,MAAMC,MAAM,KAAK,MAAM;YACzBH,mBAAmBP,IAAI,CAACS,MAAMV,OAAO;YACrC;QACF;QAEAO,OAAON,IAAI,CAACS,MAAMV,OAAO;QACzB,MAAML,QAAQ,MAAMiB,qBAAqBF,MAAMV,OAAO,EAAEvI,IAAIoJ,KAAK,CAAC,IAAM;QACxEJ,WAAWd;QAEX,yEAAyE;QACzE,wEAAwE;QACxE,sEAAsE;QACtE,IAAI;YACF,MAAMlI,GACHE,UAAU,CAACvB,6BACXwB,GAAG,CAACN,KACJ2C,GAAG,CACF;gBACE6G,YAAYrG,KAAKC,GAAG;gBACpBqG,aAAarL,WAAW4D,SAAS,CAACqG;gBAClC;;;;;;aAMC,GACDtE,iBAAiB3F,WAAWsL,MAAM;gBAClC1F,gBAAgB5F,WAAWsL,MAAM;gBACjCzF,iBAAiB7F,WAAWsL,MAAM;gBAClCnI,WAAWnD,WAAWoD,eAAe;YACvC,GACA;gBAAEoB,OAAO;YAAK;QAEpB,EAAE,OAAOC,OAAO;YACdC,QAAQD,KAAK,CAAC,+CAA+CA;QAC/D;QAEA;;;;;;;;;;;;;;;;;;KAkBC,GACD,MAAMvE,uBAAuB0B;IAC/B;IAEA,OAAO;QAAEmJ;QAASZ,WAAWU;QAAQC;IAAmB;AAC1D;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeI,qBACpB9D,KAAgC,EAChC3G,SAAe;IAEf,MAAMmB,MAAMvB,oBAAoB+G;IAChC,IAAI,CAACxF,KAAK,OAAO;IACjB,MAAMG,KAAKtB,oBAAAA,YAAaF;IACxB,MAAMgL,SAASxJ,GACZE,UAAU,CAACvB,6BACXwB,GAAG,CAACN,KACJK,UAAU,CAACtB;IAEd,IAAIoK,UAAU;IACd,0EAA0E;IAC1E,4DAA4D;IAC5D,IAAK,IAAIS,OAAO,GAAGA,OAAO,IAAIA,QAAQ,EAAG;QACvC,MAAMnJ,WAAW,MAAMkJ,OAAO5C,KAAK,CAAC,KAAKrG,GAAG;QAC5C,IAAID,SAASoH,KAAK,EAAE;QACpB,MAAMgC,QAAQ1J,GAAG0J,KAAK;QACtBpJ,SAASuG,IAAI,CAAC8C,OAAO,CAAC,CAACxJ,MAAauJ,MAAMH,MAAM,CAACpJ,IAAIF,GAAG;QACxD,MAAMyJ,MAAME,MAAM;QAClBZ,WAAW1I,SAAS2E,IAAI;QACxB,IAAI3E,SAAS2E,IAAI,GAAG,KAAK;IAC3B;IACA,OAAO+D;AACT"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-delivery-log.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 PER-RECIPIENT DELIVERY LOG,\n * `emailDeliveries/{emailKey}/messages/{providerMessageId}`.\n *\n * ## What it answers\n *\n * \"Did this person get their invite, and did they open it?\" — the question\n * every support conversation about a missing email starts with, and the one\n * that until now could only be answered by signing into the sending provider\n * and searching a list that is not scoped to the account being discussed.\n *\n * ## Why a store, and where the provider still comes in\n *\n * The READ is always local. Fanning out to the ESP on render would put a\n * vendor at the centre of a staff screen, at three specific costs: lock-in to\n * a per-vendor list shape, a rolling retention window our own record outlives,\n * and a third-party round trip on every page view. Resend's list endpoint also\n * has no recipient filter at all, so a per-person lookup would mean paging the\n * whole account's history on each render.\n *\n * The WRITE has two sources, and the second exists because the first is not\n * enough on its own:\n *\n * - **The event feed** ({@link recordEmailDeliveryEvent}) — live, complete,\n * and the only source of open and click counts. It knows nothing about\n * mail sent before it was connected.\n * - **A history import** ({@link importEmailDeliveryHistory}) — a one-off\n * (and re-runnable) sweep of the provider's own list, through the same\n * neutral vocabulary. Without it the log is empty for every message that\n * predates the webhook, which is exactly the mail a support question is\n * about. A card that shows nothing for a person we demonstrably emailed is\n * the failure this whole file exists to remove.\n *\n * ## Shape\n *\n * A subcollection per recipient rather than one flat collection with a `to`\n * field. The read is then a single ordered query inside one small collection\n * — no composite index to go missing, and no `where` clause whose absent\n * field would silently drop documents. The parent id is\n * {@link emailSuppressionKey}'s `sha256`, deliberately the SAME derivation the\n * suppression lists use, so the two can never disagree about which document\n * describes which person.\n *\n * One document per MESSAGE, not per event: `sent`, `delivered`, `opened` and\n * three `clicked`s are one row in the staff view, and an append-only event\n * collection would make the common read six documents instead of one. Opens\n * and clicks are counted rather than listed, because the count is the fact a\n * staffer uses and an unbounded array is how a document reaches the 1 MiB\n * limit on a mailing nobody was watching.\n *\n * ## Never throws\n *\n * Every function here is best-effort, on the same reasoning as the rest of the\n * mail path: a webhook must acknowledge the provider, and a staff page must\n * render, whatever Firestore is doing. A failed write loses a row from a log;\n * a thrown one loses the delivery event AND teaches the provider to retry.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type EmailDeliveryEvent,\n type EmailDeliveryEventType,\n type EmailDeliveryHistorySource,\n type EmailDeliverySnapshot,\n worstDeliveryStatus,\n} from '@aglyn/shared-util-email'\nimport { eraseConversionCredits } from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { emailSearchTokens, emailSuppressionKey } from './email-suppression'\nimport firebaseAdmin from './firebase-admin'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\nexport const EMAIL_DELIVERIES_COLLECTION = 'emailDeliveries'\nexport const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages'\n\n/** The most messages one staff read will return. */\nexport const EMAIL_DELIVERY_READ_LIMIT = 50\n\n/**\n * The most distinct links one message records.\n *\n * A newsletter with forty links clicked by one reader must not grow the\n * document without bound; the first few tell a staffer what they need.\n */\nexport const EMAIL_DELIVERY_MAX_LINKS = 10\n\n/**\n * The field a message's search tokens are stored in (AGL-3321): the staff\n * account page's delivery table searches it with `array-contains` on one\n * typed word, and its Message filter's \"contains\" reads it too.\n */\nexport const EMAIL_DELIVERY_SEARCH_FIELD = 'searchTokens'\n\n/** The most tokens one message stores; a long subject loses reach past it. */\nexport const EMAIL_DELIVERY_SEARCH_TOKEN_LIMIT = 200\n\n/**\n * A message's search tokens: its recipient the way a suppression's address\n * is tokenized (`emailSearchTokens` — the whole address, its domain and each\n * piece of it), then the word prefixes of its subject and of its sender tag.\n * In that order, deduplicated, capped.\n *\n * Every writer stamps it from the message's merged state, so a subject that\n * arrives on a later event than the first still becomes searchable, and\n * `tools/scripts/backfill-email-delivery-search.mjs` stamps the messages\n * written before the field. Both are held to\n * `tools/scripts/lib/email-search-tokens.fixtures.json`.\n */\nexport function emailDeliverySearchTokens(message: {\n to?: unknown\n subject?: unknown\n context?: unknown\n}): string[] {\n const text = (value: unknown) => (typeof value === 'string' ? value : '')\n const tokens = new Set<string>([\n ...emailSearchTokens(text(message.to)),\n ...nameSearchTokens(text(message.subject)),\n ...nameSearchTokens(text(message.context)),\n ])\n return [...tokens].slice(0, EMAIL_DELIVERY_SEARCH_TOKEN_LIMIT)\n}\n\n/** One message as the staff view reads it. */\nexport interface EmailDeliveryRecord {\n /** The provider's message id — also the document id. */\n messageId: string\n provider: string\n to: string\n subject: string | null\n /** The sender label, e.g. `'invite'`. Null for a send that carried none. */\n context: string | null\n /** Furthest-along (worst) lifecycle state seen. */\n status: EmailDeliveryEventType\n /** Epoch ms per state, absent for states that never happened. */\n timestamps: Partial<Record<EmailDeliveryEventType, number>>\n /** First event we saw for this message. Always present — the sort key. */\n firstSeenAtMs: number\n openCount: number\n clickCount: number\n /** Distinct destinations followed, capped. */\n clickedLinks: string[]\n bounceType: string | null\n detail: string | null\n hostId: string | null\n campaignId: string | null\n}\n\n/**\n * What one {@link recordEmailDeliveryEvent} call did.\n *\n * `firstOfType` exists so a CAMPAIGN counter can be incremented once per\n * recipient without buying a read of its own. This transaction already holds\n * the message's prior state, and \"has this message ever been opened before\"\n * is the fact a distinct-openers count needs — deriving it here costs\n * nothing, and deriving it anywhere else costs a document read per event.\n *\n * It is also what makes those counters idempotent, on the same reasoning the\n * webhook's replay guard rests on: a redelivered or replayed event finds the\n * state already recorded and reports `false`, so the counter cannot be\n * incremented twice for one message's first open.\n */\nexport interface EmailDeliveryEventOutcome {\n /**\n * No event of this TYPE had been recorded against this message before.\n *\n * Read off `timestamps`, which is written for every event type, rather than\n * off `openCount`/`clickCount`, which exist for two of them.\n */\n firstOfType: boolean\n /** The message this event was recorded against. */\n providerMessageId: string\n /** The recipient, lowercased — the person the event is about. */\n to: string\n /** Which event this was. */\n type: EmailDeliveryEventType\n /** When it happened, epoch ms. */\n at: number\n}\n\n/**\n * Records one normalized event against its message.\n *\n * A transaction rather than a merge-set, for one property that matters to the\n * reader: `firstSeenAtMs` must be written exactly once and must never be\n * absent. Events arrive out of order — an `opened` can beat its own `sent`\n * through the queue — so \"create with the first event's time, then leave it\n * alone\" needs a read in the same atomic step as the write. A document missing\n * that field would be dropped from the `orderBy` read entirely and the message\n * would simply not appear, which is the failure mode a delivery log can least\n * afford.\n *\n * @returns the outcome, or `null` when nothing was written. `null` is the\n * ordinary answer for an address that is not an address; it is never\n * an error.\n */\nexport async function recordEmailDeliveryEvent(\n event: EmailDeliveryEvent,\n firestore?: any,\n): Promise<EmailDeliveryEventOutcome | null> {\n const key = emailSuppressionKey(event.to)\n if (!key || !event.providerMessageId) return null\n\n let firstOfType = false\n try {\n const db = firestore ?? defaultFirestore()\n const ref = db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .collection(EMAIL_DELIVERY_MESSAGES_COLLECTION)\n .doc(event.providerMessageId)\n\n await db.runTransaction(async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const existing = (snapshot.exists ? snapshot.data() : null) ?? {}\n\n /*\n * Set INSIDE the transaction body, which may run more than once: a\n * Firestore transaction retries on contention, and a value computed\n * before the retry would describe the state that lost the race. This\n * assignment (not `||=`) makes the last attempt — the one whose write\n * committed — the one whose reading is reported.\n */\n firstOfType = !(\n existing.timestamps && existing.timestamps[event.type] !== undefined\n )\n\n const update: Record<string, unknown> = {\n messageId: event.providerMessageId,\n provider: event.provider,\n to: event.to,\n status: worstDeliveryStatus(existing.status, event.type),\n /*\n * A NESTED MAP, not a dotted key.\n *\n * `set({merge:true})` treats `'timestamps.sent'` as a field whose\n * NAME contains a dot — only `update()` reads a dot as a path. So the\n * dotted form wrote a top-level field nothing reads and left\n * `timestamps` empty, which the staff card rendered as a message with\n * no send date. What merge DOES do is merge nested maps at depth, so\n * this form keeps every sibling state rather than replacing them.\n */\n timestamps: { [event.type]: event.at },\n lastEventAtMs: event.at,\n updatedAt: FieldValue.serverTimestamp(),\n }\n\n // Written once. A later event for the same message carries the same\n // subject, but an `email.opened` payload may carry none at all — and\n // overwriting a known subject with null is how a staff row loses the\n // only thing that identifies it.\n if (!snapshot.exists) {\n update.firstSeenAtMs = event.at\n /*\n * Every message carries the fields the staff Emails sent table sorts\n * by (AGL-3680) — an `orderBy` drops a document that lacks one. A\n * subject not known yet is null until an event brings it; the counts\n * start at zero and the increments below land on top.\n */\n update.subject = event.subject || null\n update.openCount = 0\n update.clickCount = 0\n }\n if (event.subject && !existing.subject) update.subject = event.subject\n if (event.context && !existing.context) update.context = event.context\n if (event.tags?.hostId && !existing.hostId)\n update.hostId = event.tags.hostId\n if (event.tags?.campaignId && !existing.campaignId)\n update.campaignId = event.tags.campaignId\n if (event.bounceType) update.bounceType = event.bounceType\n if (event.detail) update.detail = event.detail\n // Searchable by what the message now says, merged — the subject may\n // arrive on a later event than the first (AGL-3321).\n update[EMAIL_DELIVERY_SEARCH_FIELD] = emailDeliverySearchTokens({\n to: event.to,\n subject: existing.subject || event.subject,\n context: existing.context || event.context,\n })\n\n if (event.type === 'opened') update.openCount = FieldValue.increment(1)\n if (event.type === 'clicked') {\n update.clickCount = FieldValue.increment(1)\n if (event.link) {\n const links: string[] = Array.isArray(existing.clickedLinks)\n ? existing.clickedLinks.map(String)\n : []\n if (\n !links.includes(event.link) &&\n links.length < EMAIL_DELIVERY_MAX_LINKS\n ) {\n update.clickedLinks = [...links, event.link]\n }\n }\n }\n\n transaction.set(ref, update, { merge: true })\n })\n return {\n firstOfType,\n providerMessageId: event.providerMessageId,\n to: event.to,\n type: event.type,\n at: event.at,\n }\n } catch (error) {\n console.error(\n '[email-delivery-log] write failed',\n event.providerMessageId,\n error,\n )\n return null\n }\n}\n\n/**\n * Records one message the PROVIDER already knows about — the history import.\n *\n * ## Why this is not just `recordEmailDeliveryEvent` with a made-up event\n *\n * A snapshot is weaker evidence than an event, in two specific ways, and\n * writing it as an event would silently promote it:\n *\n * - **It carries no counts.** A provider's list reports one `last_event` per\n * message and no engagement detail, so `opened` means \"at least once\" and\n * can never mean \"three times\". Incrementing `openCount` from a snapshot\n * would invent a number, and re-running the import would invent it again.\n * - **It can be STALER than what we already hold.** The event feed is live;\n * an import is a page of results fetched some time ago. So the status is\n * merged with {@link worstDeliveryStatus} rather than assigned, and a row\n * the webhook has already advanced is never walked backwards.\n *\n * Everything else it fills is a gap-fill only: `subject` and `sentAt` are\n * written when absent and left alone when present. The net effect is that\n * importing history is idempotent and can be run as often as you like, and a\n * message the event feed has covered is untouched by it.\n *\n * `context` is deliberately NOT recoverable here. It comes from a send tag,\n * and the list endpoint does not return tags — so an imported row shows the\n * subject and the status but cannot say which of our senders produced it. The\n * card renders that absence rather than guessing.\n *\n * @returns whether a row was written or updated.\n */\nexport async function recordEmailDeliverySnapshot(\n snapshot: EmailDeliverySnapshot,\n firestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(snapshot.to)\n if (!key || !snapshot.providerMessageId || !snapshot.sentAt) return false\n\n try {\n const db = firestore ?? defaultFirestore()\n const ref = db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .collection(EMAIL_DELIVERY_MESSAGES_COLLECTION)\n .doc(snapshot.providerMessageId)\n\n await db.runTransaction(async (transaction: any) => {\n const stored = await transaction.get(ref)\n const existing = (stored.exists ? stored.data() : null) ?? {}\n\n const update: Record<string, unknown> = {\n messageId: snapshot.providerMessageId,\n provider: snapshot.provider,\n to: snapshot.to,\n status: worstDeliveryStatus(existing.status, snapshot.status),\n importedAtMs: Date.now(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n if (!stored.exists) {\n update.firstSeenAtMs = snapshot.sentAt\n // The sorted fields on every message, as the event writer (AGL-3680).\n update.subject = snapshot.subject || null\n update.openCount = 0\n update.clickCount = 0\n }\n if (snapshot.subject && !existing.subject) update.subject = snapshot.subject\n // Only when the event feed has not already dated the send itself. An\n // imported `created_at` is the provider's, and so is the webhook's, but\n // the webhook's arrived with the rest of that message's truth. Nested\n // map rather than a dotted key, for the reason recorded above.\n if (!existing.timestamps?.sent) {\n update.timestamps = { sent: snapshot.sentAt }\n }\n update[EMAIL_DELIVERY_SEARCH_FIELD] = emailDeliverySearchTokens({\n to: snapshot.to,\n subject: existing.subject || snapshot.subject,\n context: existing.context,\n })\n\n transaction.set(ref, update, { merge: true })\n })\n return true\n } catch (error) {\n console.error(\n '[email-delivery-log] snapshot write failed',\n snapshot.providerMessageId,\n error,\n )\n return false\n }\n}\n\n/**\n * Records a batch, independently — one bad event must not lose the others.\n *\n * @returns one outcome per event that was WRITTEN; events that wrote nothing\n * are absent, so the length is still the count the old return value\n * reported.\n */\nexport async function recordEmailDeliveryEvents(\n events: EmailDeliveryEvent[],\n firestore?: any,\n): Promise<EmailDeliveryEventOutcome[]> {\n const results = await Promise.all(\n events.map((event) => recordEmailDeliveryEvent(event, firestore)),\n )\n return results.filter((one): one is EmailDeliveryEventOutcome => one !== null)\n}\n\n/*==========================================\n * THE PER-PERSON ENGAGEMENT ROLLUP.\n *\n * The message rows above answer \"what did we send this person\". They cannot\n * answer \"has this person engaged with anything lately\" without reading every\n * row in their `messages` subcollection, which is the expensive-read shape\n * this codebase refuses — and that single absence is what made an audience\n * rule like \"opened in the last 30 days\" unanswerable and engagement-based\n * sunsetting unbuildable.\n *\n * So the rollup lands on the PARENT of the messages, `emailDeliveries/{key}`,\n * which already exists as the erasure tombstone's home. One document per\n * person, read by key, no query and therefore no index.\n *\n * ## Address-global, not per site\n *\n * The store keys on an address, the erasure path treats it as an address, and\n * the deliverability problem the rollup exists to serve is domain-wide: every\n * tenant's mail leaves on one domain under one DKIM `d=`, so the engagement\n * that moves the platform's spam rate is engagement with ANY of it. A\n * per-site map would also have to be capped, and capping a map needs a read\n * of it on every write.\n *\n * The cost of that choice is stated rather than hidden: a person who engages\n * with one site's mail reads as engaged when a second site asks. That is the\n * lenient direction for a control whose only power is to REFUSE a send.\n *\n * ## What one webhook event costs\n *\n * A rollup that wrote on every event would be a write per event per person,\n * which is a bill — a single reader opening a newsletter six times, plus\n * mailbox-provider prefetches, is one fact and six writes. So the rollup\n * moves only on an event that is the FIRST of its type for its message, which\n * {@link recordEmailDeliveryEvent}'s transaction already decided at no extra\n * cost. `delivered`, `bounced`, `complained`, `sent` and `delayed` move\n * nothing here at all.\n *\n * That bound is also what makes it replay-proof for free, by the same\n * reasoning the campaign counters rest on: a redelivered or replayed event\n * finds its type already recorded, reports `firstOfType: false`, and\n * contributes nothing.\n *\n * ⚠️ The bound has one consequence worth naming. A reader who opens only mail\n * they have already opened does not advance their own stamp, so a person can\n * read a year-old message and still measure as cold. Every message we send\n * them afterwards is a fresh first-open, so the stamp advances the moment\n * they engage with anything new — which is the population any sunset rule is\n * actually about.\n *=========================================*/\n\n/** The event types that count as a person engaging. */\nconst ENGAGEMENT_TYPES: readonly EmailDeliveryEventType[] = ['opened', 'clicked']\n\n/** What one person's mail says about whether they are still listening. */\nexport interface EmailPersonEngagement {\n /** The later of {@link lastOpenedAtMs} and {@link lastClickedAtMs}. */\n lastEngagedAtMs: number | null\n lastOpenedAtMs: number | null\n /**\n * Clicks are the metric to lean on. Apple's Mail Privacy Protection\n * prefetches images, so an open is partly a statement about the recipient's\n * mail client; a click is a statement about the recipient.\n */\n lastClickedAtMs: number | null\n}\n\n/** The empty answer, so a caller never has to invent one. */\nexport const NO_PERSON_ENGAGEMENT: EmailPersonEngagement = {\n lastEngagedAtMs: null,\n lastOpenedAtMs: null,\n lastClickedAtMs: null,\n}\n\n/** Reads the three stamps off a parent document's data. */\nfunction engagementFrom(data: Record<string, unknown> | null | undefined) {\n const number = (value: unknown): number | null => {\n const parsed = Number(value ?? 0)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : null\n }\n const opened = number(data?.['lastOpenedAtMs'])\n const clicked = number(data?.['lastClickedAtMs'])\n const engaged = number(data?.['lastEngagedAtMs'])\n return {\n lastEngagedAtMs:\n engaged ?? (opened || clicked ? Math.max(opened ?? 0, clicked ?? 0) : null),\n lastOpenedAtMs: opened,\n lastClickedAtMs: clicked,\n }\n}\n\n/**\n * Advances the engagement stamps for the people these outcomes are about.\n *\n * A transaction, and it buys exactly one property: the stamps only ever move\n * FORWARD. Provider events are not ordered, and a replay of an event whose\n * first delivery never landed can carry an instant from months ago — a blind\n * merge-set would let that overwrite a fresh stamp and quietly make an active\n * subscriber look cold to a control whose whole job is refusing to mail cold\n * people. Reading before writing is a cheaper unit than the write beside it,\n * and it happens at most once per message per event type.\n *\n * Never throws, for the same reason nothing else in this file does: a rollup\n * that failed loses a stamp, and a rollup that threw would lose the webhook's\n * acknowledgement and teach the provider to retry the whole event.\n *\n * @returns how many person documents were written.\n */\nexport async function recordPersonEngagement(\n outcomes: readonly EmailDeliveryEventOutcome[],\n firestore?: any,\n): Promise<number> {\n /** Person key → the newest instant seen per engagement type in this batch. */\n const byPerson = new Map<\n string,\n { openedAtMs: number; clickedAtMs: number }\n >()\n for (const outcome of outcomes) {\n if (!outcome.firstOfType) continue\n if (!ENGAGEMENT_TYPES.includes(outcome.type)) continue\n const key = emailSuppressionKey(outcome.to)\n const at = Number(outcome.at)\n if (!key || !Number.isFinite(at) || at <= 0) continue\n const held = byPerson.get(key) ?? { openedAtMs: 0, clickedAtMs: 0 }\n if (outcome.type === 'opened') {\n held.openedAtMs = Math.max(held.openedAtMs, at)\n } else {\n held.clickedAtMs = Math.max(held.clickedAtMs, at)\n }\n byPerson.set(key, held)\n }\n if (!byPerson.size) return 0\n\n const db = firestore ?? defaultFirestore()\n let written = 0\n for (const [key, seen] of byPerson) {\n try {\n const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key)\n await db.runTransaction(async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const stored = engagementFrom(\n (snapshot.exists ? snapshot.data() : null) ?? {},\n )\n const opened = Math.max(stored.lastOpenedAtMs ?? 0, seen.openedAtMs)\n const clicked = Math.max(stored.lastClickedAtMs ?? 0, seen.clickedAtMs)\n const engaged = Math.max(stored.lastEngagedAtMs ?? 0, opened, clicked)\n // Nothing moved forward, so nothing is written. An out-of-order event\n // is the ordinary case this skips, and skipping it costs a write\n // rather than losing a fact.\n if (\n opened === (stored.lastOpenedAtMs ?? 0) &&\n clicked === (stored.lastClickedAtMs ?? 0) &&\n engaged === (stored.lastEngagedAtMs ?? 0)\n ) {\n return\n }\n /*\n * A merge-set that CREATES. Unlike the campaign counters, there is no\n * document here to resurrect: `emailDeliveries/{key}` is a container\n * this store owns, its only other content is the erasure tombstone,\n * and a person's first recorded open is exactly when it should come\n * into existence.\n */\n transaction.set(\n ref,\n {\n ...(opened ? { lastOpenedAtMs: opened } : {}),\n ...(clicked ? { lastClickedAtMs: clicked } : {}),\n lastEngagedAtMs: engaged,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n written += 1\n })\n } catch (error) {\n console.error('[email-delivery-log] engagement rollup failed', key, error)\n }\n }\n return written\n}\n\n/**\n * One person's engagement, by address. Never throws.\n *\n * Returns {@link NO_PERSON_ENGAGEMENT} for an address we hold nothing about,\n * AND for a read that failed. The two are deliberately the same answer here:\n * every caller uses this to decide whether to REFUSE something, and both\n * readings must resolve to \"we have no evidence this person is cold\", which\n * is the only safe direction for a control that stops mail.\n */\nexport async function readPersonEngagement(\n email: string | null | undefined,\n firestore?: any,\n): Promise<EmailPersonEngagement> {\n const key = emailSuppressionKey(email)\n if (!key) return NO_PERSON_ENGAGEMENT\n try {\n const db = firestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .get()\n // No `exists` branch: a missing document has no data, and `engagementFrom`\n // already answers an absent field with null. A second gate saying the same\n // thing would be a line no test can distinguish from its own removal.\n return engagementFrom(snapshot.data() ?? {})\n } catch (error) {\n console.error('[email-delivery-log] engagement read failed', error)\n return NO_PERSON_ENGAGEMENT\n }\n}\n\n/**\n * Engagement for many people at once, keyed by their person key.\n *\n * A `getAll` rather than a query: these are keyed document reads, so this\n * needs no index, cannot be truncated by a `limit`, and cannot drop somebody\n * for missing a field the way an `orderBy` would. The audience materializer\n * calls it a page at a time and counts every read against its scan budget.\n *\n * A key with no document is present in the result with\n * {@link NO_PERSON_ENGAGEMENT}, so a caller never has to tell \"absent\" from\n * \"not read\" — and a failure returns every requested key that way for the\n * same reason {@link readPersonEngagement} does.\n */\nexport async function readPersonEngagementByKeys(\n keys: readonly string[],\n firestore?: any,\n): Promise<Map<string, EmailPersonEngagement>> {\n const wanted = [...new Set(keys.filter(Boolean))]\n const found = new Map<string, EmailPersonEngagement>()\n for (const key of wanted) found.set(key, NO_PERSON_ENGAGEMENT)\n if (!wanted.length) return found\n try {\n const db = firestore ?? defaultFirestore()\n const collection = db.collection(EMAIL_DELIVERIES_COLLECTION)\n const snapshots = await db.getAll(\n ...wanted.map((key: string) => collection.doc(key)),\n )\n for (const snapshot of snapshots) {\n if (!snapshot?.exists) continue\n found.set(snapshot.id, engagementFrom(snapshot.data() ?? {}))\n }\n } catch (error) {\n console.error('[email-delivery-log] engagement batch read failed', error)\n }\n return found\n}\n\n/** What one {@link importEmailDeliveryHistory} run did. */\nexport interface EmailDeliveryImportResult {\n /** Provider messages read. */\n scanned: number\n /** Per-recipient rows written or refreshed. */\n recorded: number\n pages: number\n /** Cursor to resume from, or null when the history was exhausted. */\n nextCursor: string | null\n /** True when the page budget ran out before the history did. */\n truncated: boolean\n}\n\n/** Default page budget for one import run. 100 messages per page. */\nexport const EMAIL_DELIVERY_IMPORT_MAX_PAGES = 20\n\n/**\n * Imports already-sent mail from a provider into the log.\n *\n * Bounded by PAGES rather than run to completion: this is called from a\n * request handler, and an account with a large history would otherwise hold\n * one open until it timed out — losing every page it had already written,\n * because a partial import that reports nothing is indistinguishable from one\n * that did nothing. Instead it stops at the budget, returns `nextCursor`, and\n * the caller resumes. Every page is written before the next is fetched, so an\n * interrupted run keeps its work.\n *\n * Idempotent by construction — see {@link recordEmailDeliverySnapshot}: a\n * message the event feed already covered is not walked backwards, and\n * re-running invents no counts.\n *\n * The `source` is injected rather than constructed here. This module may not\n * know which provider is in use, and a test must be able to run the whole\n * loop — pagination, cursor handling, the stop condition — without a network.\n */\nexport async function importEmailDeliveryHistory(options: {\n source: EmailDeliveryHistorySource\n cursor?: string | null\n maxPages?: number\n firestore?: any\n}): Promise<EmailDeliveryImportResult> {\n const maxPages = Math.max(1, options.maxPages ?? EMAIL_DELIVERY_IMPORT_MAX_PAGES)\n let cursor = options.cursor ?? null\n let scanned = 0\n let recorded = 0\n let pages = 0\n\n while (pages < maxPages) {\n const page = await options.source({ cursor })\n pages += 1\n scanned += page.snapshots.length\n for (const snapshot of page.snapshots) {\n if (await recordEmailDeliverySnapshot(snapshot, options.firestore)) {\n recorded += 1\n }\n }\n cursor = page.nextCursor\n if (!cursor) break\n }\n\n return {\n scanned,\n recorded,\n pages,\n nextCursor: cursor,\n truncated: Boolean(cursor),\n }\n}\n\n/**\n * The messages sent to one address, newest first.\n *\n * Ordered on `firstSeenAtMs`, which the writer guarantees on creation, rather\n * than on a per-state timestamp that only some rows carry: `orderBy` drops\n * every document missing the field, so ordering on `timestamps.sent` would\n * silently hide any message whose `sent` webhook never arrived — exactly the\n * message a staffer is looking for.\n *\n * @returns the rows, or an empty array. The caller distinguishes \"none\" from\n * \"could not read\" through {@link readEmailDeliveryHistory}.\n */\nexport async function readEmailDeliveries(\n email: string | null | undefined,\n options?: { limit?: number; firestore?: any },\n): Promise<EmailDeliveryRecord[]> {\n const key = emailSuppressionKey(email)\n if (!key) return []\n const db = options?.firestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .collection(EMAIL_DELIVERY_MESSAGES_COLLECTION)\n .orderBy('firstSeenAtMs', 'desc')\n .limit(Math.max(1, options?.limit ?? EMAIL_DELIVERY_READ_LIMIT))\n .get()\n\n return snapshot.docs.map(deliveryRecordFrom)\n}\n\n/**\n * One stored message document as {@link EmailDeliveryRecord}.\n *\n * Shared by every reader in this file so the defaults are decided once. A\n * second copy would be a second answer to \"what does an absent `openCount`\n * mean\", and the two would drift the first time a field is added.\n */\nexport function deliveryRecordFrom(doc: any): EmailDeliveryRecord {\n const data = doc.data() ?? {}\n return {\n messageId: String(data.messageId ?? doc.id),\n provider: String(data.provider ?? 'unknown'),\n to: String(data.to ?? ''),\n subject: data.subject ?? null,\n context: data.context ?? null,\n status: (data.status ?? 'sent') as EmailDeliveryEventType,\n timestamps: (data.timestamps ?? {}) as EmailDeliveryRecord['timestamps'],\n firstSeenAtMs: Number(data.firstSeenAtMs ?? 0),\n openCount: Number(data.openCount ?? 0),\n clickCount: Number(data.clickCount ?? 0),\n clickedLinks: Array.isArray(data.clickedLinks)\n ? data.clickedLinks.map(String)\n : [],\n bounceType: data.bounceType ?? null,\n detail: data.detail ?? null,\n hostId: data.hostId ?? null,\n campaignId: data.campaignId ?? null,\n }\n}\n\n/**\n * {@link readEmailDeliveries} with the read failure kept separate from an\n * empty result.\n *\n * The same shape `devices` uses on the staff detail route, for the same\n * reason: \"we have no record of any email to this person\" and \"we could not\n * reach the log\" lead a staffer to opposite next actions, and a card that\n * renders both as an empty table sends them down the wrong one.\n */\nexport async function readEmailDeliveryHistory(\n email: string | null | undefined,\n options?: { limit?: number; firestore?: any },\n): Promise<{ lookupFailed: boolean; rows: EmailDeliveryRecord[] }> {\n try {\n return { lookupFailed: false, rows: await readEmailDeliveries(email, options) }\n } catch (error) {\n console.error('[email-delivery-log] read failed', error)\n return { lookupFailed: true, rows: [] }\n }\n}\n\n/*==========================================\n * ACROSS THE BULK SENDS OF ONE SITE.\n *\n * The readers above answer \"what did we send this person\". This one answers\n * the other direction — \"who did this send reach, and which of them opened\n * it\" — and it is the SAME store, queried across the recipient documents\n * instead of down one of them. A send is whatever the sender tagged its\n * messages with when they went out: the mail rail stamps every bulk message\n * with the site and the send's id (`send-email.ts`), under the field name\n * `campaignId` it has always been stored as, whichever plugin sent it.\n *\n * That direction is a collection-group query, and it is the one shape this\n * file's header says the per-address layout avoids. It is worth the index\n * here for the reason the index exists at all: the alternative is a second\n * per-recipient store keyed by send, written by the same webhook, which\n * would be two records of the same fact and one of them eventually wrong.\n *\n * ⚠️ EVERY caller must be authorised on `hostId` before calling. The rows\n * carry recipient addresses, and the `hostId` filter below is a query\n * predicate, not a permission — it narrows the read to one site's mail and\n * says nothing about who is asking.\n *=========================================*/\n\n/** The most recipient rows one send-engagement read returns. */\nexport const EMAIL_SEND_ENGAGEMENT_PAGE_SIZE = 25\n\n/**\n * How many sends one engagement read can span.\n *\n * Firestore's `in` operator takes at most 30 values, and the query below runs\n * as a merge of one sub-query per value — so this is a hard limit of the\n * store rather than a number worth tuning. A design used by more sends than\n * this reads its most recent 30, and the caller is told so.\n */\nexport const EMAIL_SEND_ENGAGEMENT_MAX_SENDS = 30\n\n/** Which recipients a send-engagement read returns. */\nexport type EmailEngagementFilter = 'all' | 'opened' | 'clicked'\n\n/** One page of recipient rows. */\nexport interface EmailSendEngagementPage {\n rows: EmailDeliveryRecord[]\n /**\n * Cursor for the next page, or null at the end.\n *\n * The full document PATH of the last row, which is\n * `emailDeliveries/{sha256(address)}/messages/{messageId}`. It is re-read\n * as a snapshot to resume the query, rather than resuming from the ordered\n * VALUE: a value cursor positions after every document sharing it, so two\n * messages recorded in the same millisecond would lose one of them between\n * pages — silently, and only under load.\n */\n cursor: string | null\n /** The read failed, as distinct from finding nothing. */\n lookupFailed: boolean\n /** Sends past {@link EMAIL_SEND_ENGAGEMENT_MAX_SENDS}. */\n sendsOmitted: number\n}\n\n/**\n * The recipients of one site's bulk sends, newest message first.\n *\n * ## What each filter orders on, and why it is not one query with a flag\n *\n * `all` orders on `firstSeenAtMs`, which {@link recordEmailDeliveryEvent}\n * guarantees on creation. `opened` and `clicked` carry an inequality —\n * `openCount > 0` — and Firestore requires the first ordering to be on the\n * inequality's own field, so those two order on the count and then on the\n * time. That is not a workaround: a message never opened has no `openCount`\n * field at all, so the inequality is also what excludes it, and the ordering\n * puts the most engaged recipient first, which is the order a merchant reads\n * such a table in.\n *\n * ## Never throws\n *\n * Same contract as the rest of this file: `lookupFailed` distinguishes a read\n * that could not run — a missing index is the likely one — from a send\n * nobody opened. Rendering those two the same way is how a merchant concludes\n * their mail reached nobody.\n */\nexport async function readSendEngagement(options: {\n /** The site whose mail this is. The caller must already have proven it. */\n hostId: string\n /** Send ids to read, most recent first. */\n sendIds: readonly string[]\n filter?: EmailEngagementFilter\n limit?: number\n /** A `cursor` from a previous page. */\n cursor?: string | null\n firestore?: any\n}): Promise<EmailSendEngagementPage> {\n const {\n hostId,\n sendIds,\n filter = 'all',\n cursor = null,\n firestore,\n } = options\n const pageSize = Math.max(\n 1,\n Math.min(\n EMAIL_SEND_ENGAGEMENT_PAGE_SIZE,\n options.limit ?? EMAIL_SEND_ENGAGEMENT_PAGE_SIZE,\n ),\n )\n const ids = sendIds\n .filter(Boolean)\n .slice(0, EMAIL_SEND_ENGAGEMENT_MAX_SENDS)\n const sendsOmitted = Math.max(\n 0,\n sendIds.filter(Boolean).length - ids.length,\n )\n const empty: EmailSendEngagementPage = {\n rows: [],\n cursor: null,\n lookupFailed: false,\n sendsOmitted,\n }\n if (!hostId || !ids.length) return empty\n\n try {\n const db = firestore ?? defaultFirestore()\n let query = db\n .collectionGroup(EMAIL_DELIVERY_MESSAGES_COLLECTION)\n // `hostId` first so the read is provably one site's mail even if a\n // caller ever passes a send id belonging to another.\n .where('hostId', '==', hostId)\n // The send tag's stored name (see the section header).\n .where('campaignId', 'in', ids)\n if (filter === 'opened') {\n query = query.where('openCount', '>', 0).orderBy('openCount', 'desc')\n } else if (filter === 'clicked') {\n query = query.where('clickCount', '>', 0).orderBy('clickCount', 'desc')\n }\n query = query.orderBy('firstSeenAtMs', 'desc')\n\n if (cursor) {\n const anchor = await db.doc(cursor).get()\n // A cursor whose document has been erased resumes nothing rather than\n // silently restarting at page one, which would loop the reader through\n // the same rows forever.\n if (!anchor.exists) return empty\n query = query.startAfter(anchor)\n }\n\n const snapshot = await query.limit(pageSize).get()\n const rows = snapshot.docs.map(deliveryRecordFrom)\n return {\n rows,\n // Null on a short page: a full page is the only state from which more\n // rows can exist, and offering a cursor that returns nothing makes a\n // finished table look unfinished.\n cursor:\n rows.length === pageSize\n ? String(snapshot.docs[snapshot.docs.length - 1].ref.path)\n : null,\n lookupFailed: false,\n sendsOmitted,\n }\n } catch (error) {\n console.error('[email-delivery-log] send engagement read failed', error)\n return { ...empty, lookupFailed: true }\n }\n}\n\n/*==========================================\n * ACROSS EVERY ADDRESS AN ACCOUNT HOLDS.\n *\n * The single-address functions above are the primitive and stay exactly as\n * they were — one address, one document. What was wrong was never the\n * primitive; it was that every CALLER passed the Auth record's current\n * primary and nothing else, so a changed address orphaned the history and an\n * erasure missed the mail sitting under the other addresses.\n *\n * The address list is resolved ONCE, by `account-addresses.ts`, and passed\n * in. This module deliberately does not resolve it: a store keyed by a hash\n * should not also own the rule for which hashes describe a person, and a copy\n * of that rule here is the second copy the whole change exists to prevent.\n *=========================================*/\n\n/**\n * A record that delivery data WAS held for an address and has been erased.\n *\n * Written into the parent `emailDeliveries/{emailKey}` document, which the\n * messages subcollection otherwise leaves empty.\n *\n * ⚠️ It carries no address, no subject, no message id and no uid — nothing\n * the erasure was performed to destroy. `count` is a magnitude, which is what\n * makes the row honest without reconstituting anything: it says data existed\n * and is gone, and nothing about what it was.\n */\nexport interface EmailDeliveryErasure {\n /** Epoch ms. */\n at: number\n /** How many messages were removed. */\n count: number\n}\n\n/** One account's mail, gathered from every address it holds. */\nexport interface EmailDeliveryHistory {\n lookupFailed: boolean\n rows: EmailDeliveryRecord[]\n /**\n * The addresses actually read, in the order they were given.\n *\n * The card names them. A staffer looking at mail sent to an address that is\n * no longer this account's primary has to be able to see that that is what\n * they are looking at.\n */\n addressesRead: string[]\n /**\n * Erasure tombstones found, keyed by address.\n *\n * An address whose records were erased under somebody's request reads as an\n * empty table otherwise — which is the precise failure this card's copy\n * warns about, recreated by the fix for it.\n */\n erasures: Record<string, EmailDeliveryErasure>\n}\n\n/** The tombstone on one address, or null. Never throws. */\nexport async function readEmailDeliveryErasure(\n email: string | null | undefined,\n firestore?: any,\n): Promise<EmailDeliveryErasure | null> {\n const key = emailSuppressionKey(email)\n if (!key) return null\n try {\n const db = firestore ?? defaultFirestore()\n const doc = await db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key).get()\n if (!doc.exists) return null\n const at = Number(doc.get('erasedAtMs') ?? 0)\n if (!at) return null\n return { at, count: Number(doc.get('erasedCount') ?? 0) }\n } catch {\n return null\n }\n}\n\n/**\n * Every message sent to any address this account holds, newest first.\n *\n * Merged and re-sorted rather than concatenated: the rows are one person's\n * mail and a staffer reads them as a timeline, so grouping them by which\n * address happened to receive them would put the answer in two places and\n * make \"what was the last thing we sent them\" a question about two tables.\n * Each row keeps its own `to`, so the card can still say which address.\n *\n * `lookupFailed` is true when ANY address failed. A partial read of a\n * delivery log is the same hazard as an empty one — it under-reports mail we\n * sent — and reporting it as a clean result is how a staffer comes to tell a\n * customer something untrue.\n */\nexport async function readEmailDeliveryHistoryForAddresses(\n addresses: readonly string[],\n options?: { limit?: number; firestore?: any },\n): Promise<EmailDeliveryHistory> {\n const limit = Math.max(1, options?.limit ?? EMAIL_DELIVERY_READ_LIMIT)\n const addressesRead: string[] = []\n const erasures: Record<string, EmailDeliveryErasure> = {}\n const rows: EmailDeliveryRecord[] = []\n let lookupFailed = false\n\n for (const address of addresses) {\n const key = emailSuppressionKey(address)\n if (!key) continue\n addressesRead.push(address)\n try {\n rows.push(...(await readEmailDeliveries(address, { limit, ...options })))\n } catch (error) {\n console.error('[email-delivery-log] read failed', error)\n lookupFailed = true\n }\n const erasure = await readEmailDeliveryErasure(address, options?.firestore)\n if (erasure) erasures[address] = erasure\n }\n\n rows.sort((a, b) => b.firstSeenAtMs - a.firstSeenAtMs)\n return { lookupFailed, rows: rows.slice(0, limit), addressesRead, erasures }\n}\n\n/** What one multi-address erasure did. */\nexport interface EmailDeliveryErasureResult {\n /** Messages removed, across every address that was erased. */\n removed: number\n /** The addresses actually erased. Tombstoned, one document each. */\n addresses: string[]\n /**\n * Addresses left INTACT because another account is also known to hold them.\n *\n * Never empty and ignorable: a caller erasing an account has to treat a\n * non-empty list as an erasure it did not finish. See\n * {@link eraseEmailDeliveriesForAddresses}.\n */\n contestedAddresses: string[]\n}\n\n/**\n * Erase the delivery log for every address an account holds, except the ones\n * a second account also holds.\n *\n * ## The shared-address decision\n *\n * The log describes an ADDRESS, not an account. Where one account holds an\n * address, erasing it is simply erasing the subject's mail, and this sweeps\n * it.\n *\n * Where TWO accounts hold one address, the same rows are two people's answer\n * to \"what did you send me\", and the two readings are incompatible:\n *\n * - **One human, two accounts** — the ordinary live shape, an account whose\n * federated provider address is another account's primary. Erasing is\n * right; the mail is the requester's.\n * - **A genuinely shared mailbox** — `billing@`, `support@`, a role account\n * two different people hold. Erasing destroys the second person's delivery\n * history for an address they legitimately hold, and they asked for\n * nothing.\n *\n * ⛔ **Nothing here can tell those apart.** The difference is a fact about the\n * humans, and the data holds no fact about the humans — only that two account\n * records name one address. So this function does not choose. It erases what\n * it can decide about and reports the rest as CONTESTED, and `eraseUser`\n * refuses the whole erasure rather than half-perform one: destroying the\n * second party's mail has no remedy, and quietly leaving it while reporting\n * the erasure complete is the gap this area exists to close. Refusing is the\n * only outcome that is neither, and it is reversible — a human decides which\n * reading applies, detaches the address or confirms the account, and the\n * erasure runs.\n *\n * ⚠️ A contested address is not tombstoned. The tombstone means \"the records\n * here were removed under an erasure request\", and writing one over rows that\n * are still present would tell the second holder their mail is gone while it\n * sits underneath — a worse misreading than the blank table, because it is\n * confidently wrong rather than merely empty. Nothing was removed, so their\n * card renders their mail exactly as before.\n *\n * ⚠️ `shared` is one-directional evidence. True proves a second holder; false\n * only means none was found, because there is no lookup for an account\n * holding an address through a federated provider (see\n * `account-addresses.ts`). So the tombstone is still written for EVERY\n * address that IS erased, not only ones believed unshared — it costs one\n * small document and closes the case where a second holder exists behind the\n * gap in the probe and would otherwise meet a blank table.\n *\n * ⛔ Only addresses the account HOLDS, resolved through the one resolver. An\n * address arriving here that the account does not hold erases a stranger's\n * mail, which no erasure request authorises.\n */\nexport async function eraseEmailDeliveriesForAddresses(\n addresses: readonly { address: string; shared?: boolean }[],\n firestore?: any,\n): Promise<EmailDeliveryErasureResult> {\n const db = firestore ?? defaultFirestore()\n const erased: string[] = []\n const contestedAddresses: string[] = []\n let removed = 0\n\n for (const entry of addresses) {\n const key = emailSuppressionKey(entry.address)\n if (!key) continue\n\n // Before any write for this address, so a contested one is untouched\n // rather than erased-then-regretted. There is no undo below this line.\n if (entry.shared === true) {\n contestedAddresses.push(entry.address)\n continue\n }\n\n erased.push(entry.address)\n const count = await eraseEmailDeliveries(entry.address, db).catch(() => 0)\n removed += count\n\n // The tombstone lands whether or not anything was removed: an address we\n // erased and found empty is still an address whose records this request\n // covered, and a later import must not be able to refill it silently.\n try {\n await db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .set(\n {\n erasedAtMs: Date.now(),\n erasedCount: FieldValue.increment(count),\n /*\n * The engagement rollup goes with the messages it was summarised\n * from. \"This person read our mail on the 3rd\" is the same\n * personal fact as the row it was derived from, and a summary\n * that outlived its source would leave an erasure that removed\n * the evidence and kept the conclusion.\n */\n lastEngagedAtMs: FieldValue.delete(),\n lastOpenedAtMs: FieldValue.delete(),\n lastClickedAtMs: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n } catch (error) {\n console.error('[email-delivery-log] tombstone write failed', error)\n }\n\n /*\n * And what was CONCLUDED from this person's clicks, on every site.\n *\n * The plugin that credits outcomes (`plugin-conversion-credit.ts`) keeps\n * which campaign a person last clicked, on this same document, and which\n * campaign each of their sign-ups, leads and bookings was credited to —\n * \"this person clicked THIS campaign on the 3rd\" names both the person\n * and what they were reading, the strongest personal fact here, and a\n * credit drawn from it is a stronger statement still. Deleting the\n * engagement above and keeping either would be an erasure that removed\n * the evidence and kept the conclusion. The orders already credited keep\n * their own record — a commercial fact about a sale, held under the\n * order's id rather than the person's.\n *\n * Asked by the same key — `personKey` is `emailSuppressionKey`, one\n * derivation — so it covers exactly the person this loop is erasing, and\n * per address rather than per site, because an erasure request names an\n * address and knows nothing about which sites it ever visited.\n */\n await eraseConversionCredits(key)\n }\n\n return { removed, addresses: erased, contestedAddresses }\n}\n\n/**\n * Deletes everything recorded for one address.\n *\n * The log holds an address, the subjects sent to it and when they were opened\n * — personal data by any reading — so the erasure path has to be able to reach\n * it. Batched because a long-lived account can hold hundreds of rows and a\n * single `delete()` per document would be one round trip each.\n */\nexport async function eraseEmailDeliveries(\n email: string | null | undefined,\n firestore?: any,\n): Promise<number> {\n const key = emailSuppressionKey(email)\n if (!key) return 0\n const db = firestore ?? defaultFirestore()\n const parent = db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .collection(EMAIL_DELIVERY_MESSAGES_COLLECTION)\n\n let removed = 0\n // Bounded loop rather than `while (true)`: a pathological collection must\n // not be able to hold an erasure request open indefinitely.\n for (let pass = 0; pass < 20; pass += 1) {\n const snapshot = await parent.limit(400).get()\n if (snapshot.empty) break\n const batch = db.batch()\n snapshot.docs.forEach((doc: any) => batch.delete(doc.ref))\n await batch.commit()\n removed += snapshot.size\n if (snapshot.size < 400) break\n }\n return removed\n}\n"],"names":["FieldValue","worstDeliveryStatus","eraseConversionCredits","nameSearchTokens","emailSearchTokens","emailSuppressionKey","firebaseAdmin","defaultFirestore","app","firestore","EMAIL_DELIVERIES_COLLECTION","EMAIL_DELIVERY_MESSAGES_COLLECTION","EMAIL_DELIVERY_READ_LIMIT","EMAIL_DELIVERY_MAX_LINKS","EMAIL_DELIVERY_SEARCH_FIELD","EMAIL_DELIVERY_SEARCH_TOKEN_LIMIT","emailDeliverySearchTokens","message","text","value","tokens","Set","to","subject","context","slice","recordEmailDeliveryEvent","event","key","providerMessageId","firstOfType","db","ref","collection","doc","runTransaction","transaction","snapshot","get","existing","exists","data","timestamps","type","undefined","update","messageId","provider","status","at","lastEventAtMs","updatedAt","serverTimestamp","firstSeenAtMs","openCount","clickCount","tags","hostId","campaignId","bounceType","detail","increment","link","links","Array","isArray","clickedLinks","map","String","includes","length","set","merge","error","console","recordEmailDeliverySnapshot","sentAt","stored","importedAtMs","Date","now","sent","recordEmailDeliveryEvents","events","results","Promise","all","filter","one","ENGAGEMENT_TYPES","NO_PERSON_ENGAGEMENT","lastEngagedAtMs","lastOpenedAtMs","lastClickedAtMs","engagementFrom","number","parsed","Number","isFinite","opened","clicked","engaged","Math","max","recordPersonEngagement","outcomes","byPerson","Map","outcome","held","openedAtMs","clickedAtMs","size","written","seen","readPersonEngagement","email","readPersonEngagementByKeys","keys","wanted","Boolean","found","snapshots","getAll","id","EMAIL_DELIVERY_IMPORT_MAX_PAGES","importEmailDeliveryHistory","options","maxPages","cursor","scanned","recorded","pages","page","source","nextCursor","truncated","readEmailDeliveries","orderBy","limit","docs","deliveryRecordFrom","readEmailDeliveryHistory","lookupFailed","rows","EMAIL_SEND_ENGAGEMENT_PAGE_SIZE","EMAIL_SEND_ENGAGEMENT_MAX_SENDS","readSendEngagement","sendIds","pageSize","min","ids","sendsOmitted","empty","query","collectionGroup","where","anchor","startAfter","path","readEmailDeliveryErasure","count","readEmailDeliveryHistoryForAddresses","addresses","addressesRead","erasures","address","push","erasure","sort","a","b","eraseEmailDeliveriesForAddresses","erased","contestedAddresses","removed","entry","shared","eraseEmailDeliveries","catch","erasedAtMs","erasedCount","delete","parent","pass","batch","forEach","commit"],"mappings":"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"}
|
|
@@ -175,14 +175,19 @@ import { scheduleCapturedEmailCheck } from "./capture-email-check.js";
|
|
|
175
175
|
email
|
|
176
176
|
}, input.name ? {
|
|
177
177
|
name: input.name
|
|
178
|
-
} : {}
|
|
178
|
+
} : (existing == null ? void 0 : existing.get('name')) !== undefined ? {} : {
|
|
179
|
+
name: null
|
|
180
|
+
}, {
|
|
179
181
|
searchTokens: listMemberSearchTokens(email, name),
|
|
180
182
|
source: input.source
|
|
181
183
|
}, input.via ? {
|
|
182
184
|
via: input.via
|
|
183
185
|
} : (existing == null ? void 0 : existing.get('via')) ? {} : {
|
|
184
186
|
via: 'manual'
|
|
185
|
-
}, consent ? membershipConsentFields(input, consent) : {}, existing ?
|
|
187
|
+
}, consent ? membershipConsentFields(input, consent) : {}, existing ? // its document's create time, the date it has always meant.
|
|
188
|
+
existing.get('addedAt') === undefined && existing.createTime ? {
|
|
189
|
+
addedAt: existing.createTime
|
|
190
|
+
} : {} : {
|
|
186
191
|
addedAt: FieldValue.serverTimestamp(),
|
|
187
192
|
[CAPTURED_BY_HOST_FIELD]: [
|
|
188
193
|
input.group.hostId
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/list-members.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 only writer of `orgs/{orgId}/lists/{listId}/members` (AGL-2499).\n *\n * Two enrollment routes reach this collection — the commerce newsletter\n * handler and the workflow `enrollList` step — and they used to derive the\n * document id two incompatible ways: a full `sha256(email)` on one side, a\n * `hmac('aglyn-list-member', email)` truncated to 20 hex on the other. The\n * same person subscribing by both routes became two members of one list.\n *\n * The id is now `personKey` on both, which is the derivation both\n * `docs/specs/email-overhaul.md` §3d and `docs/specs/reusable-forms.md` §4\n * specify, and it normalizes before hashing so casing cannot fork it either.\n *\n * ## Why a helper and not two corrected call sites\n *\n * Two call sites that merely agree today are what produced the split: nothing\n * about either one said the other existed. A third route — the reusable-forms\n * capture path is already specified — would have been written the same way.\n * Enrolling goes through this function so that the id has one definition and\n * no caller is offered the chance to derive its own.\n */\n\nimport type { DocumentReference } from 'firebase-admin/firestore'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n normalizeContactEmail,\n personKey,\n CAPTURED_BY_HOST_FIELD,\n MARKETING_CONSENT_BASIS_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n marketingConsentFieldsForGroup,\n readMarketingBasis,\n type ConsentGroup,\n} from '@aglyn/aglyn/server'\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { createHash, createHmac } from 'node:crypto'\nimport { emailSearchTokens } from './email-suppression'\nimport { scheduleCapturedEmailCheck } from './capture-email-check'\n\n/**\n * What a list's membership table searches a member by (AGL-3321): the word\n * prefixes of their name and of their address (`emailSearchTokens`), one\n * array, so one `array-contains` finds \"dana\" in either.\n *\n * Mirrored by `tools/scripts/backfill-email-list-filters.mjs`, which stamps\n * the rows written before the field; the two are held to the same fixtures\n * (`tools/scripts/lib/email-search-tokens.fixtures.json`, which the\n * script-side twin in `tools/scripts/lib/email-search-tokens.mjs` answers too).\n */\nexport function listMemberSearchTokens(\n email: string | null | undefined,\n name: string | null | undefined,\n): string[] {\n return [...new Set([...emailSearchTokens(email), ...nameSearchTokens(name)])]\n}\n\n/**\n * The two ids this collection was written under before `personKey`.\n *\n * Read-only, and never written for a new row: they exist so an address\n * already enrolled under a legacy id is *found* rather than duplicated. See\n * `enrollListMember` for why that lookup is the migration.\n *\n * Both take the normalized address, which is what the two original call sites\n * happened to pass — each lowercased at its own entry point before hashing, so\n * every row already on the collection was keyed from a lowercased address even\n * though neither derivation enforced it.\n */\nfunction legacyListMemberIds(normalizedEmail: string): string[] {\n return [\n createHash('sha256').update(normalizedEmail).digest('hex'),\n createHmac('sha256', 'aglyn-list-member')\n .update(normalizedEmail)\n .digest('hex')\n .slice(0, 20),\n ]\n}\n\n/**\n * Every document id one address may be filed under on a list's `members`:\n * the canonical key first, then the two ids earlier writers minted. A\n * reader that must find the person wherever they were enrolled — the\n * erasure sweep (AGL-2623) — asks for all of them with one `getAll`, the\n * same set `enrollListMember` reads before it writes, so a member enrolled\n * under an old id is neither missed nor duplicated.\n */\nexport function listMemberDocIds(email: unknown): string[] {\n const normalized = normalizeContactEmail(email)\n if (!normalized) return []\n const key = personKey(normalized)\n if (!key) return []\n return [key, ...legacyListMemberIds(normalized).filter((id) => id !== key)]\n}\n\n/**\n * What makes an enrollment mailable, and who is answerable for it.\n *\n * Two values and no third. They are not interchangeable and the difference is\n * the whole reason the basis is stored rather than implied:\n *\n * - `contact-opt-in` — the person ticked a box. The enrollment carries a\n * decision they made, and `atMs` is when THEY made it, not when the row was\n * written. Nobody is answerable because nobody asserted anything.\n * - `operator-attested` — a console account stated that they have this\n * person's permission. That statement is itself the basis, so it is only\n * worth storing with the account that made it and the moment they did.\n *\n * Rendering the second as though it were the first is what a support or\n * compliance question cannot recover from, which is why the basis rides\n * beside `marketingConsent` on every row that has one.\n */\nexport type ListMemberConsentBasis = 'contact-opt-in' | 'operator-attested'\n\n/** A basis, with the attribution that makes it answerable. */\nexport interface ListMemberConsent {\n basis: ListMemberConsentBasis\n /**\n * When the basis was recorded.\n *\n * For a pass-through this is the ORIGINAL opt-in moment carried over from\n * the person's own record, because the question a consent record answers is\n * when the person said yes — not when somebody copied their answer onto a\n * list.\n */\n atMs: number\n /** The console account that attested. Meaningless for a pass-through. */\n byUid?: string | null\n /**\n * Why, in the operator's own terms, for whoever audits this later.\n *\n * Written for an attestation and ignored for a pass-through, which needs no\n * explanation beyond the person's own record. An import fills it from what\n * the FILE declared — the opt-in source and date the merchant supplied per\n * row — because \"a declared basis per address, not one checkbox over the\n * file\" is only worth asking for if the declaration survives onto the row\n * it was made about.\n */\n reason?: string\n}\n\nexport interface EnrollListMemberInput {\n /** `orgs/{orgId}/lists/{listId}` — the caller has already proved it exists. */\n listRef: DocumentReference\n /**\n * The consent group the enrollment is being made IN THE NAME OF.\n *\n * Lists live on the org and every site in it can mail one, so a membership\n * carries a basis that no path could attribute to a controller. A GROUP\n * rather than a site, because a business running three sites under one name\n * legitimately enrolls somebody into all three at once — and an agency's\n * client, which declared no group, gets exactly itself.\n *\n * Required and undefaulted: a caller that has not resolved a group has not\n * decided who this person agreed to hear from.\n */\n group: ConsentGroup\n email: string\n name?: string\n /** Free-form provenance: `'newsletter'`, `'action:{actionId}'`. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` and `addHostLead` already carry.\n *\n * A list membership had NO consent field of any kind, so `audience: 'list'`\n * had nothing for the send-time join to read\n * (`docs/specs/email-overhaul.md` §1d/§3f). This is the field that closes\n * it, and it is the caller's captured checkbox: enrolling is an act, and\n * `false`/omitted therefore writes nothing rather than a basis.\n */\n marketingConsent?: boolean\n /**\n * The basis in full, for a caller that knows more than \"a box was ticked\".\n *\n * `marketingConsent: true` is the shorthand a capture surface uses: it means\n * `contact-opt-in`, recorded now, asserted by nobody. Anything else — a\n * pass-through carrying the person's ORIGINAL opt-in date, or an\n * attestation carrying the account that made it — has to say so, and this\n * is where it says it. The two inputs are normalized to one value before\n * anything is written, so there is a single writer of the consent fields.\n */\n consent?: ListMemberConsent\n /**\n * The person's own grants, as `grantEntriesAsRecorded` read them off their\n * record — what a PASS-THROUGH carries (AGL-3320).\n *\n * A `contact-opt-in` consent with these writes exactly these sites, with\n * each grant's own moment and the disclosure it was given under: an opt-in\n * given to one site before the group existed stays that site's on the list,\n * and one given under the group's sentence reaches the sites it named.\n * Re-recording the pass-through over {@link group} instead would widen the\n * first to every site the group names today.\n */\n grantEntries?: Readonly<Record<string, Readonly<Record<string, unknown>>>>\n /**\n * The `consentGroupDisclosureKey` a capture surface rendered beside its\n * checkbox, for a {@link marketingConsent} captured just now. The grant\n * pools across {@link group} only when it is the group's current key.\n *\n * Every other basis without {@link grantEntries} — an attestation, a\n * capture that rendered no disclosure — is recorded for the enrolling site\n * alone: an operator's word about one site's list is not a disclosure\n * anybody read about the rest.\n */\n disclosedConsentGroup?: string | null\n /**\n * How the person got here: enrolled by hand or by an automation\n * (`'manual'`), or selected by a dynamic list's rule (`'rule'`).\n *\n * The materializer needs to tell its own rows apart from a manual one on\n * the same list, because a person who stops matching the rule leaves and a\n * person somebody added by hand does not.\n */\n via?: 'manual' | 'rule'\n}\n\nexport interface EnrolledListMember {\n /** The document actually written — a legacy id when one was adopted. */\n memberId: string\n /** True when an existing legacy-keyed row was written instead of a new one. */\n adopted: boolean\n /** False when the row already existed under some id. */\n created: boolean\n}\n\n/** Why nothing was written. */\nexport type EnrollListMemberRefusal =\n /** Not an address this collection can key. Nothing to write, nothing to fix. */\n | 'unusable-address'\n /**\n * The person's membership records a REFUSAL. Not overridable by any caller\n * and not reported as a failure to fix, because the fix would be to ignore\n * it.\n */\n | 'declined'\n\n/**\n * Enrolled, or refused and why.\n *\n * A union rather than `EnrolledListMember | null`, because the two refusals\n * are not the same event and a caller that cannot tell them apart is a caller\n * that reports a person's recorded refusal as a malformed address.\n */\nexport type EnrollListMemberResult =\n | ({ enrolled: true } & EnrolledListMember)\n | { enrolled: false; refusal: EnrollListMemberRefusal }\n\n/**\n * Enrolls one address into a list, at one document per person.\n *\n * ## The legacy lookup is the migration\n *\n * Changing the derivation without one would strand every row written under the\n * old ids: the next enrollment of an address already on the list would key a\n * *new* document beside the old one, so a defect that produced duplicates only\n * when two routes met would start producing them on a single route. Rather\n * than rewrite those ids — a bulk operation that has to delete the row it\n * replaces, and deleting an enrollment destroys the consent record that says\n * the person asked to be there — the write resolves the person's existing\n * document and keeps using it.\n *\n * The cost is one `getAll` of three refs, on a human-triggered signup, in\n * place of the blind `set` this replaced. It is one round trip, not three.\n *\n * `addedAt` is stamped only when the document is created, so re-enrolling\n * keeps the date the person actually joined — the same \"earliest wins\" collapse\n * `docs/specs/email-overhaul.md` §3d asks a backfill to preserve.\n *\n * ## A recorded refusal stops every route through this function\n *\n * This is the only writer of the collection, so the refusal check belongs\n * here and not at the four call sites: a guard on the button that adds\n * somebody by hand leaves the newsletter handler, the workflow `enrollList`\n * step and the dynamic-list materializer free to put the same person back.\n * The membership snapshot is already in hand from the id resolution above, so\n * the check costs no read.\n *\n * It answers the question about the LIST ROW. A refusal recorded on the\n * person's CRM record is a different document this function does not fetch —\n * a caller that can see one is expected to consult it, and the send-time\n * consent join reads the row this function writes either way.\n */\nexport async function enrollListMember(\n input: EnrollListMemberInput,\n): Promise<EnrollListMemberResult> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { enrolled: false, refusal: 'unusable-address' }\n const key = personKey(email)\n if (!key) return { enrolled: false, refusal: 'unusable-address' }\n\n const members = input.listRef.collection('members')\n const canonicalRef = members.doc(key)\n const legacyRefs = legacyListMemberIds(email)\n // A legacy derivation that happens to agree with `personKey` — `sha256` of\n // the same normalized address does — must not be fetched twice: `getAll`\n // rejects duplicate references.\n .filter((id) => id !== key)\n .map((id) => members.doc(id))\n\n const snapshots = await input.listRef.firestore.getAll(\n canonicalRef,\n ...legacyRefs,\n )\n const existing = snapshots.find((snapshot) => snapshot.exists)\n const target = existing?.ref ?? canonicalRef\n\n if (\n existing &&\n readMarketingBasis(existing.data() as Record<string, unknown>, input.group)\n .basis === 'declined'\n ) {\n return { enrolled: false, refusal: 'declined' }\n }\n\n /*\n * ONE value from the two ways a caller may state a basis.\n *\n * `marketingConsent: true` is a capture surface saying \"a box was ticked\n * just now\", which is `contact-opt-in` recorded at this moment with nobody\n * asserting it. Normalizing here rather than branching at the write is what\n * keeps the consent fields to a single writer — two branches stamping\n * overlapping subsets of them is how a row comes to carry a basis with no\n * timestamp, or a timestamp with no basis.\n */\n const consent: ListMemberConsent | null =\n input.consent ??\n (input.marketingConsent\n ? { basis: 'contact-opt-in', atMs: Date.now() }\n : null)\n\n /*\n * The name the row will hold after this write — the caller's, else the one\n * it already carries — because the search tokens are the address's and\n * that name's, and a re-enrollment without a name keeps the stored one.\n */\n const name = input.name || (existing?.get('name') as string | undefined) || null\n await target.set(\n {\n email,\n ...(input.name ? { name: input.name } : {}),\n searchTokens: listMemberSearchTokens(email, name),\n source: input.source,\n /*\n * `via` is on every row, so the table's How filter can ask for it\n * (AGL-3321): the caller's, else `manual` on a row that has none — what\n * an absent `via` has always meant. A caller that says nothing never\n * moves a stored one.\n */\n ...(input.via\n ? { via: input.via }\n : existing?.get('via')\n ? {}\n : { via: 'manual' }),\n /*\n * Written only when the caller has a basis, and never unwritten.\n *\n * A merge that stamped `marketingConsent: false` on the omitted case\n * would erase a basis this person gave on an earlier enrollment, and a\n * withdrawn consent is not the same event as a re-enrollment that\n * happened to carry no checkbox. Withdrawal has its own path — the\n * unsubscribe link and the suppression list.\n *\n * `marketingConsentAtMs` moves with each fresh basis on purpose: the\n * question a consent record answers is when the person last said yes.\n *\n * The `marketingConsent` family rather than the `consent*` names\n * `docs/specs/email-overhaul.md` §3d sketched: `readMarketingBasis` is\n * the shipped reader and it reads these, so a second vocabulary here\n * would be a basis the send-time join cannot see. The two new fields\n * are attribution ON that field — they say why the person is mailable,\n * never whether — so nothing about the send decision moves into them.\n *\n * Written UNDER the enrolling GROUP, through the one writer helper. A\n * list lives on the org and every site in it can mail one, so a basis\n * written at the top of this row would enroll a person into one\n * client's newsletter and make them mailable by all of them. A group of\n * one — the default — is that client and nobody else.\n *\n * `marketingConsentByUid` is written on EVERY basis, `null` for a\n * pass-through. Leaving it absent would let a real opt-in that\n * supersedes an earlier attestation inherit the attesting account, and\n * a row reading \"this account vouched for them\" when the person ticked\n * a box themselves is a false attribution in the one direction a\n * compliance answer cannot afford.\n *\n * `marketingConsentReason` follows the same rule for the same reason:\n * always written, `''` for a pass-through, so a later real opt-in\n * cannot inherit the sentence an earlier import wrote about where the\n * merchant said the address came from.\n *\n * WHICH sites the basis covers is `membershipConsentFields`' to say,\n * and it never re-derives a grant from the group as it stands now.\n */\n ...(consent ? membershipConsentFields(input, consent) : {}),\n ...(existing\n ? {}\n : {\n addedAt: FieldValue.serverTimestamp(),\n [CAPTURED_BY_HOST_FIELD]: [input.group.hostId],\n }),\n },\n { merge: true },\n )\n\n /*\n * A person NEW to the list has their address's domain checked after the\n * response (AGL-3328), so the CRM record they are reads \"Would bounce\"\n * when it takes no mail. The list's owner names whose records those are:\n * an organization's list its own, a site's list the site's organization.\n */\n if (!existing) {\n const owner = input.listRef.parent?.parent\n const ownerKind = owner?.parent?.id\n scheduleCapturedEmailCheck({\n orgId: ownerKind === 'orgs' ? owner?.id : null,\n hostId: ownerKind === 'hosts' ? owner?.id : input.group?.hostId,\n email,\n })\n }\n\n return {\n enrolled: true,\n memberId: target.id,\n adopted: target.id !== key,\n created: !existing,\n }\n}\n\n/**\n * The consent fields one enrollment writes: the basis, its attribution, and\n * the sites it covers (AGL-3320).\n *\n * Three ways in, and the sites follow from which one it is:\n *\n * - a PASS-THROUGH with the person's {@link EnrollListMemberInput.grantEntries}\n * writes those sites and no others, each grant with its own moment and\n * the disclosure it was given under;\n * - a capture's checkbox pools over the group only when its surface sent\n * back the group's current disclosure key — `consentGroupForGrant`;\n * - everything else, an attestation among them, is the enrolling site's\n * alone.\n *\n * The attribution is the membership's own on every entry, the pass-through's\n * included, for the reason the write explains: an entry that kept an earlier\n * attestation's account under a person's own opt-in would be a false claim\n * about who vouched for them.\n */\nfunction membershipConsentFields(\n input: EnrollListMemberInput,\n consent: ListMemberConsent,\n): Record<string, unknown> {\n const attribution = {\n [MARKETING_CONSENT_BASIS_FIELD]: consent.basis,\n marketingConsentByUid: consent.byUid ?? null,\n marketingConsentReason: consent.reason ?? '',\n }\n const carried =\n consent.basis === 'contact-opt-in'\n ? Object.entries(input.grantEntries ?? {})\n : []\n if (carried.length) {\n return {\n [MARKETING_CONSENT_BY_HOST_FIELD]: Object.fromEntries(\n carried.map(([hostId, entry]) => [\n hostId,\n {\n ...entry,\n [MARKETING_CONSENT_FIELD]: true,\n // The grant's own moment; the pass-through's only when the\n // person's record never stamped one, as the basis itself falls\n // back to now.\n marketingConsentAtMs: entry['marketingConsentAtMs'] ?? consent.atMs,\n ...attribution,\n },\n ]),\n ),\n }\n }\n return marketingConsentFieldsForGroup(\n consentGroupForGrant(input.group, input.disclosedConsentGroup),\n consent.atMs,\n attribution,\n )\n}\n"],"names":["FieldValue","normalizeContactEmail","personKey","CAPTURED_BY_HOST_FIELD","MARKETING_CONSENT_BASIS_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","marketingConsentFieldsForGroup","readMarketingBasis","consentGroupForGrant","nameSearchTokens","createHash","createHmac","emailSearchTokens","scheduleCapturedEmailCheck","listMemberSearchTokens","email","name","Set","legacyListMemberIds","normalizedEmail","update","digest","slice","listMemberDocIds","normalized","key","filter","id","enrollListMember","input","enrolled","refusal","members","listRef","collection","canonicalRef","doc","legacyRefs","map","snapshots","firestore","getAll","existing","find","snapshot","exists","target","ref","data","group","basis","consent","marketingConsent","atMs","Date","now","get","set","searchTokens","source","via","membershipConsentFields","addedAt","serverTimestamp","hostId","merge","owner","parent","ownerKind","orgId","memberId","adopted","created","attribution","marketingConsentByUid","byUid","marketingConsentReason","reason","carried","Object","entries","grantEntries","length","fromEntries","entry","marketingConsentAtMs","disclosedConsentGroup"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/list-members.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 only writer of `orgs/{orgId}/lists/{listId}/members` (AGL-2499).\n *\n * Two enrollment routes reach this collection — the commerce newsletter\n * handler and the workflow `enrollList` step — and they used to derive the\n * document id two incompatible ways: a full `sha256(email)` on one side, a\n * `hmac('aglyn-list-member', email)` truncated to 20 hex on the other. The\n * same person subscribing by both routes became two members of one list.\n *\n * The id is now `personKey` on both, which is the derivation both\n * `docs/specs/email-overhaul.md` §3d and `docs/specs/reusable-forms.md` §4\n * specify, and it normalizes before hashing so casing cannot fork it either.\n *\n * ## Why a helper and not two corrected call sites\n *\n * Two call sites that merely agree today are what produced the split: nothing\n * about either one said the other existed. A third route — the reusable-forms\n * capture path is already specified — would have been written the same way.\n * Enrolling goes through this function so that the id has one definition and\n * no caller is offered the chance to derive its own.\n */\n\nimport type { DocumentReference } from 'firebase-admin/firestore'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n normalizeContactEmail,\n personKey,\n CAPTURED_BY_HOST_FIELD,\n MARKETING_CONSENT_BASIS_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n marketingConsentFieldsForGroup,\n readMarketingBasis,\n type ConsentGroup,\n} from '@aglyn/aglyn/server'\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { createHash, createHmac } from 'node:crypto'\nimport { emailSearchTokens } from './email-suppression'\nimport { scheduleCapturedEmailCheck } from './capture-email-check'\n\n/**\n * What a list's membership table searches a member by (AGL-3321): the word\n * prefixes of their name and of their address (`emailSearchTokens`), one\n * array, so one `array-contains` finds \"dana\" in either.\n *\n * Mirrored by `tools/scripts/backfill-email-list-filters.mjs`, which stamps\n * the rows written before the field; the two are held to the same fixtures\n * (`tools/scripts/lib/email-search-tokens.fixtures.json`, which the\n * script-side twin in `tools/scripts/lib/email-search-tokens.mjs` answers too).\n */\nexport function listMemberSearchTokens(\n email: string | null | undefined,\n name: string | null | undefined,\n): string[] {\n return [...new Set([...emailSearchTokens(email), ...nameSearchTokens(name)])]\n}\n\n/**\n * The two ids this collection was written under before `personKey`.\n *\n * Read-only, and never written for a new row: they exist so an address\n * already enrolled under a legacy id is *found* rather than duplicated. See\n * `enrollListMember` for why that lookup is the migration.\n *\n * Both take the normalized address, which is what the two original call sites\n * happened to pass — each lowercased at its own entry point before hashing, so\n * every row already on the collection was keyed from a lowercased address even\n * though neither derivation enforced it.\n */\nfunction legacyListMemberIds(normalizedEmail: string): string[] {\n return [\n createHash('sha256').update(normalizedEmail).digest('hex'),\n createHmac('sha256', 'aglyn-list-member')\n .update(normalizedEmail)\n .digest('hex')\n .slice(0, 20),\n ]\n}\n\n/**\n * Every document id one address may be filed under on a list's `members`:\n * the canonical key first, then the two ids earlier writers minted. A\n * reader that must find the person wherever they were enrolled — the\n * erasure sweep (AGL-2623) — asks for all of them with one `getAll`, the\n * same set `enrollListMember` reads before it writes, so a member enrolled\n * under an old id is neither missed nor duplicated.\n */\nexport function listMemberDocIds(email: unknown): string[] {\n const normalized = normalizeContactEmail(email)\n if (!normalized) return []\n const key = personKey(normalized)\n if (!key) return []\n return [key, ...legacyListMemberIds(normalized).filter((id) => id !== key)]\n}\n\n/**\n * What makes an enrollment mailable, and who is answerable for it.\n *\n * Two values and no third. They are not interchangeable and the difference is\n * the whole reason the basis is stored rather than implied:\n *\n * - `contact-opt-in` — the person ticked a box. The enrollment carries a\n * decision they made, and `atMs` is when THEY made it, not when the row was\n * written. Nobody is answerable because nobody asserted anything.\n * - `operator-attested` — a console account stated that they have this\n * person's permission. That statement is itself the basis, so it is only\n * worth storing with the account that made it and the moment they did.\n *\n * Rendering the second as though it were the first is what a support or\n * compliance question cannot recover from, which is why the basis rides\n * beside `marketingConsent` on every row that has one.\n */\nexport type ListMemberConsentBasis = 'contact-opt-in' | 'operator-attested'\n\n/** A basis, with the attribution that makes it answerable. */\nexport interface ListMemberConsent {\n basis: ListMemberConsentBasis\n /**\n * When the basis was recorded.\n *\n * For a pass-through this is the ORIGINAL opt-in moment carried over from\n * the person's own record, because the question a consent record answers is\n * when the person said yes — not when somebody copied their answer onto a\n * list.\n */\n atMs: number\n /** The console account that attested. Meaningless for a pass-through. */\n byUid?: string | null\n /**\n * Why, in the operator's own terms, for whoever audits this later.\n *\n * Written for an attestation and ignored for a pass-through, which needs no\n * explanation beyond the person's own record. An import fills it from what\n * the FILE declared — the opt-in source and date the merchant supplied per\n * row — because \"a declared basis per address, not one checkbox over the\n * file\" is only worth asking for if the declaration survives onto the row\n * it was made about.\n */\n reason?: string\n}\n\nexport interface EnrollListMemberInput {\n /** `orgs/{orgId}/lists/{listId}` — the caller has already proved it exists. */\n listRef: DocumentReference\n /**\n * The consent group the enrollment is being made IN THE NAME OF.\n *\n * Lists live on the org and every site in it can mail one, so a membership\n * carries a basis that no path could attribute to a controller. A GROUP\n * rather than a site, because a business running three sites under one name\n * legitimately enrolls somebody into all three at once — and an agency's\n * client, which declared no group, gets exactly itself.\n *\n * Required and undefaulted: a caller that has not resolved a group has not\n * decided who this person agreed to hear from.\n */\n group: ConsentGroup\n email: string\n name?: string\n /** Free-form provenance: `'newsletter'`, `'action:{actionId}'`. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` and `addHostLead` already carry.\n *\n * A list membership had NO consent field of any kind, so `audience: 'list'`\n * had nothing for the send-time join to read\n * (`docs/specs/email-overhaul.md` §1d/§3f). This is the field that closes\n * it, and it is the caller's captured checkbox: enrolling is an act, and\n * `false`/omitted therefore writes nothing rather than a basis.\n */\n marketingConsent?: boolean\n /**\n * The basis in full, for a caller that knows more than \"a box was ticked\".\n *\n * `marketingConsent: true` is the shorthand a capture surface uses: it means\n * `contact-opt-in`, recorded now, asserted by nobody. Anything else — a\n * pass-through carrying the person's ORIGINAL opt-in date, or an\n * attestation carrying the account that made it — has to say so, and this\n * is where it says it. The two inputs are normalized to one value before\n * anything is written, so there is a single writer of the consent fields.\n */\n consent?: ListMemberConsent\n /**\n * The person's own grants, as `grantEntriesAsRecorded` read them off their\n * record — what a PASS-THROUGH carries (AGL-3320).\n *\n * A `contact-opt-in` consent with these writes exactly these sites, with\n * each grant's own moment and the disclosure it was given under: an opt-in\n * given to one site before the group existed stays that site's on the list,\n * and one given under the group's sentence reaches the sites it named.\n * Re-recording the pass-through over {@link group} instead would widen the\n * first to every site the group names today.\n */\n grantEntries?: Readonly<Record<string, Readonly<Record<string, unknown>>>>\n /**\n * The `consentGroupDisclosureKey` a capture surface rendered beside its\n * checkbox, for a {@link marketingConsent} captured just now. The grant\n * pools across {@link group} only when it is the group's current key.\n *\n * Every other basis without {@link grantEntries} — an attestation, a\n * capture that rendered no disclosure — is recorded for the enrolling site\n * alone: an operator's word about one site's list is not a disclosure\n * anybody read about the rest.\n */\n disclosedConsentGroup?: string | null\n /**\n * How the person got here: enrolled by hand or by an automation\n * (`'manual'`), or selected by a dynamic list's rule (`'rule'`).\n *\n * The materializer needs to tell its own rows apart from a manual one on\n * the same list, because a person who stops matching the rule leaves and a\n * person somebody added by hand does not.\n */\n via?: 'manual' | 'rule'\n}\n\nexport interface EnrolledListMember {\n /** The document actually written — a legacy id when one was adopted. */\n memberId: string\n /** True when an existing legacy-keyed row was written instead of a new one. */\n adopted: boolean\n /** False when the row already existed under some id. */\n created: boolean\n}\n\n/** Why nothing was written. */\nexport type EnrollListMemberRefusal =\n /** Not an address this collection can key. Nothing to write, nothing to fix. */\n | 'unusable-address'\n /**\n * The person's membership records a REFUSAL. Not overridable by any caller\n * and not reported as a failure to fix, because the fix would be to ignore\n * it.\n */\n | 'declined'\n\n/**\n * Enrolled, or refused and why.\n *\n * A union rather than `EnrolledListMember | null`, because the two refusals\n * are not the same event and a caller that cannot tell them apart is a caller\n * that reports a person's recorded refusal as a malformed address.\n */\nexport type EnrollListMemberResult =\n | ({ enrolled: true } & EnrolledListMember)\n | { enrolled: false; refusal: EnrollListMemberRefusal }\n\n/**\n * Enrolls one address into a list, at one document per person.\n *\n * ## The legacy lookup is the migration\n *\n * Changing the derivation without one would strand every row written under the\n * old ids: the next enrollment of an address already on the list would key a\n * *new* document beside the old one, so a defect that produced duplicates only\n * when two routes met would start producing them on a single route. Rather\n * than rewrite those ids — a bulk operation that has to delete the row it\n * replaces, and deleting an enrollment destroys the consent record that says\n * the person asked to be there — the write resolves the person's existing\n * document and keeps using it.\n *\n * The cost is one `getAll` of three refs, on a human-triggered signup, in\n * place of the blind `set` this replaced. It is one round trip, not three.\n *\n * `addedAt` is stamped only when the document is created, so re-enrolling\n * keeps the date the person actually joined — the same \"earliest wins\" collapse\n * `docs/specs/email-overhaul.md` §3d asks a backfill to preserve.\n *\n * ## A recorded refusal stops every route through this function\n *\n * This is the only writer of the collection, so the refusal check belongs\n * here and not at the four call sites: a guard on the button that adds\n * somebody by hand leaves the newsletter handler, the workflow `enrollList`\n * step and the dynamic-list materializer free to put the same person back.\n * The membership snapshot is already in hand from the id resolution above, so\n * the check costs no read.\n *\n * It answers the question about the LIST ROW. A refusal recorded on the\n * person's CRM record is a different document this function does not fetch —\n * a caller that can see one is expected to consult it, and the send-time\n * consent join reads the row this function writes either way.\n */\nexport async function enrollListMember(\n input: EnrollListMemberInput,\n): Promise<EnrollListMemberResult> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { enrolled: false, refusal: 'unusable-address' }\n const key = personKey(email)\n if (!key) return { enrolled: false, refusal: 'unusable-address' }\n\n const members = input.listRef.collection('members')\n const canonicalRef = members.doc(key)\n const legacyRefs = legacyListMemberIds(email)\n // A legacy derivation that happens to agree with `personKey` — `sha256` of\n // the same normalized address does — must not be fetched twice: `getAll`\n // rejects duplicate references.\n .filter((id) => id !== key)\n .map((id) => members.doc(id))\n\n const snapshots = await input.listRef.firestore.getAll(\n canonicalRef,\n ...legacyRefs,\n )\n const existing = snapshots.find((snapshot) => snapshot.exists)\n const target = existing?.ref ?? canonicalRef\n\n if (\n existing &&\n readMarketingBasis(existing.data() as Record<string, unknown>, input.group)\n .basis === 'declined'\n ) {\n return { enrolled: false, refusal: 'declined' }\n }\n\n /*\n * ONE value from the two ways a caller may state a basis.\n *\n * `marketingConsent: true` is a capture surface saying \"a box was ticked\n * just now\", which is `contact-opt-in` recorded at this moment with nobody\n * asserting it. Normalizing here rather than branching at the write is what\n * keeps the consent fields to a single writer — two branches stamping\n * overlapping subsets of them is how a row comes to carry a basis with no\n * timestamp, or a timestamp with no basis.\n */\n const consent: ListMemberConsent | null =\n input.consent ??\n (input.marketingConsent\n ? { basis: 'contact-opt-in', atMs: Date.now() }\n : null)\n\n /*\n * The name the row will hold after this write — the caller's, else the one\n * it already carries — because the search tokens are the address's and\n * that name's, and a re-enrollment without a name keeps the stored one.\n */\n const name = input.name || (existing?.get('name') as string | undefined) || null\n await target.set(\n {\n email,\n /*\n * `name` and `addedAt` are on EVERY row, because the members table\n * orders its query by them (AGL-3680) and `orderBy` drops a document\n * that lacks the field: the caller's name, else the stored one, else\n * `null`. A caller that says nothing never clears a stored name.\n */\n ...(input.name\n ? { name: input.name }\n : existing?.get('name') !== undefined\n ? {}\n : { name: null }),\n searchTokens: listMemberSearchTokens(email, name),\n source: input.source,\n /*\n * `via` is on every row, so the table's How filter can ask for it\n * (AGL-3321): the caller's, else `manual` on a row that has none — what\n * an absent `via` has always meant. A caller that says nothing never\n * moves a stored one.\n */\n ...(input.via\n ? { via: input.via }\n : existing?.get('via')\n ? {}\n : { via: 'manual' }),\n /*\n * Written only when the caller has a basis, and never unwritten.\n *\n * A merge that stamped `marketingConsent: false` on the omitted case\n * would erase a basis this person gave on an earlier enrollment, and a\n * withdrawn consent is not the same event as a re-enrollment that\n * happened to carry no checkbox. Withdrawal has its own path — the\n * unsubscribe link and the suppression list.\n *\n * `marketingConsentAtMs` moves with each fresh basis on purpose: the\n * question a consent record answers is when the person last said yes.\n *\n * The `marketingConsent` family rather than the `consent*` names\n * `docs/specs/email-overhaul.md` §3d sketched: `readMarketingBasis` is\n * the shipped reader and it reads these, so a second vocabulary here\n * would be a basis the send-time join cannot see. The two new fields\n * are attribution ON that field — they say why the person is mailable,\n * never whether — so nothing about the send decision moves into them.\n *\n * Written UNDER the enrolling GROUP, through the one writer helper. A\n * list lives on the org and every site in it can mail one, so a basis\n * written at the top of this row would enroll a person into one\n * client's newsletter and make them mailable by all of them. A group of\n * one — the default — is that client and nobody else.\n *\n * `marketingConsentByUid` is written on EVERY basis, `null` for a\n * pass-through. Leaving it absent would let a real opt-in that\n * supersedes an earlier attestation inherit the attesting account, and\n * a row reading \"this account vouched for them\" when the person ticked\n * a box themselves is a false attribution in the one direction a\n * compliance answer cannot afford.\n *\n * `marketingConsentReason` follows the same rule for the same reason:\n * always written, `''` for a pass-through, so a later real opt-in\n * cannot inherit the sentence an earlier import wrote about where the\n * merchant said the address came from.\n *\n * WHICH sites the basis covers is `membershipConsentFields`' to say,\n * and it never re-derives a grant from the group as it stands now.\n */\n ...(consent ? membershipConsentFields(input, consent) : {}),\n ...(existing\n ? // A legacy row that predates `addedAt` joined when it was created:\n // its document's create time, the date it has always meant.\n existing.get('addedAt') === undefined && existing.createTime\n ? { addedAt: existing.createTime }\n : {}\n : {\n addedAt: FieldValue.serverTimestamp(),\n [CAPTURED_BY_HOST_FIELD]: [input.group.hostId],\n }),\n },\n { merge: true },\n )\n\n /*\n * A person NEW to the list has their address's domain checked after the\n * response (AGL-3328), so the CRM record they are reads \"Would bounce\"\n * when it takes no mail. The list's owner names whose records those are:\n * an organization's list its own, a site's list the site's organization.\n */\n if (!existing) {\n const owner = input.listRef.parent?.parent\n const ownerKind = owner?.parent?.id\n scheduleCapturedEmailCheck({\n orgId: ownerKind === 'orgs' ? owner?.id : null,\n hostId: ownerKind === 'hosts' ? owner?.id : input.group?.hostId,\n email,\n })\n }\n\n return {\n enrolled: true,\n memberId: target.id,\n adopted: target.id !== key,\n created: !existing,\n }\n}\n\n/**\n * The consent fields one enrollment writes: the basis, its attribution, and\n * the sites it covers (AGL-3320).\n *\n * Three ways in, and the sites follow from which one it is:\n *\n * - a PASS-THROUGH with the person's {@link EnrollListMemberInput.grantEntries}\n * writes those sites and no others, each grant with its own moment and\n * the disclosure it was given under;\n * - a capture's checkbox pools over the group only when its surface sent\n * back the group's current disclosure key — `consentGroupForGrant`;\n * - everything else, an attestation among them, is the enrolling site's\n * alone.\n *\n * The attribution is the membership's own on every entry, the pass-through's\n * included, for the reason the write explains: an entry that kept an earlier\n * attestation's account under a person's own opt-in would be a false claim\n * about who vouched for them.\n */\nfunction membershipConsentFields(\n input: EnrollListMemberInput,\n consent: ListMemberConsent,\n): Record<string, unknown> {\n const attribution = {\n [MARKETING_CONSENT_BASIS_FIELD]: consent.basis,\n marketingConsentByUid: consent.byUid ?? null,\n marketingConsentReason: consent.reason ?? '',\n }\n const carried =\n consent.basis === 'contact-opt-in'\n ? Object.entries(input.grantEntries ?? {})\n : []\n if (carried.length) {\n return {\n [MARKETING_CONSENT_BY_HOST_FIELD]: Object.fromEntries(\n carried.map(([hostId, entry]) => [\n hostId,\n {\n ...entry,\n [MARKETING_CONSENT_FIELD]: true,\n // The grant's own moment; the pass-through's only when the\n // person's record never stamped one, as the basis itself falls\n // back to now.\n marketingConsentAtMs: entry['marketingConsentAtMs'] ?? consent.atMs,\n ...attribution,\n },\n ]),\n ),\n }\n }\n return marketingConsentFieldsForGroup(\n consentGroupForGrant(input.group, input.disclosedConsentGroup),\n consent.atMs,\n attribution,\n )\n}\n"],"names":["FieldValue","normalizeContactEmail","personKey","CAPTURED_BY_HOST_FIELD","MARKETING_CONSENT_BASIS_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","marketingConsentFieldsForGroup","readMarketingBasis","consentGroupForGrant","nameSearchTokens","createHash","createHmac","emailSearchTokens","scheduleCapturedEmailCheck","listMemberSearchTokens","email","name","Set","legacyListMemberIds","normalizedEmail","update","digest","slice","listMemberDocIds","normalized","key","filter","id","enrollListMember","input","enrolled","refusal","members","listRef","collection","canonicalRef","doc","legacyRefs","map","snapshots","firestore","getAll","existing","find","snapshot","exists","target","ref","data","group","basis","consent","marketingConsent","atMs","Date","now","get","set","undefined","searchTokens","source","via","membershipConsentFields","createTime","addedAt","serverTimestamp","hostId","merge","owner","parent","ownerKind","orgId","memberId","adopted","created","attribution","marketingConsentByUid","byUid","marketingConsentReason","reason","carried","Object","entries","grantEntries","length","fromEntries","entry","marketingConsentAtMs","disclosedConsentGroup"],"mappings":"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"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { RenderedOperatorAlert } from './operator-alerts';
|
|
18
|
+
/**
|
|
19
|
+
* Every operator alert that fires is also written to Cloud Logging
|
|
20
|
+
* (AGL-3683), so the platform's Cloud Monitoring alert policies can page on
|
|
21
|
+
* it and route it to their channels (email, Slack) beside the uptime checks.
|
|
22
|
+
* Vercel stdout never reaches GCP Logging, so a `console.error` is not
|
|
23
|
+
* enough. This writes through the same admin credential the error beacon
|
|
24
|
+
* uses, which `/api/health/error-beacon` already probes.
|
|
25
|
+
*
|
|
26
|
+
* One entry per alert that fires, after its dedupe. That is a handful a day,
|
|
27
|
+
* so it needs no budget. An install with no admin credential writes nothing.
|
|
28
|
+
* Never throws.
|
|
29
|
+
*/
|
|
30
|
+
export declare const OPERATOR_ALERT_LOG_ID = "operator-alerts";
|
|
31
|
+
export declare function writeOperatorAlertLog(alert: RenderedOperatorAlert): Promise<boolean>;
|