@aglyn/plugins-email 1.0.0-beta.183 → 1.0.0-beta.184

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-email",
3
- "version": "1.0.0-beta.183",
3
+ "version": "1.0.0-beta.184",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,22 +25,22 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.183",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.183",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.183",
31
- "@aglyn/shared-data-types": "1.0.0-beta.183",
32
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.183",
33
- "@aglyn/shared-ui-jsx": "1.0.0-beta.183",
34
- "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.183",
35
- "@aglyn/shared-ui-next": "1.0.0-beta.183",
36
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.183",
37
- "@aglyn/shared-ui-theme": "1.0.0-beta.183",
38
- "@aglyn/shared-util-email": "1.0.0-beta.183",
39
- "@aglyn/shared-util-http": "1.0.0-beta.183",
40
- "@aglyn/shared-util-timestamp": "1.0.0-beta.183",
41
- "@aglyn/shared-util-tools": "1.0.0-beta.183",
42
- "@aglyn/tenant-data-admin": "1.0.0-beta.183",
43
- "@aglyn/tenant-feature-instance": "1.0.0-beta.183",
28
+ "@aglyn/aglyn": "1.0.0-beta.184",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.184",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.184",
31
+ "@aglyn/shared-data-types": "1.0.0-beta.184",
32
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.184",
33
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.184",
34
+ "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.184",
35
+ "@aglyn/shared-ui-next": "1.0.0-beta.184",
36
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.184",
37
+ "@aglyn/shared-ui-theme": "1.0.0-beta.184",
38
+ "@aglyn/shared-util-email": "1.0.0-beta.184",
39
+ "@aglyn/shared-util-http": "1.0.0-beta.184",
40
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.184",
41
+ "@aglyn/shared-util-tools": "1.0.0-beta.184",
42
+ "@aglyn/tenant-data-admin": "1.0.0-beta.184",
43
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.184",
44
44
  "@swc/helpers": "0.5.23"
45
45
  },
46
46
  "peerDependencies": {
@@ -212,7 +212,7 @@ import { consentGroupForSite, filterSendableForHost, filterSuppressedEmails, fir
212
212
  * — resolves to itself.
213
213
  */ const group = await consentGroupForSite(input.hostId);
214
214
  const [suppression, stored] = await Promise.all([
215
- suppressionFor(input.hostId, addresses),
215
+ suppressionFor(input.hostId, group, addresses),
216
216
  storedConsentFor(input.hostId, group, addresses)
217
217
  ]);
218
218
  const unrecorded = readMarketingBasis(null, group);
@@ -267,14 +267,18 @@ import { consentGroupForSite, filterSendableForHost, filterSuppressedEmails, fir
267
267
  * for the enrollment check and the send-time check to disagree about — which
268
268
  * is exactly the disagreement `an-enrollment-is-not-a-license-to-send.spec.ts`
269
269
  * exists to stop.
270
- */ async function suppressionFor(hostId, addresses) {
270
+ *
271
+ * Read across the site's consent group, as the send reads it: somebody who
272
+ * unsubscribed from a sibling of a declared group is refused here too, rather
273
+ * than enrolled onto a list whose campaigns will never reach them.
274
+ */ async function suppressionFor(hostId, group, addresses) {
271
275
  const refusals = new Map();
272
276
  if (!addresses.length) return refusals;
273
- const sendable = new Set(await filterSendableForHost(hostId, addresses));
277
+ const sendable = new Set(await filterSendableForHost(hostId, addresses, undefined, group));
274
278
  const blocked = addresses.filter((email)=>!sendable.has(email));
275
279
  if (!blocked.length) return refusals;
276
280
  // Only the blocked ones, and only to say WHICH list. Survivors of the
277
- // platform half are held by this site's own list.
281
+ // platform half are held by this site's own list, or a group sibling's.
278
282
  const platformSendable = new Set(await filterSuppressedEmails(blocked));
279
283
  for (const email of blocked){
280
284
  refusals.set(email, platformSendable.has(email) ? 'suppressed-host' : 'suppressed-platform');
@@ -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 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\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 * 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] = await Promise.all([\n suppressionFor(input.hostId, 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 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 */\nasync function suppressionFor(\n hostId: string,\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(await filterSendableForHost(hostId, addresses))\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.\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 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.\n */\nasync function storedConsentFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<Map<string, MarketingConsentRecord>> {\n const found = new Map<string, MarketingConsentRecord>()\n if (!addresses.length) return found\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 = await Promise.all(\n chunks.map((chunk) => contacts.where('email', 'in', chunk).get()),\n )\n for (const snapshot of snapshots) {\n for (const doc of snapshot.docs) {\n const email = normalizeContactEmail(doc.get('email'))\n if (!email) continue\n const record = readMarketingBasis(\n doc.data() as Record<string, unknown>,\n group,\n )\n const already = found.get(email)\n if (already?.basis === 'declined') continue\n if (already && record.basis !== 'declined') continue\n found.set(email, record)\n }\n }\n return found\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 new Map(addresses.map((email) => [email, refused]))\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","isOrgWideMember","normalizeContactEmail","readMarketingBasis","consentGroupForSite","filterSendableForHost","filterSuppressedEmails","firebaseAdmin","getOrgForHost","orgDataCollectionForHost","resolveOrgMembership","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","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","chunk","where","snapshot","docs","data","already","basis","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 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\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 * 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] = 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 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 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.\n */\nasync function storedConsentFor(\n hostId: string,\n group: ConsentGroup,\n addresses: readonly string[],\n): Promise<Map<string, MarketingConsentRecord>> {\n const found = new Map<string, MarketingConsentRecord>()\n if (!addresses.length) return found\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 = await Promise.all(\n chunks.map((chunk) => contacts.where('email', 'in', chunk).get()),\n )\n for (const snapshot of snapshots) {\n for (const doc of snapshot.docs) {\n const email = normalizeContactEmail(doc.get('email'))\n if (!email) continue\n const record = readMarketingBasis(\n doc.data() as Record<string, unknown>,\n group,\n )\n const already = found.get(email)\n if (already?.basis === 'declined') continue\n if (already && record.basis !== 'declined') continue\n found.set(email, record)\n }\n }\n return found\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 new Map(addresses.map((email) => [email, refused]))\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","isOrgWideMember","normalizeContactEmail","readMarketingBasis","consentGroupForSite","filterSendableForHost","filterSuppressedEmails","firebaseAdmin","getOrgForHost","orgDataCollectionForHost","resolveOrgMembership","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","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","chunk","where","snapshot","docs","data","already","basis","console","assertedBy","source","basisAtMs","capturedAtMs","readAddresses","Array","isArray","emails","value"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BC,GAED,SACEA,2BAA2B,EAC3BC,iBAAiB,EACjBC,eAAe,EACfC,qBAAqB,EACrBC,kBAAkB,QAMb,sBAAqB;AAC5B,SACEC,mBAAmB,EACnBC,qBAAqB,EACrBC,sBAAsB,EACtBC,aAAa,EACbC,aAAa,EACbC,wBAAwB,EACxBC,oBAAoB,QACf,2BAA0B;AAEjC;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,wBAAwB,IAAG;AAExC,yDAAyD,GACzD,MAAMC,uBAAuB;AAc7B;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,eAAeC,mBACpBC,GAAoC;qBAYPA,4BAcTC,0BA4DDC;QApFGF,WACAA,YAiCnB;IAlCH,MAAMG,SAASC,gBAAOJ,YAAAA,IAAIK,IAAI,qBAARL,UAAUG,MAAM,mBAAI;IAC1C,MAAMG,SAASF,iBAAOJ,aAAAA,IAAIK,IAAI,qBAARL,WAAUM,MAAM,oBAAI;IAC1C,IAAI,CAACH,UAAU,CAACG,QAAQ;QACtB,OAAO;YACLC,IAAI;YACJC,QAAQ;YACRH,MAAM;gBAAEI,OAAO;YAA2B;QAC5C;IACF;IAEA,MAAMC,gBAAgBN,QAAOJ,6BAAAA,IAAIW,OAAO,CAACD,aAAa,YAAzBV,6BAA6B;IAC1D,MAAMY,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcI,KAAK,CAAC,UAAUC,MAAM,IACpCC;IACJ,IAAI,CAACJ,SAAS;QACZ,OAAO;YAAEL,IAAI;YAAOC,QAAQ;YAAKH,MAAM;gBAAEI,OAAO;YAAkB;QAAE;IACtE;IAEA,MAAMQ,UAAU,MAAMxB,cAAcyB,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACR;IAC/D,MAAMS,YAAY5B,cAAcyB,GAAG,GAAGG,SAAS;IAC/C,MAAMpB,eAAe,MAAMoB,UAAUC,UAAU,CAAC,SAASC,GAAG,CAACpB,QAAQqB,GAAG;IACxE,IAAI,CAACvB,aAAawB,MAAM,EAAE;QACxB,OAAO;YAAElB,IAAI;YAAOC,QAAQ;YAAKH,MAAM;gBAAEI,OAAO;YAAe;QAAE;IACnE;IACA,MAAMiB,aAAa,EAACzB,oBAAAA,aAAauB,GAAG,CAAC,0BAAjBvB,oBAAmC,CAAC,EAAE,CAACgB,QAAQU,GAAG,CAAC;IACvE,IAAID,eAAe,WAAWA,eAAe,UAAU;QACrD,OAAO;YACLnB,IAAI;YACJC,QAAQ;YACRH,MAAM;gBAAEI,OAAO;YAA6B;QAC9C;IACF;IAEA,MAAMmB,QAAQxB,iBACX,QAAA,MAAMV,cAAcS,QAAQ0B,KAAK,CAAC,IAAM,0BAAzC,AAAC,MAAgDD,KAAK,oBAAI;IAE5D,IAAI,CAACA,OAAO;QACV,OAAO;YACLrB,IAAI;YACJC,QAAQ;YACRH,MAAM;gBAAEI,OAAO;YAAqD;QACtE;IACF;IACA,MAAMqB,aAAa,MAAMlC,qBAAqBqB,QAAQU,GAAG,EAAEC,OAAOC,KAAK,CACrE,IAAM;IAER,MAAME,SAASD,8BAAAA,WAAYC,MAAM;IACjC,MAAMC,gBACJ7C,gBAAgB4C,WACfA,CAAAA,CAAAA,0BAAAA,OAAQE,IAAI,MAAK,WAChBF,CAAAA,0BAAAA,OAAQE,IAAI,MAAK,WACjBF,CAAAA,0BAAAA,OAAQE,IAAI,MAAK,QAAO,KAC1B,CAACF,0BAAD,AAACA,OAAmDG,YAAY,MAAK;IACvE,IAAI,CAACF,eAAe;QAClB,OAAO;YACLzB,IAAI;YACJC,QAAQ;YACRH,MAAM;gBACJI,OACE,uEACA,oEACA;YACJ;QACF;IACF;IAEA,MAAM0B,UAAUd,UACbC,UAAU,CAAC,QACXC,GAAG,CAACK,OACJN,UAAU,CAAC,SACXC,GAAG,CAACjB;IACP,MAAMJ,eAAe,MAAMiC,QAAQX,GAAG;IACtC,qEAAqE;IACrE,iDAAiD;IACjD,IAAI,CAACtB,aAAauB,MAAM,EAAE;QACxB,OAAO;YAAElB,IAAI;YAAOC,QAAQ;YAAKH,MAAM;gBAAEI,OAAO;YAAe;QAAE;IACnE;IAEA,OAAO;QACLF,IAAI;QACJoB,KAAKV,QAAQU,GAAG;QAChBxB;QACAyB;QACAO;QACAC,UAAUhC,QAAOF,oBAAAA,aAAasB,GAAG,CAAC,mBAAjBtB,oBAA4BI;IAC/C;AACF;AA2DA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,eAAe+B,iBAAiBC,KAGtC;IACC,MAAMC,OAAO,IAAIC;IACjB,MAAMC,OAAuD,EAAE;IAC/D,KAAK,MAAMC,OAAOJ,MAAMK,MAAM,CAAE;QAC9B,MAAMC,QAAQxD,sBAAsBsD;QACpC,IAAIE,SAASL,KAAKM,GAAG,CAACD,QAAQ;QAC9B,IAAIA,OAAOL,KAAKO,GAAG,CAACF;QACpBH,KAAKM,IAAI,CAAC;YAAET,OAAOlC,OAAOsC,cAAAA,MAAO,IAAIM,IAAI;YAAIJ;QAAM;IACrD;IAEA,MAAMK,YAAYR,KACfS,GAAG,CAAC,CAACC,MAAQA,IAAIP,KAAK,EACtBQ,MAAM,CAAC,CAACR,QAA2BS,QAAQT;IAE9C;;;;;GAKC,GACD,MAAMU,QAAQ,MAAMhE,oBAAoBgD,MAAMnC,MAAM;IACpD,MAAM,CAACoD,aAAaC,OAAO,GAAG,MAAMC,QAAQC,GAAG,CAAC;QAC9CC,eAAerB,MAAMnC,MAAM,EAAEmD,OAAOL;QACpCW,iBAAiBtB,MAAMnC,MAAM,EAAEmD,OAAOL;KACvC;IAED,MAAMY,aAAqCxE,mBAAmB,MAAMiE;IACpE,MAAMQ,WAAWrB,KAAKS,GAAG,CAAC,CAACC;YAUVK,aAGAD,kBAiBRA;QA7BP,IAAI,CAACJ,IAAIP,KAAK,EAAE;YACd,OAAO;gBACLN,OAAOa,IAAIb,KAAK;gBAChBM,OAAO;gBACPmB,SAAS;gBACTC,qBAAqB;gBACrBC,SAAShF,2BAA2B,CAAC,qBAAqB;YAC5D;QACF;QACA,MAAMiF,UAASV,cAAAA,OAAOhC,GAAG,CAAC2B,IAAIP,KAAK,aAApBY,cAAyBK;QACxC,MAAMM,UAAUjF,kBAAkB;YAChCsE,QAAQU;YACRX,WAAW,GAAEA,mBAAAA,YAAY/B,GAAG,CAAC2B,IAAIP,KAAK,aAAzBW,mBAA8B;QAC7C;QACA,OAAO;YACLjB,OAAOa,IAAIb,KAAK;YAChBM,OAAOO,IAAIP,KAAK;YAChB;;;;;;;;;OASC,GACDmB,SAASI,QAAQC,UAAU,GACvB,QACCb,oBAAAA,YAAY/B,GAAG,CAAC2B,IAAIP,KAAK,aAAzBW,oBAA8B;YACnCS,qBAAqBG,QAAQH,mBAAmB;YAChDC,SAASE,QAAQF,OAAO;QAC1B;IACF;IAEA,OAAO;QACLH;QACAN;QACAF;QACAe,SAASP,SAASV,MAAM,CACtB,CAACkB,UAAY,CAACA,QAAQP,OAAO,IAAI,CAACO,QAAQN,mBAAmB,EAC7DjD,MAAM;QACRwD,iBAAiBT,SAASV,MAAM,CAAC,CAACkB,UAAYA,QAAQN,mBAAmB,EACtEjD,MAAM;QACTyD,SAASV,SAASV,MAAM,CACtB,CAACkB,UAAYA,QAAQP,OAAO,IAAI,CAACO,QAAQN,mBAAmB,EAC5DjD,MAAM;IACV;AACF;AAEA;;;;;;;;;;;;;CAaC,GACD,eAAe4C,eACbxD,MAAc,EACdmD,KAAmB,EACnBL,SAA4B;IAE5B,MAAMwB,WAAW,IAAIC;IACrB,IAAI,CAACzB,UAAUlC,MAAM,EAAE,OAAO0D;IAC9B,MAAME,WAAW,IAAInC,IACnB,MAAMjD,sBAAsBY,QAAQ8C,WAAWjC,WAAWsC;IAE5D,MAAMsB,UAAU3B,UAAUG,MAAM,CAAC,CAACR,QAAU,CAAC+B,SAAS9B,GAAG,CAACD;IAC1D,IAAI,CAACgC,QAAQ7D,MAAM,EAAE,OAAO0D;IAC5B,sEAAsE;IACtE,wEAAwE;IACxE,MAAMI,mBAAmB,IAAIrC,IAAI,MAAMhD,uBAAuBoF;IAC9D,KAAK,MAAMhC,SAASgC,QAAS;QAC3BH,SAASK,GAAG,CACVlC,OACAiC,iBAAiBhC,GAAG,CAACD,SAAS,oBAAoB;IAEtD;IACA,OAAO6B;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,eAAeb,iBACbzD,MAAc,EACdmD,KAAmB,EACnBL,SAA4B;IAE5B,MAAM8B,QAAQ,IAAIL;IAClB,IAAI,CAACzB,UAAUlC,MAAM,EAAE,OAAOgE;IAC9B,IAAI;QACF,MAAMC,WAAW,MAAMrF,yBAAyBQ,QAAQ;QACxD,MAAM8E,SAAqB,EAAE;QAC7B,IAAK,IAAIC,KAAK,GAAGA,KAAKjC,UAAUlC,MAAM,EAAEmE,MAAMpF,qBAAsB;YAClEmF,OAAOlC,IAAI,CAACE,UAAUnC,KAAK,CAACoE,IAAIA,KAAKpF;QACvC;QACA,MAAMqF,YAAY,MAAM1B,QAAQC,GAAG,CACjCuB,OAAO/B,GAAG,CAAC,CAACkC,QAAUJ,SAASK,KAAK,CAAC,SAAS,MAAMD,OAAO5D,GAAG;QAEhE,KAAK,MAAM8D,YAAYH,UAAW;YAChC,KAAK,MAAM5D,OAAO+D,SAASC,IAAI,CAAE;gBAC/B,MAAM3C,QAAQxD,sBAAsBmC,IAAIC,GAAG,CAAC;gBAC5C,IAAI,CAACoB,OAAO;gBACZ,MAAMsB,SAAS7E,mBACbkC,IAAIiE,IAAI,IACRlC;gBAEF,MAAMmC,UAAUV,MAAMvD,GAAG,CAACoB;gBAC1B,IAAI6C,CAAAA,2BAAAA,QAASC,KAAK,MAAK,YAAY;gBACnC,IAAID,WAAWvB,OAAOwB,KAAK,KAAK,YAAY;gBAC5CX,MAAMD,GAAG,CAAClC,OAAOsB;YACnB;QACF;QACA,OAAOa;IACT,EAAE,OAAOtE,OAAO;QACdkF,QAAQlF,KAAK,CAAC,iCAAiCA;QAC/C,MAAM+D,UAAkC,aACnCnF,mBAAmB,MAAMiE;YAC5BoC,OAAO;YACP,wEAAwE;YACxE,4BAA4B;YAC5BE,YAAY;YACZC,QAAQ;YACRC,WAAW;YACXC,cAAc;;QAEhB,OAAO,IAAIrB,IAAIzB,UAAUC,GAAG,CAAC,CAACN,QAAU;gBAACA;gBAAO4B;aAAQ;IAC1D;AACF;AAEA,qEAAqE,GACrE,OAAO,SAASwB,cACdhG,GAAoC;IAEpC,MAAMK,OAAOL,IAAIK,IAAI;IACrB,MAAMqC,MAAMuD,MAAMC,OAAO,CAAC7F,wBAAAA,KAAM8F,MAAM,IAClC9F,KAAK8F,MAAM,GACX9F,CAAAA,wBAAAA,KAAMuC,KAAK,MAAK5B,YACd,EAAE,GACF;QAACX,KAAKuC,KAAK;KAAC;IAClB,MAAMuD,SAASzD,IACZQ,GAAG,CAAC,CAACkD,QAAUhG,OAAOgG,gBAAAA,QAAS,IAAIpD,IAAI,IACvCI,MAAM,CAAC,CAACgD,QAAUA,MAAMrF,MAAM,GAAG;IACpC,IAAI,CAACoF,OAAOpF,MAAM,EAAE,OAAO;QAAEN,OAAO;IAAe;IACnD,IAAI0F,OAAOpF,MAAM,GAAGlB,uBAAuB;QACzC,OAAO;YACLY,OACE,GAAG0F,OAAOpF,MAAM,CAAC,mDAAmD,CAAC,GACrE,GAAGlB,sBAAsB,WAAW,CAAC;QACzC;IACF;IACA,OAAO;QAAEsG;IAAO;AAClB"}
package/src/lib/server.js CHANGED
@@ -22,7 +22,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
22
22
  * module is a bundle `app-router-graph.spec.ts` refuses. Every name here is a
23
23
  * pure function or a constant that lives in one leaf file.
24
24
  */ import { activeEmailTopics, mergeEmailTopics, normalizeEmailTopic, readTopicSubscriptionState, resolveCampaignTopic, EMAIL_TOPICS_COLLECTION, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
25
- import { confirmTopicSubscription, EMAIL_FREQUENCY_SUBCOLLECTION, firebaseAdmin, mirrorPlatformResubscribe, mirrorPlatformUnsubscribe, resolveCampaignSendRef, resolveOrgIdForHost, setMarketingCadence, UNSUBSCRIBE_SUPPRESSION_REASON } from "@aglyn/tenant-data-admin";
25
+ import { consentGroupOptOutHosts, soloConsentGroup } from "@aglyn/aglyn/app-utils/consent-groups";
26
+ import { confirmTopicSubscription, consentGroupForSite, EMAIL_FREQUENCY_SUBCOLLECTION, firebaseAdmin, mirrorPlatformResubscribe, mirrorPlatformUnsubscribe, resolveCampaignSendRef, resolveOrgIdForHost, setMarketingCadence, UNSUBSCRIBE_SUPPRESSION_REASON } from "@aglyn/tenant-data-admin";
26
27
  import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
27
28
  /*
28
29
  * The pure cadence rule from the shared email library, where the SEND path
@@ -165,6 +166,65 @@ function openSignedLink(req) {
165
166
  if (timer) clearTimeout(timer);
166
167
  }
167
168
  }
169
+ /**
170
+ * THE SENDER A PERSON LEAVES HERE — the link site's consent group
171
+ * (`consent-groups.ts`), or the site alone.
172
+ *
173
+ * An org may declare several sites ONE sender, and every capture form in the
174
+ * group named it as one. So these pages treat it as one: an opt-out made here
175
+ * is read by every site in it (the send paths read across the group), the
176
+ * state shown is the group's, a way back in lifts the group's records, and
177
+ * the page names the group rather than one of its sites.
178
+ *
179
+ * `consentGroupForSite` fails to the site alone, and so does this when it
180
+ * throws. `waitMs` bounds the wait where a page only NAMES the sender, for the
181
+ * reason {@link BRAND_READ_TIMEOUT_MS} gives; a page deciding what to read or
182
+ * lift waits for the real answer.
183
+ */ async function loadConsentGroup(hostId, waitMs) {
184
+ const alone = soloConsentGroup(hostId);
185
+ const resolving = (async ()=>{
186
+ try {
187
+ return await consentGroupForSite(hostId);
188
+ } catch (error) {
189
+ console.error('[email] consent group read failed', error);
190
+ return alone;
191
+ }
192
+ })();
193
+ if (!waitMs) return resolving;
194
+ let timer;
195
+ try {
196
+ return await Promise.race([
197
+ resolving,
198
+ new Promise((resolve)=>{
199
+ timer = setTimeout(()=>resolve(alone), waitMs);
200
+ })
201
+ ]);
202
+ } finally{
203
+ if (timer) clearTimeout(timer);
204
+ }
205
+ }
206
+ /**
207
+ * The sender as a page names it.
208
+ *
209
+ * The group's name is what the capture form disclosed — "You'll receive
210
+ * marketing email from" the group — so it is the name the person knows this
211
+ * sender by, and the one an opt-out here reaches. The site's own brand still
212
+ * frames the page; `reach` is what stops the two names reading as a mistake.
213
+ */ function pageSender(brand, group) {
214
+ if (!group.declared || !group.name) {
215
+ return {
216
+ name: escapeHtml(brand.name),
217
+ reach: ''
218
+ };
219
+ }
220
+ return {
221
+ name: escapeHtml(group.name),
222
+ reach: `${escapeHtml(group.name)} sends from ${group.hostIds.length} sites, ` + `${escapeHtml(brand.name)} among them, and this covers all of them.`
223
+ };
224
+ }
225
+ /** The `reach` sentence as a paragraph, or nothing for a site alone. */ function reachParagraph(sender, gap = 20) {
226
+ return sender.reach ? paragraph(sender.reach, gap) : '';
227
+ }
168
228
  const unsubscribeHandler = async (req, res)=>{
169
229
  var _req_method;
170
230
  const method = String((_req_method = req.method) != null ? _req_method : 'GET').toUpperCase();
@@ -180,10 +240,15 @@ const unsubscribeHandler = async (req, res)=>{
180
240
  const { hostId, email, campaignId, topicId } = params;
181
241
  const query = signedQuery(params);
182
242
  if (method !== 'POST') {
183
- // SAFE. A prescanner lands here and nothing is written — the brand read
184
- // is the only Firestore access on this path, and it is a read.
185
- const brand = await loadHostBrand(hostId);
186
- return void sendPage(res, page(heading('Unsubscribe?') + paragraph(`Confirm that <strong style="color:${PAL.ink}">${escapeHtml(email)}</strong> should stop receiving emails from ` + `<strong style="color:${PAL.ink}">${escapeHtml(brand.name)}</strong>.`) + `<form method="post" action="/api/email/unsubscribe?${escapeHtml(query)}">` + submitButton('Unsubscribe', {
243
+ // SAFE. A prescanner lands here and nothing is written — the brand and
244
+ // the consent group are the only Firestore access on this path, and both
245
+ // are reads.
246
+ const [brand, group] = await Promise.all([
247
+ loadHostBrand(hostId),
248
+ loadConsentGroup(hostId, BRAND_READ_TIMEOUT_MS)
249
+ ]);
250
+ const sender = pageSender(brand, group);
251
+ return void sendPage(res, page(heading('Unsubscribe?') + paragraph(`Confirm that <strong style="color:${PAL.ink}">${escapeHtml(email)}</strong> should stop receiving emails from ` + `<strong style="color:${PAL.ink}">${sender.name}</strong>.`, sender.reach ? 8 : 24) + reachParagraph(sender, 24) + `<form method="post" action="/api/email/unsubscribe?${escapeHtml(query)}">` + submitButton('Unsubscribe', {
187
252
  pal: brand.pal
188
253
  }) + '</form>' + // The way to a NARROWER choice, offered on the page rather than only
189
254
  // in the message footer: a recipient who reached the total
@@ -193,14 +258,21 @@ const unsubscribeHandler = async (req, res)=>{
193
258
  }
194
259
  try {
195
260
  const firestore = firebaseAdmin.app().firestore();
196
- const [created, brand] = await Promise.all([
261
+ /*
262
+ * Written against the ONE site the link names, and honored by every site
263
+ * in its consent group: the send paths read a group's lists together, so
264
+ * the one row is the refusal for the whole sender — and it reaches a site
265
+ * that joins the group later, which a copy written now could not.
266
+ */ const [created, brand, group] = await Promise.all([
197
267
  writeSiteSuppression(firestore, hostId, key, {
198
268
  email,
199
269
  campaignId,
200
270
  topicId
201
271
  }),
202
- loadHostBrand(hostId)
272
+ loadHostBrand(hostId),
273
+ loadConsentGroup(hostId, BRAND_READ_TIMEOUT_MS)
203
274
  ]);
275
+ const sender = pageSender(brand, group);
204
276
  /*
205
277
  * The campaign's own unsubscribe count.
206
278
  *
@@ -219,7 +291,7 @@ const unsubscribeHandler = async (req, res)=>{
219
291
  email,
220
292
  left: 'everything'
221
293
  });
222
- return void sendPage(res, page(successBadge(brand.pal) + heading("You're unsubscribed") + paragraph(`You won't receive further emails from ${escapeHtml(brand.name)}.`, 20) + // Same signed params, so the click that just proved this is really
294
+ return void sendPage(res, page(successBadge(brand.pal) + heading("You're unsubscribed") + paragraph(`You won't receive further emails from ${sender.name}.`, sender.reach ? 8 : 20) + reachParagraph(sender) + // Same signed params, so the click that just proved this is really
223
295
  // this recipient's link doubles as the resubscribe link — no new
224
296
  // token, no second email round-trip (AGL-2499).
225
297
  `<a href="/api/email/resubscribe?${escapeHtml(query)}" ` + `style="font-size:13px;color:${brand.pal.link};text-decoration:none">` + 'Changed your mind? Resubscribe</a>', 420, brand));
@@ -359,17 +431,24 @@ const unsubscribeHandler = async (req, res)=>{
359
431
  if (method !== 'POST') {
360
432
  // SAFE, same reasoning as the unsubscribe GET: a prescanner must not be
361
433
  // able to resubscribe someone either.
362
- const brand = await loadHostBrand(hostId);
363
- return void sendPage(res, page(heading('Resubscribe?') + paragraph(`Start receiving emails from <strong style="color:${PAL.ink}">` + `${escapeHtml(brand.name)}</strong> again at ` + `<strong style="color:${PAL.ink}">${escapeHtml(email)}</strong>.`) + `<form method="post" action="/api/email/resubscribe?${escapeHtml(query)}">` + submitButton('Resubscribe', {
434
+ const [brand, group] = await Promise.all([
435
+ loadHostBrand(hostId),
436
+ loadConsentGroup(hostId, BRAND_READ_TIMEOUT_MS)
437
+ ]);
438
+ const sender = pageSender(brand, group);
439
+ return void sendPage(res, page(heading('Resubscribe?') + paragraph(`Start receiving emails from <strong style="color:${PAL.ink}">` + `${sender.name}</strong> again at ` + `<strong style="color:${PAL.ink}">${escapeHtml(email)}</strong>.`, sender.reach ? 8 : 24) + reachParagraph(sender, 24) + `<form method="post" action="/api/email/resubscribe?${escapeHtml(query)}">` + submitButton('Resubscribe', {
364
440
  accent: 'link',
365
441
  pal: brand.pal
366
442
  }) + '</form>', 420, brand));
367
443
  }
368
444
  try {
369
445
  const firestore = firebaseAdmin.app().firestore();
370
- const [released, brand] = await Promise.all([
371
- releaseSiteSuppression(firestore, hostId, key),
372
- loadHostBrand(hostId)
446
+ // The group decides which rows are lifted, so it is waited for in full.
447
+ const groupRead = loadConsentGroup(hostId);
448
+ const [released, brand, group] = await Promise.all([
449
+ groupRead.then((resolved)=>releaseSiteSuppression(firestore, resolved, key)),
450
+ loadHostBrand(hostId),
451
+ groupRead
373
452
  ]);
374
453
  if (!released) {
375
454
  return void sendPage(res, page(protectedAddressBody(), 420, brand));
@@ -381,7 +460,8 @@ const unsubscribeHandler = async (req, res)=>{
381
460
  email,
382
461
  via: 'email-resubscribe'
383
462
  });
384
- return void sendPage(res, page(successBadge(brand.pal) + heading("You're resubscribed") + paragraph(`You'll receive emails from ${escapeHtml(brand.name)} again.`, 0), 420, brand));
463
+ const sender = pageSender(brand, group);
464
+ return void sendPage(res, page(successBadge(brand.pal) + heading("You're resubscribed") + paragraph(`You'll receive emails from ${sender.name} again.`, sender.reach ? 8 : 0) + reachParagraph(sender, 0), 420, brand));
385
465
  } catch (error) {
386
466
  console.error(error);
387
467
  return void res.status(500).send('Resubscribe failed — please try again');
@@ -399,18 +479,39 @@ const unsubscribeHandler = async (req, res)=>{
399
479
  * back in circulation goes through here so that there is exactly one place the
400
480
  * rule is stated.
401
481
  *
402
- * @returns false when the record was left standing because it is not an
482
+ * ## Across the consent group, all or nothing
483
+ *
484
+ * The send paths read every site's list in the link site's consent group, so
485
+ * an unsubscribe filed on a sibling holds this site's mail too — and a way
486
+ * back in that lifted only this site's row would tell the person they were
487
+ * resubscribed while the sender went on withholding. Every site's row is
488
+ * therefore read first, and the release happens only if none of them is a
489
+ * record this rule may not lift: one bounce or complaint anywhere in the
490
+ * group still holds the whole group, so lifting the unsubscribes beside it
491
+ * would change nothing the person could see, and the page says so instead.
492
+ *
493
+ * @returns false when a record was left standing because it is not an
403
494
  * unsubscribe.
404
- */ async function releaseSiteSuppression(firestore, hostId, key) {
405
- const ref = firestore.collection('hosts').doc(hostId).collection('suppressions').doc(key);
406
- const snapshot = await ref.get();
407
- if (snapshot.exists && snapshot.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON) {
495
+ */ async function releaseSiteSuppression(firestore, group, key) {
496
+ const [own, ...siblings] = consentGroupOptOutHosts(group).map((id)=>firestore.collection('hosts').doc(id).collection('suppressions').doc(key));
497
+ const [ownSnapshot, ...siblingSnapshots] = await Promise.all([
498
+ own,
499
+ ...siblings
500
+ ].map((ref)=>ref.get()));
501
+ if ([
502
+ ownSnapshot,
503
+ ...siblingSnapshots
504
+ ].some((snapshot)=>snapshot.exists && snapshot.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON)) {
408
505
  return false;
409
506
  }
410
507
  // Idempotent whether or not a doc existed — a resubscribe click on an
411
508
  // address that was never suppressed (or already resubscribed) is not an
412
- // error, it is the state the visitor wanted.
413
- await ref.delete();
509
+ // error, it is the state the visitor wanted. A sibling's row is deleted
510
+ // only where one stands.
511
+ await Promise.all([
512
+ own.delete(),
513
+ ...siblings.filter((_ref, index)=>siblingSnapshots[index].exists).map((ref)=>ref.delete())
514
+ ]);
414
515
  return true;
415
516
  }
416
517
  /** Shown wherever a resubscribe is refused, so the wording is one wording. */ function protectedAddressBody() {
@@ -459,15 +560,18 @@ const unsubscribeHandler = async (req, res)=>{
459
560
  try {
460
561
  var _req_body, _body_action;
461
562
  const firestore = firebaseAdmin.app().firestore();
462
- // Three independent reads, so they go together rather than in series —
463
- // the brand is not worth a third round trip on the page a recipient is
464
- // waiting for.
465
- const [catalog, state, brand] = await Promise.all([
563
+ // Independent reads, so they go together rather than in series — the
564
+ // brand is not worth another round trip on the page a recipient is
565
+ // waiting for. The state is the consent GROUP's, so it waits on the group.
566
+ const groupRead = loadConsentGroup(hostId);
567
+ const [catalog, state, brand, group] = await Promise.all([
466
568
  loadTopicCatalog(firestore, hostId),
467
- readSubscriptionState(firestore, hostId, key),
468
- loadHostBrand(hostId)
569
+ groupRead.then((resolved)=>readSubscriptionState(firestore, resolved, key)),
570
+ loadHostBrand(hostId),
571
+ groupRead
469
572
  ]);
470
573
  const topics = activeEmailTopics(catalog);
574
+ const sender = pageSender(brand, group);
471
575
  if (method !== 'POST') {
472
576
  // SAFE. Reads only, exactly like the other two GETs.
473
577
  return void sendPage(res, page(preferencesFormBody({
@@ -476,7 +580,8 @@ const unsubscribeHandler = async (req, res)=>{
476
580
  topics,
477
581
  state,
478
582
  topicId,
479
- brand
583
+ brand,
584
+ sender
480
585
  }), 520, brand));
481
586
  }
482
587
  const body = (_req_body = req.body) != null ? _req_body : {};
@@ -492,7 +597,7 @@ const unsubscribeHandler = async (req, res)=>{
492
597
  email,
493
598
  left: 'everything'
494
599
  });
495
- return void sendPage(res, page(successBadge(brand.pal) + heading('Sorry to see you go') + paragraph(`<strong style="color:${PAL.ink}">${escapeHtml(email)}</strong> ` + 'has been unsubscribed from every email ' + `${escapeHtml(brand.name)} sends.`, 20) + paragraph('Changed your mind? ' + `<a href="/api/email/resubscribe?${escapeHtml(query)}" ` + `style="color:${brand.pal.link};text-decoration:none">` + 'Resubscribe</a>, or ' + `<a href="/api/email/preferences?${escapeHtml(query)}" ` + `style="color:${brand.pal.link};text-decoration:none">` + 'pick just the emails you want</a>.', 0), 420, brand));
600
+ return void sendPage(res, page(successBadge(brand.pal) + heading('Sorry to see you go') + paragraph(`<strong style="color:${PAL.ink}">${escapeHtml(email)}</strong> ` + 'has been unsubscribed from every email ' + `${sender.name} sends.`, sender.reach ? 8 : 20) + reachParagraph(sender) + paragraph('Changed your mind? ' + `<a href="/api/email/resubscribe?${escapeHtml(query)}" ` + `style="color:${brand.pal.link};text-decoration:none">` + 'Resubscribe</a>, or ' + `<a href="/api/email/preferences?${escapeHtml(query)}" ` + `style="color:${brand.pal.link};text-decoration:none">` + 'pick just the emails you want</a>.', 0), 420, brand));
496
601
  }
497
602
  /*
498
603
  * A CHECKED BOX MEANS "KEEP SENDING", so the opt-outs are the complement.
@@ -514,6 +619,15 @@ const unsubscribeHandler = async (req, res)=>{
514
619
  ]
515
620
  });
516
621
  /*
622
+ * A ticked box is a stream the person wants back FROM THE SENDER, and the
623
+ * send paths read an opt-out on any site of the consent group — so an
624
+ * opt-out a sibling holds is lifted too, or the box would be a choice the
625
+ * send path goes on refusing. The unticked ones are written here alone:
626
+ * this site's record is read across the group already.
627
+ */ await resumeTopicsAcrossGroup(firestore, group, key, [
628
+ ...keep
629
+ ]);
630
+ /*
517
631
  * HOW OFTEN, recorded from the same submit as WHAT.
518
632
  *
519
633
  * They are one decision — "less of this, and less often" — so they are
@@ -537,7 +651,7 @@ const unsubscribeHandler = async (req, res)=>{
537
651
  * dropping the address at the site suppression one layer above topics.
538
652
  */ let stillBlocked = false;
539
653
  if (keep.size) {
540
- stillBlocked = !await releaseSiteSuppression(firestore, hostId, key);
654
+ stillBlocked = !await releaseSiteSuppression(firestore, group, key);
541
655
  }
542
656
  /*
543
657
  * The account's answer about product updates follows what this page just
@@ -566,8 +680,9 @@ const unsubscribeHandler = async (req, res)=>{
566
680
  ...keep
567
681
  ],
568
682
  drop,
569
- topics
570
- }), cadence === 'all' && cadenceStored ? 20 : 8) + /*
683
+ topics,
684
+ sender
685
+ }), cadence === 'all' && cadenceStored && !sender.reach ? 20 : 8) + reachParagraph(sender, cadence === 'all' && cadenceStored ? 20 : 8) + /*
571
686
  * The pace is reported only when it is a CHOICE. "As they come" is
572
687
  * the default and the absence, so announcing it would tell somebody
573
688
  * who touched nothing that they had just asked for something.
@@ -608,44 +723,82 @@ const unsubscribeHandler = async (req, res)=>{
608
723
  return cadence === 'daily' ? 'one a day' : cadence === 'weekly' ? 'one a week' : 'one a month';
609
724
  }
610
725
  /**
611
- * All three per-site records for one address, in three keyed `get()`s.
726
+ * All three per-site records for one address, in three keyed `get()`s per
727
+ * site of the link site's consent group.
612
728
  *
613
729
  * By document id rather than a query, matching `filterSendableForHost`: no
614
730
  * composite index to go missing, and nothing that can fail open on a read
615
731
  * window. The third is the send counter, which is where the recipient's
616
732
  * chosen pace lives — see `EmailFrequencyRecord.cadence` for why it is stored
617
733
  * on the document the send path already reads rather than on this page's own.
618
- */ async function readSubscriptionState(firestore, hostId, key) {
619
- var _ref;
620
- const hostRef = firestore.collection('hosts').doc(hostId);
621
- const [suppression, optOuts, frequency] = await Promise.all([
622
- hostRef.collection('suppressions').doc(key).get(),
623
- hostRef.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key).get(),
624
- hostRef.collection(EMAIL_FREQUENCY_SUBCOLLECTION).doc(key).get()// The pace is the one field on this page whose absence is a legitimate
625
- // answer, so a read that fails renders the default rather than an
626
- // error — the recipient still gets their topic checkboxes.
627
- .catch(()=>null)
628
- ]);
629
- const stored = (_ref = (optOuts == null ? void 0 : optOuts.exists) ? optOuts.get('topics') : null) != null ? _ref : {};
734
+ *
735
+ * ## The group's state, read the way the send paths read it
736
+ *
737
+ * The page shows what the SENDER will do, and the sender is the consent
738
+ * group: a suppression standing on any of its sites, and a stream left on any
739
+ * of them, holds this site's mail too, so both show here. The pace is the one
740
+ * chosen most recently on any site's page. A pending confirmation is the link
741
+ * site's own, as it is on the send path. A group of one is the three reads
742
+ * this page always made.
743
+ */ async function readSubscriptionState(firestore, group, key) {
744
+ const sites = await Promise.all(consentGroupOptOutHosts(group).map((id)=>{
745
+ const hostRef = firestore.collection('hosts').doc(id);
746
+ return Promise.all([
747
+ hostRef.collection('suppressions').doc(key).get(),
748
+ hostRef.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key).get(),
749
+ hostRef.collection(EMAIL_FREQUENCY_SUBCOLLECTION).doc(key).get()// The pace is the one field on this page whose absence is a
750
+ // legitimate answer, so a read that fails renders the default
751
+ // rather than an error — the recipient still gets their topic
752
+ // checkboxes.
753
+ .catch(()=>null)
754
+ ]);
755
+ }));
630
756
  const optedOut = new Set();
631
757
  const pending = new Set();
632
- for (const [id, record] of Object.entries(stored)){
633
- /*
634
- * The shared reader, not a field test. An entry with a `resubscribedAt`
635
- * is EVIDENCE of an opt-out that has been lifted rather than a live one —
636
- * see `writeTopicOptOuts` for why the entry stays — and an entry with a
637
- * `confirmedAt` carries the same shape of evidence for a confirmation.
638
- * Only one function knows all three states.
639
- */ const state = readTopicSubscriptionState(record);
640
- if (state === 'opted-out') optedOut.add(id);
641
- if (state === 'pending') pending.add(id);
642
- }
758
+ let suppressed = false;
759
+ let protectedReason = null;
760
+ let cadence = null;
761
+ let cadenceSetAtMs = Number.NEGATIVE_INFINITY;
762
+ sites.forEach(([suppression, optOuts, frequency], index)=>{
763
+ var _ref;
764
+ const own = index === 0;
765
+ if (suppression == null ? void 0 : suppression.exists) {
766
+ suppressed = true;
767
+ const reason = suppression.get('reason');
768
+ if (reason !== UNSUBSCRIBE_SUPPRESSION_REASON && protectedReason === null) {
769
+ protectedReason = String(reason != null ? reason : 'held');
770
+ }
771
+ }
772
+ const stored = (_ref = (optOuts == null ? void 0 : optOuts.exists) ? optOuts.get('topics') : null) != null ? _ref : {};
773
+ for (const [id, record] of Object.entries(stored)){
774
+ /*
775
+ * The shared reader, not a field test. An entry with a `resubscribedAt`
776
+ * is EVIDENCE of an opt-out that has been lifted rather than a live one
777
+ * — see `writeTopicOptOuts` for why the entry stays — and an entry with
778
+ * a `confirmedAt` carries the same shape of evidence for a
779
+ * confirmation. Only one function knows all three states.
780
+ */ const state = readTopicSubscriptionState(record);
781
+ if (state === 'opted-out') optedOut.add(id);
782
+ if (own && state === 'pending') pending.add(id);
783
+ }
784
+ // The most recent choice on any site, the link site keeping a tie — the
785
+ // rule `filterCadenceSendable` decides by.
786
+ if ((frequency == null ? void 0 : frequency.exists) && frequency.get('cadence') != null) {
787
+ const setAtMs = Number(frequency.get('cadenceSetAtMs'));
788
+ const atMs = Number.isFinite(setAtMs) ? setAtMs : 0;
789
+ if (atMs > cadenceSetAtMs) {
790
+ cadenceSetAtMs = atMs;
791
+ cadence = frequency.get('cadence');
792
+ }
793
+ }
794
+ });
643
795
  return {
644
- suppressed: !!(suppression == null ? void 0 : suppression.exists),
645
- protectedRecord: !!(suppression == null ? void 0 : suppression.exists) && suppression.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON,
796
+ suppressed,
797
+ protectedRecord: protectedReason !== null,
798
+ protectedReason,
646
799
  optedOut,
647
800
  pending,
648
- cadence: normalizeMarketingCadence((frequency == null ? void 0 : frequency.exists) ? frequency.get('cadence') : null)
801
+ cadence: normalizeMarketingCadence(cadence)
649
802
  };
650
803
  }
651
804
  /**
@@ -739,6 +892,45 @@ const unsubscribeHandler = async (req, res)=>{
739
892
  });
740
893
  });
741
894
  }
895
+ /**
896
+ * Lifts the opt-outs the link site's consent-group SIBLINGS hold on streams
897
+ * the person has just asked for back, so a stream rejoined here is rejoined
898
+ * from the whole sender — the send paths read a sibling's opt-out as this
899
+ * site's own.
900
+ *
901
+ * Only a live opt-out is touched, and it is lifted the way
902
+ * `writeTopicOptOuts` lifts one: `resubscribedAt` stamped onto the entry,
903
+ * which stays as the evidence that the opt-out was honored while it stood. A
904
+ * sibling's pending confirmation is left alone — it is that site's question,
905
+ * not a refusal of the sender. Nothing is read or written for a site alone.
906
+ */ async function resumeTopicsAcrossGroup(firestore, group, key, topicIds) {
907
+ const siblings = consentGroupOptOutHosts(group).slice(1);
908
+ if (!siblings.length || !topicIds.length) return;
909
+ await Promise.all(siblings.map(async (id)=>{
910
+ const ref = firestore.collection('hosts').doc(id).collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key);
911
+ await firestore.runTransaction(async (transaction)=>{
912
+ var _existing_get;
913
+ const existing = await transaction.get(ref);
914
+ if (!existing.exists) return;
915
+ const stored = (_existing_get = existing.get('topics')) != null ? _existing_get : {};
916
+ const lifted = {};
917
+ for (const topicId of topicIds){
918
+ const previous = stored[topicId];
919
+ if (readTopicSubscriptionState(previous) !== 'opted-out') continue;
920
+ lifted[topicId] = _extends({}, previous, {
921
+ resubscribedAt: FieldValue.serverTimestamp()
922
+ });
923
+ }
924
+ if (!Object.keys(lifted).length) return;
925
+ transaction.set(ref, {
926
+ topics: _extends({}, stored, lifted),
927
+ updatedAt: FieldValue.serverTimestamp()
928
+ }, {
929
+ merge: true
930
+ });
931
+ });
932
+ }));
933
+ }
742
934
  /**
743
935
  * Reopens ONE stream for a signed-in account (AGL-3305) — this plugin's side
744
936
  * of `plugin-email-streams`, which the console asks when an account's answer
@@ -769,13 +961,15 @@ const unsubscribeHandler = async (req, res)=>{
769
961
  status: 'held',
770
962
  reason: 'unusable-address'
771
963
  };
772
- const state = await readSubscriptionState(firestore, request.hostId, key);
964
+ // The sender the stream is rejoined FROM — the site's consent group, whose
965
+ // records hold its mail as its own do.
966
+ const group = await loadConsentGroup(request.hostId);
967
+ const state = await readSubscriptionState(firestore, group, key);
773
968
  if (state.protectedRecord) {
774
- var _suppression_get;
775
- const suppression = await firestore.collection('hosts').doc(request.hostId).collection('suppressions').doc(key).get();
969
+ var _state_protectedReason;
776
970
  return {
777
971
  status: 'held',
778
- reason: String((_suppression_get = suppression.get('reason')) != null ? _suppression_get : 'held')
972
+ reason: (_state_protectedReason = state.protectedReason) != null ? _state_protectedReason : 'held'
779
973
  };
780
974
  }
781
975
  const email = String(request.email).trim().toLowerCase();
@@ -788,6 +982,9 @@ const unsubscribeHandler = async (req, res)=>{
788
982
  ],
789
983
  confirmPending: false
790
984
  });
985
+ await resumeTopicsAcrossGroup(firestore, group, key, [
986
+ request.topicId
987
+ ]);
791
988
  return {
792
989
  status: 'rejoined',
793
990
  releasedSuppression: false,
@@ -803,7 +1000,10 @@ const unsubscribeHandler = async (req, res)=>{
803
1000
  ],
804
1001
  confirmPending: false
805
1002
  });
806
- if (!await releaseSiteSuppression(firestore, request.hostId, key)) {
1003
+ await resumeTopicsAcrossGroup(firestore, group, key, [
1004
+ request.topicId
1005
+ ]);
1006
+ if (!await releaseSiteSuppression(firestore, group, key)) {
807
1007
  // Turned into a bounce or a complaint between the read and the lift.
808
1008
  return {
809
1009
  status: 'held',
@@ -849,7 +1049,7 @@ const unsubscribeHandler = async (req, res)=>{
849
1049
  return `<div style="border-top:1px solid ${PAL.divider};padding-top:18px;margin-top:6px">` + `<div style="font-size:14px;font-weight:600;color:${PAL.ink};margin-bottom:2px">` + 'How often' + '</div>' + `<div style="font-size:13px;line-height:1.45;color:${PAL.muted};margin-bottom:6px">` + 'This applies to everything above. Nothing is canceled — messages just ' + 'wait until the next one is due.' + '</div>' + option('all', 'As they come') + option('daily', 'At most one a day') + option('weekly', 'At most one a week') + option('monthly', 'At most one a month') + '</div>';
850
1050
  }
851
1051
  /** The preference page's body. */ function preferencesFormBody(args) {
852
- const { email, query, topics, state, topicId, brand } = args;
1052
+ const { email, query, topics, state, topicId, brand, sender } = args;
853
1053
  const pal = brand.pal;
854
1054
  // A bounce or a complaint is not a preference, so the page does not pretend
855
1055
  // the recipient can edit their way out of one. Shown instead of the form
@@ -858,7 +1058,7 @@ const unsubscribeHandler = async (req, res)=>{
858
1058
  if (state.protectedRecord) return protectedAddressBody();
859
1059
  const current = resolveCampaignTopic(topicId, topics);
860
1060
  const action = `/api/email/preferences?${escapeHtml(query)}`;
861
- return heading('Email preferences') + paragraph(`Choose what <strong style="color:${PAL.ink}">${escapeHtml(email)}</strong> should keep receiving from ` + `<strong style="color:${PAL.ink}">${escapeHtml(brand.name)}</strong>. Unticked emails stop; everything else carries on.`, 8) + (state.suppressed ? paragraph('You are currently unsubscribed from everything. Tick anything ' + 'below to start receiving it again.', 8) : '') + `<form method="post" action="${action}">` + topics.map((topic)=>topicRow(topic, // A whole-site suppression outranks the per-topic record, so an
1061
+ return heading('Email preferences') + paragraph(`Choose what <strong style="color:${PAL.ink}">${escapeHtml(email)}</strong> should keep receiving from ` + `<strong style="color:${PAL.ink}">${sender.name}</strong>. ` + 'Unticked emails stop; everything else carries on.', 8) + reachParagraph(sender, 8) + (state.suppressed ? paragraph('You are currently unsubscribed from everything. Tick anything ' + 'below to start receiving it again.', 8) : '') + `<form method="post" action="${action}">` + topics.map((topic)=>topicRow(topic, // A whole-site suppression outranks the per-topic record, so an
862
1062
  // unsubscribed recipient sees every box empty — which is the state
863
1063
  // they are actually in, and the state the form must round-trip. An
864
1064
  // unconfirmed topic is empty for the same reason: the send path
@@ -883,12 +1083,13 @@ const unsubscribeHandler = async (req, res)=>{
883
1083
  }
884
1084
  /** What the result page tells the recipient actually changed. */ function changeSummary(args) {
885
1085
  const address = `<strong style="color:${PAL.ink}">${escapeHtml(args.email)}</strong>`;
1086
+ const from = args.sender.reach ? args.sender.name : 'this site';
886
1087
  if (!args.drop.length) {
887
- return `${address} keeps receiving everything this site sends.`;
1088
+ return `${address} keeps receiving everything ${from} sends.`;
888
1089
  }
889
1090
  const names = args.drop.map((topic)=>escapeHtml(topic.name)).join(', ');
890
1091
  if (!args.keep.length) {
891
- return `${address} has been unsubscribed from ${names} — everything this ` + 'site currently sends.';
1092
+ return `${address} has been unsubscribed from ${names} — everything ` + `${from} currently sends.`;
892
1093
  }
893
1094
  return `${address} will stop receiving ${names}, and keeps the rest.`;
894
1095
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server.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\nimport { registerPluginApiRoute, type PluginApiHandler } from '@aglyn/aglyn/server'\n/*\n * The MODULE, not the barrel. `@aglyn/aglyn` re-exports the app-utils index,\n * which reaches `enabled-plugins-context` and therefore React — and this file\n * is loaded by the plugin API route's SERVER graph, where a client-only\n * module is a bundle `app-router-graph.spec.ts` refuses. Every name here is a\n * pure function or a constant that lives in one leaf file.\n */\nimport {\n activeEmailTopics,\n mergeEmailTopics,\n normalizeEmailTopic,\n readTopicSubscriptionState,\n resolveCampaignTopic,\n EMAIL_TOPICS_COLLECTION,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type EmailTopic,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n confirmTopicSubscription,\n EMAIL_FREQUENCY_SUBCOLLECTION,\n firebaseAdmin,\n mirrorPlatformResubscribe,\n mirrorPlatformUnsubscribe,\n resolveCampaignSendRef,\n resolveOrgIdForHost,\n setMarketingCadence,\n UNSUBSCRIBE_SUPPRESSION_REASON,\n type ConfirmTopicResult,\n} from '@aglyn/tenant-data-admin'\nimport type {\n PluginEmailStreamRejoinRequest,\n PluginEmailStreamRejoinResult,\n} from '@aglyn/aglyn/plugin-manager/plugin-email-streams'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\n/*\n * The pure cadence rule from the shared email library, where the SEND path\n * reads it too. The preference page and the gate must agree about what\n * `'weekly'` means down to the coercion of a malformed value, and two copies\n * of that is how a page comes to record a choice the gate does not recognize.\n */\nimport {\n normalizeMarketingCadence,\n type MarketingCadence,\n} from '@aglyn/shared-util-email'\nimport { escapeHtml } from '@aglyn/shared-util-tools/escape-html'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n heading,\n isCampaignPathId,\n page,\n paragraph,\n PAL,\n PLATFORM_EMAIL_BRAND,\n readParams,\n resolveEmailPageBrand,\n sendPage,\n signatureMatches,\n signedQuery,\n submitButton,\n successBadge,\n suppressionKeyFor,\n type EmailBrandSource,\n type EmailPageBrand,\n type EmailPalette,\n type UnsubscribeLinkParams,\n} from './unsubscribe-link'\n\n/**\n * One-click unsubscribe (AGL-161), split into a safe GET and a mutating POST\n * (AGL-2408), with a preference center in front of the human-facing half.\n *\n * ## Why the GET stopped writing\n *\n * This handler used to write the suppression on GET, and the docblock called\n * that a feature: \"GET so it works from any mail client; idempotent.\"\n * Idempotent is not the property that matters. A GET must be SAFE — free of\n * side effects the user did not ask for — and this one was not.\n *\n * Every mail client and security gateway of consequence (Microsoft Defender\n * for Office 365's Safe Links, Google's own scanners, Proofpoint, Mimecast)\n * FETCHES every URL in a message before the recipient ever sees it, to check\n * where it lands. Each of those fetches silently unsubscribed the recipient\n * from that merchant's list. The recipient never clicked anything; the\n * merchant sees their audience shrink and cannot explain it; and until\n * AGL-2410 there was no screen in the product to even discover it, let alone\n * undo it. That is a customer's marketing list being destroyed by a\n * prescanner, on our side of the line.\n *\n * So: GET renders a page carrying a same-URL POST form, and only the POST\n * writes. A prescanner following any of these three links now renders a page\n * and changes nothing. That property is not negotiable and every handler in\n * this file holds it.\n *\n * ## RFC 8058 one-click\n *\n * Gmail's and Yahoo's bulk-sender rules ask for `List-Unsubscribe` PLUS\n * `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, and a client honoring\n * that pair sends a POST to the header URL with `List-Unsubscribe=One-Click`\n * as an `application/x-www-form-urlencoded` body. `unsubscribeHandler`'s POST\n * branch is exactly what that lands on — which is why the two halves had to be\n * fixed together: turning the GET into a confirmation page without accepting\n * POST would have broken unsubscribe outright, and advertising one-click while\n * the only mutating verb was GET would have been the same bug with a header on\n * top.\n *\n * THE PREFERENCE CENTER IS NOT IN THAT PATH, and must never be. The\n * `List-Unsubscribe` header still names `email/unsubscribe`, whose POST acts\n * immediately with no page in between; the preference center is what the\n * FOOTER link in the message body points at, where a human is present to make\n * a choice. Routing the header at a page of checkboxes would be advertising\n * one-click against a surface that cannot honor it — a mailbox provider POSTs\n * that URL with nobody watching, reads a 200, and reports the recipient\n * unsubscribed when nothing was written.\n *\n * The one-click POST carries no `Origin` header (it is sent by the mailbox\n * provider's servers, not a browser), which the dispatcher's same-origin gate\n * deliberately allows; the forms' POSTs are same-origin. Neither needs a CSRF\n * token beyond the HMAC already in the URL: a caller who cannot produce `sig`\n * cannot unsubscribe anyone, and a caller who can is holding the recipient's\n * own mail.\n *\n * ## No `mailto:` variant, and why that is a deliberate hole\n *\n * RFC 8058 also permits a `mailto:` fallback in the header. Adding one now\n * would point recipients at an address nobody reads — `docs/EMAIL_SETUP.md`\n * lists a monitored `hello@aglyn.com` as an unstarted idea — and an\n * unsubscribe request that lands in an unmonitored inbox is worse than no\n * fallback at all, because the recipient believes they have unsubscribed. It\n * needs a mailbox and an inbound route, which is provider setup rather than\n * repo work.\n */\n\n/** Read from both verbs; the secret the link was signed with. */\nfunction linkSecret(): string {\n return process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.CRON_SECRET || ''\n}\n\n/**\n * Params + suppression key, or the status to answer with.\n *\n * Every one of these routes opens the same way — read, check the secret is\n * configured, verify the HMAC, key the address — and every one of them has to\n * do it before touching Firestore. A shared preamble is also what keeps the\n * three from drifting into three slightly different ideas of a valid link.\n */\ninterface OpenedLink {\n /**\n * The status to answer with, or 0 when the link is good.\n *\n * A refusal CODE rather than a discriminated union: this library compiles\n * with `strictNullChecks: false`, under which a `{ok: true} | {ok: false}`\n * union does not narrow on a truthiness check, so the union shape would\n * type-error at every call site that read the status.\n */\n refusal: number\n params: UnsubscribeLinkParams\n key: string\n}\n\nfunction openSignedLink(req: Parameters<PluginApiHandler>[0]): OpenedLink {\n const params = readParams(req)\n const secret = linkSecret()\n const refuse = (status: number): OpenedLink => ({\n refusal: status,\n params,\n key: '',\n })\n if (!params.hostId || !params.email || !params.signature || !secret) {\n return refuse(400)\n }\n if (!signatureMatches({ ...params, secret })) return refuse(403)\n // `personKey` refuses a value that is not an address rather than hashing it,\n // so a signed link naming a malformed address is a bad link and not a\n // suppression document for a person who does not exist.\n const key = suppressionKeyFor(params.email)\n if (!key) return refuse(400)\n return { refusal: 0, params, key }\n}\n\n/**\n * How long the shell will wait for the sending site's identity.\n *\n * A branded page is worth one host read; it is not worth a page that never\n * arrives. These four routes are the recipient's only way to stop the mail, so\n * an unbranded page rendered promptly beats a correct one that hangs behind a\n * slow read — the timeout falls back rather than failing.\n */\nconst BRAND_READ_TIMEOUT_MS = 1500\n\n/**\n * The SENDING SITE's identity for the shell, not ours.\n *\n * One read of `hosts/{hostId}`, and every failure mode lands on the same\n * answer: no host id, a missing document, a read that throws, a read that is\n * slow, or a host that has simply set no brand all resolve to\n * {@link PLATFORM_EMAIL_BRAND}. That is also the self-host answer, so the\n * fallback path is the one an operator runs every day rather than a branch\n * only reached when something is broken.\n */\nasync function loadHostBrand(hostId: string): Promise<EmailPageBrand> {\n if (!hostId) return PLATFORM_EMAIL_BRAND\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n const firestore = firebaseAdmin.app().firestore()\n const snapshot = await Promise.race([\n firestore.collection('hosts').doc(hostId).get(),\n new Promise<null>((resolve) => {\n timer = setTimeout(() => resolve(null), BRAND_READ_TIMEOUT_MS)\n }),\n ])\n if (!snapshot?.exists) return PLATFORM_EMAIL_BRAND\n // The id LAST: it addresses the `media:` logo reference, and the copy of\n // it stored in the document is the one that can be stale or absent.\n return resolveEmailPageBrand({\n ...(snapshot.data() as EmailBrandSource),\n $id: hostId,\n })\n } catch (error) {\n console.error('[email] host brand read failed', error)\n return PLATFORM_EMAIL_BRAND\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\nconst unsubscribeHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n const opened = openSignedLink(req)\n if (opened.refusal) {\n return void res.status(opened.refusal).send('Invalid unsubscribe link')\n }\n const { params, key } = opened\n const { hostId, email, campaignId, topicId } = params\n const query = signedQuery(params)\n\n if (method !== 'POST') {\n // SAFE. A prescanner lands here and nothing is written — the brand read\n // is the only Firestore access on this path, and it is a read.\n const brand = await loadHostBrand(hostId)\n return void sendPage(\n res,\n page(\n heading('Unsubscribe?') +\n paragraph(\n `Confirm that <strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong> should stop receiving emails from ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n brand.name,\n )}</strong>.`,\n ) +\n `<form method=\"post\" action=\"/api/email/unsubscribe?${escapeHtml(\n query,\n )}\">` +\n submitButton('Unsubscribe', { pal: brand.pal }) +\n '</form>' +\n // The way to a NARROWER choice, offered on the page rather than only\n // in the message footer: a recipient who reached the total\n // unsubscribe from a mail client's own link has never been shown\n // that leaving one stream is possible.\n `<p style=\"margin:16px 0 0;font-size:13px;line-height:1.5;text-align:center\">` +\n `<a href=\"/api/email/preferences?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'Choose which emails to stop instead</a></p>',\n 420,\n brand,\n ),\n )\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const [created, brand] = await Promise.all([\n writeSiteSuppression(firestore, hostId, key, {\n email,\n campaignId,\n topicId,\n }),\n loadHostBrand(hostId),\n ])\n\n /*\n * The campaign's own unsubscribe count.\n *\n * AFTER the suppression and with its failure swallowed, for the reason\n * the delivery webhook orders its writes the same way: the suppression is\n * the write that must happen, and a statistic must never be able to cost\n * one. A lost increment understates an unsubscribe rate; a lost\n * suppression mails somebody who asked us not to. Where the send is, and\n * why the write never creates it, is {@link countSendUnsubscribe}'s.\n */\n if (created) await countSendUnsubscribe(firestore, hostId, campaignId)\n // The account's answer about product updates, when this is the\n // platform's own marketing site (AGL-3305). After the suppression, and it\n // never throws: the list is what stops the mail.\n await mirrorPlatformUnsubscribe({ hostId, email, left: 'everything' })\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading(\"You're unsubscribed\") +\n paragraph(\n `You won't receive further emails from ${escapeHtml(\n brand.name,\n )}.`,\n 20,\n ) +\n // Same signed params, so the click that just proved this is really\n // this recipient's link doubles as the resubscribe link — no new\n // token, no second email round-trip (AGL-2499).\n `<a href=\"/api/email/resubscribe?${escapeHtml(query)}\" ` +\n `style=\"font-size:13px;color:${brand.pal.link};text-decoration:none\">` +\n 'Changed your mind? Resubscribe</a>',\n 420,\n brand,\n ),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Unsubscribe failed — please try again')\n }\n}\n\n/**\n * One more unsubscribe on the send a link named (`cid`), never throwing.\n *\n * The link carries the site and the send id it was signed over, and nothing\n * else; the send itself is the organization's\n * (`orgs/{orgId}/campaigns/{sendId}`), or still the site's when the\n * migration has not reached it, and `resolveCampaignSendRef` finds whichever\n * holds it. A send in neither place was discarded, and there is nothing to\n * count against.\n *\n * `update()`, never a merge-set: a merge-set would re-create a send deleted\n * between the resolve and the write as a husk holding one `stats` map, and\n * `update()` refuses a missing document — the count for a send nobody can\n * open has no reader. Every failure is swallowed, because the suppression\n * has already been written and a statistic must never be able to cost one.\n */\nasync function countSendUnsubscribe(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n campaignId: string,\n): Promise<void> {\n if (!isCampaignPathId(campaignId)) return\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n const sendRef = await resolveCampaignSendRef({\n hostId,\n sendId: campaignId,\n orgId,\n firestore,\n })\n await sendRef?.update({ 'stats.unsubscribes': FieldValue.increment(1) })\n } catch {\n // The suppression is the write that mattered, and it has landed.\n }\n}\n\n/**\n * The whole-site suppression, written the same way from both routes that\n * write one.\n *\n * `reason: 'unsubscribe'` is written explicitly (AGL-2410). The Resend webhook\n * stamps `'bounce'` / `'complaint'`, and until now an unsubscribe was the only\n * entry with no reason at all — so a reader had to infer one from an absent\n * field, which is a rule that holds only while nothing else ever forgets to\n * write it.\n *\n * `createdAt` is written only when the document is new, matching\n * `email-events.ts`: a bounce arriving after an unsubscribe must not restamp\n * the date the person actually unsubscribed, and neither must a second click\n * on the same link.\n *\n * @returns WHETHER THIS CLICK CREATED THE SUPPRESSION, decided inside the\n * transaction and used outside it. It is the idempotency the campaign\n * counter needs, and it comes for free because the transaction\n * already reads the document to decide whether to stamp `createdAt`.\n * A second click on the same link — and there will be second clicks,\n * from a person pressing the button twice and from a client\n * re-POSTing a one-click header — finds the entry present and\n * contributes nothing, so `stats.unsubscribes` counts PEOPLE who left\n * rather than button presses.\n */\nasync function writeSiteSuppression(\n firestore: any,\n hostId: string,\n key: string,\n fields: { email: string; campaignId: string; topicId: string },\n): Promise<boolean> {\n const ref = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppressions')\n .doc(key)\n /*\n * Assigned rather than or-ed inside the body because a Firestore\n * transaction may retry, and the reading that counts is the one whose write\n * committed.\n */\n let created = false\n await firestore.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n created = !existing.exists\n transaction.set(\n ref,\n {\n email: fields.email,\n reason: UNSUBSCRIBE_SUPPRESSION_REASON,\n suppressedAt: FieldValue.serverTimestamp(),\n // WHICH mailing they left over. Written on the suppression itself as\n // well as counted on the campaign, so the Suppressions list can answer\n // \"why did this person go\" for one address without the aggregate — and\n // stamped only when this click created the entry, so a re-click cannot\n // re-attribute an old unsubscribe to whatever link the person happened\n // to press second. `topicId` rides along on the same rule: it is the\n // stream that lost them, which is the finer half of the same question.\n ...(existing.exists\n ? {}\n : {\n createdAt: FieldValue.serverTimestamp(),\n ...(fields.campaignId ? { campaignId: fields.campaignId } : {}),\n ...(fields.topicId ? { topicId: fields.topicId } : {}),\n }),\n },\n { merge: true },\n )\n })\n // The same verdict on the record the person is (AGL-3245), through\n // whichever plugin keeps the workspace's records, so the record page says\n // they left. A stamp that failed is logged by the seam and costs the\n // unsubscribe nothing.\n if (created) {\n await stampRecordEmailState({\n hostId,\n email: fields.email,\n state: {\n status: 'unsubscribed',\n atMs: Date.now(),\n source: 'campaign',\n detail: fields.campaignId ? 'Unsubscribed from a campaign email.' : 'Unsubscribed by the link.',\n },\n })\n }\n return created\n}\n\n/**\n * The self-service way back in (AGL-2499) that `unsubscribeHandler` never\n * had: same signed-link shape, same safe-GET/mutating-POST split, same\n * HMAC — a resubscribe link is only as trustworthy as the unsubscribe link\n * it rides in on, so it earns no looser a contract.\n *\n * Reverses ONLY a self-service unsubscribe (`reason: 'unsubscribe'`). A\n * bounce or spam-complaint suppression (`email-events.ts`'s Resend webhook)\n * protects the SENDER's deliverability, not a preference the recipient can\n * waive by clicking a link — undoing one from here would let anyone who\n * still holds an old campaign email re-arm sending to an address that\n * bounced or complained.\n */\nconst resubscribeHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n // The SAME verifier the unsubscribe uses, because this link is minted by\n // handing the unsubscribe's own signed query to a second route. Two\n // implementations of one signature scheme is how the resubscribe link comes\n // to reject a signature the unsubscribe link just accepted.\n const opened = openSignedLink(req)\n if (opened.refusal) {\n return void res.status(opened.refusal).send('Invalid link')\n }\n const { params, key } = opened\n const { hostId, email } = params\n const query = signedQuery(params)\n\n if (method !== 'POST') {\n // SAFE, same reasoning as the unsubscribe GET: a prescanner must not be\n // able to resubscribe someone either.\n const brand = await loadHostBrand(hostId)\n return void sendPage(\n res,\n page(\n heading('Resubscribe?') +\n paragraph(\n `Start receiving emails from <strong style=\"color:${PAL.ink}\">` +\n `${escapeHtml(brand.name)}</strong> again at ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong>.`,\n ) +\n `<form method=\"post\" action=\"/api/email/resubscribe?${escapeHtml(\n query,\n )}\">` +\n submitButton('Resubscribe', { accent: 'link', pal: brand.pal }) +\n '</form>',\n 420,\n brand,\n ),\n )\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const [released, brand] = await Promise.all([\n releaseSiteSuppression(firestore, hostId, key),\n loadHostBrand(hostId),\n ])\n if (!released) {\n return void sendPage(res, page(protectedAddressBody(), 420, brand))\n }\n // Restores the account's Yes only when an email door took it away and\n // product updates now reach them (AGL-3305).\n await mirrorPlatformResubscribe({ hostId, email, via: 'email-resubscribe' })\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading(\"You're resubscribed\") +\n paragraph(\n `You'll receive emails from ${escapeHtml(\n brand.name,\n )} again.`,\n 0,\n ),\n 420,\n brand,\n ),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Resubscribe failed — please try again')\n }\n}\n\n/**\n * Lift a whole-site suppression, or refuse to.\n *\n * THE ONE RULE THAT IS NOT A PREFERENCE. A `bounce` or `complaint` entry says\n * the mailbox is dead or its owner pressed \"report spam\", and neither is a\n * setting the person on the other end of a link may change. Both are the\n * sending domain's protection — one shared domain under `p=reject` for every\n * tenant — so honoring a resubscribe over one would be handing the recipient a\n * lever on somebody else's deliverability. Every path that puts an address\n * back in circulation goes through here so that there is exactly one place the\n * rule is stated.\n *\n * @returns false when the record was left standing because it is not an\n * unsubscribe.\n */\nasync function releaseSiteSuppression(\n firestore: any,\n hostId: string,\n key: string,\n): Promise<boolean> {\n const ref = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppressions')\n .doc(key)\n const snapshot = await ref.get()\n if (\n snapshot.exists &&\n snapshot.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON\n ) {\n return false\n }\n // Idempotent whether or not a doc existed — a resubscribe click on an\n // address that was never suppressed (or already resubscribed) is not an\n // error, it is the state the visitor wanted.\n await ref.delete()\n return true\n}\n\n/** Shown wherever a resubscribe is refused, so the wording is one wording. */\nfunction protectedAddressBody(): string {\n return (\n heading(\"Can't resubscribe this address\") +\n paragraph(\n 'This address was suppressed by a delivery problem, not an ' +\n 'unsubscribe, so it can’t be re-added from this link. Contact ' +\n 'the site directly if this looks wrong.',\n 0,\n )\n )\n}\n\n/**\n * THE PREFERENCE CENTER — the page the message footer links to.\n *\n * ## What it is for\n *\n * `docs/specs/email-competitive-gaps.md` §1f: every product compared has a\n * preference center and we had one lever, marked all-or-nothing. The cost of\n * that is not a missing feature, it is a misdirected one — the recipient who\n * only wanted the sales mail to stop had to stop everything, and the recipient\n * who did not want to stop everything pressed \"report spam\" instead, which is\n * a complaint on a shared sending domain.\n *\n * ## What it may show, and what it must not\n *\n * Reached with no session, by anyone holding the link. The HMAC is what\n * authorizes it, and it covers exactly the host, the address, the campaign and\n * the topic — so the page shows the org's topic CATALOG and this address's\n * opt-out state against it, and nothing else. Not the contact record, not the\n * lists they are on, not their name, not whether we have ever heard of them.\n *\n * It is not an enumeration oracle for two reasons that both have to hold. A\n * caller cannot ask about an address they do not already hold a signed link\n * for; and the page renders IDENTICALLY for an address with no records at all\n * — an unknown address reads as \"subscribed to everything\", which is both the\n * truthful answer and the one that reveals nothing. There is deliberately no\n * \"we don't have that address\" branch, because that branch is the oracle.\n */\nconst preferencesHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n const opened = openSignedLink(req)\n if (opened.refusal) {\n return void res.status(opened.refusal).send('Invalid preferences link')\n }\n const { params, key } = opened\n const { hostId, email, campaignId, topicId } = params\n const query = signedQuery(params)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n // Three independent reads, so they go together rather than in series —\n // the brand is not worth a third round trip on the page a recipient is\n // waiting for.\n const [catalog, state, brand] = await Promise.all([\n loadTopicCatalog(firestore, hostId),\n readSubscriptionState(firestore, hostId, key),\n loadHostBrand(hostId),\n ])\n const topics = activeEmailTopics(catalog)\n\n if (method !== 'POST') {\n // SAFE. Reads only, exactly like the other two GETs.\n return void sendPage(\n res,\n page(\n preferencesFormBody({ email, query, topics, state, topicId, brand }),\n 520,\n brand,\n ),\n )\n }\n\n const body = (req.body ?? {}) as Record<string, unknown>\n if (String(body['action'] ?? '') === 'all') {\n const created = await writeSiteSuppression(firestore, hostId, key, {\n email,\n campaignId,\n topicId,\n })\n if (created) await countSendUnsubscribe(firestore, hostId, campaignId)\n await mirrorPlatformUnsubscribe({ hostId, email, left: 'everything' })\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading('Sorry to see you go') +\n paragraph(\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(email)}</strong> ` +\n 'has been unsubscribed from every email ' +\n `${escapeHtml(brand.name)} sends.`,\n 20,\n ) +\n paragraph(\n 'Changed your mind? ' +\n `<a href=\"/api/email/resubscribe?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'Resubscribe</a>, or ' +\n `<a href=\"/api/email/preferences?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'pick just the emails you want</a>.',\n 0,\n ),\n 420,\n brand,\n ),\n )\n }\n\n /*\n * A CHECKED BOX MEANS \"KEEP SENDING\", so the opt-outs are the complement.\n *\n * Read off the catalog rather than off the form, deliberately. A browser\n * submits nothing at all for an unchecked box, so a form that named only\n * the boxes to TURN OFF would be indistinguishable from a form where the\n * recipient turned everything off — and the two mean opposite things.\n */\n const keep = new Set(\n topics\n .map((topic) => topic.id)\n .filter((id) => String(body[`topic:${id}`] ?? '') !== ''),\n )\n const drop = topics.filter((topic) => !keep.has(topic.id))\n await writeTopicOptOuts(firestore, hostId, key, {\n email,\n optOut: drop.map((topic) => topic.id),\n resume: [...keep],\n })\n\n /*\n * HOW OFTEN, recorded from the same submit as WHAT.\n *\n * They are one decision — \"less of this, and less often\" — so they are\n * one form and one round trip. It is stored on the send counter rather\n * than beside the topic opt-outs because that is the document the send\n * path already reads for every marketing message, which is what makes\n * honoring the request free at the point it has to be honored.\n *\n * A value that is not a cadence lands on `'all'` rather than erroring:\n * this page is reached with no session by anybody holding the link, so\n * `body` is untrusted, and the failure a recipient must not meet on the\n * screen they came to in order to leave is a 500.\n */\n const cadence = normalizeMarketingCadence(body['cadence'])\n const cadenceStored = await setMarketingCadence(hostId, email, cadence)\n\n /*\n * A person asking for SOME mail is asking not to be suppressed from ALL of\n * it, so a whole-site unsubscribe standing against this address is lifted\n * — through the same guard the resubscribe route uses, which refuses to\n * touch a bounce or a complaint. Without this the page would accept a\n * choice it could not honor: every box ticked, and the send path still\n * dropping the address at the site suppression one layer above topics.\n */\n let stillBlocked = false\n if (keep.size) {\n stillBlocked = !(await releaseSiteSuppression(firestore, hostId, key))\n }\n\n /*\n * The account's answer about product updates follows what this page just\n * wrote (AGL-3305), and only on the platform's own marketing site: a No\n * when product updates is among what they left, and a Yes back only when\n * an email door took it and the list is open again. Leaving the\n * newsletter says nothing about product updates, so neither call does\n * anything for it. After every write above, so both read the outcome.\n */\n if (drop.length) {\n await mirrorPlatformUnsubscribe({\n hostId,\n email,\n left: drop.map((topic) => topic.id),\n })\n }\n if (keep.size) {\n await mirrorPlatformResubscribe({ hostId, email, via: 'email-preferences' })\n }\n\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading(drop.length ? 'Sorry to see you go' : 'Preferences saved') +\n paragraph(\n changeSummary({ email, keep: [...keep], drop, topics }),\n cadence === 'all' && cadenceStored ? 20 : 8,\n ) +\n /*\n * The pace is reported only when it is a CHOICE. \"As they come\" is\n * the default and the absence, so announcing it would tell somebody\n * who touched nothing that they had just asked for something.\n */\n (cadence !== 'all' && cadenceStored\n ? paragraph(\n `They will arrive no more than ${cadenceSentence(cadence)}.`,\n 20,\n )\n : '') +\n (!cadenceStored\n ? paragraph(\n 'One thing we could not change: how often these arrive. Your ' +\n 'other choices are saved — come back to this page to try ' +\n 'that one again.',\n 20,\n )\n : '') +\n (stillBlocked\n ? paragraph(\n 'One thing we could not change: this address is on hold ' +\n 'because an earlier message could not be delivered or was ' +\n 'reported as spam. Contact the site directly if that looks ' +\n 'wrong.',\n 20,\n )\n : '') +\n paragraph(\n 'Changed your mind? ' +\n `<a href=\"/api/email/preferences?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'Come back to this page</a> and tick the boxes again — this ' +\n 'link keeps working.',\n 0,\n ),\n 520,\n brand,\n ),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Preferences failed — please try again')\n }\n}\n\n/**\n * The org's topic catalog for a site.\n *\n * Two reads, both fail-soft to the built-in defaults. A site with no owning\n * org, an org with no stored topics, or a Firestore hiccup all land on the\n * same page: the four built-ins, every box ticked. That is the right failure —\n * a preference page that renders NO topics offers the recipient nothing to\n * uncheck, which turns the one screen they came to in order to leave a stream\n * into a dead end.\n */\nasync function loadTopicCatalog(\n firestore: any,\n hostId: string,\n /**\n * Throw instead of falling back. For a caller about to lift a whole-site\n * unsubscribe on the strength of the list, where the built-ins alone would\n * leave every custom stream mailable (`rejoinStreamForAccount`).\n */\n strict = false,\n): Promise<EmailTopic[]> {\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n if (!orgId) return mergeEmailTopics(null)\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(EMAIL_TOPICS_COLLECTION)\n .get()\n const stored = (snapshot?.docs ?? [])\n .map((doc: any) => normalizeEmailTopic(doc.id, doc.data()))\n .filter((topic: EmailTopic | null): topic is EmailTopic => !!topic)\n return mergeEmailTopics(stored)\n } catch (error) {\n if (strict) throw error\n console.error('[email/preferences] topic catalog read failed', error)\n return mergeEmailTopics(null)\n }\n}\n\n/** What this address currently receives from this site. */\ninterface SubscriptionState {\n /** A whole-site suppression stands. */\n suppressed: boolean\n /**\n * The suppression is a bounce or a complaint, so nothing on this page may\n * lift it.\n */\n protectedRecord: boolean\n /** Topic ids this address has left and not rejoined. */\n optedOut: Set<string>\n /**\n * Topic ids this address was asked to confirm and has not.\n *\n * Held apart from {@link optedOut} even though neither is mailable, because\n * the page says something different about each: one is a choice the person\n * made and the other is a question they have not answered.\n */\n pending: Set<string>\n /** The pace this address last asked for, or `'all'` for never asked. */\n cadence: MarketingCadence\n}\n\n/** How a chosen cadence reads inside a sentence about what will happen. */\nfunction cadenceSentence(cadence: MarketingCadence): string {\n return cadence === 'daily'\n ? 'one a day'\n : cadence === 'weekly'\n ? 'one a week'\n : 'one a month'\n}\n\n/**\n * All three per-site records for one address, in three keyed `get()`s.\n *\n * By document id rather than a query, matching `filterSendableForHost`: no\n * composite index to go missing, and nothing that can fail open on a read\n * window. The third is the send counter, which is where the recipient's\n * chosen pace lives — see `EmailFrequencyRecord.cadence` for why it is stored\n * on the document the send path already reads rather than on this page's own.\n */\nasync function readSubscriptionState(\n firestore: any,\n hostId: string,\n key: string,\n): Promise<SubscriptionState> {\n const hostRef = firestore.collection('hosts').doc(hostId)\n const [suppression, optOuts, frequency] = await Promise.all([\n hostRef.collection('suppressions').doc(key).get(),\n hostRef.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key).get(),\n hostRef\n .collection(EMAIL_FREQUENCY_SUBCOLLECTION)\n .doc(key)\n .get()\n // The pace is the one field on this page whose absence is a legitimate\n // answer, so a read that fails renders the default rather than an\n // error — the recipient still gets their topic checkboxes.\n .catch(() => null),\n ])\n const stored = (optOuts?.exists ? optOuts.get('topics') : null) ?? {}\n const optedOut = new Set<string>()\n const pending = new Set<string>()\n for (const [id, record] of Object.entries(\n stored as Record<string, TopicSubscriptionEntry | null>,\n )) {\n /*\n * The shared reader, not a field test. An entry with a `resubscribedAt`\n * is EVIDENCE of an opt-out that has been lifted rather than a live one —\n * see `writeTopicOptOuts` for why the entry stays — and an entry with a\n * `confirmedAt` carries the same shape of evidence for a confirmation.\n * Only one function knows all three states.\n */\n const state = readTopicSubscriptionState(record)\n if (state === 'opted-out') optedOut.add(id)\n if (state === 'pending') pending.add(id)\n }\n return {\n suppressed: !!suppression?.exists,\n protectedRecord:\n !!suppression?.exists &&\n suppression.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON,\n optedOut,\n pending,\n cadence: normalizeMarketingCadence(\n frequency?.exists ? frequency.get('cadence') : null,\n ),\n }\n}\n\n/**\n * Record the recipient's per-topic choices.\n *\n * ## The record is EVIDENCE, so nothing is removed\n *\n * `email-suppression.ts` makes the argument for the suppression lists: \"a\n * revocation is a FIELD and not a delete, because the record is the evidence\n * that the suppression was honored while it was in force.\" A topic opt-out is\n * the same kind of fact — somebody asked us to stop, and the answer to \"did\n * you honor it\" has to survive them changing their mind later. So rejoining a\n * topic stamps `resubscribedAt` on the existing entry rather than deleting it,\n * and the pair of timestamps is the window the request was in force for.\n *\n * One document per address, a map keyed by topic, rather than a document per\n * (address, topic): the send path reads this by key alongside the suppression\n * lists, and one `get()` per address is what keeps a topic-filtered send the\n * same cost as an unfiltered one.\n *\n * ## Ticking a box here IS the confirmation a double opt-in asks for\n *\n * The entry also carries a pending-confirmation pair, and a recipient who\n * ticks a topic on this page has done more than the confirmation link asks:\n * they clicked a signed link delivered to that mailbox and then made a\n * choice in it. Leaving them pending would mean the page recorded a\n * subscription the send path refuses — a form whose submit does not take\n * effect, which this page refuses to be anywhere else. So a resumed topic\n * that is still pending is confirmed here, stamped with the moment they did\n * it.\n *\n * ## Every write CARRIES the entry forward\n *\n * Each branch spreads the previous entry rather than replacing it. Two pairs\n * of timestamps now live on one entry, and a branch that wrote only its own\n * pair would silently discard the other — an opt-out would erase the record\n * that somebody confirmed, and the erasure would look exactly like a person\n * who never confirmed.\n */\nasync function writeTopicOptOuts(\n firestore: any,\n hostId: string,\n key: string,\n fields: {\n email: string\n optOut: string[]\n resume: string[]\n /**\n * Whether resuming a pending topic confirms it. True for this page, whose\n * tick is the confirmation; false for a reopening that did not come\n * through a link delivered to the mailbox (`rejoinStreamForAccount`).\n */\n confirmPending?: boolean\n },\n): Promise<void> {\n const ref = firestore\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n await firestore.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n const stored = ((existing.exists ? existing.get('topics') : null) ??\n {}) as Record<string, Record<string, unknown>>\n const topics: Record<string, unknown> = {}\n for (const id of fields.optOut) {\n const previous = stored[id]\n /*\n * Already opted out and never rejoined: leave the original timestamp\n * alone. Re-submitting the same form must not restamp the date the\n * person actually left, for the reason `createdAt` is not restamped on\n * the suppression.\n *\n * The state reader, not \"an entry with no `resubscribedAt`\". That\n * shorthand reads a CONFIRMED double opt-in — which carries `pendingAt`\n * and `confirmedAt` and no `resubscribedAt` — as somebody who had\n * already left, so unticking their box would record no opt-out at all\n * and the send path would go on mailing them.\n */\n topics[id] =\n readTopicSubscriptionState(previous) === 'opted-out'\n ? previous\n : {\n ...(previous ?? {}),\n optedOutAt: FieldValue.serverTimestamp(),\n resubscribedAt: null,\n }\n }\n for (const id of fields.resume) {\n const previous = stored[id]\n if (!previous) continue\n const state = readTopicSubscriptionState(previous)\n if (state === 'pending') {\n if (fields.confirmPending !== false) {\n topics[id] = { ...previous, confirmedAt: Date.now() }\n }\n continue\n }\n topics[id] = previous['resubscribedAt']\n ? previous\n : { ...previous, resubscribedAt: FieldValue.serverTimestamp() }\n }\n transaction.set(\n ref,\n {\n email: fields.email,\n // The whole map, not a merge of one key: a topic the recipient\n // rejoined has to lose its live status, and a dotted merge cannot\n // express \"these and no others\" for a map whose keys are data.\n topics: { ...stored, ...topics },\n updatedAt: FieldValue.serverTimestamp(),\n ...(existing.exists\n ? {}\n : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n })\n}\n\n/**\n * Reopens ONE stream for a signed-in account (AGL-3305) — this plugin's side\n * of `plugin-email-streams`, which the console asks when an account's answer\n * about a stream turns back to yes. The caller has already proven the\n * mailbox; see the seam for why that is the caller's job.\n *\n * Two cases, and the second is the reason this lives here:\n *\n * - The address left just this stream: its opt-out is lifted, exactly as\n * re-ticking the box on the preference page lifts it.\n * - The address left EVERYTHING: every other active stream becomes an\n * opt-out first, and only then is the whole-site unsubscribe lifted —\n * through the same guard the resubscribe link uses. They asked for one\n * stream back, not for the newsletter and the promotions they also left,\n * and writing the opt-outs before the lift means there is no moment in\n * which the whole catalog is mailable.\n *\n * A pending confirmation is left pending. The preference page confirms by a\n * tick because the tick came through a link delivered to the mailbox; this\n * request came from the console, which is proof of an account, not of a\n * click in the mailbox the confirmation was sent to.\n *\n * A bounce, a complaint, an erasure or a staff hold is `held`, and nothing is\n * written: none of them is a preference, so no switch lifts them.\n */\nexport async function rejoinStreamForAccount(\n request: PluginEmailStreamRejoinRequest,\n firestore: any = firebaseAdmin.app().firestore(),\n): Promise<PluginEmailStreamRejoinResult> {\n const key = suppressionKeyFor(request.email)\n if (!key) return { status: 'held', reason: 'unusable-address' }\n const state = await readSubscriptionState(firestore, request.hostId, key)\n if (state.protectedRecord) {\n const suppression = await firestore\n .collection('hosts')\n .doc(request.hostId)\n .collection('suppressions')\n .doc(key)\n .get()\n return { status: 'held', reason: String(suppression.get('reason') ?? 'held') }\n }\n const email = String(request.email).trim().toLowerCase()\n if (!state.suppressed) {\n await writeTopicOptOuts(firestore, request.hostId, key, {\n email,\n optOut: [],\n resume: [request.topicId],\n confirmPending: false,\n })\n return { status: 'rejoined', releasedSuppression: false, keptLeft: 0 }\n }\n const others = activeEmailTopics(await loadTopicCatalog(firestore, request.hostId, true))\n .map((topic) => topic.id)\n .filter((id) => id !== request.topicId)\n await writeTopicOptOuts(firestore, request.hostId, key, {\n email,\n optOut: others,\n resume: [request.topicId],\n confirmPending: false,\n })\n if (!(await releaseSiteSuppression(firestore, request.hostId, key))) {\n // Turned into a bounce or a complaint between the read and the lift.\n return { status: 'held', reason: 'protected' }\n }\n return { status: 'rejoined', releasedSuppression: true, keptLeft: others.length }\n}\n\n/** One topic row: a checkbox, its name and its description. */\nfunction topicRow(\n topic: EmailTopic,\n checked: boolean,\n highlighted: boolean,\n /**\n * Asked to confirm and has not.\n *\n * The box is EMPTY for a pending topic, because empty is the truth: the\n * send path refuses this stream until it is confirmed, and a ticked box\n * over a stream nothing will send would be the page telling a lie the\n * recipient can only discover by waiting for mail that never comes. The\n * note beside it is what turns \"not ticked\" from a puzzle into an answer,\n * and ticking it here confirms — see `writeTopicOptOuts`.\n */\n pending = false,\n pal: EmailPalette = PAL,\n): string {\n return (\n `<label style=\"display:flex;gap:12px;align-items:flex-start;padding:14px 0;` +\n `border-top:1px solid ${PAL.divider};cursor:pointer\">` +\n `<input type=\"checkbox\" name=\"topic:${escapeHtml(topic.id)}\" value=\"on\"` +\n (checked ? ' checked' : '') +\n ' style=\"margin:2px 0 0;width:18px;height:18px;flex:none\">' +\n '<span style=\"flex:1\">' +\n `<span style=\"display:block;font-size:14px;font-weight:600;color:${PAL.ink}\">` +\n escapeHtml(topic.name) +\n (highlighted\n ? `<span style=\"margin-left:8px;font-size:11px;font-weight:600;` +\n `text-transform:uppercase;letter-spacing:.04em;color:${pal.link}\">` +\n 'This email</span>'\n : '') +\n '</span>' +\n (pending\n ? `<span style=\"display:block;margin-top:2px;font-size:13px;line-height:1.45;` +\n `color:${PAL.muted}\">Waiting for you to confirm — tick this and save ` +\n 'to start receiving it.</span>'\n : '') +\n (topic.description\n ? `<span style=\"display:block;margin-top:2px;font-size:13px;line-height:1.45;` +\n `color:${PAL.muted}\">${escapeHtml(topic.description)}</span>`\n : '') +\n '</span></label>'\n )\n}\n\n/**\n * HOW OFTEN — the half of the preference center that shipped without.\n *\n * `docs/specs/email-competitive-gaps.md` G10: the frequency CAP shipped and\n * this did not, so a recipient who wanted the same mail less often had two\n * options and one of them was the spam button.\n *\n * Radio buttons rather than a select, and every option written out. The whole\n * value of the control is that somebody skimming a footer link can see, in\n * one glance, that \"less\" is available at all — a collapsed select says only\n * that there is a setting.\n *\n * The default option is named (\"As they come\") rather than left as the empty\n * choice, because a radio group whose default is unlabeled reads as a\n * question the recipient has not answered, and answering it is not something\n * this page should require of somebody who came here to uncheck one box.\n */\nfunction cadenceFieldset(current: MarketingCadence): string {\n const option = (value: MarketingCadence, label: string): string =>\n `<label style=\"display:flex;gap:12px;align-items:center;padding:10px 0;cursor:pointer\">` +\n `<input type=\"radio\" name=\"cadence\" value=\"${escapeHtml(value)}\"` +\n (value === current ? ' checked' : '') +\n ' style=\"margin:0;width:18px;height:18px;flex:none\">' +\n `<span style=\"font-size:14px;color:${PAL.ink}\">${label}</span></label>`\n return (\n `<div style=\"border-top:1px solid ${PAL.divider};padding-top:18px;margin-top:6px\">` +\n `<div style=\"font-size:14px;font-weight:600;color:${PAL.ink};margin-bottom:2px\">` +\n 'How often' +\n '</div>' +\n `<div style=\"font-size:13px;line-height:1.45;color:${PAL.muted};margin-bottom:6px\">` +\n 'This applies to everything above. Nothing is canceled — messages just ' +\n 'wait until the next one is due.' +\n '</div>' +\n option('all', 'As they come') +\n option('daily', 'At most one a day') +\n option('weekly', 'At most one a week') +\n option('monthly', 'At most one a month') +\n '</div>'\n )\n}\n\n/** The preference page's body. */\nfunction preferencesFormBody(args: {\n email: string\n query: string\n topics: EmailTopic[]\n state: SubscriptionState\n topicId: string\n brand: EmailPageBrand\n}): string {\n const { email, query, topics, state, topicId, brand } = args\n const pal = brand.pal\n // A bounce or a complaint is not a preference, so the page does not pretend\n // the recipient can edit their way out of one. Shown instead of the form\n // rather than beside it: a form whose submit cannot take effect is worse\n // than no form.\n if (state.protectedRecord) return protectedAddressBody()\n const current = resolveCampaignTopic(topicId, topics)\n const action = `/api/email/preferences?${escapeHtml(query)}`\n return (\n heading('Email preferences') +\n paragraph(\n `Choose what <strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong> should keep receiving from ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n brand.name,\n )}</strong>. Unticked emails stop; everything else carries on.`,\n 8,\n ) +\n (state.suppressed\n ? paragraph(\n 'You are currently unsubscribed from everything. Tick anything ' +\n 'below to start receiving it again.',\n 8,\n )\n : '') +\n `<form method=\"post\" action=\"${action}\">` +\n topics\n .map((topic) =>\n topicRow(\n topic,\n // A whole-site suppression outranks the per-topic record, so an\n // unsubscribed recipient sees every box empty — which is the state\n // they are actually in, and the state the form must round-trip. An\n // unconfirmed topic is empty for the same reason: the send path\n // refuses it, so a ticked box would not be what is true.\n !state.suppressed &&\n !state.optedOut.has(topic.id) &&\n !state.pending.has(topic.id),\n topic.id === current.id,\n !state.suppressed && state.pending.has(topic.id),\n pal,\n ),\n )\n .join('') +\n /*\n * HOW OFTEN, inside the same form as WHAT.\n *\n * The alternative to letting somebody choose \"monthly\" is letting them\n * choose \"report spam\", and on a shared sending domain under `p=reject`\n * that choice is charged to every other tenant. It sits under the topics\n * because it is the smaller decision of the two and a recipient who has\n * already found the thing they wanted to stop should not have to read\n * past a frequency question to stop it.\n */\n cadenceFieldset(state.cadence) +\n `<div style=\"border-top:1px solid ${PAL.divider};padding-top:20px;margin-top:6px\">` +\n submitButton('Save my preferences', { pal }) +\n '</div></form>' +\n // A SECOND form, not a second button in the first one. Sharing the form\n // would submit the checkbox state along with the \"everything\" action, so a\n // browser that fell back to the first submit button — or a user pressing\n // Return in the form — would send an ambiguous request. Two forms make the\n // two intentions two requests.\n `<form method=\"post\" action=\"${action}\" style=\"margin-top:12px\">` +\n '<input type=\"hidden\" name=\"action\" value=\"all\">' +\n '<button type=\"submit\" style=\"font:inherit;font-size:13px;font-weight:600;' +\n `padding:10px 20px;border:1px solid ${PAL.divider};border-radius:8px;` +\n `background:transparent;color:${PAL.muted};cursor:pointer;width:100%\">` +\n 'Unsubscribe from everything</button></form>'\n )\n}\n\n/** What the result page tells the recipient actually changed. */\nfunction changeSummary(args: {\n email: string\n keep: string[]\n drop: EmailTopic[]\n topics: EmailTopic[]\n}): string {\n const address = `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n args.email,\n )}</strong>`\n if (!args.drop.length) {\n return `${address} keeps receiving everything this site sends.`\n }\n const names = args.drop\n .map((topic) => escapeHtml(topic.name))\n .join(', ')\n if (!args.keep.length) {\n return (\n `${address} has been unsubscribed from ${names} — everything this ` +\n 'site currently sends.'\n )\n }\n return `${address} will stop receiving ${names}, and keeps the rest.`\n}\n\n/**\n * `email/confirm` — the click that turns a pending subscription into a real\n * one (`docs/specs/email-competitive-gaps.md` P8).\n *\n * Same signed-link shape as its three siblings and the same safe-GET /\n * mutating-POST split, which matters here for exactly the reason it mattered\n * to the unsubscribe: a security gateway fetching every URL in the message\n * would otherwise confirm the subscription on the recipient's behalf, and a\n * confirmation nobody made is the one thing a double opt-in exists to\n * prevent. A prescanner following this link renders a page and changes\n * nothing.\n *\n * The subject it verifies is the confirmation form — see\n * `signedConfirmSubject` for why a topic without a campaign needs one — and\n * it is checked through the same comparison every other link goes through.\n */\nconst confirmHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n const params = readParams(req)\n const secret = linkSecret()\n if (!params.hostId || !params.email || !params.signature || !secret) {\n return void res.status(400).send('Invalid confirmation link')\n }\n if (!signatureMatches({ ...params, secret, purpose: 'confirm' })) {\n return void res.status(403).send('Invalid confirmation link')\n }\n if (!suppressionKeyFor(params.email)) {\n return void res.status(400).send('Invalid confirmation link')\n }\n const { hostId, email, topicId } = params\n const query = signedQuery(params)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const [catalog, brand] = await Promise.all([\n loadTopicCatalog(firestore, hostId),\n loadHostBrand(hostId),\n ])\n const topic = resolveCampaignTopic(topicId, catalog)\n\n if (method !== 'POST') {\n // SAFE. A prescanner lands here and confirms nothing.\n return void sendPage(\n res,\n page(\n heading('Confirm your subscription') +\n paragraph(\n `Confirm that <strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong> should receive ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n topic.name,\n )}</strong> from ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n brand.name,\n )}</strong>.`,\n ) +\n `<form method=\"post\" action=\"/api/email/confirm?${escapeHtml(\n query,\n )}\">` +\n submitButton('Yes, subscribe me', { pal: brand.pal }) +\n '</form>',\n 420,\n brand,\n ),\n )\n }\n\n const outcome = await confirmTopicSubscription(hostId, email, topicId)\n return void sendPage(\n res,\n page(confirmationBody(outcome, topic.name, brand.pal), 420, brand),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Confirmation failed — please try again')\n }\n}\n\n/**\n * What each outcome tells the person in front of it.\n *\n * Every arm names what is TRUE rather than what went wrong. Somebody who\n * clicked an expired link has not made a mistake, and somebody who clicked\n * twice has not either — telling either of them \"invalid\" would read as the\n * subscription having failed when the first case needs a fresh signup and the\n * second is already done.\n */\nfunction confirmationBody(\n outcome: ConfirmTopicResult,\n topicName: string,\n pal: EmailPalette = PAL,\n): string {\n const stream = `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n topicName,\n )}</strong>`\n switch (outcome) {\n case 'confirmed':\n return (\n successBadge(pal) +\n heading(\"You're subscribed\") +\n paragraph(`You'll start receiving ${stream} from this site.`, 0)\n )\n case 'already-confirmed':\n return (\n successBadge(pal) +\n heading('Already confirmed') +\n paragraph(`${stream} is already on its way to you.`, 0)\n )\n case 'expired':\n return (\n heading('This link has expired') +\n paragraph(\n `Confirmation links are good for three days. Sign up again and ` +\n `we'll send a fresh one — you are not subscribed to ${stream} in ` +\n 'the meantime.',\n 0,\n )\n )\n case 'opted-out':\n return (\n heading(\"Can't subscribe this address\") +\n paragraph(\n `This address asked to stop receiving ${stream} from this site, so ` +\n 'a confirmation link cannot put it back. Sign up again if that ' +\n 'was not what you meant.',\n 0,\n )\n )\n default:\n return (\n heading('Nothing to confirm') +\n paragraph(\n `There is no pending request for ${stream} at this address. If you ` +\n 'meant to subscribe, sign up on the site.',\n 0,\n )\n )\n }\n}\n\n/** Registers the email plugin's public API routes (AGL-396). */\nexport function registerEmailApi(): void {\n registerPluginApiRoute('email/unsubscribe', unsubscribeHandler)\n registerPluginApiRoute('email/resubscribe', resubscribeHandler)\n registerPluginApiRoute('email/preferences', preferencesHandler)\n registerPluginApiRoute('email/confirm', confirmHandler)\n}\n\n/*\n * The CONSOLE half of the same `email` prefix, kept in its own module.\n *\n * Two audiences, one entry point: the tenant loads this file for\n * `registerEmailApi` (the signed unsubscribe links a recipient clicks, no\n * session behind them), and the console loads it for\n * `registerEmailConsoleApi` (list membership, behind an org-wide role). The\n * manifest generator resolves both surfaces through `@aglyn/plugins-email/server`,\n * so this re-export is what makes the console half reachable — a second entry\n * point would be a second thing to keep in step with plugins.config.json.\n */\nexport {\n registerEmailConsoleApi,\n emailListMembersAddHandler,\n emailListMembersPreviewHandler,\n emailListRulePreviewHandler,\n CONSOLE_ADD_SOURCE,\n LIST_MEMBER_BATCH_MAX,\n} from './server-console'\n"],"names":["registerPluginApiRoute","activeEmailTopics","mergeEmailTopics","normalizeEmailTopic","readTopicSubscriptionState","resolveCampaignTopic","EMAIL_TOPICS_COLLECTION","TOPIC_OPT_OUTS_SUBCOLLECTION","confirmTopicSubscription","EMAIL_FREQUENCY_SUBCOLLECTION","firebaseAdmin","mirrorPlatformResubscribe","mirrorPlatformUnsubscribe","resolveCampaignSendRef","resolveOrgIdForHost","setMarketingCadence","UNSUBSCRIBE_SUPPRESSION_REASON","stampRecordEmailState","normalizeMarketingCadence","escapeHtml","FieldValue","heading","isCampaignPathId","page","paragraph","PAL","PLATFORM_EMAIL_BRAND","readParams","resolveEmailPageBrand","sendPage","signatureMatches","signedQuery","submitButton","successBadge","suppressionKeyFor","linkSecret","process","env","EMAIL_UNSUBSCRIBE_SECRET","CRON_SECRET","openSignedLink","req","params","secret","refuse","status","refusal","key","hostId","email","signature","BRAND_READ_TIMEOUT_MS","loadHostBrand","timer","firestore","app","snapshot","Promise","race","collection","doc","get","resolve","setTimeout","exists","data","$id","error","console","clearTimeout","unsubscribeHandler","res","method","String","toUpperCase","setHeader","send","opened","campaignId","topicId","query","brand","ink","name","pal","link","created","all","writeSiteSuppression","countSendUnsubscribe","left","orgId","sendRef","sendId","update","increment","fields","ref","runTransaction","transaction","existing","set","reason","suppressedAt","serverTimestamp","createdAt","merge","state","atMs","Date","now","source","detail","resubscribeHandler","accent","released","releaseSiteSuppression","protectedAddressBody","via","delete","preferencesHandler","body","catalog","loadTopicCatalog","readSubscriptionState","topics","preferencesFormBody","keep","Set","map","topic","id","filter","drop","has","writeTopicOptOuts","optOut","resume","cadence","cadenceStored","stillBlocked","size","length","changeSummary","cadenceSentence","strict","stored","docs","optOuts","hostRef","suppression","frequency","catch","optedOut","pending","record","Object","entries","add","suppressed","protectedRecord","previous","optedOutAt","resubscribedAt","confirmPending","confirmedAt","updatedAt","rejoinStreamForAccount","request","trim","toLowerCase","releasedSuppression","keptLeft","others","topicRow","checked","highlighted","divider","muted","description","cadenceFieldset","current","option","value","label","args","action","join","address","names","confirmHandler","purpose","outcome","confirmationBody","topicName","stream","registerEmailApi","registerEmailConsoleApi","emailListMembersAddHandler","emailListMembersPreviewHandler","emailListRulePreviewHandler","CONSOLE_ADD_SOURCE","LIST_MEMBER_BATCH_MAX"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server.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\nimport { registerPluginApiRoute, type PluginApiHandler } from '@aglyn/aglyn/server'\n/*\n * The MODULE, not the barrel. `@aglyn/aglyn` re-exports the app-utils index,\n * which reaches `enabled-plugins-context` and therefore React — and this file\n * is loaded by the plugin API route's SERVER graph, where a client-only\n * module is a bundle `app-router-graph.spec.ts` refuses. Every name here is a\n * pure function or a constant that lives in one leaf file.\n */\nimport {\n activeEmailTopics,\n mergeEmailTopics,\n normalizeEmailTopic,\n readTopicSubscriptionState,\n resolveCampaignTopic,\n EMAIL_TOPICS_COLLECTION,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type EmailTopic,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n consentGroupOptOutHosts,\n soloConsentGroup,\n type ConsentGroup,\n} from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n confirmTopicSubscription,\n consentGroupForSite,\n EMAIL_FREQUENCY_SUBCOLLECTION,\n firebaseAdmin,\n mirrorPlatformResubscribe,\n mirrorPlatformUnsubscribe,\n resolveCampaignSendRef,\n resolveOrgIdForHost,\n setMarketingCadence,\n UNSUBSCRIBE_SUPPRESSION_REASON,\n type ConfirmTopicResult,\n} from '@aglyn/tenant-data-admin'\nimport type {\n PluginEmailStreamRejoinRequest,\n PluginEmailStreamRejoinResult,\n} from '@aglyn/aglyn/plugin-manager/plugin-email-streams'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\n/*\n * The pure cadence rule from the shared email library, where the SEND path\n * reads it too. The preference page and the gate must agree about what\n * `'weekly'` means down to the coercion of a malformed value, and two copies\n * of that is how a page comes to record a choice the gate does not recognize.\n */\nimport {\n normalizeMarketingCadence,\n type MarketingCadence,\n} from '@aglyn/shared-util-email'\nimport { escapeHtml } from '@aglyn/shared-util-tools/escape-html'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n heading,\n isCampaignPathId,\n page,\n paragraph,\n PAL,\n PLATFORM_EMAIL_BRAND,\n readParams,\n resolveEmailPageBrand,\n sendPage,\n signatureMatches,\n signedQuery,\n submitButton,\n successBadge,\n suppressionKeyFor,\n type EmailBrandSource,\n type EmailPageBrand,\n type EmailPalette,\n type UnsubscribeLinkParams,\n} from './unsubscribe-link'\n\n/**\n * One-click unsubscribe (AGL-161), split into a safe GET and a mutating POST\n * (AGL-2408), with a preference center in front of the human-facing half.\n *\n * ## Why the GET stopped writing\n *\n * This handler used to write the suppression on GET, and the docblock called\n * that a feature: \"GET so it works from any mail client; idempotent.\"\n * Idempotent is not the property that matters. A GET must be SAFE — free of\n * side effects the user did not ask for — and this one was not.\n *\n * Every mail client and security gateway of consequence (Microsoft Defender\n * for Office 365's Safe Links, Google's own scanners, Proofpoint, Mimecast)\n * FETCHES every URL in a message before the recipient ever sees it, to check\n * where it lands. Each of those fetches silently unsubscribed the recipient\n * from that merchant's list. The recipient never clicked anything; the\n * merchant sees their audience shrink and cannot explain it; and until\n * AGL-2410 there was no screen in the product to even discover it, let alone\n * undo it. That is a customer's marketing list being destroyed by a\n * prescanner, on our side of the line.\n *\n * So: GET renders a page carrying a same-URL POST form, and only the POST\n * writes. A prescanner following any of these three links now renders a page\n * and changes nothing. That property is not negotiable and every handler in\n * this file holds it.\n *\n * ## RFC 8058 one-click\n *\n * Gmail's and Yahoo's bulk-sender rules ask for `List-Unsubscribe` PLUS\n * `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, and a client honoring\n * that pair sends a POST to the header URL with `List-Unsubscribe=One-Click`\n * as an `application/x-www-form-urlencoded` body. `unsubscribeHandler`'s POST\n * branch is exactly what that lands on — which is why the two halves had to be\n * fixed together: turning the GET into a confirmation page without accepting\n * POST would have broken unsubscribe outright, and advertising one-click while\n * the only mutating verb was GET would have been the same bug with a header on\n * top.\n *\n * THE PREFERENCE CENTER IS NOT IN THAT PATH, and must never be. The\n * `List-Unsubscribe` header still names `email/unsubscribe`, whose POST acts\n * immediately with no page in between; the preference center is what the\n * FOOTER link in the message body points at, where a human is present to make\n * a choice. Routing the header at a page of checkboxes would be advertising\n * one-click against a surface that cannot honor it — a mailbox provider POSTs\n * that URL with nobody watching, reads a 200, and reports the recipient\n * unsubscribed when nothing was written.\n *\n * The one-click POST carries no `Origin` header (it is sent by the mailbox\n * provider's servers, not a browser), which the dispatcher's same-origin gate\n * deliberately allows; the forms' POSTs are same-origin. Neither needs a CSRF\n * token beyond the HMAC already in the URL: a caller who cannot produce `sig`\n * cannot unsubscribe anyone, and a caller who can is holding the recipient's\n * own mail.\n *\n * ## No `mailto:` variant, and why that is a deliberate hole\n *\n * RFC 8058 also permits a `mailto:` fallback in the header. Adding one now\n * would point recipients at an address nobody reads — `docs/EMAIL_SETUP.md`\n * lists a monitored `hello@aglyn.com` as an unstarted idea — and an\n * unsubscribe request that lands in an unmonitored inbox is worse than no\n * fallback at all, because the recipient believes they have unsubscribed. It\n * needs a mailbox and an inbound route, which is provider setup rather than\n * repo work.\n */\n\n/** Read from both verbs; the secret the link was signed with. */\nfunction linkSecret(): string {\n return process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.CRON_SECRET || ''\n}\n\n/**\n * Params + suppression key, or the status to answer with.\n *\n * Every one of these routes opens the same way — read, check the secret is\n * configured, verify the HMAC, key the address — and every one of them has to\n * do it before touching Firestore. A shared preamble is also what keeps the\n * three from drifting into three slightly different ideas of a valid link.\n */\ninterface OpenedLink {\n /**\n * The status to answer with, or 0 when the link is good.\n *\n * A refusal CODE rather than a discriminated union: this library compiles\n * with `strictNullChecks: false`, under which a `{ok: true} | {ok: false}`\n * union does not narrow on a truthiness check, so the union shape would\n * type-error at every call site that read the status.\n */\n refusal: number\n params: UnsubscribeLinkParams\n key: string\n}\n\nfunction openSignedLink(req: Parameters<PluginApiHandler>[0]): OpenedLink {\n const params = readParams(req)\n const secret = linkSecret()\n const refuse = (status: number): OpenedLink => ({\n refusal: status,\n params,\n key: '',\n })\n if (!params.hostId || !params.email || !params.signature || !secret) {\n return refuse(400)\n }\n if (!signatureMatches({ ...params, secret })) return refuse(403)\n // `personKey` refuses a value that is not an address rather than hashing it,\n // so a signed link naming a malformed address is a bad link and not a\n // suppression document for a person who does not exist.\n const key = suppressionKeyFor(params.email)\n if (!key) return refuse(400)\n return { refusal: 0, params, key }\n}\n\n/**\n * How long the shell will wait for the sending site's identity.\n *\n * A branded page is worth one host read; it is not worth a page that never\n * arrives. These four routes are the recipient's only way to stop the mail, so\n * an unbranded page rendered promptly beats a correct one that hangs behind a\n * slow read — the timeout falls back rather than failing.\n */\nconst BRAND_READ_TIMEOUT_MS = 1500\n\n/**\n * The SENDING SITE's identity for the shell, not ours.\n *\n * One read of `hosts/{hostId}`, and every failure mode lands on the same\n * answer: no host id, a missing document, a read that throws, a read that is\n * slow, or a host that has simply set no brand all resolve to\n * {@link PLATFORM_EMAIL_BRAND}. That is also the self-host answer, so the\n * fallback path is the one an operator runs every day rather than a branch\n * only reached when something is broken.\n */\nasync function loadHostBrand(hostId: string): Promise<EmailPageBrand> {\n if (!hostId) return PLATFORM_EMAIL_BRAND\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n const firestore = firebaseAdmin.app().firestore()\n const snapshot = await Promise.race([\n firestore.collection('hosts').doc(hostId).get(),\n new Promise<null>((resolve) => {\n timer = setTimeout(() => resolve(null), BRAND_READ_TIMEOUT_MS)\n }),\n ])\n if (!snapshot?.exists) return PLATFORM_EMAIL_BRAND\n // The id LAST: it addresses the `media:` logo reference, and the copy of\n // it stored in the document is the one that can be stale or absent.\n return resolveEmailPageBrand({\n ...(snapshot.data() as EmailBrandSource),\n $id: hostId,\n })\n } catch (error) {\n console.error('[email] host brand read failed', error)\n return PLATFORM_EMAIL_BRAND\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\n/**\n * THE SENDER A PERSON LEAVES HERE — the link site's consent group\n * (`consent-groups.ts`), or the site alone.\n *\n * An org may declare several sites ONE sender, and every capture form in the\n * group named it as one. So these pages treat it as one: an opt-out made here\n * is read by every site in it (the send paths read across the group), the\n * state shown is the group's, a way back in lifts the group's records, and\n * the page names the group rather than one of its sites.\n *\n * `consentGroupForSite` fails to the site alone, and so does this when it\n * throws. `waitMs` bounds the wait where a page only NAMES the sender, for the\n * reason {@link BRAND_READ_TIMEOUT_MS} gives; a page deciding what to read or\n * lift waits for the real answer.\n */\nasync function loadConsentGroup(\n hostId: string,\n waitMs?: number,\n): Promise<ConsentGroup> {\n const alone = soloConsentGroup(hostId)\n const resolving = (async () => {\n try {\n return await consentGroupForSite(hostId)\n } catch (error) {\n console.error('[email] consent group read failed', error)\n return alone\n }\n })()\n if (!waitMs) return resolving\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n return await Promise.race([\n resolving,\n new Promise<ConsentGroup>((resolve) => {\n timer = setTimeout(() => resolve(alone), waitMs)\n }),\n ])\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\n/** How the pages name the sender, escaped for HTML. */\ninterface EmailPageSender {\n /** The group's name for a declared group, else the site's brand. */\n name: string\n /**\n * The sentence that says an act here covers every site in the group, or\n * `''` for a site alone, where the brand already says everything.\n */\n reach: string\n}\n\n/**\n * The sender as a page names it.\n *\n * The group's name is what the capture form disclosed — \"You'll receive\n * marketing email from\" the group — so it is the name the person knows this\n * sender by, and the one an opt-out here reaches. The site's own brand still\n * frames the page; `reach` is what stops the two names reading as a mistake.\n */\nfunction pageSender(brand: EmailPageBrand, group: ConsentGroup): EmailPageSender {\n if (!group.declared || !group.name) {\n return { name: escapeHtml(brand.name), reach: '' }\n }\n return {\n name: escapeHtml(group.name),\n reach:\n `${escapeHtml(group.name)} sends from ${group.hostIds.length} sites, ` +\n `${escapeHtml(brand.name)} among them, and this covers all of them.`,\n }\n}\n\n/** The `reach` sentence as a paragraph, or nothing for a site alone. */\nfunction reachParagraph(sender: EmailPageSender, gap = 20): string {\n return sender.reach ? paragraph(sender.reach, gap) : ''\n}\n\nconst unsubscribeHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n const opened = openSignedLink(req)\n if (opened.refusal) {\n return void res.status(opened.refusal).send('Invalid unsubscribe link')\n }\n const { params, key } = opened\n const { hostId, email, campaignId, topicId } = params\n const query = signedQuery(params)\n\n if (method !== 'POST') {\n // SAFE. A prescanner lands here and nothing is written — the brand and\n // the consent group are the only Firestore access on this path, and both\n // are reads.\n const [brand, group] = await Promise.all([\n loadHostBrand(hostId),\n loadConsentGroup(hostId, BRAND_READ_TIMEOUT_MS),\n ])\n const sender = pageSender(brand, group)\n return void sendPage(\n res,\n page(\n heading('Unsubscribe?') +\n paragraph(\n `Confirm that <strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong> should stop receiving emails from ` +\n `<strong style=\"color:${PAL.ink}\">${sender.name}</strong>.`,\n sender.reach ? 8 : 24,\n ) +\n reachParagraph(sender, 24) +\n `<form method=\"post\" action=\"/api/email/unsubscribe?${escapeHtml(\n query,\n )}\">` +\n submitButton('Unsubscribe', { pal: brand.pal }) +\n '</form>' +\n // The way to a NARROWER choice, offered on the page rather than only\n // in the message footer: a recipient who reached the total\n // unsubscribe from a mail client's own link has never been shown\n // that leaving one stream is possible.\n `<p style=\"margin:16px 0 0;font-size:13px;line-height:1.5;text-align:center\">` +\n `<a href=\"/api/email/preferences?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'Choose which emails to stop instead</a></p>',\n 420,\n brand,\n ),\n )\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n /*\n * Written against the ONE site the link names, and honored by every site\n * in its consent group: the send paths read a group's lists together, so\n * the one row is the refusal for the whole sender — and it reaches a site\n * that joins the group later, which a copy written now could not.\n */\n const [created, brand, group] = await Promise.all([\n writeSiteSuppression(firestore, hostId, key, {\n email,\n campaignId,\n topicId,\n }),\n loadHostBrand(hostId),\n loadConsentGroup(hostId, BRAND_READ_TIMEOUT_MS),\n ])\n const sender = pageSender(brand, group)\n\n /*\n * The campaign's own unsubscribe count.\n *\n * AFTER the suppression and with its failure swallowed, for the reason\n * the delivery webhook orders its writes the same way: the suppression is\n * the write that must happen, and a statistic must never be able to cost\n * one. A lost increment understates an unsubscribe rate; a lost\n * suppression mails somebody who asked us not to. Where the send is, and\n * why the write never creates it, is {@link countSendUnsubscribe}'s.\n */\n if (created) await countSendUnsubscribe(firestore, hostId, campaignId)\n // The account's answer about product updates, when this is the\n // platform's own marketing site (AGL-3305). After the suppression, and it\n // never throws: the list is what stops the mail.\n await mirrorPlatformUnsubscribe({ hostId, email, left: 'everything' })\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading(\"You're unsubscribed\") +\n paragraph(\n `You won't receive further emails from ${sender.name}.`,\n sender.reach ? 8 : 20,\n ) +\n reachParagraph(sender) +\n // Same signed params, so the click that just proved this is really\n // this recipient's link doubles as the resubscribe link — no new\n // token, no second email round-trip (AGL-2499).\n `<a href=\"/api/email/resubscribe?${escapeHtml(query)}\" ` +\n `style=\"font-size:13px;color:${brand.pal.link};text-decoration:none\">` +\n 'Changed your mind? Resubscribe</a>',\n 420,\n brand,\n ),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Unsubscribe failed — please try again')\n }\n}\n\n/**\n * One more unsubscribe on the send a link named (`cid`), never throwing.\n *\n * The link carries the site and the send id it was signed over, and nothing\n * else; the send itself is the organization's\n * (`orgs/{orgId}/campaigns/{sendId}`), or still the site's when the\n * migration has not reached it, and `resolveCampaignSendRef` finds whichever\n * holds it. A send in neither place was discarded, and there is nothing to\n * count against.\n *\n * `update()`, never a merge-set: a merge-set would re-create a send deleted\n * between the resolve and the write as a husk holding one `stats` map, and\n * `update()` refuses a missing document — the count for a send nobody can\n * open has no reader. Every failure is swallowed, because the suppression\n * has already been written and a statistic must never be able to cost one.\n */\nasync function countSendUnsubscribe(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n campaignId: string,\n): Promise<void> {\n if (!isCampaignPathId(campaignId)) return\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n const sendRef = await resolveCampaignSendRef({\n hostId,\n sendId: campaignId,\n orgId,\n firestore,\n })\n await sendRef?.update({ 'stats.unsubscribes': FieldValue.increment(1) })\n } catch {\n // The suppression is the write that mattered, and it has landed.\n }\n}\n\n/**\n * The whole-site suppression, written the same way from both routes that\n * write one.\n *\n * `reason: 'unsubscribe'` is written explicitly (AGL-2410). The Resend webhook\n * stamps `'bounce'` / `'complaint'`, and until now an unsubscribe was the only\n * entry with no reason at all — so a reader had to infer one from an absent\n * field, which is a rule that holds only while nothing else ever forgets to\n * write it.\n *\n * `createdAt` is written only when the document is new, matching\n * `email-events.ts`: a bounce arriving after an unsubscribe must not restamp\n * the date the person actually unsubscribed, and neither must a second click\n * on the same link.\n *\n * @returns WHETHER THIS CLICK CREATED THE SUPPRESSION, decided inside the\n * transaction and used outside it. It is the idempotency the campaign\n * counter needs, and it comes for free because the transaction\n * already reads the document to decide whether to stamp `createdAt`.\n * A second click on the same link — and there will be second clicks,\n * from a person pressing the button twice and from a client\n * re-POSTing a one-click header — finds the entry present and\n * contributes nothing, so `stats.unsubscribes` counts PEOPLE who left\n * rather than button presses.\n */\nasync function writeSiteSuppression(\n firestore: any,\n hostId: string,\n key: string,\n fields: { email: string; campaignId: string; topicId: string },\n): Promise<boolean> {\n const ref = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppressions')\n .doc(key)\n /*\n * Assigned rather than or-ed inside the body because a Firestore\n * transaction may retry, and the reading that counts is the one whose write\n * committed.\n */\n let created = false\n await firestore.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n created = !existing.exists\n transaction.set(\n ref,\n {\n email: fields.email,\n reason: UNSUBSCRIBE_SUPPRESSION_REASON,\n suppressedAt: FieldValue.serverTimestamp(),\n // WHICH mailing they left over. Written on the suppression itself as\n // well as counted on the campaign, so the Suppressions list can answer\n // \"why did this person go\" for one address without the aggregate — and\n // stamped only when this click created the entry, so a re-click cannot\n // re-attribute an old unsubscribe to whatever link the person happened\n // to press second. `topicId` rides along on the same rule: it is the\n // stream that lost them, which is the finer half of the same question.\n ...(existing.exists\n ? {}\n : {\n createdAt: FieldValue.serverTimestamp(),\n ...(fields.campaignId ? { campaignId: fields.campaignId } : {}),\n ...(fields.topicId ? { topicId: fields.topicId } : {}),\n }),\n },\n { merge: true },\n )\n })\n // The same verdict on the record the person is (AGL-3245), through\n // whichever plugin keeps the workspace's records, so the record page says\n // they left. A stamp that failed is logged by the seam and costs the\n // unsubscribe nothing.\n if (created) {\n await stampRecordEmailState({\n hostId,\n email: fields.email,\n state: {\n status: 'unsubscribed',\n atMs: Date.now(),\n source: 'campaign',\n detail: fields.campaignId ? 'Unsubscribed from a campaign email.' : 'Unsubscribed by the link.',\n },\n })\n }\n return created\n}\n\n/**\n * The self-service way back in (AGL-2499) that `unsubscribeHandler` never\n * had: same signed-link shape, same safe-GET/mutating-POST split, same\n * HMAC — a resubscribe link is only as trustworthy as the unsubscribe link\n * it rides in on, so it earns no looser a contract.\n *\n * Reverses ONLY a self-service unsubscribe (`reason: 'unsubscribe'`). A\n * bounce or spam-complaint suppression (`email-events.ts`'s Resend webhook)\n * protects the SENDER's deliverability, not a preference the recipient can\n * waive by clicking a link — undoing one from here would let anyone who\n * still holds an old campaign email re-arm sending to an address that\n * bounced or complained.\n */\nconst resubscribeHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n // The SAME verifier the unsubscribe uses, because this link is minted by\n // handing the unsubscribe's own signed query to a second route. Two\n // implementations of one signature scheme is how the resubscribe link comes\n // to reject a signature the unsubscribe link just accepted.\n const opened = openSignedLink(req)\n if (opened.refusal) {\n return void res.status(opened.refusal).send('Invalid link')\n }\n const { params, key } = opened\n const { hostId, email } = params\n const query = signedQuery(params)\n\n if (method !== 'POST') {\n // SAFE, same reasoning as the unsubscribe GET: a prescanner must not be\n // able to resubscribe someone either.\n const [brand, group] = await Promise.all([\n loadHostBrand(hostId),\n loadConsentGroup(hostId, BRAND_READ_TIMEOUT_MS),\n ])\n const sender = pageSender(brand, group)\n return void sendPage(\n res,\n page(\n heading('Resubscribe?') +\n paragraph(\n `Start receiving emails from <strong style=\"color:${PAL.ink}\">` +\n `${sender.name}</strong> again at ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong>.`,\n sender.reach ? 8 : 24,\n ) +\n reachParagraph(sender, 24) +\n `<form method=\"post\" action=\"/api/email/resubscribe?${escapeHtml(\n query,\n )}\">` +\n submitButton('Resubscribe', { accent: 'link', pal: brand.pal }) +\n '</form>',\n 420,\n brand,\n ),\n )\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n // The group decides which rows are lifted, so it is waited for in full.\n const groupRead = loadConsentGroup(hostId)\n const [released, brand, group] = await Promise.all([\n groupRead.then((resolved) =>\n releaseSiteSuppression(firestore, resolved, key),\n ),\n loadHostBrand(hostId),\n groupRead,\n ])\n if (!released) {\n return void sendPage(res, page(protectedAddressBody(), 420, brand))\n }\n // Restores the account's Yes only when an email door took it away and\n // product updates now reach them (AGL-3305).\n await mirrorPlatformResubscribe({ hostId, email, via: 'email-resubscribe' })\n const sender = pageSender(brand, group)\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading(\"You're resubscribed\") +\n paragraph(\n `You'll receive emails from ${sender.name} again.`,\n sender.reach ? 8 : 0,\n ) +\n reachParagraph(sender, 0),\n 420,\n brand,\n ),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Resubscribe failed — please try again')\n }\n}\n\n/**\n * Lift a whole-site suppression, or refuse to.\n *\n * THE ONE RULE THAT IS NOT A PREFERENCE. A `bounce` or `complaint` entry says\n * the mailbox is dead or its owner pressed \"report spam\", and neither is a\n * setting the person on the other end of a link may change. Both are the\n * sending domain's protection — one shared domain under `p=reject` for every\n * tenant — so honoring a resubscribe over one would be handing the recipient a\n * lever on somebody else's deliverability. Every path that puts an address\n * back in circulation goes through here so that there is exactly one place the\n * rule is stated.\n *\n * ## Across the consent group, all or nothing\n *\n * The send paths read every site's list in the link site's consent group, so\n * an unsubscribe filed on a sibling holds this site's mail too — and a way\n * back in that lifted only this site's row would tell the person they were\n * resubscribed while the sender went on withholding. Every site's row is\n * therefore read first, and the release happens only if none of them is a\n * record this rule may not lift: one bounce or complaint anywhere in the\n * group still holds the whole group, so lifting the unsubscribes beside it\n * would change nothing the person could see, and the page says so instead.\n *\n * @returns false when a record was left standing because it is not an\n * unsubscribe.\n */\nasync function releaseSiteSuppression(\n firestore: any,\n group: ConsentGroup,\n key: string,\n): Promise<boolean> {\n const [own, ...siblings] = consentGroupOptOutHosts(group).map((id) =>\n firestore.collection('hosts').doc(id).collection('suppressions').doc(key),\n )\n const [ownSnapshot, ...siblingSnapshots] = await Promise.all(\n [own, ...siblings].map((ref) => ref.get()),\n )\n if (\n [ownSnapshot, ...siblingSnapshots].some(\n (snapshot) =>\n snapshot.exists &&\n snapshot.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON,\n )\n ) {\n return false\n }\n // Idempotent whether or not a doc existed — a resubscribe click on an\n // address that was never suppressed (or already resubscribed) is not an\n // error, it is the state the visitor wanted. A sibling's row is deleted\n // only where one stands.\n await Promise.all([\n own.delete(),\n ...siblings\n .filter((_ref, index) => siblingSnapshots[index].exists)\n .map((ref) => ref.delete()),\n ])\n return true\n}\n\n/** Shown wherever a resubscribe is refused, so the wording is one wording. */\nfunction protectedAddressBody(): string {\n return (\n heading(\"Can't resubscribe this address\") +\n paragraph(\n 'This address was suppressed by a delivery problem, not an ' +\n 'unsubscribe, so it can’t be re-added from this link. Contact ' +\n 'the site directly if this looks wrong.',\n 0,\n )\n )\n}\n\n/**\n * THE PREFERENCE CENTER — the page the message footer links to.\n *\n * ## What it is for\n *\n * `docs/specs/email-competitive-gaps.md` §1f: every product compared has a\n * preference center and we had one lever, marked all-or-nothing. The cost of\n * that is not a missing feature, it is a misdirected one — the recipient who\n * only wanted the sales mail to stop had to stop everything, and the recipient\n * who did not want to stop everything pressed \"report spam\" instead, which is\n * a complaint on a shared sending domain.\n *\n * ## What it may show, and what it must not\n *\n * Reached with no session, by anyone holding the link. The HMAC is what\n * authorizes it, and it covers exactly the host, the address, the campaign and\n * the topic — so the page shows the org's topic CATALOG and this address's\n * opt-out state against it, and nothing else. Not the contact record, not the\n * lists they are on, not their name, not whether we have ever heard of them.\n *\n * It is not an enumeration oracle for two reasons that both have to hold. A\n * caller cannot ask about an address they do not already hold a signed link\n * for; and the page renders IDENTICALLY for an address with no records at all\n * — an unknown address reads as \"subscribed to everything\", which is both the\n * truthful answer and the one that reveals nothing. There is deliberately no\n * \"we don't have that address\" branch, because that branch is the oracle.\n */\nconst preferencesHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n const opened = openSignedLink(req)\n if (opened.refusal) {\n return void res.status(opened.refusal).send('Invalid preferences link')\n }\n const { params, key } = opened\n const { hostId, email, campaignId, topicId } = params\n const query = signedQuery(params)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n // Independent reads, so they go together rather than in series — the\n // brand is not worth another round trip on the page a recipient is\n // waiting for. The state is the consent GROUP's, so it waits on the group.\n const groupRead = loadConsentGroup(hostId)\n const [catalog, state, brand, group] = await Promise.all([\n loadTopicCatalog(firestore, hostId),\n groupRead.then((resolved) =>\n readSubscriptionState(firestore, resolved, key),\n ),\n loadHostBrand(hostId),\n groupRead,\n ])\n const topics = activeEmailTopics(catalog)\n const sender = pageSender(brand, group)\n\n if (method !== 'POST') {\n // SAFE. Reads only, exactly like the other two GETs.\n return void sendPage(\n res,\n page(\n preferencesFormBody({\n email,\n query,\n topics,\n state,\n topicId,\n brand,\n sender,\n }),\n 520,\n brand,\n ),\n )\n }\n\n const body = (req.body ?? {}) as Record<string, unknown>\n if (String(body['action'] ?? '') === 'all') {\n const created = await writeSiteSuppression(firestore, hostId, key, {\n email,\n campaignId,\n topicId,\n })\n if (created) await countSendUnsubscribe(firestore, hostId, campaignId)\n await mirrorPlatformUnsubscribe({ hostId, email, left: 'everything' })\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading('Sorry to see you go') +\n paragraph(\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(email)}</strong> ` +\n 'has been unsubscribed from every email ' +\n `${sender.name} sends.`,\n sender.reach ? 8 : 20,\n ) +\n reachParagraph(sender) +\n paragraph(\n 'Changed your mind? ' +\n `<a href=\"/api/email/resubscribe?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'Resubscribe</a>, or ' +\n `<a href=\"/api/email/preferences?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'pick just the emails you want</a>.',\n 0,\n ),\n 420,\n brand,\n ),\n )\n }\n\n /*\n * A CHECKED BOX MEANS \"KEEP SENDING\", so the opt-outs are the complement.\n *\n * Read off the catalog rather than off the form, deliberately. A browser\n * submits nothing at all for an unchecked box, so a form that named only\n * the boxes to TURN OFF would be indistinguishable from a form where the\n * recipient turned everything off — and the two mean opposite things.\n */\n const keep = new Set(\n topics\n .map((topic) => topic.id)\n .filter((id) => String(body[`topic:${id}`] ?? '') !== ''),\n )\n const drop = topics.filter((topic) => !keep.has(topic.id))\n await writeTopicOptOuts(firestore, hostId, key, {\n email,\n optOut: drop.map((topic) => topic.id),\n resume: [...keep],\n })\n /*\n * A ticked box is a stream the person wants back FROM THE SENDER, and the\n * send paths read an opt-out on any site of the consent group — so an\n * opt-out a sibling holds is lifted too, or the box would be a choice the\n * send path goes on refusing. The unticked ones are written here alone:\n * this site's record is read across the group already.\n */\n await resumeTopicsAcrossGroup(firestore, group, key, [...keep])\n\n /*\n * HOW OFTEN, recorded from the same submit as WHAT.\n *\n * They are one decision — \"less of this, and less often\" — so they are\n * one form and one round trip. It is stored on the send counter rather\n * than beside the topic opt-outs because that is the document the send\n * path already reads for every marketing message, which is what makes\n * honoring the request free at the point it has to be honored.\n *\n * A value that is not a cadence lands on `'all'` rather than erroring:\n * this page is reached with no session by anybody holding the link, so\n * `body` is untrusted, and the failure a recipient must not meet on the\n * screen they came to in order to leave is a 500.\n */\n const cadence = normalizeMarketingCadence(body['cadence'])\n const cadenceStored = await setMarketingCadence(hostId, email, cadence)\n\n /*\n * A person asking for SOME mail is asking not to be suppressed from ALL of\n * it, so a whole-site unsubscribe standing against this address is lifted\n * — through the same guard the resubscribe route uses, which refuses to\n * touch a bounce or a complaint. Without this the page would accept a\n * choice it could not honor: every box ticked, and the send path still\n * dropping the address at the site suppression one layer above topics.\n */\n let stillBlocked = false\n if (keep.size) {\n stillBlocked = !(await releaseSiteSuppression(firestore, group, key))\n }\n\n /*\n * The account's answer about product updates follows what this page just\n * wrote (AGL-3305), and only on the platform's own marketing site: a No\n * when product updates is among what they left, and a Yes back only when\n * an email door took it and the list is open again. Leaving the\n * newsletter says nothing about product updates, so neither call does\n * anything for it. After every write above, so both read the outcome.\n */\n if (drop.length) {\n await mirrorPlatformUnsubscribe({\n hostId,\n email,\n left: drop.map((topic) => topic.id),\n })\n }\n if (keep.size) {\n await mirrorPlatformResubscribe({ hostId, email, via: 'email-preferences' })\n }\n\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading(drop.length ? 'Sorry to see you go' : 'Preferences saved') +\n paragraph(\n changeSummary({ email, keep: [...keep], drop, topics, sender }),\n cadence === 'all' && cadenceStored && !sender.reach ? 20 : 8,\n ) +\n reachParagraph(sender, cadence === 'all' && cadenceStored ? 20 : 8) +\n /*\n * The pace is reported only when it is a CHOICE. \"As they come\" is\n * the default and the absence, so announcing it would tell somebody\n * who touched nothing that they had just asked for something.\n */\n (cadence !== 'all' && cadenceStored\n ? paragraph(\n `They will arrive no more than ${cadenceSentence(cadence)}.`,\n 20,\n )\n : '') +\n (!cadenceStored\n ? paragraph(\n 'One thing we could not change: how often these arrive. Your ' +\n 'other choices are saved — come back to this page to try ' +\n 'that one again.',\n 20,\n )\n : '') +\n (stillBlocked\n ? paragraph(\n 'One thing we could not change: this address is on hold ' +\n 'because an earlier message could not be delivered or was ' +\n 'reported as spam. Contact the site directly if that looks ' +\n 'wrong.',\n 20,\n )\n : '') +\n paragraph(\n 'Changed your mind? ' +\n `<a href=\"/api/email/preferences?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'Come back to this page</a> and tick the boxes again — this ' +\n 'link keeps working.',\n 0,\n ),\n 520,\n brand,\n ),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Preferences failed — please try again')\n }\n}\n\n/**\n * The org's topic catalog for a site.\n *\n * Two reads, both fail-soft to the built-in defaults. A site with no owning\n * org, an org with no stored topics, or a Firestore hiccup all land on the\n * same page: the four built-ins, every box ticked. That is the right failure —\n * a preference page that renders NO topics offers the recipient nothing to\n * uncheck, which turns the one screen they came to in order to leave a stream\n * into a dead end.\n */\nasync function loadTopicCatalog(\n firestore: any,\n hostId: string,\n /**\n * Throw instead of falling back. For a caller about to lift a whole-site\n * unsubscribe on the strength of the list, where the built-ins alone would\n * leave every custom stream mailable (`rejoinStreamForAccount`).\n */\n strict = false,\n): Promise<EmailTopic[]> {\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n if (!orgId) return mergeEmailTopics(null)\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(EMAIL_TOPICS_COLLECTION)\n .get()\n const stored = (snapshot?.docs ?? [])\n .map((doc: any) => normalizeEmailTopic(doc.id, doc.data()))\n .filter((topic: EmailTopic | null): topic is EmailTopic => !!topic)\n return mergeEmailTopics(stored)\n } catch (error) {\n if (strict) throw error\n console.error('[email/preferences] topic catalog read failed', error)\n return mergeEmailTopics(null)\n }\n}\n\n/** What this address currently receives from this site. */\ninterface SubscriptionState {\n /** A whole-site suppression stands. */\n suppressed: boolean\n /**\n * The suppression is a bounce or a complaint, so nothing on this page may\n * lift it.\n */\n protectedRecord: boolean\n /** The reason on that record, when there is one. */\n protectedReason: string | null\n /** Topic ids this address has left and not rejoined. */\n optedOut: Set<string>\n /**\n * Topic ids this address was asked to confirm and has not.\n *\n * Held apart from {@link optedOut} even though neither is mailable, because\n * the page says something different about each: one is a choice the person\n * made and the other is a question they have not answered.\n */\n pending: Set<string>\n /** The pace this address last asked for, or `'all'` for never asked. */\n cadence: MarketingCadence\n}\n\n/** How a chosen cadence reads inside a sentence about what will happen. */\nfunction cadenceSentence(cadence: MarketingCadence): string {\n return cadence === 'daily'\n ? 'one a day'\n : cadence === 'weekly'\n ? 'one a week'\n : 'one a month'\n}\n\n/**\n * All three per-site records for one address, in three keyed `get()`s per\n * site of the link site's consent group.\n *\n * By document id rather than a query, matching `filterSendableForHost`: no\n * composite index to go missing, and nothing that can fail open on a read\n * window. The third is the send counter, which is where the recipient's\n * chosen pace lives — see `EmailFrequencyRecord.cadence` for why it is stored\n * on the document the send path already reads rather than on this page's own.\n *\n * ## The group's state, read the way the send paths read it\n *\n * The page shows what the SENDER will do, and the sender is the consent\n * group: a suppression standing on any of its sites, and a stream left on any\n * of them, holds this site's mail too, so both show here. The pace is the one\n * chosen most recently on any site's page. A pending confirmation is the link\n * site's own, as it is on the send path. A group of one is the three reads\n * this page always made.\n */\nasync function readSubscriptionState(\n firestore: any,\n group: ConsentGroup,\n key: string,\n): Promise<SubscriptionState> {\n const sites = await Promise.all(\n consentGroupOptOutHosts(group).map((id) => {\n const hostRef = firestore.collection('hosts').doc(id)\n return Promise.all([\n hostRef.collection('suppressions').doc(key).get(),\n hostRef.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key).get(),\n hostRef\n .collection(EMAIL_FREQUENCY_SUBCOLLECTION)\n .doc(key)\n .get()\n // The pace is the one field on this page whose absence is a\n // legitimate answer, so a read that fails renders the default\n // rather than an error — the recipient still gets their topic\n // checkboxes.\n .catch(() => null),\n ])\n }),\n )\n const optedOut = new Set<string>()\n const pending = new Set<string>()\n let suppressed = false\n let protectedReason: string | null = null\n let cadence: unknown = null\n let cadenceSetAtMs = Number.NEGATIVE_INFINITY\n sites.forEach(([suppression, optOuts, frequency], index) => {\n const own = index === 0\n if (suppression?.exists) {\n suppressed = true\n const reason = suppression.get('reason')\n if (reason !== UNSUBSCRIBE_SUPPRESSION_REASON && protectedReason === null) {\n protectedReason = String(reason ?? 'held')\n }\n }\n const stored = (optOuts?.exists ? optOuts.get('topics') : null) ?? {}\n for (const [id, record] of Object.entries(\n stored as Record<string, TopicSubscriptionEntry | null>,\n )) {\n /*\n * The shared reader, not a field test. An entry with a `resubscribedAt`\n * is EVIDENCE of an opt-out that has been lifted rather than a live one\n * — see `writeTopicOptOuts` for why the entry stays — and an entry with\n * a `confirmedAt` carries the same shape of evidence for a\n * confirmation. Only one function knows all three states.\n */\n const state = readTopicSubscriptionState(record)\n if (state === 'opted-out') optedOut.add(id)\n if (own && state === 'pending') pending.add(id)\n }\n // The most recent choice on any site, the link site keeping a tie — the\n // rule `filterCadenceSendable` decides by.\n if (frequency?.exists && frequency.get('cadence') != null) {\n const setAtMs = Number(frequency.get('cadenceSetAtMs'))\n const atMs = Number.isFinite(setAtMs) ? setAtMs : 0\n if (atMs > cadenceSetAtMs) {\n cadenceSetAtMs = atMs\n cadence = frequency.get('cadence')\n }\n }\n })\n return {\n suppressed,\n protectedRecord: protectedReason !== null,\n protectedReason,\n optedOut,\n pending,\n cadence: normalizeMarketingCadence(cadence),\n }\n}\n\n/**\n * Record the recipient's per-topic choices.\n *\n * ## The record is EVIDENCE, so nothing is removed\n *\n * `email-suppression.ts` makes the argument for the suppression lists: \"a\n * revocation is a FIELD and not a delete, because the record is the evidence\n * that the suppression was honored while it was in force.\" A topic opt-out is\n * the same kind of fact — somebody asked us to stop, and the answer to \"did\n * you honor it\" has to survive them changing their mind later. So rejoining a\n * topic stamps `resubscribedAt` on the existing entry rather than deleting it,\n * and the pair of timestamps is the window the request was in force for.\n *\n * One document per address, a map keyed by topic, rather than a document per\n * (address, topic): the send path reads this by key alongside the suppression\n * lists, and one `get()` per address is what keeps a topic-filtered send the\n * same cost as an unfiltered one.\n *\n * ## Ticking a box here IS the confirmation a double opt-in asks for\n *\n * The entry also carries a pending-confirmation pair, and a recipient who\n * ticks a topic on this page has done more than the confirmation link asks:\n * they clicked a signed link delivered to that mailbox and then made a\n * choice in it. Leaving them pending would mean the page recorded a\n * subscription the send path refuses — a form whose submit does not take\n * effect, which this page refuses to be anywhere else. So a resumed topic\n * that is still pending is confirmed here, stamped with the moment they did\n * it.\n *\n * ## Every write CARRIES the entry forward\n *\n * Each branch spreads the previous entry rather than replacing it. Two pairs\n * of timestamps now live on one entry, and a branch that wrote only its own\n * pair would silently discard the other — an opt-out would erase the record\n * that somebody confirmed, and the erasure would look exactly like a person\n * who never confirmed.\n */\nasync function writeTopicOptOuts(\n firestore: any,\n hostId: string,\n key: string,\n fields: {\n email: string\n optOut: string[]\n resume: string[]\n /**\n * Whether resuming a pending topic confirms it. True for this page, whose\n * tick is the confirmation; false for a reopening that did not come\n * through a link delivered to the mailbox (`rejoinStreamForAccount`).\n */\n confirmPending?: boolean\n },\n): Promise<void> {\n const ref = firestore\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n await firestore.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n const stored = ((existing.exists ? existing.get('topics') : null) ??\n {}) as Record<string, Record<string, unknown>>\n const topics: Record<string, unknown> = {}\n for (const id of fields.optOut) {\n const previous = stored[id]\n /*\n * Already opted out and never rejoined: leave the original timestamp\n * alone. Re-submitting the same form must not restamp the date the\n * person actually left, for the reason `createdAt` is not restamped on\n * the suppression.\n *\n * The state reader, not \"an entry with no `resubscribedAt`\". That\n * shorthand reads a CONFIRMED double opt-in — which carries `pendingAt`\n * and `confirmedAt` and no `resubscribedAt` — as somebody who had\n * already left, so unticking their box would record no opt-out at all\n * and the send path would go on mailing them.\n */\n topics[id] =\n readTopicSubscriptionState(previous) === 'opted-out'\n ? previous\n : {\n ...(previous ?? {}),\n optedOutAt: FieldValue.serverTimestamp(),\n resubscribedAt: null,\n }\n }\n for (const id of fields.resume) {\n const previous = stored[id]\n if (!previous) continue\n const state = readTopicSubscriptionState(previous)\n if (state === 'pending') {\n if (fields.confirmPending !== false) {\n topics[id] = { ...previous, confirmedAt: Date.now() }\n }\n continue\n }\n topics[id] = previous['resubscribedAt']\n ? previous\n : { ...previous, resubscribedAt: FieldValue.serverTimestamp() }\n }\n transaction.set(\n ref,\n {\n email: fields.email,\n // The whole map, not a merge of one key: a topic the recipient\n // rejoined has to lose its live status, and a dotted merge cannot\n // express \"these and no others\" for a map whose keys are data.\n topics: { ...stored, ...topics },\n updatedAt: FieldValue.serverTimestamp(),\n ...(existing.exists\n ? {}\n : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n })\n}\n\n/**\n * Lifts the opt-outs the link site's consent-group SIBLINGS hold on streams\n * the person has just asked for back, so a stream rejoined here is rejoined\n * from the whole sender — the send paths read a sibling's opt-out as this\n * site's own.\n *\n * Only a live opt-out is touched, and it is lifted the way\n * `writeTopicOptOuts` lifts one: `resubscribedAt` stamped onto the entry,\n * which stays as the evidence that the opt-out was honored while it stood. A\n * sibling's pending confirmation is left alone — it is that site's question,\n * not a refusal of the sender. Nothing is read or written for a site alone.\n */\nasync function resumeTopicsAcrossGroup(\n firestore: any,\n group: ConsentGroup,\n key: string,\n topicIds: readonly string[],\n): Promise<void> {\n const siblings = consentGroupOptOutHosts(group).slice(1)\n if (!siblings.length || !topicIds.length) return\n await Promise.all(\n siblings.map(async (id) => {\n const ref = firestore\n .collection('hosts')\n .doc(id)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n await firestore.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n if (!existing.exists) return\n const stored = (existing.get('topics') ?? {}) as Record<\n string,\n Record<string, unknown>\n >\n const lifted: Record<string, unknown> = {}\n for (const topicId of topicIds) {\n const previous = stored[topicId]\n if (readTopicSubscriptionState(previous) !== 'opted-out') continue\n lifted[topicId] = {\n ...previous,\n resubscribedAt: FieldValue.serverTimestamp(),\n }\n }\n if (!Object.keys(lifted).length) return\n transaction.set(\n ref,\n {\n topics: { ...stored, ...lifted },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n }),\n )\n}\n\n/**\n * Reopens ONE stream for a signed-in account (AGL-3305) — this plugin's side\n * of `plugin-email-streams`, which the console asks when an account's answer\n * about a stream turns back to yes. The caller has already proven the\n * mailbox; see the seam for why that is the caller's job.\n *\n * Two cases, and the second is the reason this lives here:\n *\n * - The address left just this stream: its opt-out is lifted, exactly as\n * re-ticking the box on the preference page lifts it.\n * - The address left EVERYTHING: every other active stream becomes an\n * opt-out first, and only then is the whole-site unsubscribe lifted —\n * through the same guard the resubscribe link uses. They asked for one\n * stream back, not for the newsletter and the promotions they also left,\n * and writing the opt-outs before the lift means there is no moment in\n * which the whole catalog is mailable.\n *\n * A pending confirmation is left pending. The preference page confirms by a\n * tick because the tick came through a link delivered to the mailbox; this\n * request came from the console, which is proof of an account, not of a\n * click in the mailbox the confirmation was sent to.\n *\n * A bounce, a complaint, an erasure or a staff hold is `held`, and nothing is\n * written: none of them is a preference, so no switch lifts them.\n */\nexport async function rejoinStreamForAccount(\n request: PluginEmailStreamRejoinRequest,\n firestore: any = firebaseAdmin.app().firestore(),\n): Promise<PluginEmailStreamRejoinResult> {\n const key = suppressionKeyFor(request.email)\n if (!key) return { status: 'held', reason: 'unusable-address' }\n // The sender the stream is rejoined FROM — the site's consent group, whose\n // records hold its mail as its own do.\n const group = await loadConsentGroup(request.hostId)\n const state = await readSubscriptionState(firestore, group, key)\n if (state.protectedRecord) {\n return { status: 'held', reason: state.protectedReason ?? 'held' }\n }\n const email = String(request.email).trim().toLowerCase()\n if (!state.suppressed) {\n await writeTopicOptOuts(firestore, request.hostId, key, {\n email,\n optOut: [],\n resume: [request.topicId],\n confirmPending: false,\n })\n await resumeTopicsAcrossGroup(firestore, group, key, [request.topicId])\n return { status: 'rejoined', releasedSuppression: false, keptLeft: 0 }\n }\n const others = activeEmailTopics(await loadTopicCatalog(firestore, request.hostId, true))\n .map((topic) => topic.id)\n .filter((id) => id !== request.topicId)\n await writeTopicOptOuts(firestore, request.hostId, key, {\n email,\n optOut: others,\n resume: [request.topicId],\n confirmPending: false,\n })\n await resumeTopicsAcrossGroup(firestore, group, key, [request.topicId])\n if (!(await releaseSiteSuppression(firestore, group, key))) {\n // Turned into a bounce or a complaint between the read and the lift.\n return { status: 'held', reason: 'protected' }\n }\n return { status: 'rejoined', releasedSuppression: true, keptLeft: others.length }\n}\n\n/** One topic row: a checkbox, its name and its description. */\nfunction topicRow(\n topic: EmailTopic,\n checked: boolean,\n highlighted: boolean,\n /**\n * Asked to confirm and has not.\n *\n * The box is EMPTY for a pending topic, because empty is the truth: the\n * send path refuses this stream until it is confirmed, and a ticked box\n * over a stream nothing will send would be the page telling a lie the\n * recipient can only discover by waiting for mail that never comes. The\n * note beside it is what turns \"not ticked\" from a puzzle into an answer,\n * and ticking it here confirms — see `writeTopicOptOuts`.\n */\n pending = false,\n pal: EmailPalette = PAL,\n): string {\n return (\n `<label style=\"display:flex;gap:12px;align-items:flex-start;padding:14px 0;` +\n `border-top:1px solid ${PAL.divider};cursor:pointer\">` +\n `<input type=\"checkbox\" name=\"topic:${escapeHtml(topic.id)}\" value=\"on\"` +\n (checked ? ' checked' : '') +\n ' style=\"margin:2px 0 0;width:18px;height:18px;flex:none\">' +\n '<span style=\"flex:1\">' +\n `<span style=\"display:block;font-size:14px;font-weight:600;color:${PAL.ink}\">` +\n escapeHtml(topic.name) +\n (highlighted\n ? `<span style=\"margin-left:8px;font-size:11px;font-weight:600;` +\n `text-transform:uppercase;letter-spacing:.04em;color:${pal.link}\">` +\n 'This email</span>'\n : '') +\n '</span>' +\n (pending\n ? `<span style=\"display:block;margin-top:2px;font-size:13px;line-height:1.45;` +\n `color:${PAL.muted}\">Waiting for you to confirm — tick this and save ` +\n 'to start receiving it.</span>'\n : '') +\n (topic.description\n ? `<span style=\"display:block;margin-top:2px;font-size:13px;line-height:1.45;` +\n `color:${PAL.muted}\">${escapeHtml(topic.description)}</span>`\n : '') +\n '</span></label>'\n )\n}\n\n/**\n * HOW OFTEN — the half of the preference center that shipped without.\n *\n * `docs/specs/email-competitive-gaps.md` G10: the frequency CAP shipped and\n * this did not, so a recipient who wanted the same mail less often had two\n * options and one of them was the spam button.\n *\n * Radio buttons rather than a select, and every option written out. The whole\n * value of the control is that somebody skimming a footer link can see, in\n * one glance, that \"less\" is available at all — a collapsed select says only\n * that there is a setting.\n *\n * The default option is named (\"As they come\") rather than left as the empty\n * choice, because a radio group whose default is unlabeled reads as a\n * question the recipient has not answered, and answering it is not something\n * this page should require of somebody who came here to uncheck one box.\n */\nfunction cadenceFieldset(current: MarketingCadence): string {\n const option = (value: MarketingCadence, label: string): string =>\n `<label style=\"display:flex;gap:12px;align-items:center;padding:10px 0;cursor:pointer\">` +\n `<input type=\"radio\" name=\"cadence\" value=\"${escapeHtml(value)}\"` +\n (value === current ? ' checked' : '') +\n ' style=\"margin:0;width:18px;height:18px;flex:none\">' +\n `<span style=\"font-size:14px;color:${PAL.ink}\">${label}</span></label>`\n return (\n `<div style=\"border-top:1px solid ${PAL.divider};padding-top:18px;margin-top:6px\">` +\n `<div style=\"font-size:14px;font-weight:600;color:${PAL.ink};margin-bottom:2px\">` +\n 'How often' +\n '</div>' +\n `<div style=\"font-size:13px;line-height:1.45;color:${PAL.muted};margin-bottom:6px\">` +\n 'This applies to everything above. Nothing is canceled — messages just ' +\n 'wait until the next one is due.' +\n '</div>' +\n option('all', 'As they come') +\n option('daily', 'At most one a day') +\n option('weekly', 'At most one a week') +\n option('monthly', 'At most one a month') +\n '</div>'\n )\n}\n\n/** The preference page's body. */\nfunction preferencesFormBody(args: {\n email: string\n query: string\n topics: EmailTopic[]\n state: SubscriptionState\n topicId: string\n brand: EmailPageBrand\n /** Who the choices are made about — see {@link pageSender}. */\n sender: EmailPageSender\n}): string {\n const { email, query, topics, state, topicId, brand, sender } = args\n const pal = brand.pal\n // A bounce or a complaint is not a preference, so the page does not pretend\n // the recipient can edit their way out of one. Shown instead of the form\n // rather than beside it: a form whose submit cannot take effect is worse\n // than no form.\n if (state.protectedRecord) return protectedAddressBody()\n const current = resolveCampaignTopic(topicId, topics)\n const action = `/api/email/preferences?${escapeHtml(query)}`\n return (\n heading('Email preferences') +\n paragraph(\n `Choose what <strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong> should keep receiving from ` +\n `<strong style=\"color:${PAL.ink}\">${sender.name}</strong>. ` +\n 'Unticked emails stop; everything else carries on.',\n 8,\n ) +\n reachParagraph(sender, 8) +\n (state.suppressed\n ? paragraph(\n 'You are currently unsubscribed from everything. Tick anything ' +\n 'below to start receiving it again.',\n 8,\n )\n : '') +\n `<form method=\"post\" action=\"${action}\">` +\n topics\n .map((topic) =>\n topicRow(\n topic,\n // A whole-site suppression outranks the per-topic record, so an\n // unsubscribed recipient sees every box empty — which is the state\n // they are actually in, and the state the form must round-trip. An\n // unconfirmed topic is empty for the same reason: the send path\n // refuses it, so a ticked box would not be what is true.\n !state.suppressed &&\n !state.optedOut.has(topic.id) &&\n !state.pending.has(topic.id),\n topic.id === current.id,\n !state.suppressed && state.pending.has(topic.id),\n pal,\n ),\n )\n .join('') +\n /*\n * HOW OFTEN, inside the same form as WHAT.\n *\n * The alternative to letting somebody choose \"monthly\" is letting them\n * choose \"report spam\", and on a shared sending domain under `p=reject`\n * that choice is charged to every other tenant. It sits under the topics\n * because it is the smaller decision of the two and a recipient who has\n * already found the thing they wanted to stop should not have to read\n * past a frequency question to stop it.\n */\n cadenceFieldset(state.cadence) +\n `<div style=\"border-top:1px solid ${PAL.divider};padding-top:20px;margin-top:6px\">` +\n submitButton('Save my preferences', { pal }) +\n '</div></form>' +\n // A SECOND form, not a second button in the first one. Sharing the form\n // would submit the checkbox state along with the \"everything\" action, so a\n // browser that fell back to the first submit button — or a user pressing\n // Return in the form — would send an ambiguous request. Two forms make the\n // two intentions two requests.\n `<form method=\"post\" action=\"${action}\" style=\"margin-top:12px\">` +\n '<input type=\"hidden\" name=\"action\" value=\"all\">' +\n '<button type=\"submit\" style=\"font:inherit;font-size:13px;font-weight:600;' +\n `padding:10px 20px;border:1px solid ${PAL.divider};border-radius:8px;` +\n `background:transparent;color:${PAL.muted};cursor:pointer;width:100%\">` +\n 'Unsubscribe from everything</button></form>'\n )\n}\n\n/** What the result page tells the recipient actually changed. */\nfunction changeSummary(args: {\n email: string\n keep: string[]\n drop: EmailTopic[]\n topics: EmailTopic[]\n /** A declared group is named; a site alone is \"this site\". */\n sender: EmailPageSender\n}): string {\n const address = `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n args.email,\n )}</strong>`\n const from = args.sender.reach ? args.sender.name : 'this site'\n if (!args.drop.length) {\n return `${address} keeps receiving everything ${from} sends.`\n }\n const names = args.drop\n .map((topic) => escapeHtml(topic.name))\n .join(', ')\n if (!args.keep.length) {\n return (\n `${address} has been unsubscribed from ${names} — everything ` +\n `${from} currently sends.`\n )\n }\n return `${address} will stop receiving ${names}, and keeps the rest.`\n}\n\n/**\n * `email/confirm` — the click that turns a pending subscription into a real\n * one (`docs/specs/email-competitive-gaps.md` P8).\n *\n * Same signed-link shape as its three siblings and the same safe-GET /\n * mutating-POST split, which matters here for exactly the reason it mattered\n * to the unsubscribe: a security gateway fetching every URL in the message\n * would otherwise confirm the subscription on the recipient's behalf, and a\n * confirmation nobody made is the one thing a double opt-in exists to\n * prevent. A prescanner following this link renders a page and changes\n * nothing.\n *\n * The subject it verifies is the confirmation form — see\n * `signedConfirmSubject` for why a topic without a campaign needs one — and\n * it is checked through the same comparison every other link goes through.\n */\nconst confirmHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n const params = readParams(req)\n const secret = linkSecret()\n if (!params.hostId || !params.email || !params.signature || !secret) {\n return void res.status(400).send('Invalid confirmation link')\n }\n if (!signatureMatches({ ...params, secret, purpose: 'confirm' })) {\n return void res.status(403).send('Invalid confirmation link')\n }\n if (!suppressionKeyFor(params.email)) {\n return void res.status(400).send('Invalid confirmation link')\n }\n const { hostId, email, topicId } = params\n const query = signedQuery(params)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const [catalog, brand] = await Promise.all([\n loadTopicCatalog(firestore, hostId),\n loadHostBrand(hostId),\n ])\n const topic = resolveCampaignTopic(topicId, catalog)\n\n if (method !== 'POST') {\n // SAFE. A prescanner lands here and confirms nothing.\n return void sendPage(\n res,\n page(\n heading('Confirm your subscription') +\n paragraph(\n `Confirm that <strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong> should receive ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n topic.name,\n )}</strong> from ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n brand.name,\n )}</strong>.`,\n ) +\n `<form method=\"post\" action=\"/api/email/confirm?${escapeHtml(\n query,\n )}\">` +\n submitButton('Yes, subscribe me', { pal: brand.pal }) +\n '</form>',\n 420,\n brand,\n ),\n )\n }\n\n const outcome = await confirmTopicSubscription(hostId, email, topicId)\n return void sendPage(\n res,\n page(confirmationBody(outcome, topic.name, brand.pal), 420, brand),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Confirmation failed — please try again')\n }\n}\n\n/**\n * What each outcome tells the person in front of it.\n *\n * Every arm names what is TRUE rather than what went wrong. Somebody who\n * clicked an expired link has not made a mistake, and somebody who clicked\n * twice has not either — telling either of them \"invalid\" would read as the\n * subscription having failed when the first case needs a fresh signup and the\n * second is already done.\n */\nfunction confirmationBody(\n outcome: ConfirmTopicResult,\n topicName: string,\n pal: EmailPalette = PAL,\n): string {\n const stream = `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n topicName,\n )}</strong>`\n switch (outcome) {\n case 'confirmed':\n return (\n successBadge(pal) +\n heading(\"You're subscribed\") +\n paragraph(`You'll start receiving ${stream} from this site.`, 0)\n )\n case 'already-confirmed':\n return (\n successBadge(pal) +\n heading('Already confirmed') +\n paragraph(`${stream} is already on its way to you.`, 0)\n )\n case 'expired':\n return (\n heading('This link has expired') +\n paragraph(\n `Confirmation links are good for three days. Sign up again and ` +\n `we'll send a fresh one — you are not subscribed to ${stream} in ` +\n 'the meantime.',\n 0,\n )\n )\n case 'opted-out':\n return (\n heading(\"Can't subscribe this address\") +\n paragraph(\n `This address asked to stop receiving ${stream} from this site, so ` +\n 'a confirmation link cannot put it back. Sign up again if that ' +\n 'was not what you meant.',\n 0,\n )\n )\n default:\n return (\n heading('Nothing to confirm') +\n paragraph(\n `There is no pending request for ${stream} at this address. If you ` +\n 'meant to subscribe, sign up on the site.',\n 0,\n )\n )\n }\n}\n\n/** Registers the email plugin's public API routes (AGL-396). */\nexport function registerEmailApi(): void {\n registerPluginApiRoute('email/unsubscribe', unsubscribeHandler)\n registerPluginApiRoute('email/resubscribe', resubscribeHandler)\n registerPluginApiRoute('email/preferences', preferencesHandler)\n registerPluginApiRoute('email/confirm', confirmHandler)\n}\n\n/*\n * The CONSOLE half of the same `email` prefix, kept in its own module.\n *\n * Two audiences, one entry point: the tenant loads this file for\n * `registerEmailApi` (the signed unsubscribe links a recipient clicks, no\n * session behind them), and the console loads it for\n * `registerEmailConsoleApi` (list membership, behind an org-wide role). The\n * manifest generator resolves both surfaces through `@aglyn/plugins-email/server`,\n * so this re-export is what makes the console half reachable — a second entry\n * point would be a second thing to keep in step with plugins.config.json.\n */\nexport {\n registerEmailConsoleApi,\n emailListMembersAddHandler,\n emailListMembersPreviewHandler,\n emailListRulePreviewHandler,\n CONSOLE_ADD_SOURCE,\n LIST_MEMBER_BATCH_MAX,\n} from './server-console'\n"],"names":["registerPluginApiRoute","activeEmailTopics","mergeEmailTopics","normalizeEmailTopic","readTopicSubscriptionState","resolveCampaignTopic","EMAIL_TOPICS_COLLECTION","TOPIC_OPT_OUTS_SUBCOLLECTION","consentGroupOptOutHosts","soloConsentGroup","confirmTopicSubscription","consentGroupForSite","EMAIL_FREQUENCY_SUBCOLLECTION","firebaseAdmin","mirrorPlatformResubscribe","mirrorPlatformUnsubscribe","resolveCampaignSendRef","resolveOrgIdForHost","setMarketingCadence","UNSUBSCRIBE_SUPPRESSION_REASON","stampRecordEmailState","normalizeMarketingCadence","escapeHtml","FieldValue","heading","isCampaignPathId","page","paragraph","PAL","PLATFORM_EMAIL_BRAND","readParams","resolveEmailPageBrand","sendPage","signatureMatches","signedQuery","submitButton","successBadge","suppressionKeyFor","linkSecret","process","env","EMAIL_UNSUBSCRIBE_SECRET","CRON_SECRET","openSignedLink","req","params","secret","refuse","status","refusal","key","hostId","email","signature","BRAND_READ_TIMEOUT_MS","loadHostBrand","timer","firestore","app","snapshot","Promise","race","collection","doc","get","resolve","setTimeout","exists","data","$id","error","console","clearTimeout","loadConsentGroup","waitMs","alone","resolving","pageSender","brand","group","declared","name","reach","hostIds","length","reachParagraph","sender","gap","unsubscribeHandler","res","method","String","toUpperCase","setHeader","send","opened","campaignId","topicId","query","all","ink","pal","link","created","writeSiteSuppression","countSendUnsubscribe","left","orgId","sendRef","sendId","update","increment","fields","ref","runTransaction","transaction","existing","set","reason","suppressedAt","serverTimestamp","createdAt","merge","state","atMs","Date","now","source","detail","resubscribeHandler","accent","groupRead","released","then","resolved","releaseSiteSuppression","protectedAddressBody","via","own","siblings","map","id","ownSnapshot","siblingSnapshots","some","delete","filter","_ref","index","preferencesHandler","body","catalog","loadTopicCatalog","readSubscriptionState","topics","preferencesFormBody","keep","Set","topic","drop","has","writeTopicOptOuts","optOut","resume","resumeTopicsAcrossGroup","cadence","cadenceStored","stillBlocked","size","changeSummary","cadenceSentence","strict","stored","docs","sites","hostRef","catch","optedOut","pending","suppressed","protectedReason","cadenceSetAtMs","Number","NEGATIVE_INFINITY","forEach","suppression","optOuts","frequency","record","Object","entries","add","setAtMs","isFinite","protectedRecord","previous","optedOutAt","resubscribedAt","confirmPending","confirmedAt","updatedAt","topicIds","slice","lifted","keys","rejoinStreamForAccount","request","trim","toLowerCase","releasedSuppression","keptLeft","others","topicRow","checked","highlighted","divider","muted","description","cadenceFieldset","current","option","value","label","args","action","join","address","from","names","confirmHandler","purpose","outcome","confirmationBody","topicName","stream","registerEmailApi","registerEmailConsoleApi","emailListMembersAddHandler","emailListMembersPreviewHandler","emailListRulePreviewHandler","CONSOLE_ADD_SOURCE","LIST_MEMBER_BATCH_MAX"],"mappings":"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"}