@aglyn/plugins-email 1.0.0-beta.221 → 1.0.0-beta.222
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/package.json +16 -17
- package/src/lib/components/email-template-detail.d.ts +5 -13
- package/src/lib/components/email-template-detail.js +29 -426
- package/src/lib/components/email-template-detail.js.map +1 -1
- package/src/lib/components/email-zones.d.ts +15 -0
- package/src/lib/components/email-zones.js +1 -0
- package/src/lib/components/email-zones.js.map +1 -1
- package/src/lib/components/list-detail-card.d.ts +0 -36
- package/src/lib/components/list-detail-card.js +125 -84
- package/src/lib/components/list-detail-card.js.map +1 -1
- package/src/lib/components/list-members-panel.d.ts +17 -0
- package/src/lib/components/list-members-panel.js +14 -0
- package/src/lib/components/list-members-panel.js.map +1 -1
- package/src/lib/components/site-package-email-preview.d.ts +13 -0
- package/src/lib/components/site-package-email-preview.js +47 -0
- package/src/lib/components/site-package-email-preview.js.map +1 -0
- package/src/lib/components/suppressions-card.js +75 -6
- package/src/lib/components/suppressions-card.js.map +1 -1
- package/src/lib/components/use-org-email-topics.js +2 -9
- package/src/lib/components/use-org-email-topics.js.map +1 -1
- package/src/lib/constants/site-package.d.ts +21 -0
- package/src/lib/constants/site-package.js +21 -0
- package/src/lib/constants/site-package.js.map +1 -0
- package/src/lib/declarations.console-server.d.ts +12 -0
- package/src/lib/declarations.console-server.js +73 -0
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/list-import.d.ts +64 -75
- package/src/lib/list-import.js +43 -158
- package/src/lib/list-import.js.map +1 -1
- package/src/lib/model/email-topic-catalog.d.ts +24 -0
- package/src/lib/model/email-topic-catalog.js +19 -0
- package/src/lib/model/email-topic-catalog.js.map +1 -1
- package/src/lib/model/index.d.ts +0 -25
- package/src/lib/model/index.js +0 -27
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/plugin.d.ts +8 -0
- package/src/lib/plugin.js +76 -2
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/person-eraser.d.ts +39 -0
- package/src/lib/server/person-eraser.js +44 -0
- package/src/lib/server/person-eraser.js.map +1 -0
- package/src/lib/server-console.js +9 -9
- package/src/lib/server-console.js.map +1 -1
- package/src/lib/server-list-gate.d.ts +7 -0
- package/src/lib/server-list-gate.js +10 -3
- package/src/lib/server-list-gate.js.map +1 -1
- package/src/lib/server-list-import.d.ts +20 -177
- package/src/lib/server-list-import.js +34 -594
- package/src/lib/server-list-import.js.map +1 -1
- package/src/lib/server.js +16 -29
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-send-tally.d.ts +29 -0
- package/src/lib/testing/stand-in-send-tally.js +52 -0
- package/src/lib/testing/stand-in-send-tally.js.map +1 -0
- package/src/lib/transfer/email-transfer-catalog.d.ts +151 -0
- package/src/lib/transfer/email-transfer-catalog.js +536 -0
- package/src/lib/transfer/email-transfer-catalog.js.map +1 -0
- package/src/lib/transfer/list-import-steps.d.ts +44 -0
- package/src/lib/transfer/list-import-steps.js +201 -0
- package/src/lib/transfer/list-import-steps.js.map +1 -0
- package/src/lib/transfer/list-members.server.d.ts +165 -0
- package/src/lib/transfer/list-members.server.js +911 -0
- package/src/lib/transfer/list-members.server.js.map +1 -0
- package/src/lib/transfer/suppressions.server.d.ts +69 -0
- package/src/lib/transfer/suppressions.server.js +282 -0
- package/src/lib/transfer/suppressions.server.js.map +1 -0
- package/src/lib/transfer/topics-package.d.ts +56 -0
- package/src/lib/transfer/topics-package.js +87 -0
- package/src/lib/transfer/topics-package.js.map +1 -0
- package/src/lib/transfer/topics-package.server.d.ts +33 -0
- package/src/lib/transfer/topics-package.server.js +171 -0
- package/src/lib/transfer/topics-package.server.js.map +1 -0
- package/src/lib/transfer/transfer-reads.server.d.ts +25 -0
- package/src/lib/transfer/transfer-reads.server.js +34 -0
- package/src/lib/transfer/transfer-reads.server.js.map +1 -0
- package/src/lib/components/list-import-drawer.d.ts +0 -22
- package/src/lib/components/list-import-drawer.js +0 -662
- package/src/lib/components/list-import-drawer.js.map +0 -1
- package/src/lib/model/template-report.d.ts +0 -158
- package/src/lib/model/template-report.js +0 -249
- package/src/lib/model/template-report.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-list-gate.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 GATE IN FRONT OF EVERY LIST ENROLLMENT THE CONSOLE MAKES.\n *\n * Who is allowed to change a list's membership, and what is true about each\n * address they name. Three route modules ask it — the audience card's add and\n * preview, the filter search, and the file importer — and they ask this one\n * copy of it.\n *\n * ## Why it is a module of its own\n *\n * It began inside `server-console.ts`, beside its first two callers, and that\n * was right while there were two. The importer made three, and an importer is\n * exactly the surface where a second, laxer idea of \"may this person be\n * enrolled\" is most tempting: a file is bulk, bulk is slow, and skipping the\n * per-address consent read is the obvious saving. A bulk path that reached the\n * membership without the suppression check and the attestation would be a way\n * to enroll precisely the people the one-at-a-time path refuses, which is the\n * defect class `docs/specs/email-competitive-gaps.md` has a closed P1 entry\n * for.\n *\n * Sharing the gate is also what stops the cheaper failure: two modules that\n * merely agree today, with nothing in either one saying the other exists.\n *\n * ## What it decides, and what it deliberately does not\n *\n * {@link resolveAddresses} answers the questions that are FACTS about a\n * person — a stored refusal, a suppression on either list, an opt-in already\n * on record, a line that is not an address. It never consults an attestation,\n * because an attestation is a fact about the operator and folding it in here\n * would make the preview's numbers move as the box was ticked. The basis is\n * decided once, at the write, by `assignmentBasis`.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentReadout,\n grantEntriesAsRecorded,\n isOrgWideMember,\n normalizeContactEmail,\n readMarketingBasis,\n type AddressRefusal,\n type AssignmentRefusal,\n type ConsentGroup,\n type MarketingConsentRecord,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n filterSendableForHost,\n filterSuppressedEmails,\n firebaseAdmin,\n getOrgForHost,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n// The leaves, not the barrels: this plugin's specs substitute both barrels\n// wholesale, and what a deleted contact's refusal still says must be read\n// under them by the real store and the real merge.\nimport { readRetainedRefusals } from '@aglyn/tenant-data-admin/server/retained-refusals'\nimport { refusalsOf, withRetainedRefusals } from '@aglyn/aglyn/app-utils/retained-refusals'\n\n/**\n * The most addresses one request may name.\n *\n * A ceiling on the WRITE, not on the audience. It bounds the work one request\n * does — the resolution below is a handful of reads per address — and it can\n * never refuse a person already enrolled or make room by removing one, which\n * is the difference between a batch size and a capacity limit. Nothing in the\n * product caps list membership: `contactsPerHost` is the audience band and it\n * is metered against CONTACTS, in `upsert-contact.ts`, and enrolling somebody\n * on a list writes no contact. If a membership ceiling is ever introduced,\n * this function is where it refuses, because this is where something is\n * added.\n */\nexport const LIST_MEMBER_BATCH_MAX = 100\n\n/** How many contact addresses one `in` query may carry. */\nconst CONTACT_LOOKUP_CHUNK = 30\n\n/** Everything a list-membership route needs, or the refusal to send back. */\nexport type ListContext =\n | {\n ok: true\n uid: string\n hostId: string\n orgId: string\n listRef: FirebaseFirestore.DocumentReference\n listName: string\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\n/**\n * Who is asking, and about which list.\n *\n * ## Two gates, not one — the same two the Inbox assignment route applies\n *\n * A host role is necessary and NOT sufficient. Lists live at\n * `orgs/{orgId}/lists` and their members are contacts, so the rules put both\n * behind `isOrgWideMember()`. An editor invited to ONE site is an org member\n * with `allHosts: false`, and gating an org-wide write on the host role alone\n * would let a single-site collaborator enroll people into an audience every\n * other site in the org can mail — and, through the preview, read the consent\n * record of any address they care to type. The Admin SDK evaluates no rules,\n * so this route is the enforcement rather than an echo of it.\n *\n * The plugin's own gates sit above this and are the dispatcher's: a workspace\n * that has switched the email plugin off, or a site it is disabled for, or a\n * release flag that has not reached this org, all 404 before a handler runs.\n */\nexport async function resolveListContext(\n req: Parameters<PluginApiHandler>[0],\n): Promise<ListContext> {\n const hostId = String(req.body?.hostId ?? '')\n const listId = String(req.body?.listId ?? '')\n if (!hostId || !listId) {\n return {\n ok: false,\n status: 400,\n body: { error: 'Missing hostId or listId' },\n }\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n\n const orgId = String(\n (await getOrgForHost(hostId).catch(() => null))?.orgId ?? '',\n )\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no lists.' },\n }\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n const orgWideWriter =\n isOrgWideMember(member) &&\n (member?.role === 'owner' ||\n member?.role === 'admin' ||\n member?.role === 'editor') &&\n (member as { orgSuspended?: boolean } | undefined)?.orgSuspended !== true\n if (!orgWideWriter) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Marketing lists belong to the whole organization, so changing who ' +\n 'is on one needs organization-wide access rather than access to ' +\n 'this site.',\n },\n }\n }\n\n const listRef = firestore\n .collection('orgs')\n .doc(orgId)\n .collection('lists')\n .doc(listId)\n const listSnapshot = await listRef.get()\n // A stale or mistyped id must not CREATE a list: a campaign's `list`\n // audience would then read a list nobody set up.\n if (!listSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown list' } }\n }\n\n return {\n ok: true,\n uid: decoded.uid,\n hostId,\n orgId,\n listRef,\n listName: String(listSnapshot.get('name') ?? listId),\n }\n}\n\n/** What would happen to one address, and why. */\nexport interface AddressVerdict {\n /** Exactly what the operator typed, so a bad line can be pointed at. */\n input: string\n /** The normalized address, or `null` when there is not one. */\n email: string | null\n /**\n * Why this address can never go on the list, or `null`.\n *\n * A HARD refusal only — a stored `declined`, either suppression list, or a\n * line that is not an address. `no-basis` is deliberately NOT one of them:\n * \"you have not said you have permission yet\" is an unanswered question,\n * not a refusal, and reporting it as one would list the very people an\n * attestation is about to admit under the heading of people it cannot.\n * That distinction is {@link requiresAttestation}.\n */\n refusal: AssignmentRefusal | null\n /** True when only an attestation stands between this address and the list. */\n requiresAttestation: boolean\n /** One sentence of consent facts, in the merchant's terms. */\n summary: string\n}\n\n/** The whole answer, per address and in totals. */\nexport interface AddressResolution {\n verdicts: AddressVerdict[]\n /** Addresses a stored opt-in already covers — no attestation needed. */\n optedIn: number\n /** Addresses that need the operator to state they have permission. */\n needAttestation: number\n /** Addresses nothing can enroll, whatever the operator says. */\n refused: number\n}\n\n/**\n * The resolution plus the consent records it was computed from.\n *\n * The records ride along so the write path can derive the basis it STORES\n * from the same read the verdicts were built on. Re-reading them would be a\n * second answer to \"what does this person's record say\", between which a\n * concurrent edit fits — and the operator would have attested against the\n * first one.\n */\nexport interface ResolvedBatch extends AddressResolution {\n stored: Map<string, MarketingConsentRecord>\n /**\n * Each opted-in person's own grants, as `grantEntriesAsRecorded` read them\n * off the SAME contact document `stored` was read from — what a\n * pass-through enrollment carries onto the membership (AGL-3320).\n *\n * From the same read for the reason `stored` is: the basis decided and the\n * sites it covers must describe one version of the person's record.\n */\n grants: Map<string, Record<string, Record<string, unknown>>>\n /**\n * The consent group the resolution was made against, so the WRITE records\n * a basis for exactly the controller the verdicts were computed for.\n *\n * Returned rather than re-resolved by the caller for the same reason\n * `stored` is: two resolutions of the same question are two answers a\n * concurrent edit can fit between, and the operator attested against the\n * first one.\n */\n group: ConsentGroup\n}\n\n/**\n * Every address, normalized, deduplicated and put through the policy.\n *\n * ## Deduplicated on the NORMALIZED address, and reported once\n *\n * A pasted column routinely names the same person twice with different\n * casing, and `enrollListMember` keys the membership from the normalized\n * address, so two lines would be one row. Counting them twice would tell the\n * operator they are attesting for more people than they are.\n *\n * ## An unusable line is REPORTED, never dropped\n *\n * A paste that silently discarded its malformed lines would tell an operator\n * that 100 addresses went on the list when 94 did, and the six they never\n * hear about are the six they typed wrong. Every input line comes back with a\n * verdict, including the ones that are not addresses at all.\n *\n * ## Suppression is attributed, and costs nothing when there is none\n *\n * `filterSendableForHost` answers both lists in one pass and fails CLOSED, so\n * it is asked first; only the addresses it refused are put through the\n * platform half again to find out WHICH list holds them. In the ordinary case\n * that second call is never made.\n */\nexport async function resolveAddresses(input: {\n hostId: string\n inputs: readonly string[]\n}): Promise<ResolvedBatch> {\n const seen = new Set<string>()\n const rows: Array<{ input: string; email: string | null }> = []\n for (const raw of input.inputs) {\n const email = normalizeContactEmail(raw)\n if (email && seen.has(email)) continue\n if (email) seen.add(email)\n rows.push({ input: String(raw ?? '').trim(), email })\n }\n\n const addresses = rows\n .map((row) => row.email)\n .filter((email): email is string => Boolean(email))\n\n /*\n * The group first, because every answer below is about a CONTROLLER and\n * not about a site: a business running three sites as one sender enrolls\n * into all three at once, and an agency's client — which declared no group\n * — resolves to itself.\n */\n const group = await consentGroupForSite(input.hostId)\n const [suppression, { stored, grants }] = await Promise.all([\n suppressionFor(input.hostId, group, addresses),\n storedConsentFor(input.hostId, group, addresses),\n ])\n\n const unrecorded: MarketingConsentRecord = readMarketingBasis(null, group)\n const verdicts = rows.map((row): AddressVerdict => {\n if (!row.email) {\n return {\n input: row.input,\n email: null,\n refusal: 'unroutable-address',\n requiresAttestation: false,\n summary: ASSIGNMENT_REFUSAL_MESSAGES['unroutable-address'],\n }\n }\n const record = stored.get(row.email) ?? unrecorded\n const readout = assignmentReadout({\n stored: record,\n suppression: suppression.get(row.email) ?? null,\n })\n return {\n input: row.input,\n email: row.email,\n /*\n * Straight off the readout, with no attestation in the question.\n *\n * `assignmentReadout` answers what is TRUE about this person — a stored\n * refusal, a suppression, an opt-in, or nothing on record — and none of\n * those changes because the operator did or did not tick a box. Feeding\n * the flag in here would make the preview's answer depend on it, so the\n * count the operator stands behind would move as they answered. The\n * basis itself is decided once, at the write, by `assignmentBasis`.\n */\n refusal: readout.enrollable\n ? null\n : (suppression.get(row.email) ?? 'declined'),\n requiresAttestation: readout.requiresAttestation,\n summary: readout.summary,\n }\n })\n\n return {\n verdicts,\n stored,\n grants,\n group,\n optedIn: verdicts.filter(\n (verdict) => !verdict.refusal && !verdict.requiresAttestation,\n ).length,\n needAttestation: verdicts.filter((verdict) => verdict.requiresAttestation)\n .length,\n refused: verdicts.filter(\n (verdict) => verdict.refusal && !verdict.requiresAttestation,\n ).length,\n }\n}\n\n/**\n * Which of these addresses is suppressed, and by which list.\n *\n * Composed from the shipped helpers rather than reading the two collections\n * here: normalization, de-duplication and the fail-closed posture live in\n * `email-suppression.ts`, and a second copy of them is a second set of rules\n * for the enrollment check and the send-time check to disagree about — which\n * is exactly the disagreement `an-enrollment-is-not-a-license-to-send.spec.ts`\n * exists to stop.\n *\n * Read across the site's consent group, as the send reads it: somebody who\n * unsubscribed from a sibling of a declared group is refused here too, rather\n * than enrolled onto a list whose campaigns will never reach them.\n */\nasync function suppressionFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<Map<string, AddressRefusal>> {\n const refusals = new Map<string, AddressRefusal>()\n if (!addresses.length) return refusals\n const sendable = new Set(\n await filterSendableForHost(hostId, addresses, undefined, group),\n )\n const blocked = addresses.filter((email) => !sendable.has(email))\n if (!blocked.length) return refusals\n // Only the blocked ones, and only to say WHICH list. Survivors of the\n // platform half are held by this site's own list, or a group sibling's.\n const platformSendable = new Set(await filterSuppressedEmails(blocked))\n for (const email of blocked) {\n refusals.set(\n email,\n platformSendable.has(email) ? 'suppressed-host' : 'suppressed-platform',\n )\n }\n return refusals\n}\n\n/**\n * The stored consent facts for these addresses, off the org's contacts.\n *\n * Read UNSCOPED, deliberately, exactly as the Inbox route reads it:\n * `scopedToHost` narrows an org collection to what one site may see, and a\n * refusal filtered out by that narrowing is a refusal this route would then\n * step over — the failure mode is enrolling somebody who said no. It is safe\n * because the caller has already been proved an org-wide member, which is the\n * tier the rules grant the whole org's contacts to.\n *\n * ## A refusal wins over an opt-in when the CRM holds both\n *\n * Nothing guarantees one contact per address — the collection is keyed by\n * resource id, not by email — so two records for one person can disagree.\n * Taking whichever the query happened to answer first would make the outcome\n * depend on document order. A recorded refusal is the answer whenever one\n * exists, which is the same precedence `assignmentBasis` applies within a\n * single record.\n *\n * ## A refusal outlives the contact it was written on (AGL-3338)\n *\n * Deleting a contact keeps every refusal it held in the org's retained store,\n * so the address is read with what was retained laid over whatever record is\n * left — `withRetainedRefusals` — and an address with no contact at all still\n * reads `declined` when its person said no. A grant given after the refusal,\n * on a contact the person signed up again through, is the person changing\n * their mind and reads `granted`. The grants carried onto a membership come\n * off the same merged record.\n *\n * A failed read falls to `declined` for the whole batch, for the reason the\n * Inbox route states: a read that throws can neither say the person consented\n * nor that they refused, and the direction that costs a retry is the one that\n * does not enroll somebody whose stored refusal we simply failed to see. The\n * retained store is read the same way, and fails the same way.\n */\nasync function storedConsentFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<{\n stored: Map<string, MarketingConsentRecord>\n grants: Map<string, Record<string, Record<string, unknown>>>\n}> {\n const found = new Map<string, MarketingConsentRecord>()\n const grants = new Map<string, Record<string, Record<string, unknown>>>()\n if (!addresses.length) return { stored: found, grants }\n try {\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const chunks: string[][] = []\n for (let at = 0; at < addresses.length; at += CONTACT_LOOKUP_CHUNK) {\n chunks.push(addresses.slice(at, at + CONTACT_LOOKUP_CHUNK))\n }\n const [snapshots, retained] = await Promise.all([\n Promise.all(chunks.map((chunk) => contacts.where('email', 'in', chunk).get())),\n readRetainedRefusals(contacts, addresses),\n ])\n const consider = (email: string, data: Record<string, unknown> | null) => {\n const merged = withRetainedRefusals(data, refusalsOf(retained.get(email)))\n const record = readMarketingBasis(merged, group)\n const already = found.get(email)\n if (already?.basis === 'declined') return\n if (already && record.basis !== 'declined') return\n found.set(email, record)\n grants.set(email, grantEntriesAsRecorded(merged, group))\n }\n for (const snapshot of snapshots) {\n for (const doc of snapshot.docs) {\n const email = normalizeContactEmail(doc.get('email'))\n if (email) consider(email, doc.data() as Record<string, unknown>)\n }\n }\n // What was retained for an address no contact answers any more.\n for (const email of retained.keys()) {\n if (!found.has(email)) consider(email, null)\n }\n return { stored: found, grants }\n } catch (error) {\n console.error('[email] consent lookup failed', error)\n const refused: MarketingConsentRecord = {\n ...readMarketingBasis(null, group),\n basis: 'declined',\n // Attributed to nobody: this is what a failed read falls back to, not a\n // refusal anybody recorded.\n assertedBy: null,\n source: null,\n basisAtMs: null,\n capturedAtMs: null,\n }\n return {\n stored: new Map(addresses.map((email) => [email, refused])),\n // Nobody is enrollable on a failed read, so nobody's grants travel.\n grants: new Map(),\n }\n }\n}\n\n/** The addresses named by one request, or the refusal to send back. */\nexport function readAddresses(\n req: Parameters<PluginApiHandler>[0],\n): { emails: string[] } | { error: string } {\n const body = req.body as { emails?: unknown; email?: unknown } | undefined\n const raw = Array.isArray(body?.emails)\n ? body.emails\n : body?.email === undefined\n ? []\n : [body.email]\n const emails = raw\n .map((value) => String(value ?? '').trim())\n .filter((value) => value.length > 0)\n if (!emails.length) return { error: 'No addresses' }\n if (emails.length > LIST_MEMBER_BATCH_MAX) {\n return {\n error:\n `${emails.length} addresses is more than one go can take. Add up to ` +\n `${LIST_MEMBER_BATCH_MAX} at a time.`,\n }\n }\n return { emails }\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentReadout","grantEntriesAsRecorded","isOrgWideMember","normalizeContactEmail","readMarketingBasis","consentGroupForSite","filterSendableForHost","filterSuppressedEmails","firebaseAdmin","getOrgForHost","orgDataCollectionForHost","resolveOrgMembership","readRetainedRefusals","refusalsOf","withRetainedRefusals","LIST_MEMBER_BATCH_MAX","CONTACT_LOOKUP_CHUNK","resolveListContext","req","hostSnapshot","listSnapshot","hostId","String","body","listId","ok","status","error","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","firestore","collection","doc","get","exists","memberRole","uid","orgId","catch","membership","member","orgWideWriter","role","orgSuspended","listRef","listName","resolveAddresses","input","seen","Set","rows","raw","inputs","email","has","add","push","trim","addresses","map","row","filter","Boolean","group","suppression","stored","grants","Promise","all","suppressionFor","storedConsentFor","unrecorded","verdicts","refusal","requiresAttestation","summary","record","readout","enrollable","optedIn","verdict","needAttestation","refused","refusals","Map","sendable","blocked","platformSendable","set","found","contacts","chunks","at","snapshots","retained","chunk","where","consider","data","merged","already","basis","snapshot","docs","keys","console","assertedBy","source","basisAtMs","capturedAtMs","readAddresses","Array","isArray","emails","value"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server-list-gate.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 GATE IN FRONT OF EVERY LIST ENROLLMENT THE CONSOLE MAKES.\n *\n * Who is allowed to change a list's membership, and what is true about each\n * address they name. Three route modules ask it — the audience card's add and\n * preview, the filter search, and the file importer — and they ask this one\n * copy of it.\n *\n * ## Why it is a module of its own\n *\n * It began inside `server-console.ts`, beside its first two callers, and that\n * was right while there were two. The importer made three, and an importer is\n * exactly the surface where a second, laxer idea of \"may this person be\n * enrolled\" is most tempting: a file is bulk, bulk is slow, and skipping the\n * per-address consent read is the obvious saving. A bulk path that reached the\n * membership without the suppression check and the attestation would be a way\n * to enroll precisely the people the one-at-a-time path refuses, which is the\n * defect class `docs/specs/email-competitive-gaps.md` has a closed P1 entry\n * for.\n *\n * Sharing the gate is also what stops the cheaper failure: two modules that\n * merely agree today, with nothing in either one saying the other exists.\n *\n * ## What it decides, and what it deliberately does not\n *\n * {@link resolveAddresses} answers the questions that are FACTS about a\n * person — a stored refusal, a suppression on either list, an opt-in already\n * on record, a line that is not an address. It never consults an attestation,\n * because an attestation is a fact about the operator and folding it in here\n * would make the preview's numbers move as the box was ticked. The basis is\n * decided once, at the write, by `assignmentBasis`.\n */\n\nimport {\n ASSIGNMENT_REFUSAL_MESSAGES,\n assignmentReadout,\n grantEntriesAsRecorded,\n isOrgWideMember,\n normalizeContactEmail,\n readMarketingBasis,\n type AddressRefusal,\n type AssignmentRefusal,\n type ConsentGroup,\n type MarketingConsentRecord,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n filterSendableForHost,\n filterSuppressedEmails,\n firebaseAdmin,\n getOrgForHost,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\n// The leaves, not the barrels: this plugin's specs substitute both barrels\n// wholesale, and what a deleted contact's refusal still says must be read\n// under them by the real store and the real merge.\nimport { readRetainedRefusals } from '@aglyn/tenant-data-admin/server/retained-refusals'\nimport { refusalsOf, withRetainedRefusals } from '@aglyn/aglyn/app-utils/retained-refusals'\n\n/**\n * The most addresses one request may name.\n *\n * A ceiling on the WRITE, not on the audience. It bounds the work one request\n * does — the resolution below is a handful of reads per address — and it can\n * never refuse a person already enrolled or make room by removing one, which\n * is the difference between a batch size and a capacity limit. Nothing in the\n * product caps list membership: `contactsPerHost` is the audience band and it\n * is metered against CONTACTS, in `upsert-contact.ts`, and enrolling somebody\n * on a list writes no contact. If a membership ceiling is ever introduced,\n * this function is where it refuses, because this is where something is\n * added.\n */\nexport const LIST_MEMBER_BATCH_MAX = 100\n\n/** How many contact addresses one `in` query may carry. */\nconst CONTACT_LOOKUP_CHUNK = 30\n\n/** Everything a list-membership route needs, or the refusal to send back. */\nexport type ListContext =\n | {\n ok: true\n uid: string\n hostId: string\n orgId: string\n listRef: FirebaseFirestore.DocumentReference\n listName: string\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\n/**\n * Who is asking, and about which list.\n *\n * ## Two gates, not one — the same two the Inbox assignment route applies\n *\n * A host role is necessary and NOT sufficient. Lists live at\n * `orgs/{orgId}/lists` and their members are contacts, so the rules put both\n * behind `isOrgWideMember()`. An editor invited to ONE site is an org member\n * with `allHosts: false`, and gating an org-wide write on the host role alone\n * would let a single-site collaborator enroll people into an audience every\n * other site in the org can mail — and, through the preview, read the consent\n * record of any address they care to type. The Admin SDK evaluates no rules,\n * so this route is the enforcement rather than an echo of it.\n *\n * The plugin's own gates sit above this and are the dispatcher's: a workspace\n * that has switched the email plugin off, or a site it is disabled for, or a\n * release flag that has not reached this org, all 404 before a handler runs.\n */\nexport async function resolveListContext(\n req: Parameters<PluginApiHandler>[0],\n): Promise<ListContext> {\n const hostId = String(req.body?.hostId ?? '')\n const listId = String(req.body?.listId ?? '')\n if (!hostId || !listId) {\n return {\n ok: false,\n status: 400,\n body: { error: 'Missing hostId or listId' },\n }\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n\n const orgId = String(\n (await getOrgForHost(hostId).catch(() => null))?.orgId ?? '',\n )\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no lists.' },\n }\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n if (!isOrgWideListWriter(membership?.member)) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Marketing lists belong to the whole organization, so changing who ' +\n 'is on one needs organization-wide access rather than access to ' +\n 'this site.',\n },\n }\n }\n\n const listRef = firestore\n .collection('orgs')\n .doc(orgId)\n .collection('lists')\n .doc(listId)\n const listSnapshot = await listRef.get()\n // A stale or mistyped id must not CREATE a list: a campaign's `list`\n // audience would then read a list nobody set up.\n if (!listSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown list' } }\n }\n\n return {\n ok: true,\n uid: decoded.uid,\n hostId,\n orgId,\n listRef,\n listName: String(listSnapshot.get('name') ?? listId),\n }\n}\n\n/**\n * Whether an org member may change who is on the organization's lists: an\n * org-wide owner, admin or editor whose membership is not suspended. The one\n * answer the list routes and the list-member import ask (see\n * {@link resolveListContext} for why a site role is not enough).\n */\nexport function isOrgWideListWriter(member: unknown): boolean {\n const entry = member as\n | { role?: string; orgSuspended?: boolean }\n | null\n | undefined\n return (\n isOrgWideMember(entry as Parameters<typeof isOrgWideMember>[0]) &&\n (entry?.role === 'owner' ||\n entry?.role === 'admin' ||\n entry?.role === 'editor') &&\n entry?.orgSuspended !== true\n )\n}\n\n/** What would happen to one address, and why. */\nexport interface AddressVerdict {\n /** Exactly what the operator typed, so a bad line can be pointed at. */\n input: string\n /** The normalized address, or `null` when there is not one. */\n email: string | null\n /**\n * Why this address can never go on the list, or `null`.\n *\n * A HARD refusal only — a stored `declined`, either suppression list, or a\n * line that is not an address. `no-basis` is deliberately NOT one of them:\n * \"you have not said you have permission yet\" is an unanswered question,\n * not a refusal, and reporting it as one would list the very people an\n * attestation is about to admit under the heading of people it cannot.\n * That distinction is {@link requiresAttestation}.\n */\n refusal: AssignmentRefusal | null\n /** True when only an attestation stands between this address and the list. */\n requiresAttestation: boolean\n /** One sentence of consent facts, in the merchant's terms. */\n summary: string\n}\n\n/** The whole answer, per address and in totals. */\nexport interface AddressResolution {\n verdicts: AddressVerdict[]\n /** Addresses a stored opt-in already covers — no attestation needed. */\n optedIn: number\n /** Addresses that need the operator to state they have permission. */\n needAttestation: number\n /** Addresses nothing can enroll, whatever the operator says. */\n refused: number\n}\n\n/**\n * The resolution plus the consent records it was computed from.\n *\n * The records ride along so the write path can derive the basis it STORES\n * from the same read the verdicts were built on. Re-reading them would be a\n * second answer to \"what does this person's record say\", between which a\n * concurrent edit fits — and the operator would have attested against the\n * first one.\n */\nexport interface ResolvedBatch extends AddressResolution {\n stored: Map<string, MarketingConsentRecord>\n /**\n * Each opted-in person's own grants, as `grantEntriesAsRecorded` read them\n * off the SAME contact document `stored` was read from — what a\n * pass-through enrollment carries onto the membership (AGL-3320).\n *\n * From the same read for the reason `stored` is: the basis decided and the\n * sites it covers must describe one version of the person's record.\n */\n grants: Map<string, Record<string, Record<string, unknown>>>\n /**\n * The consent group the resolution was made against, so the WRITE records\n * a basis for exactly the controller the verdicts were computed for.\n *\n * Returned rather than re-resolved by the caller for the same reason\n * `stored` is: two resolutions of the same question are two answers a\n * concurrent edit can fit between, and the operator attested against the\n * first one.\n */\n group: ConsentGroup\n}\n\n/**\n * Every address, normalized, deduplicated and put through the policy.\n *\n * ## Deduplicated on the NORMALIZED address, and reported once\n *\n * A pasted column routinely names the same person twice with different\n * casing, and `enrollListMember` keys the membership from the normalized\n * address, so two lines would be one row. Counting them twice would tell the\n * operator they are attesting for more people than they are.\n *\n * ## An unusable line is REPORTED, never dropped\n *\n * A paste that silently discarded its malformed lines would tell an operator\n * that 100 addresses went on the list when 94 did, and the six they never\n * hear about are the six they typed wrong. Every input line comes back with a\n * verdict, including the ones that are not addresses at all.\n *\n * ## Suppression is attributed, and costs nothing when there is none\n *\n * `filterSendableForHost` answers both lists in one pass and fails CLOSED, so\n * it is asked first; only the addresses it refused are put through the\n * platform half again to find out WHICH list holds them. In the ordinary case\n * that second call is never made.\n */\nexport async function resolveAddresses(input: {\n hostId: string\n inputs: readonly string[]\n}): Promise<ResolvedBatch> {\n const seen = new Set<string>()\n const rows: Array<{ input: string; email: string | null }> = []\n for (const raw of input.inputs) {\n const email = normalizeContactEmail(raw)\n if (email && seen.has(email)) continue\n if (email) seen.add(email)\n rows.push({ input: String(raw ?? '').trim(), email })\n }\n\n const addresses = rows\n .map((row) => row.email)\n .filter((email): email is string => Boolean(email))\n\n /*\n * The group first, because every answer below is about a CONTROLLER and\n * not about a site: a business running three sites as one sender enrolls\n * into all three at once, and an agency's client — which declared no group\n * — resolves to itself.\n */\n const group = await consentGroupForSite(input.hostId)\n const [suppression, { stored, grants }] = await Promise.all([\n suppressionFor(input.hostId, group, addresses),\n storedConsentFor(input.hostId, group, addresses),\n ])\n\n const unrecorded: MarketingConsentRecord = readMarketingBasis(null, group)\n const verdicts = rows.map((row): AddressVerdict => {\n if (!row.email) {\n return {\n input: row.input,\n email: null,\n refusal: 'unroutable-address',\n requiresAttestation: false,\n summary: ASSIGNMENT_REFUSAL_MESSAGES['unroutable-address'],\n }\n }\n const record = stored.get(row.email) ?? unrecorded\n const readout = assignmentReadout({\n stored: record,\n suppression: suppression.get(row.email) ?? null,\n })\n return {\n input: row.input,\n email: row.email,\n /*\n * Straight off the readout, with no attestation in the question.\n *\n * `assignmentReadout` answers what is TRUE about this person — a stored\n * refusal, a suppression, an opt-in, or nothing on record — and none of\n * those changes because the operator did or did not tick a box. Feeding\n * the flag in here would make the preview's answer depend on it, so the\n * count the operator stands behind would move as they answered. The\n * basis itself is decided once, at the write, by `assignmentBasis`.\n */\n refusal: readout.enrollable\n ? null\n : (suppression.get(row.email) ?? 'declined'),\n requiresAttestation: readout.requiresAttestation,\n summary: readout.summary,\n }\n })\n\n return {\n verdicts,\n stored,\n grants,\n group,\n optedIn: verdicts.filter(\n (verdict) => !verdict.refusal && !verdict.requiresAttestation,\n ).length,\n needAttestation: verdicts.filter((verdict) => verdict.requiresAttestation)\n .length,\n refused: verdicts.filter(\n (verdict) => verdict.refusal && !verdict.requiresAttestation,\n ).length,\n }\n}\n\n/**\n * Which of these addresses is suppressed, and by which list.\n *\n * Composed from the shipped helpers rather than reading the two collections\n * here: normalization, de-duplication and the fail-closed posture live in\n * `email-suppression.ts`, and a second copy of them is a second set of rules\n * for the enrollment check and the send-time check to disagree about — which\n * is exactly the disagreement `an-enrollment-is-not-a-license-to-send.spec.ts`\n * exists to stop.\n *\n * Read across the site's consent group, as the send reads it: somebody who\n * unsubscribed from a sibling of a declared group is refused here too, rather\n * than enrolled onto a list whose campaigns will never reach them.\n */\nasync function suppressionFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<Map<string, AddressRefusal>> {\n const refusals = new Map<string, AddressRefusal>()\n if (!addresses.length) return refusals\n const sendable = new Set(\n await filterSendableForHost(hostId, addresses, undefined, group),\n )\n const blocked = addresses.filter((email) => !sendable.has(email))\n if (!blocked.length) return refusals\n // Only the blocked ones, and only to say WHICH list. Survivors of the\n // platform half are held by this site's own list, or a group sibling's.\n const platformSendable = new Set(await filterSuppressedEmails(blocked))\n for (const email of blocked) {\n refusals.set(\n email,\n platformSendable.has(email) ? 'suppressed-host' : 'suppressed-platform',\n )\n }\n return refusals\n}\n\n/**\n * The stored consent facts for these addresses, off the org's contacts.\n *\n * Read UNSCOPED, deliberately, exactly as the Inbox route reads it:\n * `scopedToHost` narrows an org collection to what one site may see, and a\n * refusal filtered out by that narrowing is a refusal this route would then\n * step over — the failure mode is enrolling somebody who said no. It is safe\n * because the caller has already been proved an org-wide member, which is the\n * tier the rules grant the whole org's contacts to.\n *\n * ## A refusal wins over an opt-in when the CRM holds both\n *\n * Nothing guarantees one contact per address — the collection is keyed by\n * resource id, not by email — so two records for one person can disagree.\n * Taking whichever the query happened to answer first would make the outcome\n * depend on document order. A recorded refusal is the answer whenever one\n * exists, which is the same precedence `assignmentBasis` applies within a\n * single record.\n *\n * ## A refusal outlives the contact it was written on (AGL-3338)\n *\n * Deleting a contact keeps every refusal it held in the org's retained store,\n * so the address is read with what was retained laid over whatever record is\n * left — `withRetainedRefusals` — and an address with no contact at all still\n * reads `declined` when its person said no. A grant given after the refusal,\n * on a contact the person signed up again through, is the person changing\n * their mind and reads `granted`. The grants carried onto a membership come\n * off the same merged record.\n *\n * A failed read falls to `declined` for the whole batch, for the reason the\n * Inbox route states: a read that throws can neither say the person consented\n * nor that they refused, and the direction that costs a retry is the one that\n * does not enroll somebody whose stored refusal we simply failed to see. The\n * retained store is read the same way, and fails the same way.\n */\nasync function storedConsentFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<{\n stored: Map<string, MarketingConsentRecord>\n grants: Map<string, Record<string, Record<string, unknown>>>\n}> {\n const found = new Map<string, MarketingConsentRecord>()\n const grants = new Map<string, Record<string, Record<string, unknown>>>()\n if (!addresses.length) return { stored: found, grants }\n try {\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n const chunks: string[][] = []\n for (let at = 0; at < addresses.length; at += CONTACT_LOOKUP_CHUNK) {\n chunks.push(addresses.slice(at, at + CONTACT_LOOKUP_CHUNK))\n }\n const [snapshots, retained] = await Promise.all([\n Promise.all(chunks.map((chunk) => contacts.where('email', 'in', chunk).get())),\n readRetainedRefusals(contacts, addresses),\n ])\n const consider = (email: string, data: Record<string, unknown> | null) => {\n const merged = withRetainedRefusals(data, refusalsOf(retained.get(email)))\n const record = readMarketingBasis(merged, group)\n const already = found.get(email)\n if (already?.basis === 'declined') return\n if (already && record.basis !== 'declined') return\n found.set(email, record)\n grants.set(email, grantEntriesAsRecorded(merged, group))\n }\n for (const snapshot of snapshots) {\n for (const doc of snapshot.docs) {\n const email = normalizeContactEmail(doc.get('email'))\n if (email) consider(email, doc.data() as Record<string, unknown>)\n }\n }\n // What was retained for an address no contact answers any more.\n for (const email of retained.keys()) {\n if (!found.has(email)) consider(email, null)\n }\n return { stored: found, grants }\n } catch (error) {\n console.error('[email] consent lookup failed', error)\n const refused: MarketingConsentRecord = {\n ...readMarketingBasis(null, group),\n basis: 'declined',\n // Attributed to nobody: this is what a failed read falls back to, not a\n // refusal anybody recorded.\n assertedBy: null,\n source: null,\n basisAtMs: null,\n capturedAtMs: null,\n }\n return {\n stored: new Map(addresses.map((email) => [email, refused])),\n // Nobody is enrollable on a failed read, so nobody's grants travel.\n grants: new Map(),\n }\n }\n}\n\n/** The addresses named by one request, or the refusal to send back. */\nexport function readAddresses(\n req: Parameters<PluginApiHandler>[0],\n): { emails: string[] } | { error: string } {\n const body = req.body as { emails?: unknown; email?: unknown } | undefined\n const raw = Array.isArray(body?.emails)\n ? body.emails\n : body?.email === undefined\n ? []\n : [body.email]\n const emails = raw\n .map((value) => String(value ?? '').trim())\n .filter((value) => value.length > 0)\n if (!emails.length) return { error: 'No addresses' }\n if (emails.length > LIST_MEMBER_BATCH_MAX) {\n return {\n error:\n `${emails.length} addresses is more than one go can take. Add up to ` +\n `${LIST_MEMBER_BATCH_MAX} at a time.`,\n }\n }\n return { emails }\n}\n"],"names":["ASSIGNMENT_REFUSAL_MESSAGES","assignmentReadout","grantEntriesAsRecorded","isOrgWideMember","normalizeContactEmail","readMarketingBasis","consentGroupForSite","filterSendableForHost","filterSuppressedEmails","firebaseAdmin","getOrgForHost","orgDataCollectionForHost","resolveOrgMembership","readRetainedRefusals","refusalsOf","withRetainedRefusals","LIST_MEMBER_BATCH_MAX","CONTACT_LOOKUP_CHUNK","resolveListContext","req","hostSnapshot","listSnapshot","hostId","String","body","listId","ok","status","error","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","firestore","collection","doc","get","exists","memberRole","uid","orgId","catch","membership","isOrgWideListWriter","member","listRef","listName","entry","role","orgSuspended","resolveAddresses","input","seen","Set","rows","raw","inputs","email","has","add","push","trim","addresses","map","row","filter","Boolean","group","suppression","stored","grants","Promise","all","suppressionFor","storedConsentFor","unrecorded","verdicts","refusal","requiresAttestation","summary","record","readout","enrollable","optedIn","verdict","needAttestation","refused","refusals","Map","sendable","blocked","platformSendable","set","found","contacts","chunks","at","snapshots","retained","chunk","where","consider","data","merged","already","basis","snapshot","docs","keys","console","assertedBy","source","basisAtMs","capturedAtMs","readAddresses","Array","isArray","emails","value"],"mappings":"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"}
|
|
@@ -15,185 +15,28 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* What this module adds is everything ABOVE that gate: reading a file,
|
|
36
|
-
* screening it, holding the operator's attestation, and metering the work out
|
|
37
|
-
* over as many requests as it takes.
|
|
38
|
-
*
|
|
39
|
-
* ## Four routes, because an import is four separate acts
|
|
40
|
-
*
|
|
41
|
-
* - `email/list-import-preview` — reads the file and says what is in it.
|
|
42
|
-
* Writes nothing, enrolls nobody, and resolves a BOUNDED SAMPLE through the
|
|
43
|
-
* consent gate so the numbers the operator attests against are real numbers
|
|
44
|
-
* from real records rather than a promise.
|
|
45
|
-
* - `email/list-import-start` — records the attestation and stages the
|
|
46
|
-
* addresses. Still enrolls nobody: the act of saying "I have permission for
|
|
47
|
-
* these people" is separated from the act of adding them so that the
|
|
48
|
-
* attestation has a moment of its own.
|
|
49
|
-
* - `email/list-import-run` — enrolls up to {@link LIST_IMPORT_RUN_BUDGET}
|
|
50
|
-
* addresses from the cursor and moves it. Called until it answers
|
|
51
|
-
* `complete`.
|
|
52
|
-
* - `email/list-import-status` — the unfinished import on a list, if there is
|
|
53
|
-
* one, so a merchant who closed the tab is not left with a half-added
|
|
54
|
-
* audience and no way to see it.
|
|
55
|
-
*
|
|
56
|
-
* ## A budget and a cursor, not one request and not one transaction
|
|
57
|
-
*
|
|
58
|
-
* The shape `dynamic-list-materialize.ts` already uses: a per-run bound on
|
|
59
|
-
* work, a cursor recording where the run stopped, and a next run that resumes
|
|
60
|
-
* rather than restarts. A 50,000-address file is not a request that times out
|
|
61
|
-
* halfway with no record of what it did; it is 500 bounded requests over one
|
|
62
|
-
* durable job, and stopping in the middle of it leaves the addresses already
|
|
63
|
-
* enrolled enrolled and the rest staged.
|
|
64
|
-
*
|
|
65
|
-
* The run budget is deliberately {@link LIST_MEMBER_BATCH_MAX} — the same
|
|
66
|
-
* number of addresses one hand-typed add already resolves in one request — so
|
|
67
|
-
* an import run costs exactly what an add costs and no new cost profile is
|
|
68
|
-
* introduced to discover in production.
|
|
69
|
-
*
|
|
70
|
-
* ## ⛔ Nothing here is a capacity limit
|
|
71
|
-
*
|
|
72
|
-
* {@link LIST_IMPORT_MAX_ADDRESSES} refuses a FILE before anything is written.
|
|
73
|
-
* It never trims a staged import, never drops an address to fit, and never
|
|
74
|
-
* removes anybody already on the list. A ceiling in this product is enforced
|
|
75
|
-
* at the reduction, and the reduction here is refusing the upload — which the
|
|
76
|
-
* operator sees, can argue with, and can act on by splitting the file.
|
|
77
|
-
*
|
|
78
|
-
* ## Who the attester is, and why a resumer does not become one
|
|
79
|
-
*
|
|
80
|
-
* The attestation is one person's claim about where a file came from. It is
|
|
81
|
-
* recorded on the job with the account that made it, and every run reads the
|
|
82
|
-
* basis from THAT account, not from whoever pressed Resume. A colleague who
|
|
83
|
-
* finishes somebody else's import has asserted nothing, and the consent
|
|
84
|
-
* records the run writes must not say they did.
|
|
18
|
+
* WHAT THE PERMISSION STEP SHOWS — `POST email/list-import-screening`.
|
|
19
|
+
*
|
|
20
|
+
* A list import runs on the platform's transfer framework (AGL-3529; the
|
|
21
|
+
* server half is `transfer/list-members.server.ts`). Its "Permission" step is
|
|
22
|
+
* where the operator states they have these people's permission, and the
|
|
23
|
+
* statement is only worth recording if it is made in front of the evidence:
|
|
24
|
+
* the shared mailboxes, the column names that read as a bought list, and
|
|
25
|
+
* what the consent gate says about a sample of the addresses. The dry run
|
|
26
|
+
* that runs as the operator reaches the step computes exactly that and keeps
|
|
27
|
+
* it on the list's import ledger (`orgs/{orgId}/lists/{listId}/imports/{jobId}`);
|
|
28
|
+
* this route reads it back for the step to draw. It writes nothing and
|
|
29
|
+
* enrolls nobody.
|
|
30
|
+
*
|
|
31
|
+
* Behind the list gate (`resolveListContext`): an org-wide owner, admin or
|
|
32
|
+
* editor, on a site of the organization, about a list that exists — the
|
|
33
|
+
* population that may run the import in the first place.
|
|
85
34
|
*/
|
|
86
35
|
import { type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
87
|
-
/** `source` stamped on every membership an import writes. */
|
|
88
|
-
export declare const CONSOLE_IMPORT_SOURCE = "console:list-import";
|
|
89
|
-
/** Where a list's import jobs live: `orgs/{orgId}/lists/{listId}/imports`. */
|
|
90
|
-
export declare const LIST_IMPORTS_SUBCOLLECTION = "imports";
|
|
91
|
-
/**
|
|
92
|
-
* Addresses one run enrolls before answering and handing back the cursor.
|
|
93
|
-
*
|
|
94
|
-
* The same number as {@link LIST_MEMBER_BATCH_MAX} on purpose — see the module
|
|
95
|
-
* note. It is a bound on WORK: it can never refuse a person, and the addresses
|
|
96
|
-
* it does not reach in this run are reached by the next one.
|
|
97
|
-
*/
|
|
98
|
-
export declare const LIST_IMPORT_RUN_BUDGET = 100;
|
|
99
|
-
/**
|
|
100
|
-
* Staged addresses per chunk document.
|
|
101
|
-
*
|
|
102
|
-
* Comfortably inside Firestore's one-megabyte document limit at the widths a
|
|
103
|
-
* contact file actually carries, and a multiple of the run budget so a run
|
|
104
|
-
* reads exactly one chunk. Reading two would be the common case at any size
|
|
105
|
-
* that is not a multiple, which is a round trip paid on every run to save
|
|
106
|
-
* nothing.
|
|
107
|
-
*/
|
|
108
|
-
export declare const LIST_IMPORT_CHUNK_SIZE = 500;
|
|
109
|
-
/**
|
|
110
|
-
* `POST email/list-import-preview` — what is in this file.
|
|
111
|
-
*
|
|
112
|
-
* Reads only. It answers three separate questions and keeps them separate,
|
|
113
|
-
* because collapsing them is how an import gets attested to on a number that
|
|
114
|
-
* is not the number:
|
|
115
|
-
*
|
|
116
|
-
* - what the FILE contains: usable addresses, unusable lines, duplicates
|
|
117
|
-
* collapsed, and the columns it carries;
|
|
118
|
-
* - what the SCREENING found, which decides nothing and is shown anyway;
|
|
119
|
-
* - what the CONSENT GATE says about a bounded sample of the addresses.
|
|
120
|
-
*
|
|
121
|
-
* The sample is the honest shape rather than a shortcut. Resolving fifty
|
|
122
|
-
* thousand addresses against the contacts collection and both suppression
|
|
123
|
-
* lists is the same scan the import itself performs, so a preview that did it
|
|
124
|
-
* would be the import minus the writes — twice the cost, and a request that
|
|
125
|
-
* times out on exactly the files this feature exists for. So the sample size
|
|
126
|
-
* is reported beside the total and the run reports the real figures as they
|
|
127
|
-
* become true, which is the same distinction `email/list-rule-preview` draws
|
|
128
|
-
* between `matched` and the batch it hands back.
|
|
129
|
-
*/
|
|
130
|
-
export declare const emailListImportPreviewHandler: PluginApiHandler;
|
|
131
|
-
/**
|
|
132
|
-
* `POST email/list-import-start` — record the attestation, stage the file.
|
|
133
|
-
*
|
|
134
|
-
* Body: `{ hostId, listId, text, attestConsent }`. Enrolls nobody. It writes
|
|
135
|
-
* the job document that every subsequent run reads, and the chunks holding
|
|
136
|
-
* the addresses, and then stops — so the moment the operator makes their
|
|
137
|
-
* claim is a moment of its own, with a record of who made it and when, rather
|
|
138
|
-
* than a flag riding along on the request that also did the work.
|
|
139
|
-
*
|
|
140
|
-
* `attestConsent` is the operator STATING they have these people's
|
|
141
|
-
* permission. It is not a way to name a basis: the basis is derived per
|
|
142
|
-
* address at run time from that person's own record, exactly as the
|
|
143
|
-
* one-address add path derives it, and this flag can only ever produce the
|
|
144
|
-
* attributable kind.
|
|
145
|
-
*/
|
|
146
|
-
export declare const emailListImportStartHandler: PluginApiHandler;
|
|
147
|
-
/**
|
|
148
|
-
* `POST email/list-import-run` — enroll the next batch.
|
|
149
|
-
*
|
|
150
|
-
* Body: `{ hostId, listId, importId }`. Answers `complete` when the cursor
|
|
151
|
-
* has reached the total, so the caller's loop is "call until complete" and
|
|
152
|
-
* nothing has to guess how many runs a file needs.
|
|
153
|
-
*
|
|
154
|
-
* ## The cursor moves for every address, enrolled or refused
|
|
155
|
-
*
|
|
156
|
-
* A refusal is a finished address. Advancing only on success would put a
|
|
157
|
-
* suppressed address at the head of the queue forever and turn the import
|
|
158
|
-
* into a loop that never terminates on exactly the files that most need to
|
|
159
|
-
* terminate.
|
|
160
|
-
*
|
|
161
|
-
* ## The counters are incremented, not recomputed
|
|
162
|
-
*
|
|
163
|
-
* `FieldValue.increment` rather than a read-modify-write, so two runs racing
|
|
164
|
-
* on one job — a merchant with the drawer open in two tabs — cannot lose a
|
|
165
|
-
* batch's worth of tally. The cursor is written as an absolute value because
|
|
166
|
-
* it is the position the NEXT run reads from, and two racing runs that both
|
|
167
|
-
* incremented it would skip a batch rather than repeat one; repeating is safe
|
|
168
|
-
* (`enrollListMember` is keyed by the person), skipping is not.
|
|
169
|
-
*/
|
|
170
|
-
export declare const emailListImportRunHandler: PluginApiHandler;
|
|
171
36
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* Reached when the import drawer opens, and at no other time. It exists
|
|
175
|
-
* because a browser is not a durable thing: a merchant who closed the tab
|
|
176
|
-
* during a large import has an audience that is part-way filled and, without
|
|
177
|
-
* this, no way to see that or to finish it. What they must never be offered
|
|
178
|
-
* instead is a fresh import of the same file, which would re-run the whole
|
|
179
|
-
* gate over addresses already enrolled.
|
|
180
|
-
*
|
|
181
|
-
* Ordered on `createdAt`, which every job document written by
|
|
182
|
-
* `email/list-import-start` carries — a `limit()` with no `orderBy` answers in
|
|
183
|
-
* document-id order, and the ids come from `createResourceUid()`, so the
|
|
184
|
-
* "latest" import would be an arbitrary one.
|
|
185
|
-
*/
|
|
186
|
-
export declare const emailListImportStatusHandler: PluginApiHandler;
|
|
187
|
-
/**
|
|
188
|
-
* Import route registration.
|
|
189
|
-
*
|
|
190
|
-
* Reached by a person pressing a button in a browser, like the rest of the
|
|
191
|
-
* console half, so none of these is on the machine-path exemption list in
|
|
192
|
-
* `plugin-api-rate-limit.ts` — with one consequence worth stating: the RUN
|
|
193
|
-
* route is called repeatedly by design, once per {@link
|
|
194
|
-
* LIST_IMPORT_RUN_BUDGET} addresses, so the visitor limiter's per-(site, IP)
|
|
195
|
-
* budget is the ceiling on how fast a large import can proceed. That is the
|
|
196
|
-
* correct ceiling for a path that enrolls people into a marketing audience,
|
|
197
|
-
* and it degrades into a slower import rather than a failed one.
|
|
37
|
+
* Body: `{ hostId, listId, jobId }`. Answers `{ listName, screening }`, the
|
|
38
|
+
* screening `null` until a dry run has made one.
|
|
198
39
|
*/
|
|
40
|
+
export declare const emailListImportScreeningHandler: PluginApiHandler;
|
|
41
|
+
/** Registration: one console route, reached by a person opening a step. */
|
|
199
42
|
export declare function registerEmailListImportApi(): void;
|