@aglyn/tenant-data-admin 1.0.0-beta.160 → 1.0.0-beta.163
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 +6 -6
- package/src/lib/server/campaign-conversion-attribution.d.ts +48 -3
- package/src/lib/server/campaign-conversion-attribution.js +95 -2
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +6 -0
- package/src/lib/server/crm-email-activity.js +8 -3
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +14 -1
- package/src/lib/server/email-delivery-log.js +31 -8
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +5 -0
- package/src/lib/server/email-suppression.js +20 -0
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/org-member-notice.d.ts +52 -0
- package/src/lib/server/org-member-notice.js +90 -0
- package/src/lib/server/org-member-notice.js.map +1 -0
|
@@ -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 { eraseCampaignAttributionsForPersonKey } from './campaign-attribution-store'\nimport { 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/** 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\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\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/*==========================================\n * THE CAMPAIGN TOUCH — which campaign this person last CLICKED, per site.\n *\n * The engagement rollup above answers \"is this person still listening\". It\n * cannot answer \"which email brought them here\", because it keeps instants\n * and not identities, and that second question is what revenue attribution\n * is: an order arrives, and something has to say which campaign preceded it.\n *\n * ## Here, on the person's own document\n *\n * The alternative was a per-host collection of touch documents, and it fails\n * on erasure. `eraseEmailDeliveriesForAddresses` erases by ADDRESS and knows\n * nothing about which sites have mailed it, so a per-host collection would be\n * a record of a person's clicks that an erasure request could not reach. On\n * the person document it is one field, deleted with the stamps it belongs\n * beside — a click is the same personal fact as the open recorded next to it.\n *\n * ## A CLICK ONLY\n *\n * `ENGAGEMENT_TYPES` includes opens because the control it feeds REFUSES to\n * mail people, and the generous signal is the correct one for a refusal. This\n * is the opposite kind of decision — it CREDITS a campaign with money — so it\n * takes the strict signal. Since Apple's Mail Privacy Protection an open is\n * substantially a statement about the recipient's mail client, and crediting\n * revenue to one would credit whichever campaign most recently reached an\n * Apple Mail user with orders from people who never read it.\n *\n * ## Per host, and capped\n *\n * A single global touch would credit site A's campaign with site B's order,\n * or refuse both — the send path refuses cross-site reach and the revenue\n * join has to agree with it. So the field is a map keyed by host, and a map\n * on a document has to be bounded: past {@link EMAIL_TOUCH_MAX_HOSTS} the\n * oldest touch is evicted, inside the transaction the forward-only rule\n * already pays for. A person who clicks mail from eleven different sites\n * loses their oldest click, which costs an attribution rather than a fact\n * anybody else reads.\n *=========================================*/\n\n/** The field on `emailDeliveries/{key}` holding the per-host touches. */\nexport const EMAIL_TOUCH_FIELD = 'campaignTouches'\n\n/**\n * How many sites' touches one person's document keeps.\n *\n * A cap, not a page size: the map lives in a document with a 1 MiB ceiling\n * and nothing else bounds how many sites may mail one address.\n */\nexport const EMAIL_TOUCH_MAX_HOSTS = 10\n\n/** The last campaign one person clicked on one site. */\nexport interface EmailCampaignTouch {\n hostId: string\n campaignId: string\n /** When the click happened, epoch ms — the provider's instant. */\n clickedAtMs: number\n}\n\n/** Reads the touch map off a person document's data, defensively. */\nfunction touchesFrom(\n data: Record<string, unknown> | null | undefined,\n): Record<string, { campaignId: string; atMs: number }> {\n const raw = data?.[EMAIL_TOUCH_FIELD]\n if (!raw || typeof raw !== 'object') return {}\n const found: Record<string, { campaignId: string; atMs: number }> = {}\n for (const [hostId, entry] of Object.entries(\n raw as Record<string, { campaignId?: unknown; atMs?: unknown }>,\n )) {\n const campaignId = String(entry?.campaignId ?? '')\n const atMs = Number(entry?.atMs ?? 0)\n if (!campaignId || !Number.isFinite(atMs) || atMs <= 0) continue\n found[hostId] = { campaignId, atMs }\n }\n return found\n}\n\n/**\n * Records that this person clicked this campaign's mail. Never throws.\n *\n * Forward-only, in a transaction, for the reason {@link recordPersonEngagement}\n * is: provider delivery is at-least-once and unordered, so a replayed click\n * from last month must not displace this week's. That same property is what\n * makes this idempotent — a redelivered event finds its own instant already\n * stored and writes nothing.\n *\n * @returns whether the touch moved forward.\n */\nexport async function recordEmailCampaignTouch(\n touch: {\n email: string | null | undefined\n hostId: string\n campaignId: string\n atMs: number\n },\n firestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(touch.email)\n const hostId = String(touch.hostId ?? '')\n const campaignId = String(touch.campaignId ?? '')\n const atMs = Number(touch.atMs)\n if (!key || !hostId || !campaignId) return false\n if (!Number.isFinite(atMs) || atMs <= 0) return false\n\n try {\n const db = firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key)\n let moved = false\n await db.runTransaction(async (transaction: any) => {\n moved = false\n const snapshot = await transaction.get(ref)\n const stored = touchesFrom(\n (snapshot.exists ? snapshot.data() : null) ?? {},\n )\n const held = stored[hostId]\n // Not newer than what is already there, so nothing is written. An\n // out-of-order or replayed event is the ordinary case this skips.\n if (held && held.atMs >= atMs) return\n\n const update: Record<string, unknown> = {\n [hostId]: { campaignId, atMs },\n }\n /*\n * EVICTION, and only when this host is NEW to the map. Replacing an\n * existing host's touch cannot grow it, so the cap is checked exactly\n * where the map can cross it. The oldest goes, because the window makes\n * an old touch the one least likely to be credited with anything.\n *\n * `FieldValue.delete()` INSIDE the map: a merge-set merges nested maps\n * at depth, which is what keeps every other host's touch — and is also\n * why an evicted key has to be deleted explicitly rather than by\n * omission.\n */\n if (!held && Object.keys(stored).length >= EMAIL_TOUCH_MAX_HOSTS) {\n const oldest = Object.entries(stored).sort(\n (a, b) => a[1].atMs - b[1].atMs || a[0].localeCompare(b[0]),\n )[0]\n if (oldest) update[oldest[0]] = FieldValue.delete()\n }\n\n transaction.set(\n ref,\n { [EMAIL_TOUCH_FIELD]: update, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n moved = true\n })\n return moved\n } catch (error) {\n console.error('[email-delivery-log] campaign touch write failed', error)\n return false\n }\n}\n\n/**\n * The last campaign this person clicked on this site, or `null`.\n *\n * One keyed document read — no query, no index, and nothing that can be\n * truncated. `null` for an address we hold no touch for AND for a read that\n * failed, which are the same answer on purpose: both mean \"we cannot say\n * which campaign preceded this order\", and the only safe thing to do with\n * that is credit nobody.\n */\nexport async function readEmailCampaignTouch(\n email: string | null | undefined,\n hostId: string,\n firestore?: any,\n): Promise<EmailCampaignTouch | null> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId) return null\n try {\n const db = firestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .get()\n const held = touchesFrom(snapshot.data() ?? {})[hostId]\n if (!held) return null\n return {\n hostId,\n campaignId: held.campaignId,\n clickedAtMs: held.atMs,\n }\n } catch (error) {\n console.error('[email-delivery-log] campaign touch read failed', error)\n return null\n }\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 */\nfunction 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 CAMPAIGNS 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 campaign reach, and which of them\n * opened it\" — and it is the SAME store, queried across the recipient\n * documents instead of down one of them.\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 campaign, 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 campaign-engagement read returns. */\nexport const EMAIL_CAMPAIGN_ENGAGEMENT_PAGE_SIZE = 25\n\n/**\n * How many campaigns 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 campaigns\n * than this reads its most recent 30, and the caller is told so.\n */\nexport const EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS = 30\n\n/** Which recipients a campaign-engagement read returns. */\nexport type EmailEngagementFilter = 'all' | 'opened' | 'clicked'\n\n/** One page of recipient rows. */\nexport interface EmailCampaignEngagementPage {\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 /** Campaigns past {@link EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS}. */\n campaignsOmitted: number\n}\n\n/**\n * The recipients of one site's campaigns, 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 campaign\n * nobody opened. Rendering those two the same way is how a merchant concludes\n * their campaign reached nobody.\n */\nexport async function readCampaignEngagement(options: {\n /** The site whose mail this is. The caller must already have proven it. */\n hostId: string\n /** Campaign ids to read, most recent first. */\n campaignIds: readonly string[]\n filter?: EmailEngagementFilter\n limit?: number\n /** A `cursor` from a previous page. */\n cursor?: string | null\n firestore?: any\n}): Promise<EmailCampaignEngagementPage> {\n const {\n hostId,\n campaignIds,\n filter = 'all',\n cursor = null,\n firestore,\n } = options\n const pageSize = Math.max(\n 1,\n Math.min(\n EMAIL_CAMPAIGN_ENGAGEMENT_PAGE_SIZE,\n options.limit ?? EMAIL_CAMPAIGN_ENGAGEMENT_PAGE_SIZE,\n ),\n )\n const ids = campaignIds\n .filter(Boolean)\n .slice(0, EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS)\n const campaignsOmitted = Math.max(\n 0,\n campaignIds.filter(Boolean).length - ids.length,\n )\n const empty: EmailCampaignEngagementPage = {\n rows: [],\n cursor: null,\n lookupFailed: false,\n campaignsOmitted,\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 campaign id belonging to another.\n .where('hostId', '==', hostId)\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 campaignsOmitted,\n }\n } catch (error) {\n console.error('[email-delivery-log] campaign 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 /*\n * And the campaign touches, for the same reason and one step\n * further: \"this person clicked THIS campaign on the 3rd\" names\n * both the person and what they were reading, so it is the\n * strongest personal fact on the document. The orders it has\n * already been credited with keep their own record — that one is\n * a commercial fact about a sale, held under the order's id\n * rather than the person's — but nothing here may go on\n * attributing their FUTURE orders to mail they asked us to forget.\n */\n [EMAIL_TOUCH_FIELD]: 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 the CONCLUSIONS drawn from those touches, on every site.\n *\n * A conversion attribution says \"this person came from that campaign and\n * then submitted this form / became this lead / made this booking\". It is\n * derived from the click stamp deleted a few lines above and is a\n * strictly stronger statement than the stamp was, so deleting the stamp\n * and keeping the attribution would be an erasure that removed the\n * evidence and kept the conclusion.\n *\n * Keyed on `personKey`, which is `emailSuppressionKey` — the same\n * derivation, one function — so the sweep covers exactly the person this\n * loop is erasing. Per address rather than per host, because an erasure\n * request names an address and knows nothing about which sites it ever\n * visited.\n */\n await eraseCampaignAttributionsForPersonKey(key, db)\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","eraseCampaignAttributionsForPersonKey","emailSuppressionKey","firebaseAdmin","defaultFirestore","app","firestore","EMAIL_DELIVERIES_COLLECTION","EMAIL_DELIVERY_MESSAGES_COLLECTION","EMAIL_DELIVERY_READ_LIMIT","EMAIL_DELIVERY_MAX_LINKS","recordEmailDeliveryEvent","event","key","to","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","subject","context","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","value","parsed","Number","isFinite","opened","clicked","engaged","Math","max","recordPersonEngagement","outcomes","byPerson","Map","outcome","held","openedAtMs","clickedAtMs","size","written","seen","readPersonEngagement","email","readPersonEngagementByKeys","keys","wanted","Set","Boolean","found","snapshots","getAll","id","EMAIL_TOUCH_FIELD","EMAIL_TOUCH_MAX_HOSTS","touchesFrom","raw","entry","Object","entries","atMs","recordEmailCampaignTouch","touch","moved","oldest","sort","a","b","localeCompare","delete","readEmailCampaignTouch","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_CAMPAIGN_ENGAGEMENT_PAGE_SIZE","EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS","readCampaignEngagement","campaignIds","pageSize","min","ids","slice","campaignsOmitted","empty","query","collectionGroup","where","anchor","startAfter","path","readEmailDeliveryErasure","count","readEmailDeliveryHistoryForAddresses","addresses","addressesRead","erasures","address","push","erasure","eraseEmailDeliveriesForAddresses","erased","contestedAddresses","removed","shared","eraseEmailDeliveries","catch","erasedAtMs","erasedCount","parent","pass","batch","forEach","commit"],"mappings":"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"}
|
|
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 { eraseCampaignAttributionsForPersonKey } from './campaign-attribution-store'\nimport { 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/** 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\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\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/*==========================================\n * THE CAMPAIGN TOUCH — which campaign this person last CLICKED, per site.\n *\n * The engagement rollup above answers \"is this person still listening\". It\n * cannot answer \"which email brought them here\", because it keeps instants\n * and not identities, and that second question is what revenue attribution\n * is: an order arrives, and something has to say which campaign preceded it.\n *\n * ## Here, on the person's own document\n *\n * The alternative was a per-host collection of touch documents, and it fails\n * on erasure. `eraseEmailDeliveriesForAddresses` erases by ADDRESS and knows\n * nothing about which sites have mailed it, so a per-host collection would be\n * a record of a person's clicks that an erasure request could not reach. On\n * the person document it is one field, deleted with the stamps it belongs\n * beside — a click is the same personal fact as the open recorded next to it.\n *\n * ## A CLICK ONLY\n *\n * `ENGAGEMENT_TYPES` includes opens because the control it feeds REFUSES to\n * mail people, and the generous signal is the correct one for a refusal. This\n * is the opposite kind of decision — it CREDITS a campaign with money — so it\n * takes the strict signal. Since Apple's Mail Privacy Protection an open is\n * substantially a statement about the recipient's mail client, and crediting\n * revenue to one would credit whichever campaign most recently reached an\n * Apple Mail user with orders from people who never read it.\n *\n * ## Per host, and capped\n *\n * A single global touch would credit site A's campaign with site B's order,\n * or refuse both — the send path refuses cross-site reach and the revenue\n * join has to agree with it. So the field is a map keyed by host, and a map\n * on a document has to be bounded: past {@link EMAIL_TOUCH_MAX_HOSTS} the\n * oldest touch is evicted, inside the transaction the forward-only rule\n * already pays for. A person who clicks mail from eleven different sites\n * loses their oldest click, which costs an attribution rather than a fact\n * anybody else reads.\n *=========================================*/\n\n/** The field on `emailDeliveries/{key}` holding the per-host touches. */\nexport const EMAIL_TOUCH_FIELD = 'campaignTouches'\n\n/**\n * How many sites' touches one person's document keeps.\n *\n * A cap, not a page size: the map lives in a document with a 1 MiB ceiling\n * and nothing else bounds how many sites may mail one address.\n */\nexport const EMAIL_TOUCH_MAX_HOSTS = 10\n\n/**\n * The last campaign one person clicked on one site.\n *\n * A click on a SEQUENCE email (AGL-3254) is the same touch with two more\n * facts: the sequence and the enrollment the email went out under. The\n * campaign is then the container the sequence is in, and the identify\n * moments this touch is credited to read as the sequence's rather than as\n * a campaign send's.\n */\nexport interface EmailCampaignTouch {\n hostId: string\n campaignId: string\n /** When the click happened, epoch ms — the provider's instant. */\n clickedAtMs: number\n sequenceId?: string\n enrollmentId?: string\n}\n\n/** One host's entry in the touch map, as stored. */\ninterface StoredTouch {\n campaignId: string\n atMs: number\n sequenceId?: string\n enrollmentId?: string\n}\n\n/** Reads the touch map off a person document's data, defensively. */\nfunction touchesFrom(\n data: Record<string, unknown> | null | undefined,\n): Record<string, StoredTouch> {\n const raw = data?.[EMAIL_TOUCH_FIELD]\n if (!raw || typeof raw !== 'object') return {}\n const found: Record<string, StoredTouch> = {}\n for (const [hostId, entry] of Object.entries(\n raw as Record<\n string,\n { campaignId?: unknown; atMs?: unknown; sequenceId?: unknown; enrollmentId?: unknown }\n >,\n )) {\n const campaignId = String(entry?.campaignId ?? '')\n const atMs = Number(entry?.atMs ?? 0)\n if (!campaignId || !Number.isFinite(atMs) || atMs <= 0) continue\n const sequenceId = String(entry?.sequenceId ?? '')\n const enrollmentId = String(entry?.enrollmentId ?? '')\n found[hostId] = {\n campaignId,\n atMs,\n ...(sequenceId && enrollmentId ? { sequenceId, enrollmentId } : {}),\n }\n }\n return found\n}\n\n/**\n * Records that this person clicked this campaign's mail. Never throws.\n *\n * Forward-only, in a transaction, for the reason {@link recordPersonEngagement}\n * is: provider delivery is at-least-once and unordered, so a replayed click\n * from last month must not displace this week's. That same property is what\n * makes this idempotent — a redelivered event finds its own instant already\n * stored and writes nothing.\n *\n * @returns whether the touch moved forward.\n */\nexport async function recordEmailCampaignTouch(\n touch: {\n email: string | null | undefined\n hostId: string\n campaignId: string\n atMs: number\n /** Both or neither: a sequence click names the enrollment it came through. */\n sequenceId?: string\n enrollmentId?: string\n },\n firestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(touch.email)\n const hostId = String(touch.hostId ?? '')\n const campaignId = String(touch.campaignId ?? '')\n const atMs = Number(touch.atMs)\n if (!key || !hostId || !campaignId) return false\n if (!Number.isFinite(atMs) || atMs <= 0) return false\n const sequenceId = String(touch.sequenceId ?? '')\n const enrollmentId = String(touch.enrollmentId ?? '')\n const viaSequence = sequenceId && enrollmentId ? { sequenceId, enrollmentId } : {}\n\n try {\n const db = firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key)\n let moved = false\n await db.runTransaction(async (transaction: any) => {\n moved = false\n const snapshot = await transaction.get(ref)\n const stored = touchesFrom(\n (snapshot.exists ? snapshot.data() : null) ?? {},\n )\n const held = stored[hostId]\n // Not newer than what is already there, so nothing is written. An\n // out-of-order or replayed event is the ordinary case this skips.\n if (held && held.atMs >= atMs) return\n\n /*\n * A merge-set merges nested maps at depth, so a campaign click after a\n * sequence click would keep the sequence's ids beside the new campaign\n * unless they are deleted by name: the two are written as the value\n * or as `FieldValue.delete()` whenever the held entry carried them.\n */\n const dropSequence =\n held?.sequenceId && !('sequenceId' in viaSequence)\n ? { sequenceId: FieldValue.delete(), enrollmentId: FieldValue.delete() }\n : {}\n const update: Record<string, unknown> = {\n [hostId]: { campaignId, atMs, ...viaSequence, ...dropSequence },\n }\n /*\n * EVICTION, and only when this host is NEW to the map. Replacing an\n * existing host's touch cannot grow it, so the cap is checked exactly\n * where the map can cross it. The oldest goes, because the window makes\n * an old touch the one least likely to be credited with anything.\n *\n * `FieldValue.delete()` INSIDE the map: a merge-set merges nested maps\n * at depth, which is what keeps every other host's touch — and is also\n * why an evicted key has to be deleted explicitly rather than by\n * omission.\n */\n if (!held && Object.keys(stored).length >= EMAIL_TOUCH_MAX_HOSTS) {\n const oldest = Object.entries(stored).sort(\n (a, b) => a[1].atMs - b[1].atMs || a[0].localeCompare(b[0]),\n )[0]\n if (oldest) update[oldest[0]] = FieldValue.delete()\n }\n\n transaction.set(\n ref,\n { [EMAIL_TOUCH_FIELD]: update, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n moved = true\n })\n return moved\n } catch (error) {\n console.error('[email-delivery-log] campaign touch write failed', error)\n return false\n }\n}\n\n/**\n * The last campaign this person clicked on this site, or `null`.\n *\n * One keyed document read — no query, no index, and nothing that can be\n * truncated. `null` for an address we hold no touch for AND for a read that\n * failed, which are the same answer on purpose: both mean \"we cannot say\n * which campaign preceded this order\", and the only safe thing to do with\n * that is credit nobody.\n */\nexport async function readEmailCampaignTouch(\n email: string | null | undefined,\n hostId: string,\n firestore?: any,\n): Promise<EmailCampaignTouch | null> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId) return null\n try {\n const db = firestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .get()\n const held = touchesFrom(snapshot.data() ?? {})[hostId]\n if (!held) return null\n return {\n hostId,\n campaignId: held.campaignId,\n clickedAtMs: held.atMs,\n ...(held.sequenceId && held.enrollmentId\n ? { sequenceId: held.sequenceId, enrollmentId: held.enrollmentId }\n : {}),\n }\n } catch (error) {\n console.error('[email-delivery-log] campaign touch read failed', error)\n return null\n }\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 */\nfunction 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 CAMPAIGNS 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 campaign reach, and which of them\n * opened it\" — and it is the SAME store, queried across the recipient\n * documents instead of down one of them.\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 campaign, 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 campaign-engagement read returns. */\nexport const EMAIL_CAMPAIGN_ENGAGEMENT_PAGE_SIZE = 25\n\n/**\n * How many campaigns 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 campaigns\n * than this reads its most recent 30, and the caller is told so.\n */\nexport const EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS = 30\n\n/** Which recipients a campaign-engagement read returns. */\nexport type EmailEngagementFilter = 'all' | 'opened' | 'clicked'\n\n/** One page of recipient rows. */\nexport interface EmailCampaignEngagementPage {\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 /** Campaigns past {@link EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS}. */\n campaignsOmitted: number\n}\n\n/**\n * The recipients of one site's campaigns, 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 campaign\n * nobody opened. Rendering those two the same way is how a merchant concludes\n * their campaign reached nobody.\n */\nexport async function readCampaignEngagement(options: {\n /** The site whose mail this is. The caller must already have proven it. */\n hostId: string\n /** Campaign ids to read, most recent first. */\n campaignIds: readonly string[]\n filter?: EmailEngagementFilter\n limit?: number\n /** A `cursor` from a previous page. */\n cursor?: string | null\n firestore?: any\n}): Promise<EmailCampaignEngagementPage> {\n const {\n hostId,\n campaignIds,\n filter = 'all',\n cursor = null,\n firestore,\n } = options\n const pageSize = Math.max(\n 1,\n Math.min(\n EMAIL_CAMPAIGN_ENGAGEMENT_PAGE_SIZE,\n options.limit ?? EMAIL_CAMPAIGN_ENGAGEMENT_PAGE_SIZE,\n ),\n )\n const ids = campaignIds\n .filter(Boolean)\n .slice(0, EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS)\n const campaignsOmitted = Math.max(\n 0,\n campaignIds.filter(Boolean).length - ids.length,\n )\n const empty: EmailCampaignEngagementPage = {\n rows: [],\n cursor: null,\n lookupFailed: false,\n campaignsOmitted,\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 campaign id belonging to another.\n .where('hostId', '==', hostId)\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 campaignsOmitted,\n }\n } catch (error) {\n console.error('[email-delivery-log] campaign 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 /*\n * And the campaign touches, for the same reason and one step\n * further: \"this person clicked THIS campaign on the 3rd\" names\n * both the person and what they were reading, so it is the\n * strongest personal fact on the document. The orders it has\n * already been credited with keep their own record — that one is\n * a commercial fact about a sale, held under the order's id\n * rather than the person's — but nothing here may go on\n * attributing their FUTURE orders to mail they asked us to forget.\n */\n [EMAIL_TOUCH_FIELD]: 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 the CONCLUSIONS drawn from those touches, on every site.\n *\n * A conversion attribution says \"this person came from that campaign and\n * then submitted this form / became this lead / made this booking\". It is\n * derived from the click stamp deleted a few lines above and is a\n * strictly stronger statement than the stamp was, so deleting the stamp\n * and keeping the attribution would be an erasure that removed the\n * evidence and kept the conclusion.\n *\n * Keyed on `personKey`, which is `emailSuppressionKey` — the same\n * derivation, one function — so the sweep covers exactly the person this\n * loop is erasing. Per address rather than per host, because an erasure\n * request names an address and knows nothing about which sites it ever\n * visited.\n */\n await eraseCampaignAttributionsForPersonKey(key, db)\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","eraseCampaignAttributionsForPersonKey","emailSuppressionKey","firebaseAdmin","defaultFirestore","app","firestore","EMAIL_DELIVERIES_COLLECTION","EMAIL_DELIVERY_MESSAGES_COLLECTION","EMAIL_DELIVERY_READ_LIMIT","EMAIL_DELIVERY_MAX_LINKS","recordEmailDeliveryEvent","event","key","to","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","subject","context","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","value","parsed","Number","isFinite","opened","clicked","engaged","Math","max","recordPersonEngagement","outcomes","byPerson","Map","outcome","held","openedAtMs","clickedAtMs","size","written","seen","readPersonEngagement","email","readPersonEngagementByKeys","keys","wanted","Set","Boolean","found","snapshots","getAll","id","EMAIL_TOUCH_FIELD","EMAIL_TOUCH_MAX_HOSTS","touchesFrom","raw","entry","Object","entries","atMs","sequenceId","enrollmentId","recordEmailCampaignTouch","touch","viaSequence","moved","dropSequence","delete","oldest","sort","a","b","localeCompare","readEmailCampaignTouch","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_CAMPAIGN_ENGAGEMENT_PAGE_SIZE","EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS","readCampaignEngagement","campaignIds","pageSize","min","ids","slice","campaignsOmitted","empty","query","collectionGroup","where","anchor","startAfter","path","readEmailDeliveryErasure","count","readEmailDeliveryHistoryForAddresses","addresses","addressesRead","erasures","address","push","erasure","eraseEmailDeliveriesForAddresses","erased","contestedAddresses","removed","shared","eraseEmailDeliveries","catch","erasedAtMs","erasedCount","parent","pass","batch","forEach","commit"],"mappings":"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"}
|
|
@@ -193,6 +193,11 @@ export interface SuppressEmailInput {
|
|
|
193
193
|
context?: string | null;
|
|
194
194
|
/** The site the failed send was attributed to, when it named one. */
|
|
195
195
|
hostId?: string | null;
|
|
196
|
+
/**
|
|
197
|
+
* Whether to say so on the person's record too (AGL-3245); on by default.
|
|
198
|
+
* A sender that stamps a richer verdict of its own passes `false`.
|
|
199
|
+
*/
|
|
200
|
+
stampRecord?: boolean;
|
|
196
201
|
/** Injectable for tests; defaults to the admin app's Firestore. */
|
|
197
202
|
firestore?: any;
|
|
198
203
|
}
|
|
@@ -91,6 +91,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
91
91
|
// mocked in nearly every spec that touches them.
|
|
92
92
|
import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
|
|
93
93
|
import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
94
|
+
import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
|
|
94
95
|
import firebaseAdmin from "./firebase-admin.js";
|
|
95
96
|
const defaultFirestore = ()=>firebaseAdmin.app().firestore();
|
|
96
97
|
export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
@@ -199,6 +200,25 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
199
200
|
}), {
|
|
200
201
|
merge: true
|
|
201
202
|
});
|
|
203
|
+
/*
|
|
204
|
+
* The same verdict on the record the person reads (AGL-3245), through
|
|
205
|
+
* whichever plugin keeps the workspace's records: a bounce or a complaint
|
|
206
|
+
* on a send that named a site stamps the site's records. A send that
|
|
207
|
+
* named none has no records to find, and a caller that stamps its own,
|
|
208
|
+
* richer verdict — the sequence runtime, with the domain block and the
|
|
209
|
+
* enrollment — says `stampRecord: false` and is left to it.
|
|
210
|
+
*/ if (input.hostId && input.stampRecord !== false && input.reason !== 'staff') {
|
|
211
|
+
await stampRecordEmailState({
|
|
212
|
+
hostId: input.hostId,
|
|
213
|
+
email: input.email,
|
|
214
|
+
state: {
|
|
215
|
+
status: input.reason === 'complaint' ? 'complained' : 'bounced',
|
|
216
|
+
atMs: Date.now(),
|
|
217
|
+
source: 'campaign',
|
|
218
|
+
detail: input.context ? `Reported by the ${input.context} send.` : null
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
202
222
|
return {
|
|
203
223
|
key,
|
|
204
224
|
created: !snapshot.exists
|