@aglyn/plugins-email 1.0.0-beta.258 → 1.0.0-beta.259

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.258",
3
+ "version": "1.0.0-beta.259",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,21 +25,21 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.258",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.258",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.258",
31
- "@aglyn/shared-data-types": "1.0.0-beta.258",
32
- "@aglyn/shared-ui-jsx": "1.0.0-beta.258",
33
- "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.258",
34
- "@aglyn/shared-ui-next": "1.0.0-beta.258",
35
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.258",
36
- "@aglyn/shared-ui-theme": "1.0.0-beta.258",
37
- "@aglyn/shared-util-email": "1.0.0-beta.258",
38
- "@aglyn/shared-util-http": "1.0.0-beta.258",
39
- "@aglyn/shared-util-timestamp": "1.0.0-beta.258",
40
- "@aglyn/shared-util-tools": "1.0.0-beta.258",
41
- "@aglyn/tenant-data-admin": "1.0.0-beta.258",
42
- "@aglyn/tenant-feature-instance": "1.0.0-beta.258",
28
+ "@aglyn/aglyn": "1.0.0-beta.259",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.259",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.259",
31
+ "@aglyn/shared-data-types": "1.0.0-beta.259",
32
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.259",
33
+ "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.259",
34
+ "@aglyn/shared-ui-next": "1.0.0-beta.259",
35
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.259",
36
+ "@aglyn/shared-ui-theme": "1.0.0-beta.259",
37
+ "@aglyn/shared-util-email": "1.0.0-beta.259",
38
+ "@aglyn/shared-util-http": "1.0.0-beta.259",
39
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.259",
40
+ "@aglyn/shared-util-tools": "1.0.0-beta.259",
41
+ "@aglyn/tenant-data-admin": "1.0.0-beta.259",
42
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.259",
43
43
  "@swc/helpers": "0.5.23"
44
44
  },
45
45
  "peerDependencies": {
package/src/lib/server.js CHANGED
@@ -438,7 +438,6 @@ const unsubscribeHandler = async (req, res)=>{
438
438
  ]);
439
439
  const sender = pageSender(brand, group);
440
440
  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', {
441
- accent: 'link',
442
441
  pal: brand.pal
443
442
  }) + '</form>', 420, brand));
444
443
  }
@@ -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 activeSubscriptionTopics,\n mergeSubscriptionTopics,\n readTopicSubscriptionState,\n resolveSubscriptionTopic,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type SubscriptionTopic,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\nimport { readOrgTopicCatalog, registerSubscriptionTopicIndex } from './server-topic-index'\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 emailSearchTokens,\n firebaseAdmin,\n mirrorPlatformResubscribe,\n mirrorPlatformUnsubscribe,\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'\nimport { tallySendUnsubscribe } from '@aglyn/aglyn/plugin-manager/plugin-send-tallies'\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(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, and the unsubscribe count its report reads, belong to the\n * plugin that sent it, which counts it through the send-tally contract\n * (`plugin-send-tallies`) — finding the send where it is and never creating\n * one it cannot find. A send nobody keeps is not counted, and that is the\n * honest answer: the count for a send nobody can open has no reader. The\n * seam swallows every failure, because the suppression has already been\n * written and a statistic must never be able to cost one.\n */\nasync function countSendUnsubscribe(hostId: string, campaignId: string): Promise<void> {\n if (!isCampaignPathId(campaignId)) return\n await tallySendUnsubscribe({ hostId, sendId: campaignId })\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 /*\n * A row of ANOTHER kind already refuses this address — a bounce, an\n * erasure, a locked account's hold — and it stays that kind. Rewritten as\n * an unsubscribe, the resubscribe link could delete it, and an erasure\n * row would get the address it removed written back.\n */\n // An absent reason is a pre-AGL-2408 unsubscribe (see SUPPRESSION_REASONS).\n const kind = existing.exists ? (existing.get('reason') ?? UNSUBSCRIBE_SUPPRESSION_REASON) : null\n if (kind && kind !== UNSUBSCRIBE_SUPPRESSION_REASON) {\n return\n }\n transaction.set(\n ref,\n {\n email: fields.email,\n // What the Suppressions list's search queries (AGL-3321).\n emailTokens: emailSearchTokens(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 = activeSubscriptionTopics(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(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 — and so is a sibling's pending confirmation,\n * where the org said the group waits for it. The unticked ones are written\n * here alone: this site's record is read across the group already.\n */\n await resumeTopicsAcrossGroup(firestore, group, key, [...keep], {\n confirmPending: true,\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, 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<SubscriptionTopic[]> {\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n return await readOrgTopicCatalog(firestore, orgId ?? '')\n } catch (error) {\n if (strict) throw error\n console.error('[email/preferences] topic catalog read failed', error)\n return mergeSubscriptionTopics(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 — or any site's in the group, where\n * the org said the group waits for the click, because then that is what holds\n * this site's mail. A group of one is the three reads 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 || group.awaitsConfirmation === true) && state === 'pending') {\n pending.add(id)\n }\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.\n *\n * A sibling's pending confirmation is that site's question, and is left alone\n * — unless the org said the group waits for the click AND the caller's tick\n * is itself a confirmation (`confirmPending`, the preference page's). The\n * sibling's question is then what holds this site's mail, and the page showed\n * it as waiting on exactly this tick, so it is answered the way\n * `writeTopicOptOuts` answers the link site's own: `confirmedAt` beside the\n * `pendingAt` it answers. 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 options?: { confirmPending?: boolean },\n): Promise<void> {\n const siblings = consentGroupOptOutHosts(group).slice(1)\n if (!siblings.length || !topicIds.length) return\n const confirmsSiblings =\n options?.confirmPending === true && group.awaitsConfirmation === true\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 const state = readTopicSubscriptionState(previous)\n if (state === 'pending' && confirmsSiblings) {\n lifted[topicId] = { ...previous, confirmedAt: Date.now() }\n continue\n }\n if (state !== '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 = activeSubscriptionTopics(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: SubscriptionTopic,\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: SubscriptionTopic[]\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 = resolveSubscriptionTopic(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: SubscriptionTopic[]\n topics: SubscriptionTopic[]\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 = resolveSubscriptionTopic(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 // The org's topic catalog, for a plugin that sends under a stream and has\n // to ask what the org made of it (a newsletter's confirmation setting).\n registerSubscriptionTopicIndex(() => firebaseAdmin.app().firestore())\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","activeSubscriptionTopics","mergeSubscriptionTopics","readTopicSubscriptionState","resolveSubscriptionTopic","TOPIC_OPT_OUTS_SUBCOLLECTION","readOrgTopicCatalog","registerSubscriptionTopicIndex","consentGroupOptOutHosts","soloConsentGroup","confirmTopicSubscription","consentGroupForSite","EMAIL_FREQUENCY_SUBCOLLECTION","emailSearchTokens","firebaseAdmin","mirrorPlatformResubscribe","mirrorPlatformUnsubscribe","resolveOrgIdForHost","setMarketingCadence","UNSUBSCRIBE_SUPPRESSION_REASON","stampRecordEmailState","tallySendUnsubscribe","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","sendId","fields","ref","runTransaction","transaction","existing","kind","set","emailTokens","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","confirmPending","cadence","cadenceStored","stillBlocked","size","changeSummary","cadenceSentence","strict","orgId","sites","hostRef","catch","optedOut","pending","suppressed","protectedReason","cadenceSetAtMs","Number","NEGATIVE_INFINITY","forEach","suppression","optOuts","frequency","stored","record","Object","entries","add","awaitsConfirmation","setAtMs","isFinite","protectedRecord","previous","optedOutAt","resubscribedAt","confirmedAt","updatedAt","topicIds","options","slice","confirmsSiblings","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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"}
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 activeSubscriptionTopics,\n mergeSubscriptionTopics,\n readTopicSubscriptionState,\n resolveSubscriptionTopic,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type SubscriptionTopic,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\nimport { readOrgTopicCatalog, registerSubscriptionTopicIndex } from './server-topic-index'\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 emailSearchTokens,\n firebaseAdmin,\n mirrorPlatformResubscribe,\n mirrorPlatformUnsubscribe,\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'\nimport { tallySendUnsubscribe } from '@aglyn/aglyn/plugin-manager/plugin-send-tallies'\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(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, and the unsubscribe count its report reads, belong to the\n * plugin that sent it, which counts it through the send-tally contract\n * (`plugin-send-tallies`) — finding the send where it is and never creating\n * one it cannot find. A send nobody keeps is not counted, and that is the\n * honest answer: the count for a send nobody can open has no reader. The\n * seam swallows every failure, because the suppression has already been\n * written and a statistic must never be able to cost one.\n */\nasync function countSendUnsubscribe(hostId: string, campaignId: string): Promise<void> {\n if (!isCampaignPathId(campaignId)) return\n await tallySendUnsubscribe({ hostId, sendId: campaignId })\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 /*\n * A row of ANOTHER kind already refuses this address — a bounce, an\n * erasure, a locked account's hold — and it stays that kind. Rewritten as\n * an unsubscribe, the resubscribe link could delete it, and an erasure\n * row would get the address it removed written back.\n */\n // An absent reason is a pre-AGL-2408 unsubscribe (see SUPPRESSION_REASONS).\n const kind = existing.exists ? (existing.get('reason') ?? UNSUBSCRIBE_SUPPRESSION_REASON) : null\n if (kind && kind !== UNSUBSCRIBE_SUPPRESSION_REASON) {\n return\n }\n transaction.set(\n ref,\n {\n email: fields.email,\n // What the Suppressions list's search queries (AGL-3321).\n emailTokens: emailSearchTokens(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', { 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 = activeSubscriptionTopics(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(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 — and so is a sibling's pending confirmation,\n * where the org said the group waits for it. The unticked ones are written\n * here alone: this site's record is read across the group already.\n */\n await resumeTopicsAcrossGroup(firestore, group, key, [...keep], {\n confirmPending: true,\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, 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<SubscriptionTopic[]> {\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n return await readOrgTopicCatalog(firestore, orgId ?? '')\n } catch (error) {\n if (strict) throw error\n console.error('[email/preferences] topic catalog read failed', error)\n return mergeSubscriptionTopics(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 — or any site's in the group, where\n * the org said the group waits for the click, because then that is what holds\n * this site's mail. A group of one is the three reads 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 || group.awaitsConfirmation === true) && state === 'pending') {\n pending.add(id)\n }\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.\n *\n * A sibling's pending confirmation is that site's question, and is left alone\n * — unless the org said the group waits for the click AND the caller's tick\n * is itself a confirmation (`confirmPending`, the preference page's). The\n * sibling's question is then what holds this site's mail, and the page showed\n * it as waiting on exactly this tick, so it is answered the way\n * `writeTopicOptOuts` answers the link site's own: `confirmedAt` beside the\n * `pendingAt` it answers. 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 options?: { confirmPending?: boolean },\n): Promise<void> {\n const siblings = consentGroupOptOutHosts(group).slice(1)\n if (!siblings.length || !topicIds.length) return\n const confirmsSiblings =\n options?.confirmPending === true && group.awaitsConfirmation === true\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 const state = readTopicSubscriptionState(previous)\n if (state === 'pending' && confirmsSiblings) {\n lifted[topicId] = { ...previous, confirmedAt: Date.now() }\n continue\n }\n if (state !== '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 = activeSubscriptionTopics(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: SubscriptionTopic,\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: SubscriptionTopic[]\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 = resolveSubscriptionTopic(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: SubscriptionTopic[]\n topics: SubscriptionTopic[]\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 = resolveSubscriptionTopic(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 // The org's topic catalog, for a plugin that sends under a stream and has\n // to ask what the org made of it (a newsletter's confirmation setting).\n registerSubscriptionTopicIndex(() => firebaseAdmin.app().firestore())\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","activeSubscriptionTopics","mergeSubscriptionTopics","readTopicSubscriptionState","resolveSubscriptionTopic","TOPIC_OPT_OUTS_SUBCOLLECTION","readOrgTopicCatalog","registerSubscriptionTopicIndex","consentGroupOptOutHosts","soloConsentGroup","confirmTopicSubscription","consentGroupForSite","EMAIL_FREQUENCY_SUBCOLLECTION","emailSearchTokens","firebaseAdmin","mirrorPlatformResubscribe","mirrorPlatformUnsubscribe","resolveOrgIdForHost","setMarketingCadence","UNSUBSCRIBE_SUPPRESSION_REASON","stampRecordEmailState","tallySendUnsubscribe","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","sendId","fields","ref","runTransaction","transaction","existing","kind","set","emailTokens","reason","suppressedAt","serverTimestamp","createdAt","merge","state","atMs","Date","now","source","detail","resubscribeHandler","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","confirmPending","cadence","cadenceStored","stillBlocked","size","changeSummary","cadenceSentence","strict","orgId","sites","hostRef","catch","optedOut","pending","suppressed","protectedReason","cadenceSetAtMs","Number","NEGATIVE_INFINITY","forEach","suppression","optOuts","frequency","stored","record","Object","entries","add","awaitsConfirmation","setAtMs","isFinite","protectedRecord","previous","optedOutAt","resubscribedAt","confirmedAt","updatedAt","topicIds","options","slice","confirmsSiblings","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":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,sBAAsB,QAA+B,sBAAqB;AACnF;;;;;;CAMC,GACD,SACEC,wBAAwB,EACxBC,uBAAuB,EACvBC,0BAA0B,EAC1BC,wBAAwB,EACxBC,4BAA4B,QAGvB,6CAA4C;AACnD,SAASC,mBAAmB,EAAEC,8BAA8B,QAAQ,0BAAsB;AAC1F,SACEC,uBAAuB,EACvBC,gBAAgB,QAEX,wCAAuC;AAC9C,SACEC,wBAAwB,EACxBC,mBAAmB,EACnBC,6BAA6B,EAC7BC,iBAAiB,EACjBC,aAAa,EACbC,yBAAyB,EACzBC,yBAAyB,EACzBC,mBAAmB,EACnBC,mBAAmB,EACnBC,8BAA8B,QAEzB,2BAA0B;AAKjC,SAASC,qBAAqB,QAAQ,wDAAuD;AAC7F,SAASC,oBAAoB,QAAQ,kDAAiD;AACtF;;;;;CAKC,GACD,SACEC,yBAAyB,QAEpB,2BAA0B;AACjC,SAASC,UAAU,QAAQ,uCAAsC;AACjE,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SACEC,OAAO,EACPC,gBAAgB,EAChBC,IAAI,EACJC,SAAS,EACTC,GAAG,EACHC,oBAAoB,EACpBC,UAAU,EACVC,qBAAqB,EACrBC,QAAQ,EACRC,gBAAgB,EAChBC,WAAW,EACXC,YAAY,EACZC,YAAY,EACZC,iBAAiB,QAKZ,wBAAoB;AAE3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+DC,GAED,+DAA+D,GAC/D,SAASC;IACP,OAAOC,QAAQC,GAAG,CAACC,wBAAwB,IAAIF,QAAQC,GAAG,CAACE,WAAW,IAAI;AAC5E;AAwBA,SAASC,eAAeC,GAAoC;IAC1D,MAAMC,SAASf,WAAWc;IAC1B,MAAME,SAASR;IACf,MAAMS,SAAS,CAACC,SAAgC,CAAA;YAC9CC,SAASD;YACTH;YACAK,KAAK;QACP,CAAA;IACA,IAAI,CAACL,OAAOM,MAAM,IAAI,CAACN,OAAOO,KAAK,IAAI,CAACP,OAAOQ,SAAS,IAAI,CAACP,QAAQ;QACnE,OAAOC,OAAO;IAChB;IACA,IAAI,CAACd,iBAAiB,aAAKY;QAAQC;SAAW,OAAOC,OAAO;IAC5D,6EAA6E;IAC7E,sEAAsE;IACtE,wDAAwD;IACxD,MAAMG,MAAMb,kBAAkBQ,OAAOO,KAAK;IAC1C,IAAI,CAACF,KAAK,OAAOH,OAAO;IACxB,OAAO;QAAEE,SAAS;QAAGJ;QAAQK;IAAI;AACnC;AAEA;;;;;;;CAOC,GACD,MAAMI,wBAAwB;AAE9B;;;;;;;;;CASC,GACD,eAAeC,cAAcJ,MAAc;IACzC,IAAI,CAACA,QAAQ,OAAOtB;IACpB,IAAI2B;IACJ,IAAI;QACF,MAAMC,YAAY5C,cAAc6C,GAAG,GAAGD,SAAS;QAC/C,MAAME,WAAW,MAAMC,QAAQC,IAAI,CAAC;YAClCJ,UAAUK,UAAU,CAAC,SAASC,GAAG,CAACZ,QAAQa,GAAG;YAC7C,IAAIJ,QAAc,CAACK;gBACjBT,QAAQU,WAAW,IAAMD,QAAQ,OAAOX;YAC1C;SACD;QACD,IAAI,EAACK,4BAAAA,SAAUQ,MAAM,GAAE,OAAOtC;QAC9B,yEAAyE;QACzE,oEAAoE;QACpE,OAAOE,sBAAsB,aACvB4B,SAASS,IAAI;YACjBC,KAAKlB;;IAET,EAAE,OAAOmB,OAAO;QACdC,QAAQD,KAAK,CAAC,kCAAkCA;QAChD,OAAOzC;IACT,SAAU;QACR,IAAI2B,OAAOgB,aAAahB;IAC1B;AACF;AAEA;;;;;;;;;;;;;;CAcC,GACD,eAAeiB,iBACbtB,MAAc,EACduB,MAAe;IAEf,MAAMC,QAAQnE,iBAAiB2C;IAC/B,MAAMyB,YAAY,AAAC,CAAA;QACjB,IAAI;YACF,OAAO,MAAMlE,oBAAoByC;QACnC,EAAE,OAAOmB,OAAO;YACdC,QAAQD,KAAK,CAAC,qCAAqCA;YACnD,OAAOK;QACT;IACF,CAAA;IACA,IAAI,CAACD,QAAQ,OAAOE;IACpB,IAAIpB;IACJ,IAAI;QACF,OAAO,MAAMI,QAAQC,IAAI,CAAC;YACxBe;YACA,IAAIhB,QAAsB,CAACK;gBACzBT,QAAQU,WAAW,IAAMD,QAAQU,QAAQD;YAC3C;SACD;IACH,SAAU;QACR,IAAIlB,OAAOgB,aAAahB;IAC1B;AACF;AAaA;;;;;;;CAOC,GACD,SAASqB,WAAWC,KAAqB,EAAEC,KAAmB;IAC5D,IAAI,CAACA,MAAMC,QAAQ,IAAI,CAACD,MAAME,IAAI,EAAE;QAClC,OAAO;YAAEA,MAAM3D,WAAWwD,MAAMG,IAAI;YAAGC,OAAO;QAAG;IACnD;IACA,OAAO;QACLD,MAAM3D,WAAWyD,MAAME,IAAI;QAC3BC,OACE,GAAG5D,WAAWyD,MAAME,IAAI,EAAE,YAAY,EAAEF,MAAMI,OAAO,CAACC,MAAM,CAAC,QAAQ,CAAC,GACtE,GAAG9D,WAAWwD,MAAMG,IAAI,EAAE,yCAAyC,CAAC;IACxE;AACF;AAEA,sEAAsE,GACtE,SAASI,eAAeC,MAAuB,EAAEC,MAAM,EAAE;IACvD,OAAOD,OAAOJ,KAAK,GAAGvD,UAAU2D,OAAOJ,KAAK,EAAEK,OAAO;AACvD;AAEA,MAAMC,qBAAuC,OAAO5C,KAAK6C;QACjC7C;IAAtB,MAAM8C,SAASC,QAAO/C,cAAAA,IAAI8C,MAAM,YAAV9C,cAAc,OAAOgD,WAAW;IACtD,IAAIF,WAAW,SAASA,WAAW,UAAUA,WAAW,QAAQ;QAC9DD,IAAII,SAAS,CAAC,SAAS;QACvB,OAAO,KAAKJ,IAAIzC,MAAM,CAAC,KAAK8C,IAAI,CAAC;IACnC;IAEA,MAAMC,SAASpD,eAAeC;IAC9B,IAAImD,OAAO9C,OAAO,EAAE;QAClB,OAAO,KAAKwC,IAAIzC,MAAM,CAAC+C,OAAO9C,OAAO,EAAE6C,IAAI,CAAC;IAC9C;IACA,MAAM,EAAEjD,MAAM,EAAEK,GAAG,EAAE,GAAG6C;IACxB,MAAM,EAAE5C,MAAM,EAAEC,KAAK,EAAE4C,UAAU,EAAEC,OAAO,EAAE,GAAGpD;IAC/C,MAAMqD,QAAQhE,YAAYW;IAE1B,IAAI6C,WAAW,QAAQ;QACrB,uEAAuE;QACvE,yEAAyE;QACzE,aAAa;QACb,MAAM,CAACZ,OAAOC,MAAM,GAAG,MAAMnB,QAAQuC,GAAG,CAAC;YACvC5C,cAAcJ;YACdsB,iBAAiBtB,QAAQG;SAC1B;QACD,MAAMgC,SAAST,WAAWC,OAAOC;QACjC,OAAO,KAAK/C,SACVyD,KACA/D,KACEF,QAAQ,kBACNG,UACE,CAAC,kCAAkC,EAAEC,IAAIwE,GAAG,CAAC,EAAE,EAAE9E,WAC/C8B,OACA,4CAA4C,CAAC,GAC/C,CAAC,qBAAqB,EAAExB,IAAIwE,GAAG,CAAC,EAAE,EAAEd,OAAOL,IAAI,CAAC,UAAU,CAAC,EAC3DK,OAAOJ,KAAK,GAAG,IAAI,MAErBG,eAAeC,QAAQ,MACvB,CAAC,mDAAmD,EAAEhE,WACpD4E,OACA,EAAE,CAAC,GACL/D,aAAa,eAAe;YAAEkE,KAAKvB,MAAMuB,GAAG;QAAC,KAC7C,YACA,qEAAqE;QACrE,2DAA2D;QAC3D,iEAAiE;QACjE,uCAAuC;QACvC,CAAC,4EAA4E,CAAC,GAC9E,CAAC,gCAAgC,EAAE/E,WAAW4E,OAAO,EAAE,CAAC,GACxD,CAAC,aAAa,EAAEpB,MAAMuB,GAAG,CAACC,IAAI,CAAC,uBAAuB,CAAC,GACvD,+CACF,KACAxB;IAGN;IAEA,IAAI;QACF,MAAMrB,YAAY5C,cAAc6C,GAAG,GAAGD,SAAS;QAC/C;;;;;KAKC,GACD,MAAM,CAAC8C,SAASzB,OAAOC,MAAM,GAAG,MAAMnB,QAAQuC,GAAG,CAAC;YAChDK,qBAAqB/C,WAAWN,QAAQD,KAAK;gBAC3CE;gBACA4C;gBACAC;YACF;YACA1C,cAAcJ;YACdsB,iBAAiBtB,QAAQG;SAC1B;QACD,MAAMgC,SAAST,WAAWC,OAAOC;QAEjC;;;;;;;;;KASC,GACD,IAAIwB,SAAS,MAAME,qBAAqBtD,QAAQ6C;QAChD,+DAA+D;QAC/D,0EAA0E;QAC1E,iDAAiD;QACjD,MAAMjF,0BAA0B;YAAEoC;YAAQC;YAAOsD,MAAM;QAAa;QACpE,OAAO,KAAK1E,SACVyD,KACA/D,KACEU,aAAa0C,MAAMuB,GAAG,IACpB7E,QAAQ,yBACRG,UACE,CAAC,sCAAsC,EAAE2D,OAAOL,IAAI,CAAC,CAAC,CAAC,EACvDK,OAAOJ,KAAK,GAAG,IAAI,MAErBG,eAAeC,UACf,mEAAmE;QACnE,iEAAiE;QACjE,gDAAgD;QAChD,CAAC,gCAAgC,EAAEhE,WAAW4E,OAAO,EAAE,CAAC,GACxD,CAAC,4BAA4B,EAAEpB,MAAMuB,GAAG,CAACC,IAAI,CAAC,uBAAuB,CAAC,GACtE,sCACF,KACAxB;IAGN,EAAE,OAAOR,OAAO;QACdC,QAAQD,KAAK,CAACA;QACd,OAAO,KAAKmB,IAAIzC,MAAM,CAAC,KAAK8C,IAAI,CAAC;IACnC;AACF;AAEA;;;;;;;;;;;CAWC,GACD,eAAeW,qBAAqBtD,MAAc,EAAE6C,UAAkB;IACpE,IAAI,CAACvE,iBAAiBuE,aAAa;IACnC,MAAM5E,qBAAqB;QAAE+B;QAAQwD,QAAQX;IAAW;AAC1D;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,eAAeQ,qBACb/C,SAAc,EACdN,MAAc,EACdD,GAAW,EACX0D,MAA8D;IAE9D,MAAMC,MAAMpD,UACTK,UAAU,CAAC,SACXC,GAAG,CAACZ,QACJW,UAAU,CAAC,gBACXC,GAAG,CAACb;IACP;;;;GAIC,GACD,IAAIqD,UAAU;IACd,MAAM9C,UAAUqD,cAAc,CAAC,OAAOC;YAUJC;QAThC,MAAMA,WAAW,MAAMD,YAAY/C,GAAG,CAAC6C;QACvCN,UAAU,CAACS,SAAS7C,MAAM;QAC1B;;;;;KAKC,GACD,4EAA4E;QAC5E,MAAM8C,OAAOD,SAAS7C,MAAM,IAAI6C,gBAAAA,SAAShD,GAAG,CAAC,qBAAbgD,gBAA0B9F,iCAAkC;QAC5F,IAAI+F,QAAQA,SAAS/F,gCAAgC;YACnD;QACF;QACA6F,YAAYG,GAAG,CACbL,KACA;YACEzD,OAAOwD,OAAOxD,KAAK;YACnB,0DAA0D;YAC1D+D,aAAavG,kBAAkBgG,OAAOxD,KAAK;YAC3CgE,QAAQlG;YACRmG,cAAc9F,WAAW+F,eAAe;WAQpCN,SAAS7C,MAAM,GACf,CAAC,IACD;YACEoD,WAAWhG,WAAW+F,eAAe;WACjCV,OAAOZ,UAAU,GAAG;YAAEA,YAAYY,OAAOZ,UAAU;QAAC,IAAI,CAAC,GACzDY,OAAOX,OAAO,GAAG;YAAEA,SAASW,OAAOX,OAAO;QAAC,IAAI,CAAC,KAG5D;YAAEuB,OAAO;QAAK;IAElB;IACA,mEAAmE;IACnE,0EAA0E;IAC1E,qEAAqE;IACrE,uBAAuB;IACvB,IAAIjB,SAAS;QACX,MAAMpF,sBAAsB;YAC1BgC;YACAC,OAAOwD,OAAOxD,KAAK;YACnBqE,OAAO;gBACLzE,QAAQ;gBACR0E,MAAMC,KAAKC,GAAG;gBACdC,QAAQ;gBACRC,QAAQlB,OAAOZ,UAAU,GAAG,wCAAwC;YACtE;QACF;IACF;IACA,OAAOO;AACT;AAEA;;;;;;;;;;;;CAYC,GACD,MAAMwB,qBAAuC,OAAOnF,KAAK6C;QACjC7C;IAAtB,MAAM8C,SAASC,QAAO/C,cAAAA,IAAI8C,MAAM,YAAV9C,cAAc,OAAOgD,WAAW;IACtD,IAAIF,WAAW,SAASA,WAAW,UAAUA,WAAW,QAAQ;QAC9DD,IAAII,SAAS,CAAC,SAAS;QACvB,OAAO,KAAKJ,IAAIzC,MAAM,CAAC,KAAK8C,IAAI,CAAC;IACnC;IAEA,yEAAyE;IACzE,oEAAoE;IACpE,4EAA4E;IAC5E,4DAA4D;IAC5D,MAAMC,SAASpD,eAAeC;IAC9B,IAAImD,OAAO9C,OAAO,EAAE;QAClB,OAAO,KAAKwC,IAAIzC,MAAM,CAAC+C,OAAO9C,OAAO,EAAE6C,IAAI,CAAC;IAC9C;IACA,MAAM,EAAEjD,MAAM,EAAEK,GAAG,EAAE,GAAG6C;IACxB,MAAM,EAAE5C,MAAM,EAAEC,KAAK,EAAE,GAAGP;IAC1B,MAAMqD,QAAQhE,YAAYW;IAE1B,IAAI6C,WAAW,QAAQ;QACrB,wEAAwE;QACxE,sCAAsC;QACtC,MAAM,CAACZ,OAAOC,MAAM,GAAG,MAAMnB,QAAQuC,GAAG,CAAC;YACvC5C,cAAcJ;YACdsB,iBAAiBtB,QAAQG;SAC1B;QACD,MAAMgC,SAAST,WAAWC,OAAOC;QACjC,OAAO,KAAK/C,SACVyD,KACA/D,KACEF,QAAQ,kBACNG,UACE,CAAC,iDAAiD,EAAEC,IAAIwE,GAAG,CAAC,EAAE,CAAC,GAC7D,GAAGd,OAAOL,IAAI,CAAC,mBAAmB,CAAC,GACnC,CAAC,qBAAqB,EAAErD,IAAIwE,GAAG,CAAC,EAAE,EAAE9E,WAClC8B,OACA,UAAU,CAAC,EACfkC,OAAOJ,KAAK,GAAG,IAAI,MAErBG,eAAeC,QAAQ,MACvB,CAAC,mDAAmD,EAAEhE,WACpD4E,OACA,EAAE,CAAC,GACL/D,aAAa,eAAe;YAAEkE,KAAKvB,MAAMuB,GAAG;QAAC,KAC7C,WACF,KACAvB;IAGN;IAEA,IAAI;QACF,MAAMrB,YAAY5C,cAAc6C,GAAG,GAAGD,SAAS;QAC/C,wEAAwE;QACxE,MAAMuE,YAAYvD,iBAAiBtB;QACnC,MAAM,CAAC8E,UAAUnD,OAAOC,MAAM,GAAG,MAAMnB,QAAQuC,GAAG,CAAC;YACjD6B,UAAUE,IAAI,CAAC,CAACC,WACdC,uBAAuB3E,WAAW0E,UAAUjF;YAE9CK,cAAcJ;YACd6E;SACD;QACD,IAAI,CAACC,UAAU;YACb,OAAO,KAAKjG,SAASyD,KAAK/D,KAAK2G,wBAAwB,KAAKvD;QAC9D;QACA,sEAAsE;QACtE,6CAA6C;QAC7C,MAAMhE,0BAA0B;YAAEqC;YAAQC;YAAOkF,KAAK;QAAoB;QAC1E,MAAMhD,SAAST,WAAWC,OAAOC;QACjC,OAAO,KAAK/C,SACVyD,KACA/D,KACEU,aAAa0C,MAAMuB,GAAG,IACpB7E,QAAQ,yBACRG,UACE,CAAC,2BAA2B,EAAE2D,OAAOL,IAAI,CAAC,OAAO,CAAC,EAClDK,OAAOJ,KAAK,GAAG,IAAI,KAErBG,eAAeC,QAAQ,IACzB,KACAR;IAGN,EAAE,OAAOR,OAAO;QACdC,QAAQD,KAAK,CAACA;QACd,OAAO,KAAKmB,IAAIzC,MAAM,CAAC,KAAK8C,IAAI,CAAC;IACnC;AACF;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,eAAesC,uBACb3E,SAAc,EACdsB,KAAmB,EACnB7B,GAAW;IAEX,MAAM,CAACqF,KAAK,GAAGC,SAAS,GAAGjI,wBAAwBwE,OAAO0D,GAAG,CAAC,CAACC,KAC7DjF,UAAUK,UAAU,CAAC,SAASC,GAAG,CAAC2E,IAAI5E,UAAU,CAAC,gBAAgBC,GAAG,CAACb;IAEvE,MAAM,CAACyF,aAAa,GAAGC,iBAAiB,GAAG,MAAMhF,QAAQuC,GAAG,CAC1D;QAACoC;WAAQC;KAAS,CAACC,GAAG,CAAC,CAAC5B,MAAQA,IAAI7C,GAAG;IAEzC,IACE;QAAC2E;WAAgBC;KAAiB,CAACC,IAAI,CACrC,CAAClF,WACCA,SAASQ,MAAM,IACfR,SAASK,GAAG,CAAC,cAAc9C,iCAE/B;QACA,OAAO;IACT;IACA,sEAAsE;IACtE,wEAAwE;IACxE,wEAAwE;IACxE,yBAAyB;IACzB,MAAM0C,QAAQuC,GAAG,CAAC;QAChBoC,IAAIO,MAAM;WACPN,SACAO,MAAM,CAAC,CAACC,MAAMC,QAAUL,gBAAgB,CAACK,MAAM,CAAC9E,MAAM,EACtDsE,GAAG,CAAC,CAAC5B,MAAQA,IAAIiC,MAAM;KAC3B;IACD,OAAO;AACT;AAEA,4EAA4E,GAC5E,SAAST;IACP,OACE7G,QAAQ,oCACRG,UACE,+DACE,kEACA,0CACF;AAGN;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,MAAMuH,qBAAuC,OAAOtG,KAAK6C;QACjC7C;IAAtB,MAAM8C,SAASC,QAAO/C,cAAAA,IAAI8C,MAAM,YAAV9C,cAAc,OAAOgD,WAAW;IACtD,IAAIF,WAAW,SAASA,WAAW,UAAUA,WAAW,QAAQ;QAC9DD,IAAII,SAAS,CAAC,SAAS;QACvB,OAAO,KAAKJ,IAAIzC,MAAM,CAAC,KAAK8C,IAAI,CAAC;IACnC;IAEA,MAAMC,SAASpD,eAAeC;IAC9B,IAAImD,OAAO9C,OAAO,EAAE;QAClB,OAAO,KAAKwC,IAAIzC,MAAM,CAAC+C,OAAO9C,OAAO,EAAE6C,IAAI,CAAC;IAC9C;IACA,MAAM,EAAEjD,MAAM,EAAEK,GAAG,EAAE,GAAG6C;IACxB,MAAM,EAAE5C,MAAM,EAAEC,KAAK,EAAE4C,UAAU,EAAEC,OAAO,EAAE,GAAGpD;IAC/C,MAAMqD,QAAQhE,YAAYW;IAE1B,IAAI;YAqCYD,WACHuG;QArCX,MAAM1F,YAAY5C,cAAc6C,GAAG,GAAGD,SAAS;QAC/C,qEAAqE;QACrE,mEAAmE;QACnE,2EAA2E;QAC3E,MAAMuE,YAAYvD,iBAAiBtB;QACnC,MAAM,CAACiG,SAAS3B,OAAO3C,OAAOC,MAAM,GAAG,MAAMnB,QAAQuC,GAAG,CAAC;YACvDkD,iBAAiB5F,WAAWN;YAC5B6E,UAAUE,IAAI,CAAC,CAACC,WACdmB,sBAAsB7F,WAAW0E,UAAUjF;YAE7CK,cAAcJ;YACd6E;SACD;QACD,MAAMuB,SAASvJ,yBAAyBoJ;QACxC,MAAM9D,SAAST,WAAWC,OAAOC;QAEjC,IAAIW,WAAW,QAAQ;YACrB,qDAAqD;YACrD,OAAO,KAAK1D,SACVyD,KACA/D,KACE8H,oBAAoB;gBAClBpG;gBACA8C;gBACAqD;gBACA9B;gBACAxB;gBACAnB;gBACAQ;YACF,IACA,KACAR;QAGN;QAEA,MAAMqE,QAAQvG,YAAAA,IAAIuG,IAAI,YAARvG,YAAY,CAAC;QAC3B,IAAI+C,QAAOwD,eAAAA,IAAI,CAAC,SAAS,YAAdA,eAAkB,QAAQ,OAAO;YAC1C,MAAM5C,UAAU,MAAMC,qBAAqB/C,WAAWN,QAAQD,KAAK;gBACjEE;gBACA4C;gBACAC;YACF;YACA,IAAIM,SAAS,MAAME,qBAAqBtD,QAAQ6C;YAChD,MAAMjF,0BAA0B;gBAAEoC;gBAAQC;gBAAOsD,MAAM;YAAa;YACpE,OAAO,KAAK1E,SACVyD,KACA/D,KACEU,aAAa0C,MAAMuB,GAAG,IACpB7E,QAAQ,yBACRG,UACE,CAAC,qBAAqB,EAAEC,IAAIwE,GAAG,CAAC,EAAE,EAAE9E,WAAW8B,OAAO,UAAU,CAAC,GAC/D,4CACA,GAAGkC,OAAOL,IAAI,CAAC,OAAO,CAAC,EACzBK,OAAOJ,KAAK,GAAG,IAAI,MAErBG,eAAeC,UACf3D,UACE,wBACE,CAAC,gCAAgC,EAAEL,WAAW4E,OAAO,EAAE,CAAC,GACxD,CAAC,aAAa,EAAEpB,MAAMuB,GAAG,CAACC,IAAI,CAAC,uBAAuB,CAAC,GACvD,yBACA,CAAC,gCAAgC,EAAEhF,WAAW4E,OAAO,EAAE,CAAC,GACxD,CAAC,aAAa,EAAEpB,MAAMuB,GAAG,CAACC,IAAI,CAAC,uBAAuB,CAAC,GACvD,sCACF,IAEJ,KACAxB;QAGN;QAEA;;;;;;;KAOC,GACD,MAAM2E,OAAO,IAAIC,IACfH,OACGd,GAAG,CAAC,CAACkB,QAAUA,MAAMjB,EAAE,EACvBK,MAAM,CAAC,CAACL;gBAAcS;mBAAPxD,QAAOwD,SAAAA,IAAI,CAAC,CAAC,MAAM,EAAET,IAAI,CAAC,YAAnBS,SAAuB,QAAQ;;QAE1D,MAAMS,OAAOL,OAAOR,MAAM,CAAC,CAACY,QAAU,CAACF,KAAKI,GAAG,CAACF,MAAMjB,EAAE;QACxD,MAAMoB,kBAAkBrG,WAAWN,QAAQD,KAAK;YAC9CE;YACA2G,QAAQH,KAAKnB,GAAG,CAAC,CAACkB,QAAUA,MAAMjB,EAAE;YACpCsB,QAAQ;mBAAIP;aAAK;QACnB;QACA;;;;;;;KAOC,GACD,MAAMQ,wBAAwBxG,WAAWsB,OAAO7B,KAAK;eAAIuG;SAAK,EAAE;YAC9DS,gBAAgB;QAClB;QAEA;;;;;;;;;;;;;KAaC,GACD,MAAMC,UAAU9I,0BAA0B8H,IAAI,CAAC,UAAU;QACzD,MAAMiB,gBAAgB,MAAMnJ,oBAAoBkC,QAAQC,OAAO+G;QAE/D;;;;;;;KAOC,GACD,IAAIE,eAAe;QACnB,IAAIZ,KAAKa,IAAI,EAAE;YACbD,eAAe,CAAE,MAAMjC,uBAAuB3E,WAAWsB,OAAO7B;QAClE;QAEA;;;;;;;KAOC,GACD,IAAI0G,KAAKxE,MAAM,EAAE;YACf,MAAMrE,0BAA0B;gBAC9BoC;gBACAC;gBACAsD,MAAMkD,KAAKnB,GAAG,CAAC,CAACkB,QAAUA,MAAMjB,EAAE;YACpC;QACF;QACA,IAAIe,KAAKa,IAAI,EAAE;YACb,MAAMxJ,0BAA0B;gBAAEqC;gBAAQC;gBAAOkF,KAAK;YAAoB;QAC5E;QAEA,OAAO,KAAKtG,SACVyD,KACA/D,KACEU,aAAa0C,MAAMuB,GAAG,IACpB7E,QAAQoI,KAAKxE,MAAM,GAAG,wBAAwB,uBAC9CzD,UACE4I,cAAc;YAAEnH;YAAOqG,MAAM;mBAAIA;aAAK;YAAEG;YAAML;YAAQjE;QAAO,IAC7D6E,YAAY,SAASC,iBAAiB,CAAC9E,OAAOJ,KAAK,GAAG,KAAK,KAE7DG,eAAeC,QAAQ6E,YAAY,SAASC,gBAAgB,KAAK,KACjE;;;;WAIC,GACAD,CAAAA,YAAY,SAASC,gBAClBzI,UACE,CAAC,8BAA8B,EAAE6I,gBAAgBL,SAAS,CAAC,CAAC,EAC5D,MAEF,EAAC,IACJ,CAAA,CAACC,gBACEzI,UACE,iEACE,6DACA,mBACF,MAEF,EAAC,IACJ0I,CAAAA,eACG1I,UACE,4DACE,8DACA,+DACA,UACF,MAEF,EAAC,IACLA,UACE,wBACE,CAAC,gCAAgC,EAAEL,WAAW4E,OAAO,EAAE,CAAC,GACxD,CAAC,aAAa,EAAEpB,MAAMuB,GAAG,CAACC,IAAI,CAAC,uBAAuB,CAAC,GACvD,gEACA,uBACF,IAEJ,KACAxB;IAGN,EAAE,OAAOR,OAAO;QACdC,QAAQD,KAAK,CAACA;QACd,OAAO,KAAKmB,IAAIzC,MAAM,CAAC,KAAK8C,IAAI,CAAC;IACnC;AACF;AAEA;;;;;;;;;CASC,GACD,eAAeuD,iBACb5F,SAAc,EACdN,MAAc,EACd;;;;GAIC,GACDsH,SAAS,KAAK;IAEd,IAAI;QACF,MAAMC,QAAQ,MAAM1J,oBAAoBmC;QACxC,OAAO,MAAM9C,oBAAoBoD,WAAWiH,gBAAAA,QAAS;IACvD,EAAE,OAAOpG,OAAO;QACd,IAAImG,QAAQ,MAAMnG;QAClBC,QAAQD,KAAK,CAAC,iDAAiDA;QAC/D,OAAOrE,wBAAwB;IACjC;AACF;AA2BA,yEAAyE,GACzE,SAASuK,gBAAgBL,OAAyB;IAChD,OAAOA,YAAY,UACf,cACAA,YAAY,WACV,eACA;AACR;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,eAAeb,sBACb7F,SAAc,EACdsB,KAAmB,EACnB7B,GAAW;IAEX,MAAMyH,QAAQ,MAAM/G,QAAQuC,GAAG,CAC7B5F,wBAAwBwE,OAAO0D,GAAG,CAAC,CAACC;QAClC,MAAMkC,UAAUnH,UAAUK,UAAU,CAAC,SAASC,GAAG,CAAC2E;QAClD,OAAO9E,QAAQuC,GAAG,CAAC;YACjByE,QAAQ9G,UAAU,CAAC,gBAAgBC,GAAG,CAACb,KAAKc,GAAG;YAC/C4G,QAAQ9G,UAAU,CAAC1D,8BAA8B2D,GAAG,CAACb,KAAKc,GAAG;YAC7D4G,QACG9G,UAAU,CAACnD,+BACXoD,GAAG,CAACb,KACJc,GAAG,EACJ,4DAA4D;YAC5D,8DAA8D;YAC9D,8DAA8D;YAC9D,cAAc;aACb6G,KAAK,CAAC,IAAM;SAChB;IACH;IAEF,MAAMC,WAAW,IAAIpB;IACrB,MAAMqB,UAAU,IAAIrB;IACpB,IAAIsB,aAAa;IACjB,IAAIC,kBAAiC;IACrC,IAAId,UAAmB;IACvB,IAAIe,iBAAiBC,OAAOC,iBAAiB;IAC7CT,MAAMU,OAAO,CAAC,CAAC,CAACC,aAAaC,SAASC,UAAU,EAAEvC;YAShCsC;QARhB,MAAMhD,MAAMU,UAAU;QACtB,IAAIqC,+BAAAA,YAAanH,MAAM,EAAE;YACvB6G,aAAa;YACb,MAAM5D,SAASkE,YAAYtH,GAAG,CAAC;YAC/B,IAAIoD,WAAWlG,kCAAkC+J,oBAAoB,MAAM;gBACzEA,kBAAkBtF,OAAOyB,iBAAAA,SAAU;YACrC;QACF;QACA,MAAMqE,UAAUF,OAAAA,CAAAA,2BAAAA,QAASpH,MAAM,IAAGoH,QAAQvH,GAAG,CAAC,YAAY,gBAA1CuH,OAAmD,CAAC;QACpE,KAAK,MAAM,CAAC7C,IAAIgD,OAAO,IAAIC,OAAOC,OAAO,CACvCH,QACC;YACD;;;;;;OAMC,GACD,MAAMhE,QAAQvH,2BAA2BwL;YACzC,IAAIjE,UAAU,aAAaqD,SAASe,GAAG,CAACnD;YACxC,IAAI,AAACH,CAAAA,OAAOxD,MAAM+G,kBAAkB,KAAK,IAAG,KAAMrE,UAAU,WAAW;gBACrEsD,QAAQc,GAAG,CAACnD;YACd;QACF;QACA,wEAAwE;QACxE,2CAA2C;QAC3C,IAAI8C,CAAAA,6BAAAA,UAAWrH,MAAM,KAAIqH,UAAUxH,GAAG,CAAC,cAAc,MAAM;YACzD,MAAM+H,UAAUZ,OAAOK,UAAUxH,GAAG,CAAC;YACrC,MAAM0D,OAAOyD,OAAOa,QAAQ,CAACD,WAAWA,UAAU;YAClD,IAAIrE,OAAOwD,gBAAgB;gBACzBA,iBAAiBxD;gBACjByC,UAAUqB,UAAUxH,GAAG,CAAC;YAC1B;QACF;IACF;IACA,OAAO;QACLgH;QACAiB,iBAAiBhB,oBAAoB;QACrCA;QACAH;QACAC;QACAZ,SAAS9I,0BAA0B8I;IACrC;AACF;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GACD,eAAeL,kBACbrG,SAAc,EACdN,MAAc,EACdD,GAAW,EACX0D,MAUC;IAED,MAAMC,MAAMpD,UACTK,UAAU,CAAC,SACXC,GAAG,CAACZ,QACJW,UAAU,CAAC1D,8BACX2D,GAAG,CAACb;IACP,MAAMO,UAAUqD,cAAc,CAAC,OAAOC;YAEnBC;QADjB,MAAMA,WAAW,MAAMD,YAAY/C,GAAG,CAAC6C;QACvC,MAAM4E,UAAWzE,OAAAA,SAAS7C,MAAM,GAAG6C,SAAShD,GAAG,CAAC,YAAY,gBAA3CgD,OACf,CAAC;QACH,MAAMuC,SAAkC,CAAC;QACzC,KAAK,MAAMb,MAAM9B,OAAOmD,MAAM,CAAE;YAC9B,MAAMmC,WAAWT,MAAM,CAAC/C,GAAG;YAC3B;;;;;;;;;;;OAWC,GACDa,MAAM,CAACb,GAAG,GACRxI,2BAA2BgM,cAAc,cACrCA,WACA,aACMA,mBAAAA,WAAY,CAAC;gBACjBC,YAAY5K,WAAW+F,eAAe;gBACtC8E,gBAAgB;;QAE1B;QACA,KAAK,MAAM1D,MAAM9B,OAAOoD,MAAM,CAAE;YAC9B,MAAMkC,WAAWT,MAAM,CAAC/C,GAAG;YAC3B,IAAI,CAACwD,UAAU;YACf,MAAMzE,QAAQvH,2BAA2BgM;YACzC,IAAIzE,UAAU,WAAW;gBACvB,IAAIb,OAAOsD,cAAc,KAAK,OAAO;oBACnCX,MAAM,CAACb,GAAG,GAAG,aAAKwD;wBAAUG,aAAa1E,KAAKC,GAAG;;gBACnD;gBACA;YACF;YACA2B,MAAM,CAACb,GAAG,GAAGwD,QAAQ,CAAC,iBAAiB,GACnCA,WACA,aAAKA;gBAAUE,gBAAgB7K,WAAW+F,eAAe;;QAC/D;QACAP,YAAYG,GAAG,CACbL,KACA;YACEzD,OAAOwD,OAAOxD,KAAK;YACnB,+DAA+D;YAC/D,kEAAkE;YAClE,+DAA+D;YAC/DmG,QAAQ,aAAKkC,QAAWlC;YACxB+C,WAAW/K,WAAW+F,eAAe;WACjCN,SAAS7C,MAAM,GACf,CAAC,IACD;YAAEoD,WAAWhG,WAAW+F,eAAe;QAAG,IAEhD;YAAEE,OAAO;QAAK;IAElB;AACF;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,eAAeyC,wBACbxG,SAAc,EACdsB,KAAmB,EACnB7B,GAAW,EACXqJ,QAA2B,EAC3BC,OAAsC;IAEtC,MAAMhE,WAAWjI,wBAAwBwE,OAAO0H,KAAK,CAAC;IACtD,IAAI,CAACjE,SAASpD,MAAM,IAAI,CAACmH,SAASnH,MAAM,EAAE;IAC1C,MAAMsH,mBACJF,CAAAA,2BAAAA,QAAStC,cAAc,MAAK,QAAQnF,MAAM+G,kBAAkB,KAAK;IACnE,MAAMlI,QAAQuC,GAAG,CACfqC,SAASC,GAAG,CAAC,OAAOC;QAClB,MAAM7B,MAAMpD,UACTK,UAAU,CAAC,SACXC,GAAG,CAAC2E,IACJ5E,UAAU,CAAC1D,8BACX2D,GAAG,CAACb;QACP,MAAMO,UAAUqD,cAAc,CAAC,OAAOC;gBAGpBC;YAFhB,MAAMA,WAAW,MAAMD,YAAY/C,GAAG,CAAC6C;YACvC,IAAI,CAACG,SAAS7C,MAAM,EAAE;YACtB,MAAMsH,UAAUzE,gBAAAA,SAAShD,GAAG,CAAC,qBAAbgD,gBAA0B,CAAC;YAI3C,MAAM2F,SAAkC,CAAC;YACzC,KAAK,MAAM1G,WAAWsG,SAAU;gBAC9B,MAAML,WAAWT,MAAM,CAACxF,QAAQ;gBAChC,MAAMwB,QAAQvH,2BAA2BgM;gBACzC,IAAIzE,UAAU,aAAaiF,kBAAkB;oBAC3CC,MAAM,CAAC1G,QAAQ,GAAG,aAAKiG;wBAAUG,aAAa1E,KAAKC,GAAG;;oBACtD;gBACF;gBACA,IAAIH,UAAU,aAAa;gBAC3BkF,MAAM,CAAC1G,QAAQ,GAAG,aACbiG;oBACHE,gBAAgB7K,WAAW+F,eAAe;;YAE9C;YACA,IAAI,CAACqE,OAAOiB,IAAI,CAACD,QAAQvH,MAAM,EAAE;YACjC2B,YAAYG,GAAG,CACbL,KACA;gBACE0C,QAAQ,aAAKkC,QAAWkB;gBACxBL,WAAW/K,WAAW+F,eAAe;YACvC,GACA;gBAAEE,OAAO;YAAK;QAElB;IACF;AAEJ;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,eAAeqF,uBACpBC,OAAuC,EACvCrJ,YAAiB5C,cAAc6C,GAAG,GAAGD,SAAS,EAAE;IAEhD,MAAMP,MAAMb,kBAAkByK,QAAQ1J,KAAK;IAC3C,IAAI,CAACF,KAAK,OAAO;QAAEF,QAAQ;QAAQoE,QAAQ;IAAmB;IAC9D,2EAA2E;IAC3E,uCAAuC;IACvC,MAAMrC,QAAQ,MAAMN,iBAAiBqI,QAAQ3J,MAAM;IACnD,MAAMsE,QAAQ,MAAM6B,sBAAsB7F,WAAWsB,OAAO7B;IAC5D,IAAIuE,MAAMwE,eAAe,EAAE;YACQxE;QAAjC,OAAO;YAAEzE,QAAQ;YAAQoE,MAAM,GAAEK,yBAAAA,MAAMwD,eAAe,YAArBxD,yBAAyB;QAAO;IACnE;IACA,MAAMrE,QAAQuC,OAAOmH,QAAQ1J,KAAK,EAAE2J,IAAI,GAAGC,WAAW;IACtD,IAAI,CAACvF,MAAMuD,UAAU,EAAE;QACrB,MAAMlB,kBAAkBrG,WAAWqJ,QAAQ3J,MAAM,EAAED,KAAK;YACtDE;YACA2G,QAAQ,EAAE;YACVC,QAAQ;gBAAC8C,QAAQ7G,OAAO;aAAC;YACzBiE,gBAAgB;QAClB;QACA,MAAMD,wBAAwBxG,WAAWsB,OAAO7B,KAAK;YAAC4J,QAAQ7G,OAAO;SAAC;QACtE,OAAO;YAAEjD,QAAQ;YAAYiK,qBAAqB;YAAOC,UAAU;QAAE;IACvE;IACA,MAAMC,SAASnN,yBAAyB,MAAMqJ,iBAAiB5F,WAAWqJ,QAAQ3J,MAAM,EAAE,OACvFsF,GAAG,CAAC,CAACkB,QAAUA,MAAMjB,EAAE,EACvBK,MAAM,CAAC,CAACL,KAAOA,OAAOoE,QAAQ7G,OAAO;IACxC,MAAM6D,kBAAkBrG,WAAWqJ,QAAQ3J,MAAM,EAAED,KAAK;QACtDE;QACA2G,QAAQoD;QACRnD,QAAQ;YAAC8C,QAAQ7G,OAAO;SAAC;QACzBiE,gBAAgB;IAClB;IACA,MAAMD,wBAAwBxG,WAAWsB,OAAO7B,KAAK;QAAC4J,QAAQ7G,OAAO;KAAC;IACtE,IAAI,CAAE,MAAMmC,uBAAuB3E,WAAWsB,OAAO7B,MAAO;QAC1D,qEAAqE;QACrE,OAAO;YAAEF,QAAQ;YAAQoE,QAAQ;QAAY;IAC/C;IACA,OAAO;QAAEpE,QAAQ;QAAYiK,qBAAqB;QAAMC,UAAUC,OAAO/H,MAAM;IAAC;AAClF;AAEA,6DAA6D,GAC7D,SAASgI,SACPzD,KAAwB,EACxB0D,OAAgB,EAChBC,WAAoB,EACpB;;;;;;;;;GASC,GACDvC,UAAU,KAAK,EACf1E,MAAoBzE,GAAG;IAEvB,OACE,CAAC,0EAA0E,CAAC,GAC5E,CAAC,qBAAqB,EAAEA,IAAI2L,OAAO,CAAC,iBAAiB,CAAC,GACtD,CAAC,mCAAmC,EAAEjM,WAAWqI,MAAMjB,EAAE,EAAE,YAAY,CAAC,GACvE2E,CAAAA,UAAU,aAAa,EAAC,IACzB,8DACA,0BACA,CAAC,gEAAgE,EAAEzL,IAAIwE,GAAG,CAAC,EAAE,CAAC,GAC9E9E,WAAWqI,MAAM1E,IAAI,IACpBqI,CAAAA,cACG,CAAC,4DAA4D,CAAC,GAC9D,CAAC,oDAAoD,EAAEjH,IAAIC,IAAI,CAAC,EAAE,CAAC,GACnE,sBACA,EAAC,IACL,YACCyE,CAAAA,UACG,CAAC,0EAA0E,CAAC,GAC5E,CAAC,MAAM,EAAEnJ,IAAI4L,KAAK,CAAC,kDAAkD,CAAC,GACtE,kCACA,EAAC,IACJ7D,CAAAA,MAAM8D,WAAW,GACd,CAAC,0EAA0E,CAAC,GAC5E,CAAC,MAAM,EAAE7L,IAAI4L,KAAK,CAAC,EAAE,EAAElM,WAAWqI,MAAM8D,WAAW,EAAE,OAAO,CAAC,GAC7D,EAAC,IACL;AAEJ;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,SAASC,gBAAgBC,OAAyB;IAChD,MAAMC,SAAS,CAACC,OAAyBC,QACvC,CAAC,sFAAsF,CAAC,GACxF,CAAC,0CAA0C,EAAExM,WAAWuM,OAAO,CAAC,CAAC,GAChEA,CAAAA,UAAUF,UAAU,aAAa,EAAC,IACnC,wDACA,CAAC,kCAAkC,EAAE/L,IAAIwE,GAAG,CAAC,EAAE,EAAE0H,MAAM,eAAe,CAAC;IACzE,OACE,CAAC,iCAAiC,EAAElM,IAAI2L,OAAO,CAAC,kCAAkC,CAAC,GACnF,CAAC,iDAAiD,EAAE3L,IAAIwE,GAAG,CAAC,oBAAoB,CAAC,GACjF,cACA,WACA,CAAC,kDAAkD,EAAExE,IAAI4L,KAAK,CAAC,oBAAoB,CAAC,GACpF,2EACA,oCACA,WACAI,OAAO,OAAO,kBACdA,OAAO,SAAS,uBAChBA,OAAO,UAAU,wBACjBA,OAAO,WAAW,yBAClB;AAEJ;AAEA,gCAAgC,GAChC,SAASpE,oBAAoBuE,IAS5B;IACC,MAAM,EAAE3K,KAAK,EAAE8C,KAAK,EAAEqD,MAAM,EAAE9B,KAAK,EAAExB,OAAO,EAAEnB,KAAK,EAAEQ,MAAM,EAAE,GAAGyI;IAChE,MAAM1H,MAAMvB,MAAMuB,GAAG;IACrB,4EAA4E;IAC5E,yEAAyE;IACzE,yEAAyE;IACzE,gBAAgB;IAChB,IAAIoB,MAAMwE,eAAe,EAAE,OAAO5D;IAClC,MAAMsF,UAAUxN,yBAAyB8F,SAASsD;IAClD,MAAMyE,SAAS,CAAC,uBAAuB,EAAE1M,WAAW4E,QAAQ;IAC5D,OACE1E,QAAQ,uBACRG,UACE,CAAC,iCAAiC,EAAEC,IAAIwE,GAAG,CAAC,EAAE,EAAE9E,WAC9C8B,OACA,qCAAqC,CAAC,GACtC,CAAC,qBAAqB,EAAExB,IAAIwE,GAAG,CAAC,EAAE,EAAEd,OAAOL,IAAI,CAAC,WAAW,CAAC,GAC5D,qDACF,KAEFI,eAAeC,QAAQ,KACtBmC,CAAAA,MAAMuD,UAAU,GACbrJ,UACE,mEACE,sCACF,KAEF,EAAC,IACL,CAAC,4BAA4B,EAAEqM,OAAO,EAAE,CAAC,GACzCzE,OACGd,GAAG,CAAC,CAACkB,QACJyD,SACEzD,OACA,gEAAgE;QAChE,mEAAmE;QACnE,mEAAmE;QACnE,gEAAgE;QAChE,yDAAyD;QACzD,CAAClC,MAAMuD,UAAU,IACf,CAACvD,MAAMqD,QAAQ,CAACjB,GAAG,CAACF,MAAMjB,EAAE,KAC5B,CAACjB,MAAMsD,OAAO,CAAClB,GAAG,CAACF,MAAMjB,EAAE,GAC7BiB,MAAMjB,EAAE,KAAKiF,QAAQjF,EAAE,EACvB,CAACjB,MAAMuD,UAAU,IAAIvD,MAAMsD,OAAO,CAAClB,GAAG,CAACF,MAAMjB,EAAE,GAC/CrC,MAGH4H,IAAI,CAAC,MACR;;;;;;;;;KASC,GACDP,gBAAgBjG,MAAM0C,OAAO,IAC7B,CAAC,iCAAiC,EAAEvI,IAAI2L,OAAO,CAAC,kCAAkC,CAAC,GACnFpL,aAAa,uBAAuB;QAAEkE;IAAI,KAC1C,kBACA,wEAAwE;IACxE,2EAA2E;IAC3E,yEAAyE;IACzE,2EAA2E;IAC3E,+BAA+B;IAC/B,CAAC,4BAA4B,EAAE2H,OAAO,0BAA0B,CAAC,GACjE,oDACA,8EACA,CAAC,mCAAmC,EAAEpM,IAAI2L,OAAO,CAAC,mBAAmB,CAAC,GACtE,CAAC,6BAA6B,EAAE3L,IAAI4L,KAAK,CAAC,4BAA4B,CAAC,GACvE;AAEJ;AAEA,+DAA+D,GAC/D,SAASjD,cAAcwD,IAOtB;IACC,MAAMG,UAAU,CAAC,qBAAqB,EAAEtM,IAAIwE,GAAG,CAAC,EAAE,EAAE9E,WAClDyM,KAAK3K,KAAK,EACV,SAAS,CAAC;IACZ,MAAM+K,OAAOJ,KAAKzI,MAAM,CAACJ,KAAK,GAAG6I,KAAKzI,MAAM,CAACL,IAAI,GAAG;IACpD,IAAI,CAAC8I,KAAKnE,IAAI,CAACxE,MAAM,EAAE;QACrB,OAAO,GAAG8I,QAAQ,4BAA4B,EAAEC,KAAK,OAAO,CAAC;IAC/D;IACA,MAAMC,QAAQL,KAAKnE,IAAI,CACpBnB,GAAG,CAAC,CAACkB,QAAUrI,WAAWqI,MAAM1E,IAAI,GACpCgJ,IAAI,CAAC;IACR,IAAI,CAACF,KAAKtE,IAAI,CAACrE,MAAM,EAAE;QACrB,OACE,GAAG8I,QAAQ,4BAA4B,EAAEE,MAAM,cAAc,CAAC,GAC9D,GAAGD,KAAK,iBAAiB,CAAC;IAE9B;IACA,OAAO,GAAGD,QAAQ,qBAAqB,EAAEE,MAAM,qBAAqB,CAAC;AACvE;AAEA;;;;;;;;;;;;;;;CAeC,GACD,MAAMC,iBAAmC,OAAOzL,KAAK6C;QAC7B7C;IAAtB,MAAM8C,SAASC,QAAO/C,cAAAA,IAAI8C,MAAM,YAAV9C,cAAc,OAAOgD,WAAW;IACtD,IAAIF,WAAW,SAASA,WAAW,UAAUA,WAAW,QAAQ;QAC9DD,IAAII,SAAS,CAAC,SAAS;QACvB,OAAO,KAAKJ,IAAIzC,MAAM,CAAC,KAAK8C,IAAI,CAAC;IACnC;IAEA,MAAMjD,SAASf,WAAWc;IAC1B,MAAME,SAASR;IACf,IAAI,CAACO,OAAOM,MAAM,IAAI,CAACN,OAAOO,KAAK,IAAI,CAACP,OAAOQ,SAAS,IAAI,CAACP,QAAQ;QACnE,OAAO,KAAK2C,IAAIzC,MAAM,CAAC,KAAK8C,IAAI,CAAC;IACnC;IACA,IAAI,CAAC7D,iBAAiB,aAAKY;QAAQC;QAAQwL,SAAS;SAAc;QAChE,OAAO,KAAK7I,IAAIzC,MAAM,CAAC,KAAK8C,IAAI,CAAC;IACnC;IACA,IAAI,CAACzD,kBAAkBQ,OAAOO,KAAK,GAAG;QACpC,OAAO,KAAKqC,IAAIzC,MAAM,CAAC,KAAK8C,IAAI,CAAC;IACnC;IACA,MAAM,EAAE3C,MAAM,EAAEC,KAAK,EAAE6C,OAAO,EAAE,GAAGpD;IACnC,MAAMqD,QAAQhE,YAAYW;IAE1B,IAAI;QACF,MAAMY,YAAY5C,cAAc6C,GAAG,GAAGD,SAAS;QAC/C,MAAM,CAAC2F,SAAStE,MAAM,GAAG,MAAMlB,QAAQuC,GAAG,CAAC;YACzCkD,iBAAiB5F,WAAWN;YAC5BI,cAAcJ;SACf;QACD,MAAMwG,QAAQxJ,yBAAyB8F,SAASmD;QAEhD,IAAI1D,WAAW,QAAQ;YACrB,sDAAsD;YACtD,OAAO,KAAK1D,SACVyD,KACA/D,KACEF,QAAQ,+BACNG,UACE,CAAC,kCAAkC,EAAEC,IAAIwE,GAAG,CAAC,EAAE,EAAE9E,WAC/C8B,OACA,yBAAyB,CAAC,GAC1B,CAAC,qBAAqB,EAAExB,IAAIwE,GAAG,CAAC,EAAE,EAAE9E,WAClCqI,MAAM1E,IAAI,EACV,eAAe,CAAC,GAClB,CAAC,qBAAqB,EAAErD,IAAIwE,GAAG,CAAC,EAAE,EAAE9E,WAClCwD,MAAMG,IAAI,EACV,UAAU,CAAC,IAEjB,CAAC,+CAA+C,EAAE3D,WAChD4E,OACA,EAAE,CAAC,GACL/D,aAAa,qBAAqB;gBAAEkE,KAAKvB,MAAMuB,GAAG;YAAC,KACnD,WACF,KACAvB;QAGN;QAEA,MAAMyJ,UAAU,MAAM9N,yBAAyB0C,QAAQC,OAAO6C;QAC9D,OAAO,KAAKjE,SACVyD,KACA/D,KAAK8M,iBAAiBD,SAAS5E,MAAM1E,IAAI,EAAEH,MAAMuB,GAAG,GAAG,KAAKvB;IAEhE,EAAE,OAAOR,OAAO;QACdC,QAAQD,KAAK,CAACA;QACd,OAAO,KAAKmB,IAAIzC,MAAM,CAAC,KAAK8C,IAAI,CAAC;IACnC;AACF;AAEA;;;;;;;;CAQC,GACD,SAAS0I,iBACPD,OAA2B,EAC3BE,SAAiB,EACjBpI,MAAoBzE,GAAG;IAEvB,MAAM8M,SAAS,CAAC,qBAAqB,EAAE9M,IAAIwE,GAAG,CAAC,EAAE,EAAE9E,WACjDmN,WACA,SAAS,CAAC;IACZ,OAAQF;QACN,KAAK;YACH,OACEnM,aAAaiE,OACb7E,QAAQ,uBACRG,UAAU,CAAC,uBAAuB,EAAE+M,OAAO,gBAAgB,CAAC,EAAE;QAElE,KAAK;YACH,OACEtM,aAAaiE,OACb7E,QAAQ,uBACRG,UAAU,GAAG+M,OAAO,8BAA8B,CAAC,EAAE;QAEzD,KAAK;YACH,OACElN,QAAQ,2BACRG,UACE,CAAC,8DAA8D,CAAC,GAC9D,CAAC,mDAAmD,EAAE+M,OAAO,IAAI,CAAC,GAClE,iBACF;QAGN,KAAK;YACH,OACElN,QAAQ,kCACRG,UACE,CAAC,qCAAqC,EAAE+M,OAAO,oBAAoB,CAAC,GAClE,mEACA,2BACF;QAGN;YACE,OACElN,QAAQ,wBACRG,UACE,CAAC,gCAAgC,EAAE+M,OAAO,yBAAyB,CAAC,GAClE,4CACF;IAGR;AACF;AAEA,8DAA8D,GAC9D,OAAO,SAASC;IACd,0EAA0E;IAC1E,wEAAwE;IACxErO,+BAA+B,IAAMO,cAAc6C,GAAG,GAAGD,SAAS;IAClE1D,uBAAuB,qBAAqByF;IAC5CzF,uBAAuB,qBAAqBgI;IAC5ChI,uBAAuB,qBAAqBmJ;IAC5CnJ,uBAAuB,iBAAiBsO;AAC1C;AAEA;;;;;;;;;;CAUC,GACD,SACEO,uBAAuB,EACvBC,0BAA0B,EAC1BC,8BAA8B,EAC9BC,2BAA2B,EAC3BC,kBAAkB,EAClBC,qBAAqB,QAChB,sBAAkB"}
@@ -43,8 +43,12 @@ export declare function suppressionKeyFor(email: string): string | null;
43
43
  /**
44
44
  * The email palette, named once (AGL-2499 / AGL-2025).
45
45
  *
46
- * These are TRANSCRIPTIONS of the console theme's tokens — see the note on
47
- * {@link page} below — not new colors. They have to be literal hex because
46
+ * The neutrals are TRANSCRIPTIONS of the console theme's tokens — see the
47
+ * note on {@link page} below — not new colors. The interactive slots (`link`,
48
+ * `button`, `onButton`) are not typed at all: they are the console theme's
49
+ * own accent, read from `PLATFORM_EMAIL_ACCENT`, so a link here is the color
50
+ * of a console Link and a button the color of a console contained Button.
51
+ * The neutrals have to be literal hex because
48
52
  * this is email-adjacent HTML served without a stylesheet: no CSS variables,
49
53
  * no theme provider, so `theme.palette.*` cannot reach the wire. Naming them
50
54
  * here keeps that unavoidable literal to ONE place per color instead of once
@@ -60,7 +64,7 @@ export declare const PAL: {
60
64
  readonly pageBg: "#F5F5F5";
61
65
  /** The card itself. */
62
66
  readonly cardBg: "#FFFFFF";
63
- /** Brand slate — wordmark and primary button fill. */
67
+ /** Brand slate — the wordmark and the success check. */
64
68
  readonly brand: "#404C5C";
65
69
  /** The short accent rule under the wordmark. */
66
70
  readonly accentRule: "#e040fb";
@@ -68,12 +72,16 @@ export declare const PAL: {
68
72
  readonly ink: "#212121";
69
73
  /** Body copy. */
70
74
  readonly muted: "#616161";
71
- /** Text on a filled brand/link button. */
75
+ /** Ink on a fill in {@link PAL.brand}. */
72
76
  readonly onBrand: "#fff";
73
77
  /** Soft circle behind the success checkmark. */
74
78
  readonly badgeBg: "#EEF0F2";
75
- /** Links and the resubscribe button fill. */
76
- readonly link: "#00B0FF";
79
+ /** Links: the accent as text, `primary.dark`. */
80
+ readonly link: string;
81
+ /** A submit button's fill: `primary.main`. */
82
+ readonly button: string;
83
+ /** The label on that fill: `primary.contrastText`. */
84
+ readonly onButton: string;
77
85
  /** Hairline between the preference page's topic rows. */
78
86
  readonly divider: "#E0E0E0";
79
87
  };
@@ -163,16 +171,16 @@ export declare function paragraph(html: string, marginBottom?: number): string;
163
171
  /** The success checkmark badge the result pages open with. */
164
172
  export declare function successBadge(pal?: EmailPalette): string;
165
173
  /**
166
- * A full-width submit button in either the brand or the link fill.
174
+ * A full-width submit button: the accent fill and its label, as a console
175
+ * contained Button draws them.
167
176
  *
168
- * The label ink is contrast-matched to the fill rather than fixed white: with
169
- * a host palette the fill is merchant-chosen, and a pale one would otherwise
170
- * hide the label on the button a recipient came here to press.
177
+ * With a host palette the label ink is contrast-matched to the merchant's
178
+ * fill ({@link resolveEmailPageBrand}), since a pale one would otherwise hide
179
+ * the label on the button a recipient came here to press.
171
180
  */
172
181
  export declare function submitButton(label: string, options?: {
173
182
  name?: string;
174
183
  value?: string;
175
- accent?: 'brand' | 'link';
176
184
  pal?: EmailPalette;
177
185
  }): string;
178
186
  /** Headers every one of these pages sets: never indexed, never cached. */
@@ -42,6 +42,7 @@ import { prefersDarkInk } from "@aglyn/shared-util-tools/contrast";
42
42
  // double-quoted attributes AND into element text, so the set has to be the
43
43
  // one that covers both.
44
44
  import { escapeHtml } from "@aglyn/shared-util-tools/escape-html";
45
+ import { buildEmailPalette, emailAccent, emailPaletteBaseForHost, PLATFORM_EMAIL_ACCENT } from "@aglyn/shared-util-email/email-palette";
45
46
  // The leaf module, not the barrel: it imports `node:crypto` and nothing else,
46
47
  // which is what keeps this file free of Firestore. See `signedConfirmSubject`.
47
48
  import { confirmSignatureSubject } from "@aglyn/tenant-data-admin/server/email-unsubscribe-link";
@@ -73,8 +74,12 @@ import { createHmac, timingSafeEqual } from "crypto";
73
74
  /**
74
75
  * The email palette, named once (AGL-2499 / AGL-2025).
75
76
  *
76
- * These are TRANSCRIPTIONS of the console theme's tokens — see the note on
77
- * {@link page} below — not new colors. They have to be literal hex because
77
+ * The neutrals are TRANSCRIPTIONS of the console theme's tokens — see the
78
+ * note on {@link page} below — not new colors. The interactive slots (`link`,
79
+ * `button`, `onButton`) are not typed at all: they are the console theme's
80
+ * own accent, read from `PLATFORM_EMAIL_ACCENT`, so a link here is the color
81
+ * of a console Link and a button the color of a console contained Button.
82
+ * The neutrals have to be literal hex because
78
83
  * this is email-adjacent HTML served without a stylesheet: no CSS variables,
79
84
  * no theme provider, so `theme.palette.*` cannot reach the wire. Naming them
80
85
  * here keeps that unavoidable literal to ONE place per color instead of once
@@ -87,13 +92,15 @@ import { createHmac, timingSafeEqual } from "crypto";
87
92
  */ export const PAL = {
88
93
  /** Page backdrop behind the card. */ pageBg: '#F5F5F5',
89
94
  /** The card itself. */ cardBg: '#FFFFFF',
90
- /** Brand slate — wordmark and primary button fill. */ brand: '#404C5C',
95
+ /** Brand slate — the wordmark and the success check. */ brand: '#404C5C',
91
96
  /** The short accent rule under the wordmark. */ accentRule: '#e040fb',
92
97
  /** Heading ink. */ ink: '#212121',
93
98
  /** Body copy. */ muted: '#616161',
94
- /** Text on a filled brand/link button. */ onBrand: '#fff',
99
+ /** Ink on a fill in {@link PAL.brand}. */ onBrand: '#fff',
95
100
  /** Soft circle behind the success checkmark. */ badgeBg: '#EEF0F2',
96
- /** Links and the resubscribe button fill. */ link: '#00B0FF',
101
+ /** Links: the accent as text, `primary.dark`. */ link: PLATFORM_EMAIL_ACCENT.link,
102
+ /** A submit button's fill: `primary.main`. */ button: PLATFORM_EMAIL_ACCENT.fill,
103
+ /** The label on that fill: `primary.contrastText`. */ onButton: PLATFORM_EMAIL_ACCENT.label,
97
104
  /** Hairline between the preference page's topic rows. */ divider: '#E0E0E0'
98
105
  };
99
106
  /**
@@ -197,13 +204,22 @@ function safeColor(value, fallback) {
197
204
  // wants the platform's accent rather than a slate rule nobody chose.
198
205
  const primary = safeColor(scheme == null ? void 0 : (_scheme_primary = scheme.primary) == null ? void 0 : _scheme_primary.main, '');
199
206
  const brand = primary || PAL.brand;
207
+ // The link is the site's accent AS TEXT — `primary.dark`, derived the way
208
+ // the site's own theme derives it — never the fill, which may be too pale
209
+ // to read. `safeColor` again: the derived value lands in a style attribute.
210
+ const link = primary ? safeColor(emailAccent(buildEmailPalette({
211
+ base: emailPaletteBaseForHost(host),
212
+ colors: scheme
213
+ })).link, PAL.link) : PAL.link;
200
214
  return {
201
215
  name,
202
216
  logoUrl: safeLogoUrl(host),
203
217
  pal: _extends({}, PAL, {
204
218
  brand,
205
219
  onBrand: readableInkOn(brand),
206
- link: brand,
220
+ link,
221
+ button: primary || PAL.button,
222
+ onButton: primary ? readableInkOn(primary) : PAL.onButton,
207
223
  accentRule: safeColor(scheme == null ? void 0 : (_scheme_secondary = scheme.secondary) == null ? void 0 : _scheme_secondary.main, primary || PAL.accentRule)
208
224
  })
209
225
  };
@@ -250,16 +266,16 @@ function safeColor(value, fallback) {
250
266
  return `<div style="width:40px;height:40px;border-radius:50%;background:${pal.badgeBg};` + 'display:flex;align-items:center;justify-content:center;margin-bottom:16px;' + `font-size:18px;color:${pal.brand}">&#x2713;</div>`;
251
267
  }
252
268
  /**
253
- * A full-width submit button in either the brand or the link fill.
269
+ * A full-width submit button: the accent fill and its label, as a console
270
+ * contained Button draws them.
254
271
  *
255
- * The label ink is contrast-matched to the fill rather than fixed white: with
256
- * a host palette the fill is merchant-chosen, and a pale one would otherwise
257
- * hide the label on the button a recipient came here to press.
272
+ * With a host palette the label ink is contrast-matched to the merchant's
273
+ * fill ({@link resolveEmailPageBrand}), since a pale one would otherwise hide
274
+ * the label on the button a recipient came here to press.
258
275
  */ export function submitButton(label, options) {
259
276
  var _ref;
260
277
  const pal = (_ref = options == null ? void 0 : options.pal) != null ? _ref : PAL;
261
- const background = (options == null ? void 0 : options.accent) === 'link' ? pal.link : pal.brand;
262
- return '<button type="submit"' + ((options == null ? void 0 : options.name) ? ` name="${escapeHtml(options.name)}"` : '') + ((options == null ? void 0 : options.value) ? ` value="${escapeHtml(options.value)}"` : '') + ' style="font:inherit;font-size:14px;font-weight:600;' + `padding:11px 20px;border:0;border-radius:8px;background:${background};` + `color:${readableInkOn(background)};cursor:pointer;width:100%">${label}</button>`;
278
+ return '<button type="submit"' + ((options == null ? void 0 : options.name) ? ` name="${escapeHtml(options.name)}"` : '') + ((options == null ? void 0 : options.value) ? ` value="${escapeHtml(options.value)}"` : '') + ' style="font:inherit;font-size:14px;font-weight:600;' + `padding:11px 20px;border:0;border-radius:8px;background:${pal.button};` + `color:${pal.onButton};cursor:pointer;width:100%">${label}</button>`;
263
279
  }
264
280
  /** Headers every one of these pages sets: never indexed, never cached. */ export function sendPage(res, html) {
265
281
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/unsubscribe-link.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The SIGNED-LINK primitives every recipient-facing email route shares.\n *\n * Three routes read the same link — `email/unsubscribe`, `email/resubscribe`\n * and `email/preferences` — and every one of them verifies the same HMAC,\n * derives the same suppression key, and renders the same branded shell. They\n * live here rather than in one route's module because the alternative is what\n * the resubscribe route's docblock already warns about: \"Two implementations\n * of one signature scheme is how the resubscribe link comes to reject a\n * signature the unsubscribe link just accepted.\"\n *\n * Nothing here touches Firestore. That is deliberate — the verification and\n * the rendering are pure, so a spec can exercise the signature scheme without\n * standing up a database double.\n */\n\nimport {\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/site-theme'\nimport { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n resolveHostToken,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { absoluteMediaSrc } from '@aglyn/aglyn/app-utils/media-ref'\nimport { personKey, type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { BRAND } from '@aglyn/shared-data-enums'\nimport type { HostTheme } from '@aglyn/shared-data-types'\n// Subpath, not the library index — see the note there.\nimport { prefersDarkInk } from '@aglyn/shared-util-tools/contrast'\n// The one escaper (AGL-2706). This module had its own, which escaped four\n// characters where the serialization needs five: a brand name, a topic label\n// and a signed query all reach these pages, and they are interpolated into\n// double-quoted attributes AND into element text, so the set has to be the\n// one that covers both.\nimport { escapeHtml } from '@aglyn/shared-util-tools/escape-html'\n// The leaf module, not the barrel: it imports `node:crypto` and nothing else,\n// which is what keeps this file free of Firestore. See `signedConfirmSubject`.\nimport { confirmSignatureSubject } from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { createHmac, timingSafeEqual } from 'crypto'\n\n/**\n * Suppression list keys are the SHA-256 of the normalized address (emails are\n * PII).\n *\n * `personKey` and NOT a fourth local `createHash` call. D5 of\n * `docs/specs/email-competitive-gaps.md` records two derivations that agree\n * only by luck: this module's predecessor hashed the address without\n * lowercasing or trimming, `campaign-send.ts` lowercased, and the two matched\n * only because `performCampaignSend` lowercases every address upstream before\n * the link is minted. The preference page is a THIRD caller, and the\n * instruction that comes with a third caller is to unify rather than to add a\n * variant.\n *\n * The digest is unchanged for every address these routes have ever seen —\n * `readParams` already trims and lowercases, which is exactly what\n * `normalizeContactEmail` does — so no stored suppression moves.\n *\n * @returns the key, or `null` for a value that is not an address. The old\n * local helper hashed anything it was handed, which meant a\n * malformed `email` parameter addressed a suppression document for a\n * person who does not exist.\n */\nexport function suppressionKeyFor(email: string): string | null {\n return personKey(email)\n}\n\n/**\n * The email palette, named once (AGL-2499 / AGL-2025).\n *\n * These are TRANSCRIPTIONS of the console theme's tokens — see the note on\n * {@link page} below — not new colors. They have to be literal hex because\n * this is email-adjacent HTML served without a stylesheet: no CSS variables,\n * no theme provider, so `theme.palette.*` cannot reach the wire. Naming them\n * here keeps that unavoidable literal to ONE place per color instead of once\n * per use, which is also what keeps this file under the AGL-2025 color\n * ratchet.\n *\n * A `const` is not a style slot, so the ratchet does not count these — and\n * that is the point: the check exists to catch a color typed inline where a\n * token would do, not a documented email palette.\n */\nexport const PAL = {\n /** Page backdrop behind the card. */\n pageBg: '#F5F5F5',\n /** The card itself. */\n cardBg: '#FFFFFF',\n /** Brand slate — wordmark and primary button fill. */\n brand: '#404C5C',\n /** The short accent rule under the wordmark. */\n accentRule: '#e040fb',\n /** Heading ink. */\n ink: '#212121',\n /** Body copy. */\n muted: '#616161',\n /** Text on a filled brand/link button. */\n onBrand: '#fff',\n /** Soft circle behind the success checkmark. */\n badgeBg: '#EEF0F2',\n /** Links and the resubscribe button fill. */\n link: '#00B0FF',\n /** Hairline between the preference page's topic rows. */\n divider: '#E0E0E0',\n} as const\n\n/** The palette a page paints with: {@link PAL}, or a host's colors over it. */\nexport type EmailPalette = { [K in keyof typeof PAL]: string }\n\n/**\n * Who the recipient is being shown as the sender.\n *\n * The four routes are reached from a MERCHANT's campaign, so the identity on\n * the page is the merchant's — not ours and not the deployment's. See\n * {@link resolveEmailPageBrand} for the fallback chain.\n */\nexport interface EmailPageBrand {\n /** The wordmark, the `<title>`, and the `alt` behind a logo that fails. */\n name: string\n /** An absolute `https:` logo, or nothing. */\n logoUrl?: string\n pal: EmailPalette\n}\n\n/**\n * The deployment's own identity — the fallback, and what a self-host operator\n * sees. `BRAND.ORG_NAME` stays correct per-DEPLOYMENT; the host brand layers\n * on top of it rather than replacing it.\n */\nexport const PLATFORM_EMAIL_BRAND: EmailPageBrand = Object.freeze({\n name: BRAND.ORG_NAME,\n pal: PAL,\n})\n\n/** The fields {@link resolveEmailPageBrand} reads off a `hosts/{hostId}` doc. */\nexport type EmailBrandSource = HostTokenSource & {\n $id?: string\n theme?: HostTheme\n}\n\n/**\n * A color safe to interpolate into an inline `style` attribute.\n *\n * HEX ONLY, and that is a security boundary rather than a style preference.\n * These values come from a merchant-editable theme and land inside\n * `style=\"…\"`, so any syntax richer than a hex literal is an injection: a\n * stored `red;background:url(https://evil/?e=` would close the declaration and\n * make the browser fetch a URL from a page whose own address carries the\n * recipient's email and its HMAC. `url()`, `expression()`, custom properties\n * and even `rgb()` are all refused for that one reason — none of them is worth\n * a parser here. Anything unrecognized falls back, so a malformed theme is a\n * plain page rather than a broken one.\n */\nconst HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i\n\nfunction safeColor(value: unknown, fallback: string): string {\n const text = typeof value === 'string' ? value.trim() : ''\n return HEX_COLOR.test(text) ? text : fallback\n}\n\n/**\n * Black or white text for a filled button, whichever the eye can actually read.\n *\n * Without this, a host whose primary is a pale yellow gets white-on-white and\n * the recipient cannot find the button that unsubscribes them — a legibility\n * failure on this page is a compliance failure, not a cosmetic one.\n *\n * The luminance and the split come from `shared-util-tools/contrast`, which\n * is the same math this file used to carry and the console carried a wrong\n * copy of. `safeColor` has already refused anything but a hex literal by the\n * time a color reaches here, so an unmeasurable one is impossible rather than\n * merely unlikely — and it still falls back to the on-brand ink if one does.\n */\nfunction readableInkOn(background: string): string {\n return prefersDarkInk(background) === true ? PAL.ink : PAL.onBrand\n}\n\n/**\n * The host's logo as an absolute `https:` URL, or nothing.\n *\n * `absoluteMediaSrc` because `logoUrl` has three stored generations and two of\n * them — a `media:` reference and the AGL-175 relative CDN path — resolve\n * site-RELATIVE (AGL-1407). This page is opened from an email, so there is no\n * page origin to resolve against and the stored value cannot be used as-is.\n *\n * `https:` only. The resolver will hand back whatever scheme an author typed,\n * and a `data:` URI here would be an unbounded payload inlined into a page we\n * must keep small and predictable, while `javascript:` is exactly the value a\n * merchant-editable field must never reach an attribute with. A value we\n * cannot make into an absolute `https:` URL yields no logo, which the shell\n * already handles by showing the wordmark.\n */\nfunction safeLogoUrl(host: EmailBrandSource): string | undefined {\n const stored = resolveHostToken('logo', host)\n if (!stored) return undefined\n const resolved = absoluteMediaSrc(stored, {\n hostId: host.$id,\n origin: hostPublicOrigin(host),\n })\n return resolved && /^https:\\/\\//i.test(resolved) ? resolved : undefined\n}\n\n/** A hostile 10KB display name must not become the whole page. */\nconst BRAND_NAME_MAX = 60\n\n/**\n * The merchant's identity for the shell, falling back to the deployment's.\n *\n * ONLY the brand-carrying slots are host-derived — the wordmark color, the\n * accent rule, the link/marker color and the primary button fill. The\n * neutrals (page, card, ink, muted, divider, badge) are deliberately FIXED.\n *\n * That split is the safety property. A theme is merchant-editable, and a site\n * whose `background.default` and `text.primary` are both white would otherwise\n * render an unreadable opt-out page — which is a compliance failure. Keeping\n * the surface and the body copy on known-legible values means the worst a\n * careless or hostile theme can do is pick an ugly accent, while the page a\n * recipient needs in order to leave still works. The button's own text is\n * contrast-matched to whatever fill it lands on ({@link readableInkOn}).\n *\n * Reads the LIGHT scheme only. There is no scheme toggle on a page opened from\n * an email, and the shell paints a light card unconditionally, so a host's\n * dark-scheme colors would be picked against the wrong background.\n *\n * Pure, and never a network call. A host with nothing set — or no host at all\n * — resolves to {@link PLATFORM_EMAIL_BRAND}, which is the self-host answer\n * as much as it is the missing-data one.\n */\nexport function resolveEmailPageBrand(\n host: EmailBrandSource | null | undefined,\n): EmailPageBrand {\n if (!host) return PLATFORM_EMAIL_BRAND\n // `resolveHostToken` rather than a fourth reading of these fields: the\n // closed registry already knows a business name is `seo.entity.name` before\n // `displayName`, and already says a missing logo falls back to the wordmark.\n const name =\n resolveHostToken('businessName', host)?.slice(0, BRAND_NAME_MAX).trim() ||\n BRAND.ORG_NAME\n // Resolved: a site's edits are an override on its picked theme (AGL-3404).\n const scheme = resolveSiteTheme(host as ThemeHostDocument)?.colorSchemes?.light\n // `''` distinguishes \"set to something unusable\" from \"not set\", which the\n // accent rule below needs: a host that themed a primary and no secondary\n // wants the rule in ITS color, while a host that themed nothing at all\n // wants the platform's accent rather than a slate rule nobody chose.\n const primary = safeColor(scheme?.primary?.main, '')\n const brand = primary || PAL.brand\n return {\n name,\n logoUrl: safeLogoUrl(host),\n pal: {\n ...PAL,\n brand,\n onBrand: readableInkOn(brand),\n link: brand,\n accentRule: safeColor(\n scheme?.secondary?.main,\n primary || PAL.accentRule,\n ),\n },\n }\n}\n\n/**\n * Branded shell (AGL-2411, per-tenant since AGL-2408): the plain `system-ui`\n * box this used to be read as an unstyled error page, not a page this product\n * owns — which matters here specifically, because this is the one screen a\n * recipient who does NOT trust the sender is looking at.\n *\n * The identity is the SENDING SITE's. A recipient of a merchant's newsletter\n * arrives here from that merchant's email, and a page headed with our name is\n * both wrong and, on the screen where somebody decides whether this mail is\n * legitimate, actively unhelpful. `brand` defaults to\n * {@link PLATFORM_EMAIL_BRAND} so a caller with no host — and a self-host\n * deployment — still gets a complete, correctly-named page.\n *\n * `maxWidth` widens for the preference page, which is a list of choices rather\n * than a single sentence and a button.\n *\n * `<meta charset>` even though `sendPage` sets the header: a client that\n * ignores the header, or a page saved to disk, otherwise renders the em-dashes\n * in this copy as mojibake.\n *\n * `<meta name=\"referrer\" content=\"no-referrer\">` because THIS page's own URL\n * carries the recipient's address and the HMAC that authorizes acting on it.\n * A host logo is a third-party request, and the default referrer policy would\n * hand that whole link to whoever serves the image.\n */\nexport function page(\n body: string,\n maxWidth = 420,\n brand: EmailPageBrand = PLATFORM_EMAIL_BRAND,\n): string {\n const pal = brand.pal\n const brandName = escapeHtml(brand.name)\n // `alt` is the business name, so a logo that 404s or is blocked degrades to\n // the wordmark with no script and no second request.\n const mark = brand.logoUrl\n ? `<img src=\"${escapeHtml(brand.logoUrl)}\" alt=\"${brandName}\" ` +\n 'referrerpolicy=\"no-referrer\" style=\"display:block;max-height:40px;' +\n 'max-width:200px;width:auto;height:auto;object-fit:contain;' +\n 'margin-bottom:4px\">'\n : `<div style=\"font-size:15px;font-weight:700;letter-spacing:.02em;` +\n `color:${pal.brand};margin-bottom:4px\">${brandName}</div>`\n return (\n '<!doctype html><meta charset=\"utf-8\">' +\n '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">' +\n '<meta name=\"referrer\" content=\"no-referrer\">' +\n `<title>${brandName}</title>` +\n '<div style=\"min-height:100vh;display:flex;align-items:center;justify-content:center;' +\n `background:${pal.pageBg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,` +\n 'Helvetica,Arial,sans-serif;padding:24px;box-sizing:border-box\">' +\n `<div style=\"max-width:${maxWidth}px;width:100%;background:${pal.cardBg};border-radius:12px;` +\n 'padding:36px 32px;box-shadow:0 1px 3px rgba(0,0,0,.08),0 8px 24px rgba(0,0,0,.06)\">' +\n mark +\n `<div style=\"width:32px;height:3px;border-radius:2px;background:${pal.accentRule};` +\n 'margin-bottom:24px\"></div>' +\n body +\n '</div></div>'\n )\n}\n\n/** A heading in the shell's type scale. */\nexport function heading(text: string): string {\n return (\n `<h1 style=\"margin:0 0 8px;font-size:20px;font-weight:700;color:${PAL.ink}\">` +\n `${text}</h1>`\n )\n}\n\n/** Body copy in the shell's type scale. */\nexport function paragraph(html: string, marginBottom = 24): string {\n return (\n `<p style=\"margin:0 0 ${marginBottom}px;font-size:14px;line-height:1.5;` +\n `color:${PAL.muted}\">${html}</p>`\n )\n}\n\n/** The success checkmark badge the result pages open with. */\nexport function successBadge(pal: EmailPalette = PAL): string {\n return (\n `<div style=\"width:40px;height:40px;border-radius:50%;background:${pal.badgeBg};` +\n 'display:flex;align-items:center;justify-content:center;margin-bottom:16px;' +\n `font-size:18px;color:${pal.brand}\">&#x2713;</div>`\n )\n}\n\n/**\n * A full-width submit button in either the brand or the link fill.\n *\n * The label ink is contrast-matched to the fill rather than fixed white: with\n * a host palette the fill is merchant-chosen, and a pale one would otherwise\n * hide the label on the button a recipient came here to press.\n */\nexport function submitButton(\n label: string,\n options?: {\n name?: string\n value?: string\n accent?: 'brand' | 'link'\n pal?: EmailPalette\n },\n): string {\n const pal = options?.pal ?? PAL\n const background = options?.accent === 'link' ? pal.link : pal.brand\n return (\n '<button type=\"submit\"' +\n (options?.name ? ` name=\"${escapeHtml(options.name)}\"` : '') +\n (options?.value ? ` value=\"${escapeHtml(options.value)}\"` : '') +\n ' style=\"font:inherit;font-size:14px;font-weight:600;' +\n `padding:11px 20px;border:0;border-radius:8px;background:${background};` +\n `color:${readableInkOn(background)};cursor:pointer;width:100%\">${label}</button>`\n )\n}\n\n/** Headers every one of these pages sets: never indexed, never cached. */\nexport function sendPage(\n res: Parameters<PluginApiHandler>[1],\n html: string,\n): void {\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n // Never indexed, never cached — the page names the recipient's address.\n res.setHeader('X-Robots-Tag', 'noindex, nofollow')\n res.setHeader('Cache-Control', 'no-store')\n res.status(200).send(html)\n}\n\n/** Everything the signed link carries. */\nexport interface UnsubscribeLinkParams {\n hostId: string\n email: string\n signature: string\n campaignId: string\n topicId: string\n}\n\n/**\n * The link's parameters, from the query for both verbs and from the form body\n * as a fallback.\n *\n * The one-click POST keeps the full query string (it posts to the header URL\n * verbatim), and so does the confirmation form's action — so query-first is\n * the path both real callers take. The body fallback exists so a form posted\n * to the bare path still works rather than failing as an invalid link.\n */\nexport function readParams(\n req: Parameters<PluginApiHandler>[0],\n): UnsubscribeLinkParams {\n const body = (req.body ?? {}) as Record<string, unknown>\n const pick = (name: string): string =>\n String(req.query[name] ?? body[name] ?? '')\n return {\n hostId: pick('hostId').trim(),\n email: pick('email').trim().toLowerCase(),\n signature: pick('sig').trim(),\n // `cid` names the campaign whose copy of this link was clicked. Absent on\n // every link minted before it existed, which is the whole design\n // constraint below.\n campaignId: pick('cid').trim(),\n // `tid` names the STREAM the message belonged to. Absent on every link\n // minted before topics existed, on the same footing as `cid`.\n topicId: pick('tid').trim(),\n }\n}\n\n/**\n * The subject a signature covers, or `null` when the parameters cannot make\n * one unambiguously.\n *\n * ## Three signed forms, and why that is not a weakening\n *\n * A link minted before campaign attribution existed signs `hostId:email`. A\n * link minted since signs `hostId:email:campaignId`. A link minted since\n * topics signs `hostId:email:campaignId:topicId`. All three are in inboxes\n * right now and all three have to keep working — an email is not recallable,\n * and an unsubscribe link that has stopped honoring itself is the one failure\n * in this area nobody gets to shrug at.\n *\n * Which form is checked is decided by the LINK, not by the signature: the\n * arity of the subject follows exactly which of `cid` and `tid` the URL\n * carries. There is no fallback between the forms, and that is what stops\n * this being a downgrade — an attacker cannot take a three-part link, drop\n * the `cid` and have it verify, because the two-part check over the same\n * `hostId:email` produces a different digest. Nor can they bolt a `cid` or a\n * `tid` onto a shorter link: the longer check then fails.\n *\n * ## What makes the three forms unambiguous\n *\n * The forms are joined with `:`, so a `cid` or `tid` that CONTAINED a colon\n * would let one subject string be read as two different parameter tuples —\n * a four-part `host:email:c:t` is byte-identical to a three-part subject\n * whose campaign id is `c:t`. That is the edit this function has to refuse:\n * an attacker holding a topic link could otherwise re-present it as a\n * campaign link with the topic spliced into the campaign id, and the topic\n * they were signed for would silently become something else.\n *\n * So a colon in either id is refused outright, on every form. It costs\n * nothing real: campaign ids come from `createResourceUid()`, which is\n * `nanoid`'s `A-Za-z0-9_-` alphabet, and `isSubscriptionTopicId` refuses a colon at\n * the point a topic is created.\n *\n * A `tid` with no `cid` is refused for the same reason — the empty middle\n * component would make `host:email::t` and a campaign id of `:t` the same\n * string. Every link the send path mints carries both.\n */\nexport function signedSubject(params: {\n hostId: string\n email: string\n campaignId: string\n topicId: string\n}): string | null {\n const { hostId, email, campaignId, topicId } = params\n if (campaignId.includes(':') || topicId.includes(':')) return null\n if (topicId && !campaignId) return null\n if (topicId) return `${hostId}:${email}:${campaignId}:${topicId}`\n if (campaignId) return `${hostId}:${email}:${campaignId}`\n return `${hostId}:${email}`\n}\n\n/**\n * What a CONFIRMATION link's signature covers, or `null` when it cannot be\n * made unambiguously.\n *\n * ## The ONE derivation, not a second one\n *\n * The subject itself is `confirmSignatureSubject` in\n * `@aglyn/tenant-data-admin`, where the SENDER that mints the link lives. The\n * three unsubscribe forms are stated twice — once there, once here — and they\n * agree today by inspection; adding a fourth form the same way would be\n * adding a fourth chance for a signer and a verifier to disagree about a link\n * that is already sitting in somebody's inbox.\n *\n * The deep path rather than the barrel, for the reason `email-suppression.ts`\n * gives at length: that module imports nothing but `node:crypto`, so this\n * file stays free of Firestore and a spec can still exercise the signature\n * scheme without standing up a database double.\n */\nexport function signedConfirmSubject(params: {\n hostId: string\n email: string\n topicId: string\n}): string | null {\n return (\n confirmSignatureSubject(params.hostId, params.email, params.topicId) || null\n )\n}\n\n/**\n * Whether a signature is this link's.\n *\n * `timingSafeEqual` needs equal lengths, so the length is compared first — it\n * is not a secret, both digests are fixed-width hex, and the call throws on a\n * mismatch rather than returning false.\n */\nexport function signatureMatches(args: {\n hostId: string\n email: string\n campaignId: string\n topicId: string\n signature: string\n secret: string\n /**\n * Verify the CONFIRMATION subject instead of the unsubscribe forms.\n *\n * A flag rather than a second function, so that every signed link in the\n * product is checked by one comparison against one digest: the warning in\n * this module's own header — that two implementations of one signature\n * scheme is how the resubscribe link comes to reject a signature the\n * unsubscribe link just accepted — applies to a fourth route as much as a\n * third.\n */\n purpose?: 'unsubscribe' | 'confirm'\n}): boolean {\n const subject =\n args.purpose === 'confirm'\n ? signedConfirmSubject(args)\n : signedSubject(args)\n if (subject === null) return false\n const expected = createHmac('sha256', args.secret)\n .update(subject)\n .digest('hex')\n return (\n expected.length === args.signature.length &&\n timingSafeEqual(\n new Uint8Array(Buffer.from(expected)),\n new Uint8Array(Buffer.from(args.signature)),\n )\n )\n}\n\n\n/**\n * The signed parameters, re-encoded for a form action or a sibling link.\n *\n * Every id rides through to the POST form, the preference page and the\n * resubscribe link, because the signature covers them: dropping one would\n * produce a URL whose shorter check fails against a longer signature, i.e. a\n * confirmation button that refuses itself.\n */\nexport function signedQuery(params: UnsubscribeLinkParams): string {\n return (\n `hostId=${encodeURIComponent(params.hostId)}` +\n `&email=${encodeURIComponent(params.email)}` +\n `&sig=${encodeURIComponent(params.signature)}` +\n (params.campaignId ? `&cid=${encodeURIComponent(params.campaignId)}` : '') +\n (params.topicId ? `&tid=${encodeURIComponent(params.topicId)}` : '')\n )\n}\n\n/**\n * A campaign id that is safe to use as a Firestore path component.\n *\n * `cid` arrives on a URL, so \"non-empty\" was never the question — a value of\n * `a/b/c` addresses `campaigns/a/b/c`, a path the merchant can neither see\n * nor delete. The signature already proves the value is ours, so this is the\n * second lock rather than the only one; it is here because a path component\n * built from request text gets validated at the place it becomes a path.\n */\nexport function isCampaignPathId(value: string): boolean {\n return (\n !!value &&\n value.length <= 1500 &&\n !value.includes('/') &&\n value !== '.' &&\n value !== '..' &&\n !/^__.*__$/.test(value)\n )\n}\n"],"names":["resolveSiteTheme","hostPublicOrigin","resolveHostToken","absoluteMediaSrc","personKey","BRAND","prefersDarkInk","escapeHtml","confirmSignatureSubject","createHmac","timingSafeEqual","suppressionKeyFor","email","PAL","pageBg","cardBg","brand","accentRule","ink","muted","onBrand","badgeBg","link","divider","PLATFORM_EMAIL_BRAND","Object","freeze","name","ORG_NAME","pal","HEX_COLOR","safeColor","value","fallback","text","trim","test","readableInkOn","background","safeLogoUrl","host","stored","undefined","resolved","hostId","$id","origin","BRAND_NAME_MAX","resolveEmailPageBrand","scheme","slice","colorSchemes","light","primary","main","logoUrl","secondary","page","body","maxWidth","brandName","mark","heading","paragraph","html","marginBottom","successBadge","submitButton","label","options","accent","sendPage","res","setHeader","status","send","readParams","req","pick","String","query","toLowerCase","signature","campaignId","topicId","signedSubject","params","includes","signedConfirmSubject","signatureMatches","args","subject","purpose","expected","secret","update","digest","length","Uint8Array","Buffer","from","signedQuery","encodeURIComponent","isCampaignPathId"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/unsubscribe-link.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The SIGNED-LINK primitives every recipient-facing email route shares.\n *\n * Three routes read the same link — `email/unsubscribe`, `email/resubscribe`\n * and `email/preferences` — and every one of them verifies the same HMAC,\n * derives the same suppression key, and renders the same branded shell. They\n * live here rather than in one route's module because the alternative is what\n * the resubscribe route's docblock already warns about: \"Two implementations\n * of one signature scheme is how the resubscribe link comes to reject a\n * signature the unsubscribe link just accepted.\"\n *\n * Nothing here touches Firestore. That is deliberate — the verification and\n * the rendering are pure, so a spec can exercise the signature scheme without\n * standing up a database double.\n */\n\nimport {\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/site-theme'\nimport { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n resolveHostToken,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { absoluteMediaSrc } from '@aglyn/aglyn/app-utils/media-ref'\nimport { personKey, type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { BRAND } from '@aglyn/shared-data-enums'\nimport type { HostTheme } from '@aglyn/shared-data-types'\n// Subpath, not the library index — see the note there.\nimport { prefersDarkInk } from '@aglyn/shared-util-tools/contrast'\n// The one escaper (AGL-2706). This module had its own, which escaped four\n// characters where the serialization needs five: a brand name, a topic label\n// and a signed query all reach these pages, and they are interpolated into\n// double-quoted attributes AND into element text, so the set has to be the\n// one that covers both.\nimport { escapeHtml } from '@aglyn/shared-util-tools/escape-html'\nimport {\n buildEmailPalette,\n emailAccent,\n emailPaletteBaseForHost,\n PLATFORM_EMAIL_ACCENT,\n} from '@aglyn/shared-util-email/email-palette'\n// The leaf module, not the barrel: it imports `node:crypto` and nothing else,\n// which is what keeps this file free of Firestore. See `signedConfirmSubject`.\nimport { confirmSignatureSubject } from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { createHmac, timingSafeEqual } from 'crypto'\n\n/**\n * Suppression list keys are the SHA-256 of the normalized address (emails are\n * PII).\n *\n * `personKey` and NOT a fourth local `createHash` call. D5 of\n * `docs/specs/email-competitive-gaps.md` records two derivations that agree\n * only by luck: this module's predecessor hashed the address without\n * lowercasing or trimming, `campaign-send.ts` lowercased, and the two matched\n * only because `performCampaignSend` lowercases every address upstream before\n * the link is minted. The preference page is a THIRD caller, and the\n * instruction that comes with a third caller is to unify rather than to add a\n * variant.\n *\n * The digest is unchanged for every address these routes have ever seen —\n * `readParams` already trims and lowercases, which is exactly what\n * `normalizeContactEmail` does — so no stored suppression moves.\n *\n * @returns the key, or `null` for a value that is not an address. The old\n * local helper hashed anything it was handed, which meant a\n * malformed `email` parameter addressed a suppression document for a\n * person who does not exist.\n */\nexport function suppressionKeyFor(email: string): string | null {\n return personKey(email)\n}\n\n/**\n * The email palette, named once (AGL-2499 / AGL-2025).\n *\n * The neutrals are TRANSCRIPTIONS of the console theme's tokens — see the\n * note on {@link page} below — not new colors. The interactive slots (`link`,\n * `button`, `onButton`) are not typed at all: they are the console theme's\n * own accent, read from `PLATFORM_EMAIL_ACCENT`, so a link here is the color\n * of a console Link and a button the color of a console contained Button.\n * The neutrals have to be literal hex because\n * this is email-adjacent HTML served without a stylesheet: no CSS variables,\n * no theme provider, so `theme.palette.*` cannot reach the wire. Naming them\n * here keeps that unavoidable literal to ONE place per color instead of once\n * per use, which is also what keeps this file under the AGL-2025 color\n * ratchet.\n *\n * A `const` is not a style slot, so the ratchet does not count these — and\n * that is the point: the check exists to catch a color typed inline where a\n * token would do, not a documented email palette.\n */\nexport const PAL = {\n /** Page backdrop behind the card. */\n pageBg: '#F5F5F5',\n /** The card itself. */\n cardBg: '#FFFFFF',\n /** Brand slate — the wordmark and the success check. */\n brand: '#404C5C',\n /** The short accent rule under the wordmark. */\n accentRule: '#e040fb',\n /** Heading ink. */\n ink: '#212121',\n /** Body copy. */\n muted: '#616161',\n /** Ink on a fill in {@link PAL.brand}. */\n onBrand: '#fff',\n /** Soft circle behind the success checkmark. */\n badgeBg: '#EEF0F2',\n /** Links: the accent as text, `primary.dark`. */\n link: PLATFORM_EMAIL_ACCENT.link,\n /** A submit button's fill: `primary.main`. */\n button: PLATFORM_EMAIL_ACCENT.fill,\n /** The label on that fill: `primary.contrastText`. */\n onButton: PLATFORM_EMAIL_ACCENT.label,\n /** Hairline between the preference page's topic rows. */\n divider: '#E0E0E0',\n} as const\n\n/** The palette a page paints with: {@link PAL}, or a host's colors over it. */\nexport type EmailPalette = { [K in keyof typeof PAL]: string }\n\n/**\n * Who the recipient is being shown as the sender.\n *\n * The four routes are reached from a MERCHANT's campaign, so the identity on\n * the page is the merchant's — not ours and not the deployment's. See\n * {@link resolveEmailPageBrand} for the fallback chain.\n */\nexport interface EmailPageBrand {\n /** The wordmark, the `<title>`, and the `alt` behind a logo that fails. */\n name: string\n /** An absolute `https:` logo, or nothing. */\n logoUrl?: string\n pal: EmailPalette\n}\n\n/**\n * The deployment's own identity — the fallback, and what a self-host operator\n * sees. `BRAND.ORG_NAME` stays correct per-DEPLOYMENT; the host brand layers\n * on top of it rather than replacing it.\n */\nexport const PLATFORM_EMAIL_BRAND: EmailPageBrand = Object.freeze({\n name: BRAND.ORG_NAME,\n pal: PAL,\n})\n\n/** The fields {@link resolveEmailPageBrand} reads off a `hosts/{hostId}` doc. */\nexport type EmailBrandSource = HostTokenSource & {\n $id?: string\n theme?: HostTheme\n}\n\n/**\n * A color safe to interpolate into an inline `style` attribute.\n *\n * HEX ONLY, and that is a security boundary rather than a style preference.\n * These values come from a merchant-editable theme and land inside\n * `style=\"…\"`, so any syntax richer than a hex literal is an injection: a\n * stored `red;background:url(https://evil/?e=` would close the declaration and\n * make the browser fetch a URL from a page whose own address carries the\n * recipient's email and its HMAC. `url()`, `expression()`, custom properties\n * and even `rgb()` are all refused for that one reason — none of them is worth\n * a parser here. Anything unrecognized falls back, so a malformed theme is a\n * plain page rather than a broken one.\n */\nconst HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i\n\nfunction safeColor(value: unknown, fallback: string): string {\n const text = typeof value === 'string' ? value.trim() : ''\n return HEX_COLOR.test(text) ? text : fallback\n}\n\n/**\n * Black or white text for a filled button, whichever the eye can actually read.\n *\n * Without this, a host whose primary is a pale yellow gets white-on-white and\n * the recipient cannot find the button that unsubscribes them — a legibility\n * failure on this page is a compliance failure, not a cosmetic one.\n *\n * The luminance and the split come from `shared-util-tools/contrast`, which\n * is the same math this file used to carry and the console carried a wrong\n * copy of. `safeColor` has already refused anything but a hex literal by the\n * time a color reaches here, so an unmeasurable one is impossible rather than\n * merely unlikely — and it still falls back to the on-brand ink if one does.\n */\nfunction readableInkOn(background: string): string {\n return prefersDarkInk(background) === true ? PAL.ink : PAL.onBrand\n}\n\n/**\n * The host's logo as an absolute `https:` URL, or nothing.\n *\n * `absoluteMediaSrc` because `logoUrl` has three stored generations and two of\n * them — a `media:` reference and the AGL-175 relative CDN path — resolve\n * site-RELATIVE (AGL-1407). This page is opened from an email, so there is no\n * page origin to resolve against and the stored value cannot be used as-is.\n *\n * `https:` only. The resolver will hand back whatever scheme an author typed,\n * and a `data:` URI here would be an unbounded payload inlined into a page we\n * must keep small and predictable, while `javascript:` is exactly the value a\n * merchant-editable field must never reach an attribute with. A value we\n * cannot make into an absolute `https:` URL yields no logo, which the shell\n * already handles by showing the wordmark.\n */\nfunction safeLogoUrl(host: EmailBrandSource): string | undefined {\n const stored = resolveHostToken('logo', host)\n if (!stored) return undefined\n const resolved = absoluteMediaSrc(stored, {\n hostId: host.$id,\n origin: hostPublicOrigin(host),\n })\n return resolved && /^https:\\/\\//i.test(resolved) ? resolved : undefined\n}\n\n/** A hostile 10KB display name must not become the whole page. */\nconst BRAND_NAME_MAX = 60\n\n/**\n * The merchant's identity for the shell, falling back to the deployment's.\n *\n * ONLY the brand-carrying slots are host-derived — the wordmark color, the\n * accent rule, the link/marker color and the primary button fill. The\n * neutrals (page, card, ink, muted, divider, badge) are deliberately FIXED.\n *\n * That split is the safety property. A theme is merchant-editable, and a site\n * whose `background.default` and `text.primary` are both white would otherwise\n * render an unreadable opt-out page — which is a compliance failure. Keeping\n * the surface and the body copy on known-legible values means the worst a\n * careless or hostile theme can do is pick an ugly accent, while the page a\n * recipient needs in order to leave still works. The button's own text is\n * contrast-matched to whatever fill it lands on ({@link readableInkOn}).\n *\n * Reads the LIGHT scheme only. There is no scheme toggle on a page opened from\n * an email, and the shell paints a light card unconditionally, so a host's\n * dark-scheme colors would be picked against the wrong background.\n *\n * Pure, and never a network call. A host with nothing set — or no host at all\n * — resolves to {@link PLATFORM_EMAIL_BRAND}, which is the self-host answer\n * as much as it is the missing-data one.\n */\nexport function resolveEmailPageBrand(\n host: EmailBrandSource | null | undefined,\n): EmailPageBrand {\n if (!host) return PLATFORM_EMAIL_BRAND\n // `resolveHostToken` rather than a fourth reading of these fields: the\n // closed registry already knows a business name is `seo.entity.name` before\n // `displayName`, and already says a missing logo falls back to the wordmark.\n const name =\n resolveHostToken('businessName', host)?.slice(0, BRAND_NAME_MAX).trim() ||\n BRAND.ORG_NAME\n // Resolved: a site's edits are an override on its picked theme (AGL-3404).\n const scheme = resolveSiteTheme(host as ThemeHostDocument)?.colorSchemes?.light\n // `''` distinguishes \"set to something unusable\" from \"not set\", which the\n // accent rule below needs: a host that themed a primary and no secondary\n // wants the rule in ITS color, while a host that themed nothing at all\n // wants the platform's accent rather than a slate rule nobody chose.\n const primary = safeColor(scheme?.primary?.main, '')\n const brand = primary || PAL.brand\n // The link is the site's accent AS TEXT — `primary.dark`, derived the way\n // the site's own theme derives it — never the fill, which may be too pale\n // to read. `safeColor` again: the derived value lands in a style attribute.\n const link = primary\n ? safeColor(\n emailAccent(\n buildEmailPalette({\n base: emailPaletteBaseForHost(host),\n colors: scheme as Readonly<Record<string, unknown>> | undefined,\n }),\n ).link,\n PAL.link,\n )\n : PAL.link\n return {\n name,\n logoUrl: safeLogoUrl(host),\n pal: {\n ...PAL,\n brand,\n onBrand: readableInkOn(brand),\n link,\n button: primary || PAL.button,\n onButton: primary ? readableInkOn(primary) : PAL.onButton,\n accentRule: safeColor(\n scheme?.secondary?.main,\n primary || PAL.accentRule,\n ),\n },\n }\n}\n\n/**\n * Branded shell (AGL-2411, per-tenant since AGL-2408): the plain `system-ui`\n * box this used to be read as an unstyled error page, not a page this product\n * owns — which matters here specifically, because this is the one screen a\n * recipient who does NOT trust the sender is looking at.\n *\n * The identity is the SENDING SITE's. A recipient of a merchant's newsletter\n * arrives here from that merchant's email, and a page headed with our name is\n * both wrong and, on the screen where somebody decides whether this mail is\n * legitimate, actively unhelpful. `brand` defaults to\n * {@link PLATFORM_EMAIL_BRAND} so a caller with no host — and a self-host\n * deployment — still gets a complete, correctly-named page.\n *\n * `maxWidth` widens for the preference page, which is a list of choices rather\n * than a single sentence and a button.\n *\n * `<meta charset>` even though `sendPage` sets the header: a client that\n * ignores the header, or a page saved to disk, otherwise renders the em-dashes\n * in this copy as mojibake.\n *\n * `<meta name=\"referrer\" content=\"no-referrer\">` because THIS page's own URL\n * carries the recipient's address and the HMAC that authorizes acting on it.\n * A host logo is a third-party request, and the default referrer policy would\n * hand that whole link to whoever serves the image.\n */\nexport function page(\n body: string,\n maxWidth = 420,\n brand: EmailPageBrand = PLATFORM_EMAIL_BRAND,\n): string {\n const pal = brand.pal\n const brandName = escapeHtml(brand.name)\n // `alt` is the business name, so a logo that 404s or is blocked degrades to\n // the wordmark with no script and no second request.\n const mark = brand.logoUrl\n ? `<img src=\"${escapeHtml(brand.logoUrl)}\" alt=\"${brandName}\" ` +\n 'referrerpolicy=\"no-referrer\" style=\"display:block;max-height:40px;' +\n 'max-width:200px;width:auto;height:auto;object-fit:contain;' +\n 'margin-bottom:4px\">'\n : `<div style=\"font-size:15px;font-weight:700;letter-spacing:.02em;` +\n `color:${pal.brand};margin-bottom:4px\">${brandName}</div>`\n return (\n '<!doctype html><meta charset=\"utf-8\">' +\n '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">' +\n '<meta name=\"referrer\" content=\"no-referrer\">' +\n `<title>${brandName}</title>` +\n '<div style=\"min-height:100vh;display:flex;align-items:center;justify-content:center;' +\n `background:${pal.pageBg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,` +\n 'Helvetica,Arial,sans-serif;padding:24px;box-sizing:border-box\">' +\n `<div style=\"max-width:${maxWidth}px;width:100%;background:${pal.cardBg};border-radius:12px;` +\n 'padding:36px 32px;box-shadow:0 1px 3px rgba(0,0,0,.08),0 8px 24px rgba(0,0,0,.06)\">' +\n mark +\n `<div style=\"width:32px;height:3px;border-radius:2px;background:${pal.accentRule};` +\n 'margin-bottom:24px\"></div>' +\n body +\n '</div></div>'\n )\n}\n\n/** A heading in the shell's type scale. */\nexport function heading(text: string): string {\n return (\n `<h1 style=\"margin:0 0 8px;font-size:20px;font-weight:700;color:${PAL.ink}\">` +\n `${text}</h1>`\n )\n}\n\n/** Body copy in the shell's type scale. */\nexport function paragraph(html: string, marginBottom = 24): string {\n return (\n `<p style=\"margin:0 0 ${marginBottom}px;font-size:14px;line-height:1.5;` +\n `color:${PAL.muted}\">${html}</p>`\n )\n}\n\n/** The success checkmark badge the result pages open with. */\nexport function successBadge(pal: EmailPalette = PAL): string {\n return (\n `<div style=\"width:40px;height:40px;border-radius:50%;background:${pal.badgeBg};` +\n 'display:flex;align-items:center;justify-content:center;margin-bottom:16px;' +\n `font-size:18px;color:${pal.brand}\">&#x2713;</div>`\n )\n}\n\n/**\n * A full-width submit button: the accent fill and its label, as a console\n * contained Button draws them.\n *\n * With a host palette the label ink is contrast-matched to the merchant's\n * fill ({@link resolveEmailPageBrand}), since a pale one would otherwise hide\n * the label on the button a recipient came here to press.\n */\nexport function submitButton(\n label: string,\n options?: {\n name?: string\n value?: string\n pal?: EmailPalette\n },\n): string {\n const pal = options?.pal ?? PAL\n return (\n '<button type=\"submit\"' +\n (options?.name ? ` name=\"${escapeHtml(options.name)}\"` : '') +\n (options?.value ? ` value=\"${escapeHtml(options.value)}\"` : '') +\n ' style=\"font:inherit;font-size:14px;font-weight:600;' +\n `padding:11px 20px;border:0;border-radius:8px;background:${pal.button};` +\n `color:${pal.onButton};cursor:pointer;width:100%\">${label}</button>`\n )\n}\n\n/** Headers every one of these pages sets: never indexed, never cached. */\nexport function sendPage(\n res: Parameters<PluginApiHandler>[1],\n html: string,\n): void {\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n // Never indexed, never cached — the page names the recipient's address.\n res.setHeader('X-Robots-Tag', 'noindex, nofollow')\n res.setHeader('Cache-Control', 'no-store')\n res.status(200).send(html)\n}\n\n/** Everything the signed link carries. */\nexport interface UnsubscribeLinkParams {\n hostId: string\n email: string\n signature: string\n campaignId: string\n topicId: string\n}\n\n/**\n * The link's parameters, from the query for both verbs and from the form body\n * as a fallback.\n *\n * The one-click POST keeps the full query string (it posts to the header URL\n * verbatim), and so does the confirmation form's action — so query-first is\n * the path both real callers take. The body fallback exists so a form posted\n * to the bare path still works rather than failing as an invalid link.\n */\nexport function readParams(\n req: Parameters<PluginApiHandler>[0],\n): UnsubscribeLinkParams {\n const body = (req.body ?? {}) as Record<string, unknown>\n const pick = (name: string): string =>\n String(req.query[name] ?? body[name] ?? '')\n return {\n hostId: pick('hostId').trim(),\n email: pick('email').trim().toLowerCase(),\n signature: pick('sig').trim(),\n // `cid` names the campaign whose copy of this link was clicked. Absent on\n // every link minted before it existed, which is the whole design\n // constraint below.\n campaignId: pick('cid').trim(),\n // `tid` names the STREAM the message belonged to. Absent on every link\n // minted before topics existed, on the same footing as `cid`.\n topicId: pick('tid').trim(),\n }\n}\n\n/**\n * The subject a signature covers, or `null` when the parameters cannot make\n * one unambiguously.\n *\n * ## Three signed forms, and why that is not a weakening\n *\n * A link minted before campaign attribution existed signs `hostId:email`. A\n * link minted since signs `hostId:email:campaignId`. A link minted since\n * topics signs `hostId:email:campaignId:topicId`. All three are in inboxes\n * right now and all three have to keep working — an email is not recallable,\n * and an unsubscribe link that has stopped honoring itself is the one failure\n * in this area nobody gets to shrug at.\n *\n * Which form is checked is decided by the LINK, not by the signature: the\n * arity of the subject follows exactly which of `cid` and `tid` the URL\n * carries. There is no fallback between the forms, and that is what stops\n * this being a downgrade — an attacker cannot take a three-part link, drop\n * the `cid` and have it verify, because the two-part check over the same\n * `hostId:email` produces a different digest. Nor can they bolt a `cid` or a\n * `tid` onto a shorter link: the longer check then fails.\n *\n * ## What makes the three forms unambiguous\n *\n * The forms are joined with `:`, so a `cid` or `tid` that CONTAINED a colon\n * would let one subject string be read as two different parameter tuples —\n * a four-part `host:email:c:t` is byte-identical to a three-part subject\n * whose campaign id is `c:t`. That is the edit this function has to refuse:\n * an attacker holding a topic link could otherwise re-present it as a\n * campaign link with the topic spliced into the campaign id, and the topic\n * they were signed for would silently become something else.\n *\n * So a colon in either id is refused outright, on every form. It costs\n * nothing real: campaign ids come from `createResourceUid()`, which is\n * `nanoid`'s `A-Za-z0-9_-` alphabet, and `isSubscriptionTopicId` refuses a colon at\n * the point a topic is created.\n *\n * A `tid` with no `cid` is refused for the same reason — the empty middle\n * component would make `host:email::t` and a campaign id of `:t` the same\n * string. Every link the send path mints carries both.\n */\nexport function signedSubject(params: {\n hostId: string\n email: string\n campaignId: string\n topicId: string\n}): string | null {\n const { hostId, email, campaignId, topicId } = params\n if (campaignId.includes(':') || topicId.includes(':')) return null\n if (topicId && !campaignId) return null\n if (topicId) return `${hostId}:${email}:${campaignId}:${topicId}`\n if (campaignId) return `${hostId}:${email}:${campaignId}`\n return `${hostId}:${email}`\n}\n\n/**\n * What a CONFIRMATION link's signature covers, or `null` when it cannot be\n * made unambiguously.\n *\n * ## The ONE derivation, not a second one\n *\n * The subject itself is `confirmSignatureSubject` in\n * `@aglyn/tenant-data-admin`, where the SENDER that mints the link lives. The\n * three unsubscribe forms are stated twice — once there, once here — and they\n * agree today by inspection; adding a fourth form the same way would be\n * adding a fourth chance for a signer and a verifier to disagree about a link\n * that is already sitting in somebody's inbox.\n *\n * The deep path rather than the barrel, for the reason `email-suppression.ts`\n * gives at length: that module imports nothing but `node:crypto`, so this\n * file stays free of Firestore and a spec can still exercise the signature\n * scheme without standing up a database double.\n */\nexport function signedConfirmSubject(params: {\n hostId: string\n email: string\n topicId: string\n}): string | null {\n return (\n confirmSignatureSubject(params.hostId, params.email, params.topicId) || null\n )\n}\n\n/**\n * Whether a signature is this link's.\n *\n * `timingSafeEqual` needs equal lengths, so the length is compared first — it\n * is not a secret, both digests are fixed-width hex, and the call throws on a\n * mismatch rather than returning false.\n */\nexport function signatureMatches(args: {\n hostId: string\n email: string\n campaignId: string\n topicId: string\n signature: string\n secret: string\n /**\n * Verify the CONFIRMATION subject instead of the unsubscribe forms.\n *\n * A flag rather than a second function, so that every signed link in the\n * product is checked by one comparison against one digest: the warning in\n * this module's own header — that two implementations of one signature\n * scheme is how the resubscribe link comes to reject a signature the\n * unsubscribe link just accepted — applies to a fourth route as much as a\n * third.\n */\n purpose?: 'unsubscribe' | 'confirm'\n}): boolean {\n const subject =\n args.purpose === 'confirm'\n ? signedConfirmSubject(args)\n : signedSubject(args)\n if (subject === null) return false\n const expected = createHmac('sha256', args.secret)\n .update(subject)\n .digest('hex')\n return (\n expected.length === args.signature.length &&\n timingSafeEqual(\n new Uint8Array(Buffer.from(expected)),\n new Uint8Array(Buffer.from(args.signature)),\n )\n )\n}\n\n\n/**\n * The signed parameters, re-encoded for a form action or a sibling link.\n *\n * Every id rides through to the POST form, the preference page and the\n * resubscribe link, because the signature covers them: dropping one would\n * produce a URL whose shorter check fails against a longer signature, i.e. a\n * confirmation button that refuses itself.\n */\nexport function signedQuery(params: UnsubscribeLinkParams): string {\n return (\n `hostId=${encodeURIComponent(params.hostId)}` +\n `&email=${encodeURIComponent(params.email)}` +\n `&sig=${encodeURIComponent(params.signature)}` +\n (params.campaignId ? `&cid=${encodeURIComponent(params.campaignId)}` : '') +\n (params.topicId ? `&tid=${encodeURIComponent(params.topicId)}` : '')\n )\n}\n\n/**\n * A campaign id that is safe to use as a Firestore path component.\n *\n * `cid` arrives on a URL, so \"non-empty\" was never the question — a value of\n * `a/b/c` addresses `campaigns/a/b/c`, a path the merchant can neither see\n * nor delete. The signature already proves the value is ours, so this is the\n * second lock rather than the only one; it is here because a path component\n * built from request text gets validated at the place it becomes a path.\n */\nexport function isCampaignPathId(value: string): boolean {\n return (\n !!value &&\n value.length <= 1500 &&\n !value.includes('/') &&\n value !== '.' &&\n value !== '..' &&\n !/^__.*__$/.test(value)\n )\n}\n"],"names":["resolveSiteTheme","hostPublicOrigin","resolveHostToken","absoluteMediaSrc","personKey","BRAND","prefersDarkInk","escapeHtml","buildEmailPalette","emailAccent","emailPaletteBaseForHost","PLATFORM_EMAIL_ACCENT","confirmSignatureSubject","createHmac","timingSafeEqual","suppressionKeyFor","email","PAL","pageBg","cardBg","brand","accentRule","ink","muted","onBrand","badgeBg","link","button","fill","onButton","label","divider","PLATFORM_EMAIL_BRAND","Object","freeze","name","ORG_NAME","pal","HEX_COLOR","safeColor","value","fallback","text","trim","test","readableInkOn","background","safeLogoUrl","host","stored","undefined","resolved","hostId","$id","origin","BRAND_NAME_MAX","resolveEmailPageBrand","scheme","slice","colorSchemes","light","primary","main","base","colors","logoUrl","secondary","page","body","maxWidth","brandName","mark","heading","paragraph","html","marginBottom","successBadge","submitButton","options","sendPage","res","setHeader","status","send","readParams","req","pick","String","query","toLowerCase","signature","campaignId","topicId","signedSubject","params","includes","signedConfirmSubject","signatureMatches","args","subject","purpose","expected","secret","update","digest","length","Uint8Array","Buffer","from","signedQuery","encodeURIComponent","isCampaignPathId"],"mappings":"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"}