@aglyn/plugins-email 1.0.0-beta.182 → 1.0.0-beta.184
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/lib/components/email-blocks.d.ts +20 -0
- package/src/lib/components/email-blocks.js +135 -2
- package/src/lib/components/email-blocks.js.map +1 -1
- package/src/lib/components/email-design-preview.d.ts +7 -0
- package/src/lib/components/email-design-preview.js +79 -3
- package/src/lib/components/email-design-preview.js.map +1 -1
- package/src/lib/server-email-drafts.d.ts +11 -4
- package/src/lib/server-email-drafts.js +23 -4
- package/src/lib/server-email-drafts.js.map +1 -1
- package/src/lib/server-list-gate.js +8 -4
- package/src/lib/server-list-gate.js.map +1 -1
- package/src/lib/server.js +269 -68
- package/src/lib/server.js.map +1 -1
package/src/lib/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { registerPluginApiRoute, type PluginApiHandler } from '@aglyn/aglyn/server'\n/*\n * The MODULE, not the barrel. `@aglyn/aglyn` re-exports the app-utils index,\n * which reaches `enabled-plugins-context` and therefore React — and this file\n * is loaded by the plugin API route's SERVER graph, where a client-only\n * module is a bundle `app-router-graph.spec.ts` refuses. Every name here is a\n * pure function or a constant that lives in one leaf file.\n */\nimport {\n activeEmailTopics,\n mergeEmailTopics,\n normalizeEmailTopic,\n readTopicSubscriptionState,\n resolveCampaignTopic,\n EMAIL_TOPICS_COLLECTION,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type EmailTopic,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n confirmTopicSubscription,\n EMAIL_FREQUENCY_SUBCOLLECTION,\n firebaseAdmin,\n mirrorPlatformResubscribe,\n mirrorPlatformUnsubscribe,\n resolveCampaignSendRef,\n resolveOrgIdForHost,\n setMarketingCadence,\n UNSUBSCRIBE_SUPPRESSION_REASON,\n type ConfirmTopicResult,\n} from '@aglyn/tenant-data-admin'\nimport type {\n PluginEmailStreamRejoinRequest,\n PluginEmailStreamRejoinResult,\n} from '@aglyn/aglyn/plugin-manager/plugin-email-streams'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\n/*\n * The pure cadence rule from the shared email library, where the SEND path\n * reads it too. The preference page and the gate must agree about what\n * `'weekly'` means down to the coercion of a malformed value, and two copies\n * of that is how a page comes to record a choice the gate does not recognize.\n */\nimport {\n normalizeMarketingCadence,\n type MarketingCadence,\n} from '@aglyn/shared-util-email'\nimport { escapeHtml } from '@aglyn/shared-util-tools/escape-html'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n heading,\n isCampaignPathId,\n page,\n paragraph,\n PAL,\n PLATFORM_EMAIL_BRAND,\n readParams,\n resolveEmailPageBrand,\n sendPage,\n signatureMatches,\n signedQuery,\n submitButton,\n successBadge,\n suppressionKeyFor,\n type EmailBrandSource,\n type EmailPageBrand,\n type EmailPalette,\n type UnsubscribeLinkParams,\n} from './unsubscribe-link'\n\n/**\n * One-click unsubscribe (AGL-161), split into a safe GET and a mutating POST\n * (AGL-2408), with a preference center in front of the human-facing half.\n *\n * ## Why the GET stopped writing\n *\n * This handler used to write the suppression on GET, and the docblock called\n * that a feature: \"GET so it works from any mail client; idempotent.\"\n * Idempotent is not the property that matters. A GET must be SAFE — free of\n * side effects the user did not ask for — and this one was not.\n *\n * Every mail client and security gateway of consequence (Microsoft Defender\n * for Office 365's Safe Links, Google's own scanners, Proofpoint, Mimecast)\n * FETCHES every URL in a message before the recipient ever sees it, to check\n * where it lands. Each of those fetches silently unsubscribed the recipient\n * from that merchant's list. The recipient never clicked anything; the\n * merchant sees their audience shrink and cannot explain it; and until\n * AGL-2410 there was no screen in the product to even discover it, let alone\n * undo it. That is a customer's marketing list being destroyed by a\n * prescanner, on our side of the line.\n *\n * So: GET renders a page carrying a same-URL POST form, and only the POST\n * writes. A prescanner following any of these three links now renders a page\n * and changes nothing. That property is not negotiable and every handler in\n * this file holds it.\n *\n * ## RFC 8058 one-click\n *\n * Gmail's and Yahoo's bulk-sender rules ask for `List-Unsubscribe` PLUS\n * `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, and a client honoring\n * that pair sends a POST to the header URL with `List-Unsubscribe=One-Click`\n * as an `application/x-www-form-urlencoded` body. `unsubscribeHandler`'s POST\n * branch is exactly what that lands on — which is why the two halves had to be\n * fixed together: turning the GET into a confirmation page without accepting\n * POST would have broken unsubscribe outright, and advertising one-click while\n * the only mutating verb was GET would have been the same bug with a header on\n * top.\n *\n * THE PREFERENCE CENTER IS NOT IN THAT PATH, and must never be. The\n * `List-Unsubscribe` header still names `email/unsubscribe`, whose POST acts\n * immediately with no page in between; the preference center is what the\n * FOOTER link in the message body points at, where a human is present to make\n * a choice. Routing the header at a page of checkboxes would be advertising\n * one-click against a surface that cannot honor it — a mailbox provider POSTs\n * that URL with nobody watching, reads a 200, and reports the recipient\n * unsubscribed when nothing was written.\n *\n * The one-click POST carries no `Origin` header (it is sent by the mailbox\n * provider's servers, not a browser), which the dispatcher's same-origin gate\n * deliberately allows; the forms' POSTs are same-origin. Neither needs a CSRF\n * token beyond the HMAC already in the URL: a caller who cannot produce `sig`\n * cannot unsubscribe anyone, and a caller who can is holding the recipient's\n * own mail.\n *\n * ## No `mailto:` variant, and why that is a deliberate hole\n *\n * RFC 8058 also permits a `mailto:` fallback in the header. Adding one now\n * would point recipients at an address nobody reads — `docs/EMAIL_SETUP.md`\n * lists a monitored `hello@aglyn.com` as an unstarted idea — and an\n * unsubscribe request that lands in an unmonitored inbox is worse than no\n * fallback at all, because the recipient believes they have unsubscribed. It\n * needs a mailbox and an inbound route, which is provider setup rather than\n * repo work.\n */\n\n/** Read from both verbs; the secret the link was signed with. */\nfunction linkSecret(): string {\n return process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.CRON_SECRET || ''\n}\n\n/**\n * Params + suppression key, or the status to answer with.\n *\n * Every one of these routes opens the same way — read, check the secret is\n * configured, verify the HMAC, key the address — and every one of them has to\n * do it before touching Firestore. A shared preamble is also what keeps the\n * three from drifting into three slightly different ideas of a valid link.\n */\ninterface OpenedLink {\n /**\n * The status to answer with, or 0 when the link is good.\n *\n * A refusal CODE rather than a discriminated union: this library compiles\n * with `strictNullChecks: false`, under which a `{ok: true} | {ok: false}`\n * union does not narrow on a truthiness check, so the union shape would\n * type-error at every call site that read the status.\n */\n refusal: number\n params: UnsubscribeLinkParams\n key: string\n}\n\nfunction openSignedLink(req: Parameters<PluginApiHandler>[0]): OpenedLink {\n const params = readParams(req)\n const secret = linkSecret()\n const refuse = (status: number): OpenedLink => ({\n refusal: status,\n params,\n key: '',\n })\n if (!params.hostId || !params.email || !params.signature || !secret) {\n return refuse(400)\n }\n if (!signatureMatches({ ...params, secret })) return refuse(403)\n // `personKey` refuses a value that is not an address rather than hashing it,\n // so a signed link naming a malformed address is a bad link and not a\n // suppression document for a person who does not exist.\n const key = suppressionKeyFor(params.email)\n if (!key) return refuse(400)\n return { refusal: 0, params, key }\n}\n\n/**\n * How long the shell will wait for the sending site's identity.\n *\n * A branded page is worth one host read; it is not worth a page that never\n * arrives. These four routes are the recipient's only way to stop the mail, so\n * an unbranded page rendered promptly beats a correct one that hangs behind a\n * slow read — the timeout falls back rather than failing.\n */\nconst BRAND_READ_TIMEOUT_MS = 1500\n\n/**\n * The SENDING SITE's identity for the shell, not ours.\n *\n * One read of `hosts/{hostId}`, and every failure mode lands on the same\n * answer: no host id, a missing document, a read that throws, a read that is\n * slow, or a host that has simply set no brand all resolve to\n * {@link PLATFORM_EMAIL_BRAND}. That is also the self-host answer, so the\n * fallback path is the one an operator runs every day rather than a branch\n * only reached when something is broken.\n */\nasync function loadHostBrand(hostId: string): Promise<EmailPageBrand> {\n if (!hostId) return PLATFORM_EMAIL_BRAND\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n const firestore = firebaseAdmin.app().firestore()\n const snapshot = await Promise.race([\n firestore.collection('hosts').doc(hostId).get(),\n new Promise<null>((resolve) => {\n timer = setTimeout(() => resolve(null), BRAND_READ_TIMEOUT_MS)\n }),\n ])\n if (!snapshot?.exists) return PLATFORM_EMAIL_BRAND\n // The id LAST: it addresses the `media:` logo reference, and the copy of\n // it stored in the document is the one that can be stale or absent.\n return resolveEmailPageBrand({\n ...(snapshot.data() as EmailBrandSource),\n $id: hostId,\n })\n } catch (error) {\n console.error('[email] host brand read failed', error)\n return PLATFORM_EMAIL_BRAND\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\nconst unsubscribeHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n const opened = openSignedLink(req)\n if (opened.refusal) {\n return void res.status(opened.refusal).send('Invalid unsubscribe link')\n }\n const { params, key } = opened\n const { hostId, email, campaignId, topicId } = params\n const query = signedQuery(params)\n\n if (method !== 'POST') {\n // SAFE. A prescanner lands here and nothing is written — the brand read\n // is the only Firestore access on this path, and it is a read.\n const brand = await loadHostBrand(hostId)\n return void sendPage(\n res,\n page(\n heading('Unsubscribe?') +\n paragraph(\n `Confirm that <strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong> should stop receiving emails from ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n brand.name,\n )}</strong>.`,\n ) +\n `<form method=\"post\" action=\"/api/email/unsubscribe?${escapeHtml(\n query,\n )}\">` +\n submitButton('Unsubscribe', { pal: brand.pal }) +\n '</form>' +\n // The way to a NARROWER choice, offered on the page rather than only\n // in the message footer: a recipient who reached the total\n // unsubscribe from a mail client's own link has never been shown\n // that leaving one stream is possible.\n `<p style=\"margin:16px 0 0;font-size:13px;line-height:1.5;text-align:center\">` +\n `<a href=\"/api/email/preferences?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'Choose which emails to stop instead</a></p>',\n 420,\n brand,\n ),\n )\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const [created, brand] = await Promise.all([\n writeSiteSuppression(firestore, hostId, key, {\n email,\n campaignId,\n topicId,\n }),\n loadHostBrand(hostId),\n ])\n\n /*\n * The campaign's own unsubscribe count.\n *\n * AFTER the suppression and with its failure swallowed, for the reason\n * the delivery webhook orders its writes the same way: the suppression is\n * the write that must happen, and a statistic must never be able to cost\n * one. A lost increment understates an unsubscribe rate; a lost\n * suppression mails somebody who asked us not to. Where the send is, and\n * why the write never creates it, is {@link countSendUnsubscribe}'s.\n */\n if (created) await countSendUnsubscribe(firestore, hostId, campaignId)\n // The account's answer about product updates, when this is the\n // platform's own marketing site (AGL-3305). After the suppression, and it\n // never throws: the list is what stops the mail.\n await mirrorPlatformUnsubscribe({ hostId, email, left: 'everything' })\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading(\"You're unsubscribed\") +\n paragraph(\n `You won't receive further emails from ${escapeHtml(\n brand.name,\n )}.`,\n 20,\n ) +\n // Same signed params, so the click that just proved this is really\n // this recipient's link doubles as the resubscribe link — no new\n // token, no second email round-trip (AGL-2499).\n `<a href=\"/api/email/resubscribe?${escapeHtml(query)}\" ` +\n `style=\"font-size:13px;color:${brand.pal.link};text-decoration:none\">` +\n 'Changed your mind? Resubscribe</a>',\n 420,\n brand,\n ),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Unsubscribe failed — please try again')\n }\n}\n\n/**\n * One more unsubscribe on the send a link named (`cid`), never throwing.\n *\n * The link carries the site and the send id it was signed over, and nothing\n * else; the send itself is the organization's\n * (`orgs/{orgId}/campaigns/{sendId}`), or still the site's when the\n * migration has not reached it, and `resolveCampaignSendRef` finds whichever\n * holds it. A send in neither place was discarded, and there is nothing to\n * count against.\n *\n * `update()`, never a merge-set: a merge-set would re-create a send deleted\n * between the resolve and the write as a husk holding one `stats` map, and\n * `update()` refuses a missing document — the count for a send nobody can\n * open has no reader. Every failure is swallowed, because the suppression\n * has already been written and a statistic must never be able to cost one.\n */\nasync function countSendUnsubscribe(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n campaignId: string,\n): Promise<void> {\n if (!isCampaignPathId(campaignId)) return\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n const sendRef = await resolveCampaignSendRef({\n hostId,\n sendId: campaignId,\n orgId,\n firestore,\n })\n await sendRef?.update({ 'stats.unsubscribes': FieldValue.increment(1) })\n } catch {\n // The suppression is the write that mattered, and it has landed.\n }\n}\n\n/**\n * The whole-site suppression, written the same way from both routes that\n * write one.\n *\n * `reason: 'unsubscribe'` is written explicitly (AGL-2410). The Resend webhook\n * stamps `'bounce'` / `'complaint'`, and until now an unsubscribe was the only\n * entry with no reason at all — so a reader had to infer one from an absent\n * field, which is a rule that holds only while nothing else ever forgets to\n * write it.\n *\n * `createdAt` is written only when the document is new, matching\n * `email-events.ts`: a bounce arriving after an unsubscribe must not restamp\n * the date the person actually unsubscribed, and neither must a second click\n * on the same link.\n *\n * @returns WHETHER THIS CLICK CREATED THE SUPPRESSION, decided inside the\n * transaction and used outside it. It is the idempotency the campaign\n * counter needs, and it comes for free because the transaction\n * already reads the document to decide whether to stamp `createdAt`.\n * A second click on the same link — and there will be second clicks,\n * from a person pressing the button twice and from a client\n * re-POSTing a one-click header — finds the entry present and\n * contributes nothing, so `stats.unsubscribes` counts PEOPLE who left\n * rather than button presses.\n */\nasync function writeSiteSuppression(\n firestore: any,\n hostId: string,\n key: string,\n fields: { email: string; campaignId: string; topicId: string },\n): Promise<boolean> {\n const ref = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppressions')\n .doc(key)\n /*\n * Assigned rather than or-ed inside the body because a Firestore\n * transaction may retry, and the reading that counts is the one whose write\n * committed.\n */\n let created = false\n await firestore.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n created = !existing.exists\n transaction.set(\n ref,\n {\n email: fields.email,\n reason: UNSUBSCRIBE_SUPPRESSION_REASON,\n suppressedAt: FieldValue.serverTimestamp(),\n // WHICH mailing they left over. Written on the suppression itself as\n // well as counted on the campaign, so the Suppressions list can answer\n // \"why did this person go\" for one address without the aggregate — and\n // stamped only when this click created the entry, so a re-click cannot\n // re-attribute an old unsubscribe to whatever link the person happened\n // to press second. `topicId` rides along on the same rule: it is the\n // stream that lost them, which is the finer half of the same question.\n ...(existing.exists\n ? {}\n : {\n createdAt: FieldValue.serverTimestamp(),\n ...(fields.campaignId ? { campaignId: fields.campaignId } : {}),\n ...(fields.topicId ? { topicId: fields.topicId } : {}),\n }),\n },\n { merge: true },\n )\n })\n // The same verdict on the record the person is (AGL-3245), through\n // whichever plugin keeps the workspace's records, so the record page says\n // they left. A stamp that failed is logged by the seam and costs the\n // unsubscribe nothing.\n if (created) {\n await stampRecordEmailState({\n hostId,\n email: fields.email,\n state: {\n status: 'unsubscribed',\n atMs: Date.now(),\n source: 'campaign',\n detail: fields.campaignId ? 'Unsubscribed from a campaign email.' : 'Unsubscribed by the link.',\n },\n })\n }\n return created\n}\n\n/**\n * The self-service way back in (AGL-2499) that `unsubscribeHandler` never\n * had: same signed-link shape, same safe-GET/mutating-POST split, same\n * HMAC — a resubscribe link is only as trustworthy as the unsubscribe link\n * it rides in on, so it earns no looser a contract.\n *\n * Reverses ONLY a self-service unsubscribe (`reason: 'unsubscribe'`). A\n * bounce or spam-complaint suppression (`email-events.ts`'s Resend webhook)\n * protects the SENDER's deliverability, not a preference the recipient can\n * waive by clicking a link — undoing one from here would let anyone who\n * still holds an old campaign email re-arm sending to an address that\n * bounced or complained.\n */\nconst resubscribeHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n // The SAME verifier the unsubscribe uses, because this link is minted by\n // handing the unsubscribe's own signed query to a second route. Two\n // implementations of one signature scheme is how the resubscribe link comes\n // to reject a signature the unsubscribe link just accepted.\n const opened = openSignedLink(req)\n if (opened.refusal) {\n return void res.status(opened.refusal).send('Invalid link')\n }\n const { params, key } = opened\n const { hostId, email } = params\n const query = signedQuery(params)\n\n if (method !== 'POST') {\n // SAFE, same reasoning as the unsubscribe GET: a prescanner must not be\n // able to resubscribe someone either.\n const brand = await loadHostBrand(hostId)\n return void sendPage(\n res,\n page(\n heading('Resubscribe?') +\n paragraph(\n `Start receiving emails from <strong style=\"color:${PAL.ink}\">` +\n `${escapeHtml(brand.name)}</strong> again at ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong>.`,\n ) +\n `<form method=\"post\" action=\"/api/email/resubscribe?${escapeHtml(\n query,\n )}\">` +\n submitButton('Resubscribe', { accent: 'link', pal: brand.pal }) +\n '</form>',\n 420,\n brand,\n ),\n )\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const [released, brand] = await Promise.all([\n releaseSiteSuppression(firestore, hostId, key),\n loadHostBrand(hostId),\n ])\n if (!released) {\n return void sendPage(res, page(protectedAddressBody(), 420, brand))\n }\n // Restores the account's Yes only when an email door took it away and\n // product updates now reach them (AGL-3305).\n await mirrorPlatformResubscribe({ hostId, email, via: 'email-resubscribe' })\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading(\"You're resubscribed\") +\n paragraph(\n `You'll receive emails from ${escapeHtml(\n brand.name,\n )} again.`,\n 0,\n ),\n 420,\n brand,\n ),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Resubscribe failed — please try again')\n }\n}\n\n/**\n * Lift a whole-site suppression, or refuse to.\n *\n * THE ONE RULE THAT IS NOT A PREFERENCE. A `bounce` or `complaint` entry says\n * the mailbox is dead or its owner pressed \"report spam\", and neither is a\n * setting the person on the other end of a link may change. Both are the\n * sending domain's protection — one shared domain under `p=reject` for every\n * tenant — so honoring a resubscribe over one would be handing the recipient a\n * lever on somebody else's deliverability. Every path that puts an address\n * back in circulation goes through here so that there is exactly one place the\n * rule is stated.\n *\n * @returns false when the record was left standing because it is not an\n * unsubscribe.\n */\nasync function releaseSiteSuppression(\n firestore: any,\n hostId: string,\n key: string,\n): Promise<boolean> {\n const ref = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppressions')\n .doc(key)\n const snapshot = await ref.get()\n if (\n snapshot.exists &&\n snapshot.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON\n ) {\n return false\n }\n // Idempotent whether or not a doc existed — a resubscribe click on an\n // address that was never suppressed (or already resubscribed) is not an\n // error, it is the state the visitor wanted.\n await ref.delete()\n return true\n}\n\n/** Shown wherever a resubscribe is refused, so the wording is one wording. */\nfunction protectedAddressBody(): string {\n return (\n heading(\"Can't resubscribe this address\") +\n paragraph(\n 'This address was suppressed by a delivery problem, not an ' +\n 'unsubscribe, so it can’t be re-added from this link. Contact ' +\n 'the site directly if this looks wrong.',\n 0,\n )\n )\n}\n\n/**\n * THE PREFERENCE CENTER — the page the message footer links to.\n *\n * ## What it is for\n *\n * `docs/specs/email-competitive-gaps.md` §1f: every product compared has a\n * preference center and we had one lever, marked all-or-nothing. The cost of\n * that is not a missing feature, it is a misdirected one — the recipient who\n * only wanted the sales mail to stop had to stop everything, and the recipient\n * who did not want to stop everything pressed \"report spam\" instead, which is\n * a complaint on a shared sending domain.\n *\n * ## What it may show, and what it must not\n *\n * Reached with no session, by anyone holding the link. The HMAC is what\n * authorizes it, and it covers exactly the host, the address, the campaign and\n * the topic — so the page shows the org's topic CATALOG and this address's\n * opt-out state against it, and nothing else. Not the contact record, not the\n * lists they are on, not their name, not whether we have ever heard of them.\n *\n * It is not an enumeration oracle for two reasons that both have to hold. A\n * caller cannot ask about an address they do not already hold a signed link\n * for; and the page renders IDENTICALLY for an address with no records at all\n * — an unknown address reads as \"subscribed to everything\", which is both the\n * truthful answer and the one that reveals nothing. There is deliberately no\n * \"we don't have that address\" branch, because that branch is the oracle.\n */\nconst preferencesHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n const opened = openSignedLink(req)\n if (opened.refusal) {\n return void res.status(opened.refusal).send('Invalid preferences link')\n }\n const { params, key } = opened\n const { hostId, email, campaignId, topicId } = params\n const query = signedQuery(params)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n // Three independent reads, so they go together rather than in series —\n // the brand is not worth a third round trip on the page a recipient is\n // waiting for.\n const [catalog, state, brand] = await Promise.all([\n loadTopicCatalog(firestore, hostId),\n readSubscriptionState(firestore, hostId, key),\n loadHostBrand(hostId),\n ])\n const topics = activeEmailTopics(catalog)\n\n if (method !== 'POST') {\n // SAFE. Reads only, exactly like the other two GETs.\n return void sendPage(\n res,\n page(\n preferencesFormBody({ email, query, topics, state, topicId, brand }),\n 520,\n brand,\n ),\n )\n }\n\n const body = (req.body ?? {}) as Record<string, unknown>\n if (String(body['action'] ?? '') === 'all') {\n const created = await writeSiteSuppression(firestore, hostId, key, {\n email,\n campaignId,\n topicId,\n })\n if (created) await countSendUnsubscribe(firestore, hostId, campaignId)\n await mirrorPlatformUnsubscribe({ hostId, email, left: 'everything' })\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading('Sorry to see you go') +\n paragraph(\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(email)}</strong> ` +\n 'has been unsubscribed from every email ' +\n `${escapeHtml(brand.name)} sends.`,\n 20,\n ) +\n paragraph(\n 'Changed your mind? ' +\n `<a href=\"/api/email/resubscribe?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'Resubscribe</a>, or ' +\n `<a href=\"/api/email/preferences?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'pick just the emails you want</a>.',\n 0,\n ),\n 420,\n brand,\n ),\n )\n }\n\n /*\n * A CHECKED BOX MEANS \"KEEP SENDING\", so the opt-outs are the complement.\n *\n * Read off the catalog rather than off the form, deliberately. A browser\n * submits nothing at all for an unchecked box, so a form that named only\n * the boxes to TURN OFF would be indistinguishable from a form where the\n * recipient turned everything off — and the two mean opposite things.\n */\n const keep = new Set(\n topics\n .map((topic) => topic.id)\n .filter((id) => String(body[`topic:${id}`] ?? '') !== ''),\n )\n const drop = topics.filter((topic) => !keep.has(topic.id))\n await writeTopicOptOuts(firestore, hostId, key, {\n email,\n optOut: drop.map((topic) => topic.id),\n resume: [...keep],\n })\n\n /*\n * HOW OFTEN, recorded from the same submit as WHAT.\n *\n * They are one decision — \"less of this, and less often\" — so they are\n * one form and one round trip. It is stored on the send counter rather\n * than beside the topic opt-outs because that is the document the send\n * path already reads for every marketing message, which is what makes\n * honoring the request free at the point it has to be honored.\n *\n * A value that is not a cadence lands on `'all'` rather than erroring:\n * this page is reached with no session by anybody holding the link, so\n * `body` is untrusted, and the failure a recipient must not meet on the\n * screen they came to in order to leave is a 500.\n */\n const cadence = normalizeMarketingCadence(body['cadence'])\n const cadenceStored = await setMarketingCadence(hostId, email, cadence)\n\n /*\n * A person asking for SOME mail is asking not to be suppressed from ALL of\n * it, so a whole-site unsubscribe standing against this address is lifted\n * — through the same guard the resubscribe route uses, which refuses to\n * touch a bounce or a complaint. Without this the page would accept a\n * choice it could not honor: every box ticked, and the send path still\n * dropping the address at the site suppression one layer above topics.\n */\n let stillBlocked = false\n if (keep.size) {\n stillBlocked = !(await releaseSiteSuppression(firestore, hostId, key))\n }\n\n /*\n * The account's answer about product updates follows what this page just\n * wrote (AGL-3305), and only on the platform's own marketing site: a No\n * when product updates is among what they left, and a Yes back only when\n * an email door took it and the list is open again. Leaving the\n * newsletter says nothing about product updates, so neither call does\n * anything for it. After every write above, so both read the outcome.\n */\n if (drop.length) {\n await mirrorPlatformUnsubscribe({\n hostId,\n email,\n left: drop.map((topic) => topic.id),\n })\n }\n if (keep.size) {\n await mirrorPlatformResubscribe({ hostId, email, via: 'email-preferences' })\n }\n\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading(drop.length ? 'Sorry to see you go' : 'Preferences saved') +\n paragraph(\n changeSummary({ email, keep: [...keep], drop, topics }),\n cadence === 'all' && cadenceStored ? 20 : 8,\n ) +\n /*\n * The pace is reported only when it is a CHOICE. \"As they come\" is\n * the default and the absence, so announcing it would tell somebody\n * who touched nothing that they had just asked for something.\n */\n (cadence !== 'all' && cadenceStored\n ? paragraph(\n `They will arrive no more than ${cadenceSentence(cadence)}.`,\n 20,\n )\n : '') +\n (!cadenceStored\n ? paragraph(\n 'One thing we could not change: how often these arrive. Your ' +\n 'other choices are saved — come back to this page to try ' +\n 'that one again.',\n 20,\n )\n : '') +\n (stillBlocked\n ? paragraph(\n 'One thing we could not change: this address is on hold ' +\n 'because an earlier message could not be delivered or was ' +\n 'reported as spam. Contact the site directly if that looks ' +\n 'wrong.',\n 20,\n )\n : '') +\n paragraph(\n 'Changed your mind? ' +\n `<a href=\"/api/email/preferences?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'Come back to this page</a> and tick the boxes again — this ' +\n 'link keeps working.',\n 0,\n ),\n 520,\n brand,\n ),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Preferences failed — please try again')\n }\n}\n\n/**\n * The org's topic catalog for a site.\n *\n * Two reads, both fail-soft to the built-in defaults. A site with no owning\n * org, an org with no stored topics, or a Firestore hiccup all land on the\n * same page: the four built-ins, every box ticked. That is the right failure —\n * a preference page that renders NO topics offers the recipient nothing to\n * uncheck, which turns the one screen they came to in order to leave a stream\n * into a dead end.\n */\nasync function loadTopicCatalog(\n firestore: any,\n hostId: string,\n /**\n * Throw instead of falling back. For a caller about to lift a whole-site\n * unsubscribe on the strength of the list, where the built-ins alone would\n * leave every custom stream mailable (`rejoinStreamForAccount`).\n */\n strict = false,\n): Promise<EmailTopic[]> {\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n if (!orgId) return mergeEmailTopics(null)\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(EMAIL_TOPICS_COLLECTION)\n .get()\n const stored = (snapshot?.docs ?? [])\n .map((doc: any) => normalizeEmailTopic(doc.id, doc.data()))\n .filter((topic: EmailTopic | null): topic is EmailTopic => !!topic)\n return mergeEmailTopics(stored)\n } catch (error) {\n if (strict) throw error\n console.error('[email/preferences] topic catalog read failed', error)\n return mergeEmailTopics(null)\n }\n}\n\n/** What this address currently receives from this site. */\ninterface SubscriptionState {\n /** A whole-site suppression stands. */\n suppressed: boolean\n /**\n * The suppression is a bounce or a complaint, so nothing on this page may\n * lift it.\n */\n protectedRecord: boolean\n /** Topic ids this address has left and not rejoined. */\n optedOut: Set<string>\n /**\n * Topic ids this address was asked to confirm and has not.\n *\n * Held apart from {@link optedOut} even though neither is mailable, because\n * the page says something different about each: one is a choice the person\n * made and the other is a question they have not answered.\n */\n pending: Set<string>\n /** The pace this address last asked for, or `'all'` for never asked. */\n cadence: MarketingCadence\n}\n\n/** How a chosen cadence reads inside a sentence about what will happen. */\nfunction cadenceSentence(cadence: MarketingCadence): string {\n return cadence === 'daily'\n ? 'one a day'\n : cadence === 'weekly'\n ? 'one a week'\n : 'one a month'\n}\n\n/**\n * All three per-site records for one address, in three keyed `get()`s.\n *\n * By document id rather than a query, matching `filterSendableForHost`: no\n * composite index to go missing, and nothing that can fail open on a read\n * window. The third is the send counter, which is where the recipient's\n * chosen pace lives — see `EmailFrequencyRecord.cadence` for why it is stored\n * on the document the send path already reads rather than on this page's own.\n */\nasync function readSubscriptionState(\n firestore: any,\n hostId: string,\n key: string,\n): Promise<SubscriptionState> {\n const hostRef = firestore.collection('hosts').doc(hostId)\n const [suppression, optOuts, frequency] = await Promise.all([\n hostRef.collection('suppressions').doc(key).get(),\n hostRef.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key).get(),\n hostRef\n .collection(EMAIL_FREQUENCY_SUBCOLLECTION)\n .doc(key)\n .get()\n // The pace is the one field on this page whose absence is a legitimate\n // answer, so a read that fails renders the default rather than an\n // error — the recipient still gets their topic checkboxes.\n .catch(() => null),\n ])\n const stored = (optOuts?.exists ? optOuts.get('topics') : null) ?? {}\n const optedOut = new Set<string>()\n const pending = new Set<string>()\n for (const [id, record] of Object.entries(\n stored as Record<string, TopicSubscriptionEntry | null>,\n )) {\n /*\n * The shared reader, not a field test. An entry with a `resubscribedAt`\n * is EVIDENCE of an opt-out that has been lifted rather than a live one —\n * see `writeTopicOptOuts` for why the entry stays — and an entry with a\n * `confirmedAt` carries the same shape of evidence for a confirmation.\n * Only one function knows all three states.\n */\n const state = readTopicSubscriptionState(record)\n if (state === 'opted-out') optedOut.add(id)\n if (state === 'pending') pending.add(id)\n }\n return {\n suppressed: !!suppression?.exists,\n protectedRecord:\n !!suppression?.exists &&\n suppression.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON,\n optedOut,\n pending,\n cadence: normalizeMarketingCadence(\n frequency?.exists ? frequency.get('cadence') : null,\n ),\n }\n}\n\n/**\n * Record the recipient's per-topic choices.\n *\n * ## The record is EVIDENCE, so nothing is removed\n *\n * `email-suppression.ts` makes the argument for the suppression lists: \"a\n * revocation is a FIELD and not a delete, because the record is the evidence\n * that the suppression was honored while it was in force.\" A topic opt-out is\n * the same kind of fact — somebody asked us to stop, and the answer to \"did\n * you honor it\" has to survive them changing their mind later. So rejoining a\n * topic stamps `resubscribedAt` on the existing entry rather than deleting it,\n * and the pair of timestamps is the window the request was in force for.\n *\n * One document per address, a map keyed by topic, rather than a document per\n * (address, topic): the send path reads this by key alongside the suppression\n * lists, and one `get()` per address is what keeps a topic-filtered send the\n * same cost as an unfiltered one.\n *\n * ## Ticking a box here IS the confirmation a double opt-in asks for\n *\n * The entry also carries a pending-confirmation pair, and a recipient who\n * ticks a topic on this page has done more than the confirmation link asks:\n * they clicked a signed link delivered to that mailbox and then made a\n * choice in it. Leaving them pending would mean the page recorded a\n * subscription the send path refuses — a form whose submit does not take\n * effect, which this page refuses to be anywhere else. So a resumed topic\n * that is still pending is confirmed here, stamped with the moment they did\n * it.\n *\n * ## Every write CARRIES the entry forward\n *\n * Each branch spreads the previous entry rather than replacing it. Two pairs\n * of timestamps now live on one entry, and a branch that wrote only its own\n * pair would silently discard the other — an opt-out would erase the record\n * that somebody confirmed, and the erasure would look exactly like a person\n * who never confirmed.\n */\nasync function writeTopicOptOuts(\n firestore: any,\n hostId: string,\n key: string,\n fields: {\n email: string\n optOut: string[]\n resume: string[]\n /**\n * Whether resuming a pending topic confirms it. True for this page, whose\n * tick is the confirmation; false for a reopening that did not come\n * through a link delivered to the mailbox (`rejoinStreamForAccount`).\n */\n confirmPending?: boolean\n },\n): Promise<void> {\n const ref = firestore\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n await firestore.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n const stored = ((existing.exists ? existing.get('topics') : null) ??\n {}) as Record<string, Record<string, unknown>>\n const topics: Record<string, unknown> = {}\n for (const id of fields.optOut) {\n const previous = stored[id]\n /*\n * Already opted out and never rejoined: leave the original timestamp\n * alone. Re-submitting the same form must not restamp the date the\n * person actually left, for the reason `createdAt` is not restamped on\n * the suppression.\n *\n * The state reader, not \"an entry with no `resubscribedAt`\". That\n * shorthand reads a CONFIRMED double opt-in — which carries `pendingAt`\n * and `confirmedAt` and no `resubscribedAt` — as somebody who had\n * already left, so unticking their box would record no opt-out at all\n * and the send path would go on mailing them.\n */\n topics[id] =\n readTopicSubscriptionState(previous) === 'opted-out'\n ? previous\n : {\n ...(previous ?? {}),\n optedOutAt: FieldValue.serverTimestamp(),\n resubscribedAt: null,\n }\n }\n for (const id of fields.resume) {\n const previous = stored[id]\n if (!previous) continue\n const state = readTopicSubscriptionState(previous)\n if (state === 'pending') {\n if (fields.confirmPending !== false) {\n topics[id] = { ...previous, confirmedAt: Date.now() }\n }\n continue\n }\n topics[id] = previous['resubscribedAt']\n ? previous\n : { ...previous, resubscribedAt: FieldValue.serverTimestamp() }\n }\n transaction.set(\n ref,\n {\n email: fields.email,\n // The whole map, not a merge of one key: a topic the recipient\n // rejoined has to lose its live status, and a dotted merge cannot\n // express \"these and no others\" for a map whose keys are data.\n topics: { ...stored, ...topics },\n updatedAt: FieldValue.serverTimestamp(),\n ...(existing.exists\n ? {}\n : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n })\n}\n\n/**\n * Reopens ONE stream for a signed-in account (AGL-3305) — this plugin's side\n * of `plugin-email-streams`, which the console asks when an account's answer\n * about a stream turns back to yes. The caller has already proven the\n * mailbox; see the seam for why that is the caller's job.\n *\n * Two cases, and the second is the reason this lives here:\n *\n * - The address left just this stream: its opt-out is lifted, exactly as\n * re-ticking the box on the preference page lifts it.\n * - The address left EVERYTHING: every other active stream becomes an\n * opt-out first, and only then is the whole-site unsubscribe lifted —\n * through the same guard the resubscribe link uses. They asked for one\n * stream back, not for the newsletter and the promotions they also left,\n * and writing the opt-outs before the lift means there is no moment in\n * which the whole catalog is mailable.\n *\n * A pending confirmation is left pending. The preference page confirms by a\n * tick because the tick came through a link delivered to the mailbox; this\n * request came from the console, which is proof of an account, not of a\n * click in the mailbox the confirmation was sent to.\n *\n * A bounce, a complaint, an erasure or a staff hold is `held`, and nothing is\n * written: none of them is a preference, so no switch lifts them.\n */\nexport async function rejoinStreamForAccount(\n request: PluginEmailStreamRejoinRequest,\n firestore: any = firebaseAdmin.app().firestore(),\n): Promise<PluginEmailStreamRejoinResult> {\n const key = suppressionKeyFor(request.email)\n if (!key) return { status: 'held', reason: 'unusable-address' }\n const state = await readSubscriptionState(firestore, request.hostId, key)\n if (state.protectedRecord) {\n const suppression = await firestore\n .collection('hosts')\n .doc(request.hostId)\n .collection('suppressions')\n .doc(key)\n .get()\n return { status: 'held', reason: String(suppression.get('reason') ?? 'held') }\n }\n const email = String(request.email).trim().toLowerCase()\n if (!state.suppressed) {\n await writeTopicOptOuts(firestore, request.hostId, key, {\n email,\n optOut: [],\n resume: [request.topicId],\n confirmPending: false,\n })\n return { status: 'rejoined', releasedSuppression: false, keptLeft: 0 }\n }\n const others = activeEmailTopics(await loadTopicCatalog(firestore, request.hostId, true))\n .map((topic) => topic.id)\n .filter((id) => id !== request.topicId)\n await writeTopicOptOuts(firestore, request.hostId, key, {\n email,\n optOut: others,\n resume: [request.topicId],\n confirmPending: false,\n })\n if (!(await releaseSiteSuppression(firestore, request.hostId, key))) {\n // Turned into a bounce or a complaint between the read and the lift.\n return { status: 'held', reason: 'protected' }\n }\n return { status: 'rejoined', releasedSuppression: true, keptLeft: others.length }\n}\n\n/** One topic row: a checkbox, its name and its description. */\nfunction topicRow(\n topic: EmailTopic,\n checked: boolean,\n highlighted: boolean,\n /**\n * Asked to confirm and has not.\n *\n * The box is EMPTY for a pending topic, because empty is the truth: the\n * send path refuses this stream until it is confirmed, and a ticked box\n * over a stream nothing will send would be the page telling a lie the\n * recipient can only discover by waiting for mail that never comes. The\n * note beside it is what turns \"not ticked\" from a puzzle into an answer,\n * and ticking it here confirms — see `writeTopicOptOuts`.\n */\n pending = false,\n pal: EmailPalette = PAL,\n): string {\n return (\n `<label style=\"display:flex;gap:12px;align-items:flex-start;padding:14px 0;` +\n `border-top:1px solid ${PAL.divider};cursor:pointer\">` +\n `<input type=\"checkbox\" name=\"topic:${escapeHtml(topic.id)}\" value=\"on\"` +\n (checked ? ' checked' : '') +\n ' style=\"margin:2px 0 0;width:18px;height:18px;flex:none\">' +\n '<span style=\"flex:1\">' +\n `<span style=\"display:block;font-size:14px;font-weight:600;color:${PAL.ink}\">` +\n escapeHtml(topic.name) +\n (highlighted\n ? `<span style=\"margin-left:8px;font-size:11px;font-weight:600;` +\n `text-transform:uppercase;letter-spacing:.04em;color:${pal.link}\">` +\n 'This email</span>'\n : '') +\n '</span>' +\n (pending\n ? `<span style=\"display:block;margin-top:2px;font-size:13px;line-height:1.45;` +\n `color:${PAL.muted}\">Waiting for you to confirm — tick this and save ` +\n 'to start receiving it.</span>'\n : '') +\n (topic.description\n ? `<span style=\"display:block;margin-top:2px;font-size:13px;line-height:1.45;` +\n `color:${PAL.muted}\">${escapeHtml(topic.description)}</span>`\n : '') +\n '</span></label>'\n )\n}\n\n/**\n * HOW OFTEN — the half of the preference center that shipped without.\n *\n * `docs/specs/email-competitive-gaps.md` G10: the frequency CAP shipped and\n * this did not, so a recipient who wanted the same mail less often had two\n * options and one of them was the spam button.\n *\n * Radio buttons rather than a select, and every option written out. The whole\n * value of the control is that somebody skimming a footer link can see, in\n * one glance, that \"less\" is available at all — a collapsed select says only\n * that there is a setting.\n *\n * The default option is named (\"As they come\") rather than left as the empty\n * choice, because a radio group whose default is unlabeled reads as a\n * question the recipient has not answered, and answering it is not something\n * this page should require of somebody who came here to uncheck one box.\n */\nfunction cadenceFieldset(current: MarketingCadence): string {\n const option = (value: MarketingCadence, label: string): string =>\n `<label style=\"display:flex;gap:12px;align-items:center;padding:10px 0;cursor:pointer\">` +\n `<input type=\"radio\" name=\"cadence\" value=\"${escapeHtml(value)}\"` +\n (value === current ? ' checked' : '') +\n ' style=\"margin:0;width:18px;height:18px;flex:none\">' +\n `<span style=\"font-size:14px;color:${PAL.ink}\">${label}</span></label>`\n return (\n `<div style=\"border-top:1px solid ${PAL.divider};padding-top:18px;margin-top:6px\">` +\n `<div style=\"font-size:14px;font-weight:600;color:${PAL.ink};margin-bottom:2px\">` +\n 'How often' +\n '</div>' +\n `<div style=\"font-size:13px;line-height:1.45;color:${PAL.muted};margin-bottom:6px\">` +\n 'This applies to everything above. Nothing is canceled — messages just ' +\n 'wait until the next one is due.' +\n '</div>' +\n option('all', 'As they come') +\n option('daily', 'At most one a day') +\n option('weekly', 'At most one a week') +\n option('monthly', 'At most one a month') +\n '</div>'\n )\n}\n\n/** The preference page's body. */\nfunction preferencesFormBody(args: {\n email: string\n query: string\n topics: EmailTopic[]\n state: SubscriptionState\n topicId: string\n brand: EmailPageBrand\n}): string {\n const { email, query, topics, state, topicId, brand } = args\n const pal = brand.pal\n // A bounce or a complaint is not a preference, so the page does not pretend\n // the recipient can edit their way out of one. Shown instead of the form\n // rather than beside it: a form whose submit cannot take effect is worse\n // than no form.\n if (state.protectedRecord) return protectedAddressBody()\n const current = resolveCampaignTopic(topicId, topics)\n const action = `/api/email/preferences?${escapeHtml(query)}`\n return (\n heading('Email preferences') +\n paragraph(\n `Choose what <strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong> should keep receiving from ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n brand.name,\n )}</strong>. Unticked emails stop; everything else carries on.`,\n 8,\n ) +\n (state.suppressed\n ? paragraph(\n 'You are currently unsubscribed from everything. Tick anything ' +\n 'below to start receiving it again.',\n 8,\n )\n : '') +\n `<form method=\"post\" action=\"${action}\">` +\n topics\n .map((topic) =>\n topicRow(\n topic,\n // A whole-site suppression outranks the per-topic record, so an\n // unsubscribed recipient sees every box empty — which is the state\n // they are actually in, and the state the form must round-trip. An\n // unconfirmed topic is empty for the same reason: the send path\n // refuses it, so a ticked box would not be what is true.\n !state.suppressed &&\n !state.optedOut.has(topic.id) &&\n !state.pending.has(topic.id),\n topic.id === current.id,\n !state.suppressed && state.pending.has(topic.id),\n pal,\n ),\n )\n .join('') +\n /*\n * HOW OFTEN, inside the same form as WHAT.\n *\n * The alternative to letting somebody choose \"monthly\" is letting them\n * choose \"report spam\", and on a shared sending domain under `p=reject`\n * that choice is charged to every other tenant. It sits under the topics\n * because it is the smaller decision of the two and a recipient who has\n * already found the thing they wanted to stop should not have to read\n * past a frequency question to stop it.\n */\n cadenceFieldset(state.cadence) +\n `<div style=\"border-top:1px solid ${PAL.divider};padding-top:20px;margin-top:6px\">` +\n submitButton('Save my preferences', { pal }) +\n '</div></form>' +\n // A SECOND form, not a second button in the first one. Sharing the form\n // would submit the checkbox state along with the \"everything\" action, so a\n // browser that fell back to the first submit button — or a user pressing\n // Return in the form — would send an ambiguous request. Two forms make the\n // two intentions two requests.\n `<form method=\"post\" action=\"${action}\" style=\"margin-top:12px\">` +\n '<input type=\"hidden\" name=\"action\" value=\"all\">' +\n '<button type=\"submit\" style=\"font:inherit;font-size:13px;font-weight:600;' +\n `padding:10px 20px;border:1px solid ${PAL.divider};border-radius:8px;` +\n `background:transparent;color:${PAL.muted};cursor:pointer;width:100%\">` +\n 'Unsubscribe from everything</button></form>'\n )\n}\n\n/** What the result page tells the recipient actually changed. */\nfunction changeSummary(args: {\n email: string\n keep: string[]\n drop: EmailTopic[]\n topics: EmailTopic[]\n}): string {\n const address = `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n args.email,\n )}</strong>`\n if (!args.drop.length) {\n return `${address} keeps receiving everything this site sends.`\n }\n const names = args.drop\n .map((topic) => escapeHtml(topic.name))\n .join(', ')\n if (!args.keep.length) {\n return (\n `${address} has been unsubscribed from ${names} — everything this ` +\n 'site currently sends.'\n )\n }\n return `${address} will stop receiving ${names}, and keeps the rest.`\n}\n\n/**\n * `email/confirm` — the click that turns a pending subscription into a real\n * one (`docs/specs/email-competitive-gaps.md` P8).\n *\n * Same signed-link shape as its three siblings and the same safe-GET /\n * mutating-POST split, which matters here for exactly the reason it mattered\n * to the unsubscribe: a security gateway fetching every URL in the message\n * would otherwise confirm the subscription on the recipient's behalf, and a\n * confirmation nobody made is the one thing a double opt-in exists to\n * prevent. A prescanner following this link renders a page and changes\n * nothing.\n *\n * The subject it verifies is the confirmation form — see\n * `signedConfirmSubject` for why a topic without a campaign needs one — and\n * it is checked through the same comparison every other link goes through.\n */\nconst confirmHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n const params = readParams(req)\n const secret = linkSecret()\n if (!params.hostId || !params.email || !params.signature || !secret) {\n return void res.status(400).send('Invalid confirmation link')\n }\n if (!signatureMatches({ ...params, secret, purpose: 'confirm' })) {\n return void res.status(403).send('Invalid confirmation link')\n }\n if (!suppressionKeyFor(params.email)) {\n return void res.status(400).send('Invalid confirmation link')\n }\n const { hostId, email, topicId } = params\n const query = signedQuery(params)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const [catalog, brand] = await Promise.all([\n loadTopicCatalog(firestore, hostId),\n loadHostBrand(hostId),\n ])\n const topic = resolveCampaignTopic(topicId, catalog)\n\n if (method !== 'POST') {\n // SAFE. A prescanner lands here and confirms nothing.\n return void sendPage(\n res,\n page(\n heading('Confirm your subscription') +\n paragraph(\n `Confirm that <strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong> should receive ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n topic.name,\n )}</strong> from ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n brand.name,\n )}</strong>.`,\n ) +\n `<form method=\"post\" action=\"/api/email/confirm?${escapeHtml(\n query,\n )}\">` +\n submitButton('Yes, subscribe me', { pal: brand.pal }) +\n '</form>',\n 420,\n brand,\n ),\n )\n }\n\n const outcome = await confirmTopicSubscription(hostId, email, topicId)\n return void sendPage(\n res,\n page(confirmationBody(outcome, topic.name, brand.pal), 420, brand),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Confirmation failed — please try again')\n }\n}\n\n/**\n * What each outcome tells the person in front of it.\n *\n * Every arm names what is TRUE rather than what went wrong. Somebody who\n * clicked an expired link has not made a mistake, and somebody who clicked\n * twice has not either — telling either of them \"invalid\" would read as the\n * subscription having failed when the first case needs a fresh signup and the\n * second is already done.\n */\nfunction confirmationBody(\n outcome: ConfirmTopicResult,\n topicName: string,\n pal: EmailPalette = PAL,\n): string {\n const stream = `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n topicName,\n )}</strong>`\n switch (outcome) {\n case 'confirmed':\n return (\n successBadge(pal) +\n heading(\"You're subscribed\") +\n paragraph(`You'll start receiving ${stream} from this site.`, 0)\n )\n case 'already-confirmed':\n return (\n successBadge(pal) +\n heading('Already confirmed') +\n paragraph(`${stream} is already on its way to you.`, 0)\n )\n case 'expired':\n return (\n heading('This link has expired') +\n paragraph(\n `Confirmation links are good for three days. Sign up again and ` +\n `we'll send a fresh one — you are not subscribed to ${stream} in ` +\n 'the meantime.',\n 0,\n )\n )\n case 'opted-out':\n return (\n heading(\"Can't subscribe this address\") +\n paragraph(\n `This address asked to stop receiving ${stream} from this site, so ` +\n 'a confirmation link cannot put it back. Sign up again if that ' +\n 'was not what you meant.',\n 0,\n )\n )\n default:\n return (\n heading('Nothing to confirm') +\n paragraph(\n `There is no pending request for ${stream} at this address. If you ` +\n 'meant to subscribe, sign up on the site.',\n 0,\n )\n )\n }\n}\n\n/** Registers the email plugin's public API routes (AGL-396). */\nexport function registerEmailApi(): void {\n registerPluginApiRoute('email/unsubscribe', unsubscribeHandler)\n registerPluginApiRoute('email/resubscribe', resubscribeHandler)\n registerPluginApiRoute('email/preferences', preferencesHandler)\n registerPluginApiRoute('email/confirm', confirmHandler)\n}\n\n/*\n * The CONSOLE half of the same `email` prefix, kept in its own module.\n *\n * Two audiences, one entry point: the tenant loads this file for\n * `registerEmailApi` (the signed unsubscribe links a recipient clicks, no\n * session behind them), and the console loads it for\n * `registerEmailConsoleApi` (list membership, behind an org-wide role). The\n * manifest generator resolves both surfaces through `@aglyn/plugins-email/server`,\n * so this re-export is what makes the console half reachable — a second entry\n * point would be a second thing to keep in step with plugins.config.json.\n */\nexport {\n registerEmailConsoleApi,\n emailListMembersAddHandler,\n emailListMembersPreviewHandler,\n emailListRulePreviewHandler,\n CONSOLE_ADD_SOURCE,\n LIST_MEMBER_BATCH_MAX,\n} from './server-console'\n"],"names":["registerPluginApiRoute","activeEmailTopics","mergeEmailTopics","normalizeEmailTopic","readTopicSubscriptionState","resolveCampaignTopic","EMAIL_TOPICS_COLLECTION","TOPIC_OPT_OUTS_SUBCOLLECTION","confirmTopicSubscription","EMAIL_FREQUENCY_SUBCOLLECTION","firebaseAdmin","mirrorPlatformResubscribe","mirrorPlatformUnsubscribe","resolveCampaignSendRef","resolveOrgIdForHost","setMarketingCadence","UNSUBSCRIBE_SUPPRESSION_REASON","stampRecordEmailState","normalizeMarketingCadence","escapeHtml","FieldValue","heading","isCampaignPathId","page","paragraph","PAL","PLATFORM_EMAIL_BRAND","readParams","resolveEmailPageBrand","sendPage","signatureMatches","signedQuery","submitButton","successBadge","suppressionKeyFor","linkSecret","process","env","EMAIL_UNSUBSCRIBE_SECRET","CRON_SECRET","openSignedLink","req","params","secret","refuse","status","refusal","key","hostId","email","signature","BRAND_READ_TIMEOUT_MS","loadHostBrand","timer","firestore","app","snapshot","Promise","race","collection","doc","get","resolve","setTimeout","exists","data","$id","error","console","clearTimeout","unsubscribeHandler","res","method","String","toUpperCase","setHeader","send","opened","campaignId","topicId","query","brand","ink","name","pal","link","created","all","writeSiteSuppression","countSendUnsubscribe","left","orgId","sendRef","sendId","update","increment","fields","ref","runTransaction","transaction","existing","set","reason","suppressedAt","serverTimestamp","createdAt","merge","state","atMs","Date","now","source","detail","resubscribeHandler","accent","released","releaseSiteSuppression","protectedAddressBody","via","delete","preferencesHandler","body","catalog","loadTopicCatalog","readSubscriptionState","topics","preferencesFormBody","keep","Set","map","topic","id","filter","drop","has","writeTopicOptOuts","optOut","resume","cadence","cadenceStored","stillBlocked","size","length","changeSummary","cadenceSentence","strict","stored","docs","optOuts","hostRef","suppression","frequency","catch","optedOut","pending","record","Object","entries","add","suppressed","protectedRecord","previous","optedOutAt","resubscribedAt","confirmPending","confirmedAt","updatedAt","rejoinStreamForAccount","request","trim","toLowerCase","releasedSuppression","keptLeft","others","topicRow","checked","highlighted","divider","muted","description","cadenceFieldset","current","option","value","label","args","action","join","address","names","confirmHandler","purpose","outcome","confirmationBody","topicName","stream","registerEmailApi","registerEmailConsoleApi","emailListMembersAddHandler","emailListMembersPreviewHandler","emailListRulePreviewHandler","CONSOLE_ADD_SOURCE","LIST_MEMBER_BATCH_MAX"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/email/src/lib/server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { registerPluginApiRoute, type PluginApiHandler } from '@aglyn/aglyn/server'\n/*\n * The MODULE, not the barrel. `@aglyn/aglyn` re-exports the app-utils index,\n * which reaches `enabled-plugins-context` and therefore React — and this file\n * is loaded by the plugin API route's SERVER graph, where a client-only\n * module is a bundle `app-router-graph.spec.ts` refuses. Every name here is a\n * pure function or a constant that lives in one leaf file.\n */\nimport {\n activeEmailTopics,\n mergeEmailTopics,\n normalizeEmailTopic,\n readTopicSubscriptionState,\n resolveCampaignTopic,\n EMAIL_TOPICS_COLLECTION,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type EmailTopic,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n consentGroupOptOutHosts,\n soloConsentGroup,\n type ConsentGroup,\n} from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n confirmTopicSubscription,\n consentGroupForSite,\n EMAIL_FREQUENCY_SUBCOLLECTION,\n firebaseAdmin,\n mirrorPlatformResubscribe,\n mirrorPlatformUnsubscribe,\n resolveCampaignSendRef,\n resolveOrgIdForHost,\n setMarketingCadence,\n UNSUBSCRIBE_SUPPRESSION_REASON,\n type ConfirmTopicResult,\n} from '@aglyn/tenant-data-admin'\nimport type {\n PluginEmailStreamRejoinRequest,\n PluginEmailStreamRejoinResult,\n} from '@aglyn/aglyn/plugin-manager/plugin-email-streams'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\n/*\n * The pure cadence rule from the shared email library, where the SEND path\n * reads it too. The preference page and the gate must agree about what\n * `'weekly'` means down to the coercion of a malformed value, and two copies\n * of that is how a page comes to record a choice the gate does not recognize.\n */\nimport {\n normalizeMarketingCadence,\n type MarketingCadence,\n} from '@aglyn/shared-util-email'\nimport { escapeHtml } from '@aglyn/shared-util-tools/escape-html'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n heading,\n isCampaignPathId,\n page,\n paragraph,\n PAL,\n PLATFORM_EMAIL_BRAND,\n readParams,\n resolveEmailPageBrand,\n sendPage,\n signatureMatches,\n signedQuery,\n submitButton,\n successBadge,\n suppressionKeyFor,\n type EmailBrandSource,\n type EmailPageBrand,\n type EmailPalette,\n type UnsubscribeLinkParams,\n} from './unsubscribe-link'\n\n/**\n * One-click unsubscribe (AGL-161), split into a safe GET and a mutating POST\n * (AGL-2408), with a preference center in front of the human-facing half.\n *\n * ## Why the GET stopped writing\n *\n * This handler used to write the suppression on GET, and the docblock called\n * that a feature: \"GET so it works from any mail client; idempotent.\"\n * Idempotent is not the property that matters. A GET must be SAFE — free of\n * side effects the user did not ask for — and this one was not.\n *\n * Every mail client and security gateway of consequence (Microsoft Defender\n * for Office 365's Safe Links, Google's own scanners, Proofpoint, Mimecast)\n * FETCHES every URL in a message before the recipient ever sees it, to check\n * where it lands. Each of those fetches silently unsubscribed the recipient\n * from that merchant's list. The recipient never clicked anything; the\n * merchant sees their audience shrink and cannot explain it; and until\n * AGL-2410 there was no screen in the product to even discover it, let alone\n * undo it. That is a customer's marketing list being destroyed by a\n * prescanner, on our side of the line.\n *\n * So: GET renders a page carrying a same-URL POST form, and only the POST\n * writes. A prescanner following any of these three links now renders a page\n * and changes nothing. That property is not negotiable and every handler in\n * this file holds it.\n *\n * ## RFC 8058 one-click\n *\n * Gmail's and Yahoo's bulk-sender rules ask for `List-Unsubscribe` PLUS\n * `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, and a client honoring\n * that pair sends a POST to the header URL with `List-Unsubscribe=One-Click`\n * as an `application/x-www-form-urlencoded` body. `unsubscribeHandler`'s POST\n * branch is exactly what that lands on — which is why the two halves had to be\n * fixed together: turning the GET into a confirmation page without accepting\n * POST would have broken unsubscribe outright, and advertising one-click while\n * the only mutating verb was GET would have been the same bug with a header on\n * top.\n *\n * THE PREFERENCE CENTER IS NOT IN THAT PATH, and must never be. The\n * `List-Unsubscribe` header still names `email/unsubscribe`, whose POST acts\n * immediately with no page in between; the preference center is what the\n * FOOTER link in the message body points at, where a human is present to make\n * a choice. Routing the header at a page of checkboxes would be advertising\n * one-click against a surface that cannot honor it — a mailbox provider POSTs\n * that URL with nobody watching, reads a 200, and reports the recipient\n * unsubscribed when nothing was written.\n *\n * The one-click POST carries no `Origin` header (it is sent by the mailbox\n * provider's servers, not a browser), which the dispatcher's same-origin gate\n * deliberately allows; the forms' POSTs are same-origin. Neither needs a CSRF\n * token beyond the HMAC already in the URL: a caller who cannot produce `sig`\n * cannot unsubscribe anyone, and a caller who can is holding the recipient's\n * own mail.\n *\n * ## No `mailto:` variant, and why that is a deliberate hole\n *\n * RFC 8058 also permits a `mailto:` fallback in the header. Adding one now\n * would point recipients at an address nobody reads — `docs/EMAIL_SETUP.md`\n * lists a monitored `hello@aglyn.com` as an unstarted idea — and an\n * unsubscribe request that lands in an unmonitored inbox is worse than no\n * fallback at all, because the recipient believes they have unsubscribed. It\n * needs a mailbox and an inbound route, which is provider setup rather than\n * repo work.\n */\n\n/** Read from both verbs; the secret the link was signed with. */\nfunction linkSecret(): string {\n return process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.CRON_SECRET || ''\n}\n\n/**\n * Params + suppression key, or the status to answer with.\n *\n * Every one of these routes opens the same way — read, check the secret is\n * configured, verify the HMAC, key the address — and every one of them has to\n * do it before touching Firestore. A shared preamble is also what keeps the\n * three from drifting into three slightly different ideas of a valid link.\n */\ninterface OpenedLink {\n /**\n * The status to answer with, or 0 when the link is good.\n *\n * A refusal CODE rather than a discriminated union: this library compiles\n * with `strictNullChecks: false`, under which a `{ok: true} | {ok: false}`\n * union does not narrow on a truthiness check, so the union shape would\n * type-error at every call site that read the status.\n */\n refusal: number\n params: UnsubscribeLinkParams\n key: string\n}\n\nfunction openSignedLink(req: Parameters<PluginApiHandler>[0]): OpenedLink {\n const params = readParams(req)\n const secret = linkSecret()\n const refuse = (status: number): OpenedLink => ({\n refusal: status,\n params,\n key: '',\n })\n if (!params.hostId || !params.email || !params.signature || !secret) {\n return refuse(400)\n }\n if (!signatureMatches({ ...params, secret })) return refuse(403)\n // `personKey` refuses a value that is not an address rather than hashing it,\n // so a signed link naming a malformed address is a bad link and not a\n // suppression document for a person who does not exist.\n const key = suppressionKeyFor(params.email)\n if (!key) return refuse(400)\n return { refusal: 0, params, key }\n}\n\n/**\n * How long the shell will wait for the sending site's identity.\n *\n * A branded page is worth one host read; it is not worth a page that never\n * arrives. These four routes are the recipient's only way to stop the mail, so\n * an unbranded page rendered promptly beats a correct one that hangs behind a\n * slow read — the timeout falls back rather than failing.\n */\nconst BRAND_READ_TIMEOUT_MS = 1500\n\n/**\n * The SENDING SITE's identity for the shell, not ours.\n *\n * One read of `hosts/{hostId}`, and every failure mode lands on the same\n * answer: no host id, a missing document, a read that throws, a read that is\n * slow, or a host that has simply set no brand all resolve to\n * {@link PLATFORM_EMAIL_BRAND}. That is also the self-host answer, so the\n * fallback path is the one an operator runs every day rather than a branch\n * only reached when something is broken.\n */\nasync function loadHostBrand(hostId: string): Promise<EmailPageBrand> {\n if (!hostId) return PLATFORM_EMAIL_BRAND\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n const firestore = firebaseAdmin.app().firestore()\n const snapshot = await Promise.race([\n firestore.collection('hosts').doc(hostId).get(),\n new Promise<null>((resolve) => {\n timer = setTimeout(() => resolve(null), BRAND_READ_TIMEOUT_MS)\n }),\n ])\n if (!snapshot?.exists) return PLATFORM_EMAIL_BRAND\n // The id LAST: it addresses the `media:` logo reference, and the copy of\n // it stored in the document is the one that can be stale or absent.\n return resolveEmailPageBrand({\n ...(snapshot.data() as EmailBrandSource),\n $id: hostId,\n })\n } catch (error) {\n console.error('[email] host brand read failed', error)\n return PLATFORM_EMAIL_BRAND\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\n/**\n * THE SENDER A PERSON LEAVES HERE — the link site's consent group\n * (`consent-groups.ts`), or the site alone.\n *\n * An org may declare several sites ONE sender, and every capture form in the\n * group named it as one. So these pages treat it as one: an opt-out made here\n * is read by every site in it (the send paths read across the group), the\n * state shown is the group's, a way back in lifts the group's records, and\n * the page names the group rather than one of its sites.\n *\n * `consentGroupForSite` fails to the site alone, and so does this when it\n * throws. `waitMs` bounds the wait where a page only NAMES the sender, for the\n * reason {@link BRAND_READ_TIMEOUT_MS} gives; a page deciding what to read or\n * lift waits for the real answer.\n */\nasync function loadConsentGroup(\n hostId: string,\n waitMs?: number,\n): Promise<ConsentGroup> {\n const alone = soloConsentGroup(hostId)\n const resolving = (async () => {\n try {\n return await consentGroupForSite(hostId)\n } catch (error) {\n console.error('[email] consent group read failed', error)\n return alone\n }\n })()\n if (!waitMs) return resolving\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n return await Promise.race([\n resolving,\n new Promise<ConsentGroup>((resolve) => {\n timer = setTimeout(() => resolve(alone), waitMs)\n }),\n ])\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\n/** How the pages name the sender, escaped for HTML. */\ninterface EmailPageSender {\n /** The group's name for a declared group, else the site's brand. */\n name: string\n /**\n * The sentence that says an act here covers every site in the group, or\n * `''` for a site alone, where the brand already says everything.\n */\n reach: string\n}\n\n/**\n * The sender as a page names it.\n *\n * The group's name is what the capture form disclosed — \"You'll receive\n * marketing email from\" the group — so it is the name the person knows this\n * sender by, and the one an opt-out here reaches. The site's own brand still\n * frames the page; `reach` is what stops the two names reading as a mistake.\n */\nfunction pageSender(brand: EmailPageBrand, group: ConsentGroup): EmailPageSender {\n if (!group.declared || !group.name) {\n return { name: escapeHtml(brand.name), reach: '' }\n }\n return {\n name: escapeHtml(group.name),\n reach:\n `${escapeHtml(group.name)} sends from ${group.hostIds.length} sites, ` +\n `${escapeHtml(brand.name)} among them, and this covers all of them.`,\n }\n}\n\n/** The `reach` sentence as a paragraph, or nothing for a site alone. */\nfunction reachParagraph(sender: EmailPageSender, gap = 20): string {\n return sender.reach ? paragraph(sender.reach, gap) : ''\n}\n\nconst unsubscribeHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n const opened = openSignedLink(req)\n if (opened.refusal) {\n return void res.status(opened.refusal).send('Invalid unsubscribe link')\n }\n const { params, key } = opened\n const { hostId, email, campaignId, topicId } = params\n const query = signedQuery(params)\n\n if (method !== 'POST') {\n // SAFE. A prescanner lands here and nothing is written — the brand and\n // the consent group are the only Firestore access on this path, and both\n // are reads.\n const [brand, group] = await Promise.all([\n loadHostBrand(hostId),\n loadConsentGroup(hostId, BRAND_READ_TIMEOUT_MS),\n ])\n const sender = pageSender(brand, group)\n return void sendPage(\n res,\n page(\n heading('Unsubscribe?') +\n paragraph(\n `Confirm that <strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong> should stop receiving emails from ` +\n `<strong style=\"color:${PAL.ink}\">${sender.name}</strong>.`,\n sender.reach ? 8 : 24,\n ) +\n reachParagraph(sender, 24) +\n `<form method=\"post\" action=\"/api/email/unsubscribe?${escapeHtml(\n query,\n )}\">` +\n submitButton('Unsubscribe', { pal: brand.pal }) +\n '</form>' +\n // The way to a NARROWER choice, offered on the page rather than only\n // in the message footer: a recipient who reached the total\n // unsubscribe from a mail client's own link has never been shown\n // that leaving one stream is possible.\n `<p style=\"margin:16px 0 0;font-size:13px;line-height:1.5;text-align:center\">` +\n `<a href=\"/api/email/preferences?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'Choose which emails to stop instead</a></p>',\n 420,\n brand,\n ),\n )\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n /*\n * Written against the ONE site the link names, and honored by every site\n * in its consent group: the send paths read a group's lists together, so\n * the one row is the refusal for the whole sender — and it reaches a site\n * that joins the group later, which a copy written now could not.\n */\n const [created, brand, group] = await Promise.all([\n writeSiteSuppression(firestore, hostId, key, {\n email,\n campaignId,\n topicId,\n }),\n loadHostBrand(hostId),\n loadConsentGroup(hostId, BRAND_READ_TIMEOUT_MS),\n ])\n const sender = pageSender(brand, group)\n\n /*\n * The campaign's own unsubscribe count.\n *\n * AFTER the suppression and with its failure swallowed, for the reason\n * the delivery webhook orders its writes the same way: the suppression is\n * the write that must happen, and a statistic must never be able to cost\n * one. A lost increment understates an unsubscribe rate; a lost\n * suppression mails somebody who asked us not to. Where the send is, and\n * why the write never creates it, is {@link countSendUnsubscribe}'s.\n */\n if (created) await countSendUnsubscribe(firestore, hostId, campaignId)\n // The account's answer about product updates, when this is the\n // platform's own marketing site (AGL-3305). After the suppression, and it\n // never throws: the list is what stops the mail.\n await mirrorPlatformUnsubscribe({ hostId, email, left: 'everything' })\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading(\"You're unsubscribed\") +\n paragraph(\n `You won't receive further emails from ${sender.name}.`,\n sender.reach ? 8 : 20,\n ) +\n reachParagraph(sender) +\n // Same signed params, so the click that just proved this is really\n // this recipient's link doubles as the resubscribe link — no new\n // token, no second email round-trip (AGL-2499).\n `<a href=\"/api/email/resubscribe?${escapeHtml(query)}\" ` +\n `style=\"font-size:13px;color:${brand.pal.link};text-decoration:none\">` +\n 'Changed your mind? Resubscribe</a>',\n 420,\n brand,\n ),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Unsubscribe failed — please try again')\n }\n}\n\n/**\n * One more unsubscribe on the send a link named (`cid`), never throwing.\n *\n * The link carries the site and the send id it was signed over, and nothing\n * else; the send itself is the organization's\n * (`orgs/{orgId}/campaigns/{sendId}`), or still the site's when the\n * migration has not reached it, and `resolveCampaignSendRef` finds whichever\n * holds it. A send in neither place was discarded, and there is nothing to\n * count against.\n *\n * `update()`, never a merge-set: a merge-set would re-create a send deleted\n * between the resolve and the write as a husk holding one `stats` map, and\n * `update()` refuses a missing document — the count for a send nobody can\n * open has no reader. Every failure is swallowed, because the suppression\n * has already been written and a statistic must never be able to cost one.\n */\nasync function countSendUnsubscribe(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n campaignId: string,\n): Promise<void> {\n if (!isCampaignPathId(campaignId)) return\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n const sendRef = await resolveCampaignSendRef({\n hostId,\n sendId: campaignId,\n orgId,\n firestore,\n })\n await sendRef?.update({ 'stats.unsubscribes': FieldValue.increment(1) })\n } catch {\n // The suppression is the write that mattered, and it has landed.\n }\n}\n\n/**\n * The whole-site suppression, written the same way from both routes that\n * write one.\n *\n * `reason: 'unsubscribe'` is written explicitly (AGL-2410). The Resend webhook\n * stamps `'bounce'` / `'complaint'`, and until now an unsubscribe was the only\n * entry with no reason at all — so a reader had to infer one from an absent\n * field, which is a rule that holds only while nothing else ever forgets to\n * write it.\n *\n * `createdAt` is written only when the document is new, matching\n * `email-events.ts`: a bounce arriving after an unsubscribe must not restamp\n * the date the person actually unsubscribed, and neither must a second click\n * on the same link.\n *\n * @returns WHETHER THIS CLICK CREATED THE SUPPRESSION, decided inside the\n * transaction and used outside it. It is the idempotency the campaign\n * counter needs, and it comes for free because the transaction\n * already reads the document to decide whether to stamp `createdAt`.\n * A second click on the same link — and there will be second clicks,\n * from a person pressing the button twice and from a client\n * re-POSTing a one-click header — finds the entry present and\n * contributes nothing, so `stats.unsubscribes` counts PEOPLE who left\n * rather than button presses.\n */\nasync function writeSiteSuppression(\n firestore: any,\n hostId: string,\n key: string,\n fields: { email: string; campaignId: string; topicId: string },\n): Promise<boolean> {\n const ref = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppressions')\n .doc(key)\n /*\n * Assigned rather than or-ed inside the body because a Firestore\n * transaction may retry, and the reading that counts is the one whose write\n * committed.\n */\n let created = false\n await firestore.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n created = !existing.exists\n transaction.set(\n ref,\n {\n email: fields.email,\n reason: UNSUBSCRIBE_SUPPRESSION_REASON,\n suppressedAt: FieldValue.serverTimestamp(),\n // WHICH mailing they left over. Written on the suppression itself as\n // well as counted on the campaign, so the Suppressions list can answer\n // \"why did this person go\" for one address without the aggregate — and\n // stamped only when this click created the entry, so a re-click cannot\n // re-attribute an old unsubscribe to whatever link the person happened\n // to press second. `topicId` rides along on the same rule: it is the\n // stream that lost them, which is the finer half of the same question.\n ...(existing.exists\n ? {}\n : {\n createdAt: FieldValue.serverTimestamp(),\n ...(fields.campaignId ? { campaignId: fields.campaignId } : {}),\n ...(fields.topicId ? { topicId: fields.topicId } : {}),\n }),\n },\n { merge: true },\n )\n })\n // The same verdict on the record the person is (AGL-3245), through\n // whichever plugin keeps the workspace's records, so the record page says\n // they left. A stamp that failed is logged by the seam and costs the\n // unsubscribe nothing.\n if (created) {\n await stampRecordEmailState({\n hostId,\n email: fields.email,\n state: {\n status: 'unsubscribed',\n atMs: Date.now(),\n source: 'campaign',\n detail: fields.campaignId ? 'Unsubscribed from a campaign email.' : 'Unsubscribed by the link.',\n },\n })\n }\n return created\n}\n\n/**\n * The self-service way back in (AGL-2499) that `unsubscribeHandler` never\n * had: same signed-link shape, same safe-GET/mutating-POST split, same\n * HMAC — a resubscribe link is only as trustworthy as the unsubscribe link\n * it rides in on, so it earns no looser a contract.\n *\n * Reverses ONLY a self-service unsubscribe (`reason: 'unsubscribe'`). A\n * bounce or spam-complaint suppression (`email-events.ts`'s Resend webhook)\n * protects the SENDER's deliverability, not a preference the recipient can\n * waive by clicking a link — undoing one from here would let anyone who\n * still holds an old campaign email re-arm sending to an address that\n * bounced or complained.\n */\nconst resubscribeHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n // The SAME verifier the unsubscribe uses, because this link is minted by\n // handing the unsubscribe's own signed query to a second route. Two\n // implementations of one signature scheme is how the resubscribe link comes\n // to reject a signature the unsubscribe link just accepted.\n const opened = openSignedLink(req)\n if (opened.refusal) {\n return void res.status(opened.refusal).send('Invalid link')\n }\n const { params, key } = opened\n const { hostId, email } = params\n const query = signedQuery(params)\n\n if (method !== 'POST') {\n // SAFE, same reasoning as the unsubscribe GET: a prescanner must not be\n // able to resubscribe someone either.\n const [brand, group] = await Promise.all([\n loadHostBrand(hostId),\n loadConsentGroup(hostId, BRAND_READ_TIMEOUT_MS),\n ])\n const sender = pageSender(brand, group)\n return void sendPage(\n res,\n page(\n heading('Resubscribe?') +\n paragraph(\n `Start receiving emails from <strong style=\"color:${PAL.ink}\">` +\n `${sender.name}</strong> again at ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong>.`,\n sender.reach ? 8 : 24,\n ) +\n reachParagraph(sender, 24) +\n `<form method=\"post\" action=\"/api/email/resubscribe?${escapeHtml(\n query,\n )}\">` +\n submitButton('Resubscribe', { accent: 'link', pal: brand.pal }) +\n '</form>',\n 420,\n brand,\n ),\n )\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n // The group decides which rows are lifted, so it is waited for in full.\n const groupRead = loadConsentGroup(hostId)\n const [released, brand, group] = await Promise.all([\n groupRead.then((resolved) =>\n releaseSiteSuppression(firestore, resolved, key),\n ),\n loadHostBrand(hostId),\n groupRead,\n ])\n if (!released) {\n return void sendPage(res, page(protectedAddressBody(), 420, brand))\n }\n // Restores the account's Yes only when an email door took it away and\n // product updates now reach them (AGL-3305).\n await mirrorPlatformResubscribe({ hostId, email, via: 'email-resubscribe' })\n const sender = pageSender(brand, group)\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading(\"You're resubscribed\") +\n paragraph(\n `You'll receive emails from ${sender.name} again.`,\n sender.reach ? 8 : 0,\n ) +\n reachParagraph(sender, 0),\n 420,\n brand,\n ),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Resubscribe failed — please try again')\n }\n}\n\n/**\n * Lift a whole-site suppression, or refuse to.\n *\n * THE ONE RULE THAT IS NOT A PREFERENCE. A `bounce` or `complaint` entry says\n * the mailbox is dead or its owner pressed \"report spam\", and neither is a\n * setting the person on the other end of a link may change. Both are the\n * sending domain's protection — one shared domain under `p=reject` for every\n * tenant — so honoring a resubscribe over one would be handing the recipient a\n * lever on somebody else's deliverability. Every path that puts an address\n * back in circulation goes through here so that there is exactly one place the\n * rule is stated.\n *\n * ## Across the consent group, all or nothing\n *\n * The send paths read every site's list in the link site's consent group, so\n * an unsubscribe filed on a sibling holds this site's mail too — and a way\n * back in that lifted only this site's row would tell the person they were\n * resubscribed while the sender went on withholding. Every site's row is\n * therefore read first, and the release happens only if none of them is a\n * record this rule may not lift: one bounce or complaint anywhere in the\n * group still holds the whole group, so lifting the unsubscribes beside it\n * would change nothing the person could see, and the page says so instead.\n *\n * @returns false when a record was left standing because it is not an\n * unsubscribe.\n */\nasync function releaseSiteSuppression(\n firestore: any,\n group: ConsentGroup,\n key: string,\n): Promise<boolean> {\n const [own, ...siblings] = consentGroupOptOutHosts(group).map((id) =>\n firestore.collection('hosts').doc(id).collection('suppressions').doc(key),\n )\n const [ownSnapshot, ...siblingSnapshots] = await Promise.all(\n [own, ...siblings].map((ref) => ref.get()),\n )\n if (\n [ownSnapshot, ...siblingSnapshots].some(\n (snapshot) =>\n snapshot.exists &&\n snapshot.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON,\n )\n ) {\n return false\n }\n // Idempotent whether or not a doc existed — a resubscribe click on an\n // address that was never suppressed (or already resubscribed) is not an\n // error, it is the state the visitor wanted. A sibling's row is deleted\n // only where one stands.\n await Promise.all([\n own.delete(),\n ...siblings\n .filter((_ref, index) => siblingSnapshots[index].exists)\n .map((ref) => ref.delete()),\n ])\n return true\n}\n\n/** Shown wherever a resubscribe is refused, so the wording is one wording. */\nfunction protectedAddressBody(): string {\n return (\n heading(\"Can't resubscribe this address\") +\n paragraph(\n 'This address was suppressed by a delivery problem, not an ' +\n 'unsubscribe, so it can’t be re-added from this link. Contact ' +\n 'the site directly if this looks wrong.',\n 0,\n )\n )\n}\n\n/**\n * THE PREFERENCE CENTER — the page the message footer links to.\n *\n * ## What it is for\n *\n * `docs/specs/email-competitive-gaps.md` §1f: every product compared has a\n * preference center and we had one lever, marked all-or-nothing. The cost of\n * that is not a missing feature, it is a misdirected one — the recipient who\n * only wanted the sales mail to stop had to stop everything, and the recipient\n * who did not want to stop everything pressed \"report spam\" instead, which is\n * a complaint on a shared sending domain.\n *\n * ## What it may show, and what it must not\n *\n * Reached with no session, by anyone holding the link. The HMAC is what\n * authorizes it, and it covers exactly the host, the address, the campaign and\n * the topic — so the page shows the org's topic CATALOG and this address's\n * opt-out state against it, and nothing else. Not the contact record, not the\n * lists they are on, not their name, not whether we have ever heard of them.\n *\n * It is not an enumeration oracle for two reasons that both have to hold. A\n * caller cannot ask about an address they do not already hold a signed link\n * for; and the page renders IDENTICALLY for an address with no records at all\n * — an unknown address reads as \"subscribed to everything\", which is both the\n * truthful answer and the one that reveals nothing. There is deliberately no\n * \"we don't have that address\" branch, because that branch is the oracle.\n */\nconst preferencesHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n const opened = openSignedLink(req)\n if (opened.refusal) {\n return void res.status(opened.refusal).send('Invalid preferences link')\n }\n const { params, key } = opened\n const { hostId, email, campaignId, topicId } = params\n const query = signedQuery(params)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n // Independent reads, so they go together rather than in series — the\n // brand is not worth another round trip on the page a recipient is\n // waiting for. The state is the consent GROUP's, so it waits on the group.\n const groupRead = loadConsentGroup(hostId)\n const [catalog, state, brand, group] = await Promise.all([\n loadTopicCatalog(firestore, hostId),\n groupRead.then((resolved) =>\n readSubscriptionState(firestore, resolved, key),\n ),\n loadHostBrand(hostId),\n groupRead,\n ])\n const topics = activeEmailTopics(catalog)\n const sender = pageSender(brand, group)\n\n if (method !== 'POST') {\n // SAFE. Reads only, exactly like the other two GETs.\n return void sendPage(\n res,\n page(\n preferencesFormBody({\n email,\n query,\n topics,\n state,\n topicId,\n brand,\n sender,\n }),\n 520,\n brand,\n ),\n )\n }\n\n const body = (req.body ?? {}) as Record<string, unknown>\n if (String(body['action'] ?? '') === 'all') {\n const created = await writeSiteSuppression(firestore, hostId, key, {\n email,\n campaignId,\n topicId,\n })\n if (created) await countSendUnsubscribe(firestore, hostId, campaignId)\n await mirrorPlatformUnsubscribe({ hostId, email, left: 'everything' })\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading('Sorry to see you go') +\n paragraph(\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(email)}</strong> ` +\n 'has been unsubscribed from every email ' +\n `${sender.name} sends.`,\n sender.reach ? 8 : 20,\n ) +\n reachParagraph(sender) +\n paragraph(\n 'Changed your mind? ' +\n `<a href=\"/api/email/resubscribe?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'Resubscribe</a>, or ' +\n `<a href=\"/api/email/preferences?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'pick just the emails you want</a>.',\n 0,\n ),\n 420,\n brand,\n ),\n )\n }\n\n /*\n * A CHECKED BOX MEANS \"KEEP SENDING\", so the opt-outs are the complement.\n *\n * Read off the catalog rather than off the form, deliberately. A browser\n * submits nothing at all for an unchecked box, so a form that named only\n * the boxes to TURN OFF would be indistinguishable from a form where the\n * recipient turned everything off — and the two mean opposite things.\n */\n const keep = new Set(\n topics\n .map((topic) => topic.id)\n .filter((id) => String(body[`topic:${id}`] ?? '') !== ''),\n )\n const drop = topics.filter((topic) => !keep.has(topic.id))\n await writeTopicOptOuts(firestore, hostId, key, {\n email,\n optOut: drop.map((topic) => topic.id),\n resume: [...keep],\n })\n /*\n * A ticked box is a stream the person wants back FROM THE SENDER, and the\n * send paths read an opt-out on any site of the consent group — so an\n * opt-out a sibling holds is lifted too, or the box would be a choice the\n * send path goes on refusing. The unticked ones are written here alone:\n * this site's record is read across the group already.\n */\n await resumeTopicsAcrossGroup(firestore, group, key, [...keep])\n\n /*\n * HOW OFTEN, recorded from the same submit as WHAT.\n *\n * They are one decision — \"less of this, and less often\" — so they are\n * one form and one round trip. It is stored on the send counter rather\n * than beside the topic opt-outs because that is the document the send\n * path already reads for every marketing message, which is what makes\n * honoring the request free at the point it has to be honored.\n *\n * A value that is not a cadence lands on `'all'` rather than erroring:\n * this page is reached with no session by anybody holding the link, so\n * `body` is untrusted, and the failure a recipient must not meet on the\n * screen they came to in order to leave is a 500.\n */\n const cadence = normalizeMarketingCadence(body['cadence'])\n const cadenceStored = await setMarketingCadence(hostId, email, cadence)\n\n /*\n * A person asking for SOME mail is asking not to be suppressed from ALL of\n * it, so a whole-site unsubscribe standing against this address is lifted\n * — through the same guard the resubscribe route uses, which refuses to\n * touch a bounce or a complaint. Without this the page would accept a\n * choice it could not honor: every box ticked, and the send path still\n * dropping the address at the site suppression one layer above topics.\n */\n let stillBlocked = false\n if (keep.size) {\n stillBlocked = !(await releaseSiteSuppression(firestore, group, key))\n }\n\n /*\n * The account's answer about product updates follows what this page just\n * wrote (AGL-3305), and only on the platform's own marketing site: a No\n * when product updates is among what they left, and a Yes back only when\n * an email door took it and the list is open again. Leaving the\n * newsletter says nothing about product updates, so neither call does\n * anything for it. After every write above, so both read the outcome.\n */\n if (drop.length) {\n await mirrorPlatformUnsubscribe({\n hostId,\n email,\n left: drop.map((topic) => topic.id),\n })\n }\n if (keep.size) {\n await mirrorPlatformResubscribe({ hostId, email, via: 'email-preferences' })\n }\n\n return void sendPage(\n res,\n page(\n successBadge(brand.pal) +\n heading(drop.length ? 'Sorry to see you go' : 'Preferences saved') +\n paragraph(\n changeSummary({ email, keep: [...keep], drop, topics, sender }),\n cadence === 'all' && cadenceStored && !sender.reach ? 20 : 8,\n ) +\n reachParagraph(sender, cadence === 'all' && cadenceStored ? 20 : 8) +\n /*\n * The pace is reported only when it is a CHOICE. \"As they come\" is\n * the default and the absence, so announcing it would tell somebody\n * who touched nothing that they had just asked for something.\n */\n (cadence !== 'all' && cadenceStored\n ? paragraph(\n `They will arrive no more than ${cadenceSentence(cadence)}.`,\n 20,\n )\n : '') +\n (!cadenceStored\n ? paragraph(\n 'One thing we could not change: how often these arrive. Your ' +\n 'other choices are saved — come back to this page to try ' +\n 'that one again.',\n 20,\n )\n : '') +\n (stillBlocked\n ? paragraph(\n 'One thing we could not change: this address is on hold ' +\n 'because an earlier message could not be delivered or was ' +\n 'reported as spam. Contact the site directly if that looks ' +\n 'wrong.',\n 20,\n )\n : '') +\n paragraph(\n 'Changed your mind? ' +\n `<a href=\"/api/email/preferences?${escapeHtml(query)}\" ` +\n `style=\"color:${brand.pal.link};text-decoration:none\">` +\n 'Come back to this page</a> and tick the boxes again — this ' +\n 'link keeps working.',\n 0,\n ),\n 520,\n brand,\n ),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Preferences failed — please try again')\n }\n}\n\n/**\n * The org's topic catalog for a site.\n *\n * Two reads, both fail-soft to the built-in defaults. A site with no owning\n * org, an org with no stored topics, or a Firestore hiccup all land on the\n * same page: the four built-ins, every box ticked. That is the right failure —\n * a preference page that renders NO topics offers the recipient nothing to\n * uncheck, which turns the one screen they came to in order to leave a stream\n * into a dead end.\n */\nasync function loadTopicCatalog(\n firestore: any,\n hostId: string,\n /**\n * Throw instead of falling back. For a caller about to lift a whole-site\n * unsubscribe on the strength of the list, where the built-ins alone would\n * leave every custom stream mailable (`rejoinStreamForAccount`).\n */\n strict = false,\n): Promise<EmailTopic[]> {\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n if (!orgId) return mergeEmailTopics(null)\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(EMAIL_TOPICS_COLLECTION)\n .get()\n const stored = (snapshot?.docs ?? [])\n .map((doc: any) => normalizeEmailTopic(doc.id, doc.data()))\n .filter((topic: EmailTopic | null): topic is EmailTopic => !!topic)\n return mergeEmailTopics(stored)\n } catch (error) {\n if (strict) throw error\n console.error('[email/preferences] topic catalog read failed', error)\n return mergeEmailTopics(null)\n }\n}\n\n/** What this address currently receives from this site. */\ninterface SubscriptionState {\n /** A whole-site suppression stands. */\n suppressed: boolean\n /**\n * The suppression is a bounce or a complaint, so nothing on this page may\n * lift it.\n */\n protectedRecord: boolean\n /** The reason on that record, when there is one. */\n protectedReason: string | null\n /** Topic ids this address has left and not rejoined. */\n optedOut: Set<string>\n /**\n * Topic ids this address was asked to confirm and has not.\n *\n * Held apart from {@link optedOut} even though neither is mailable, because\n * the page says something different about each: one is a choice the person\n * made and the other is a question they have not answered.\n */\n pending: Set<string>\n /** The pace this address last asked for, or `'all'` for never asked. */\n cadence: MarketingCadence\n}\n\n/** How a chosen cadence reads inside a sentence about what will happen. */\nfunction cadenceSentence(cadence: MarketingCadence): string {\n return cadence === 'daily'\n ? 'one a day'\n : cadence === 'weekly'\n ? 'one a week'\n : 'one a month'\n}\n\n/**\n * All three per-site records for one address, in three keyed `get()`s per\n * site of the link site's consent group.\n *\n * By document id rather than a query, matching `filterSendableForHost`: no\n * composite index to go missing, and nothing that can fail open on a read\n * window. The third is the send counter, which is where the recipient's\n * chosen pace lives — see `EmailFrequencyRecord.cadence` for why it is stored\n * on the document the send path already reads rather than on this page's own.\n *\n * ## The group's state, read the way the send paths read it\n *\n * The page shows what the SENDER will do, and the sender is the consent\n * group: a suppression standing on any of its sites, and a stream left on any\n * of them, holds this site's mail too, so both show here. The pace is the one\n * chosen most recently on any site's page. A pending confirmation is the link\n * site's own, as it is on the send path. A group of one is the three reads\n * this page always made.\n */\nasync function readSubscriptionState(\n firestore: any,\n group: ConsentGroup,\n key: string,\n): Promise<SubscriptionState> {\n const sites = await Promise.all(\n consentGroupOptOutHosts(group).map((id) => {\n const hostRef = firestore.collection('hosts').doc(id)\n return Promise.all([\n hostRef.collection('suppressions').doc(key).get(),\n hostRef.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key).get(),\n hostRef\n .collection(EMAIL_FREQUENCY_SUBCOLLECTION)\n .doc(key)\n .get()\n // The pace is the one field on this page whose absence is a\n // legitimate answer, so a read that fails renders the default\n // rather than an error — the recipient still gets their topic\n // checkboxes.\n .catch(() => null),\n ])\n }),\n )\n const optedOut = new Set<string>()\n const pending = new Set<string>()\n let suppressed = false\n let protectedReason: string | null = null\n let cadence: unknown = null\n let cadenceSetAtMs = Number.NEGATIVE_INFINITY\n sites.forEach(([suppression, optOuts, frequency], index) => {\n const own = index === 0\n if (suppression?.exists) {\n suppressed = true\n const reason = suppression.get('reason')\n if (reason !== UNSUBSCRIBE_SUPPRESSION_REASON && protectedReason === null) {\n protectedReason = String(reason ?? 'held')\n }\n }\n const stored = (optOuts?.exists ? optOuts.get('topics') : null) ?? {}\n for (const [id, record] of Object.entries(\n stored as Record<string, TopicSubscriptionEntry | null>,\n )) {\n /*\n * The shared reader, not a field test. An entry with a `resubscribedAt`\n * is EVIDENCE of an opt-out that has been lifted rather than a live one\n * — see `writeTopicOptOuts` for why the entry stays — and an entry with\n * a `confirmedAt` carries the same shape of evidence for a\n * confirmation. Only one function knows all three states.\n */\n const state = readTopicSubscriptionState(record)\n if (state === 'opted-out') optedOut.add(id)\n if (own && state === 'pending') pending.add(id)\n }\n // The most recent choice on any site, the link site keeping a tie — the\n // rule `filterCadenceSendable` decides by.\n if (frequency?.exists && frequency.get('cadence') != null) {\n const setAtMs = Number(frequency.get('cadenceSetAtMs'))\n const atMs = Number.isFinite(setAtMs) ? setAtMs : 0\n if (atMs > cadenceSetAtMs) {\n cadenceSetAtMs = atMs\n cadence = frequency.get('cadence')\n }\n }\n })\n return {\n suppressed,\n protectedRecord: protectedReason !== null,\n protectedReason,\n optedOut,\n pending,\n cadence: normalizeMarketingCadence(cadence),\n }\n}\n\n/**\n * Record the recipient's per-topic choices.\n *\n * ## The record is EVIDENCE, so nothing is removed\n *\n * `email-suppression.ts` makes the argument for the suppression lists: \"a\n * revocation is a FIELD and not a delete, because the record is the evidence\n * that the suppression was honored while it was in force.\" A topic opt-out is\n * the same kind of fact — somebody asked us to stop, and the answer to \"did\n * you honor it\" has to survive them changing their mind later. So rejoining a\n * topic stamps `resubscribedAt` on the existing entry rather than deleting it,\n * and the pair of timestamps is the window the request was in force for.\n *\n * One document per address, a map keyed by topic, rather than a document per\n * (address, topic): the send path reads this by key alongside the suppression\n * lists, and one `get()` per address is what keeps a topic-filtered send the\n * same cost as an unfiltered one.\n *\n * ## Ticking a box here IS the confirmation a double opt-in asks for\n *\n * The entry also carries a pending-confirmation pair, and a recipient who\n * ticks a topic on this page has done more than the confirmation link asks:\n * they clicked a signed link delivered to that mailbox and then made a\n * choice in it. Leaving them pending would mean the page recorded a\n * subscription the send path refuses — a form whose submit does not take\n * effect, which this page refuses to be anywhere else. So a resumed topic\n * that is still pending is confirmed here, stamped with the moment they did\n * it.\n *\n * ## Every write CARRIES the entry forward\n *\n * Each branch spreads the previous entry rather than replacing it. Two pairs\n * of timestamps now live on one entry, and a branch that wrote only its own\n * pair would silently discard the other — an opt-out would erase the record\n * that somebody confirmed, and the erasure would look exactly like a person\n * who never confirmed.\n */\nasync function writeTopicOptOuts(\n firestore: any,\n hostId: string,\n key: string,\n fields: {\n email: string\n optOut: string[]\n resume: string[]\n /**\n * Whether resuming a pending topic confirms it. True for this page, whose\n * tick is the confirmation; false for a reopening that did not come\n * through a link delivered to the mailbox (`rejoinStreamForAccount`).\n */\n confirmPending?: boolean\n },\n): Promise<void> {\n const ref = firestore\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n await firestore.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n const stored = ((existing.exists ? existing.get('topics') : null) ??\n {}) as Record<string, Record<string, unknown>>\n const topics: Record<string, unknown> = {}\n for (const id of fields.optOut) {\n const previous = stored[id]\n /*\n * Already opted out and never rejoined: leave the original timestamp\n * alone. Re-submitting the same form must not restamp the date the\n * person actually left, for the reason `createdAt` is not restamped on\n * the suppression.\n *\n * The state reader, not \"an entry with no `resubscribedAt`\". That\n * shorthand reads a CONFIRMED double opt-in — which carries `pendingAt`\n * and `confirmedAt` and no `resubscribedAt` — as somebody who had\n * already left, so unticking their box would record no opt-out at all\n * and the send path would go on mailing them.\n */\n topics[id] =\n readTopicSubscriptionState(previous) === 'opted-out'\n ? previous\n : {\n ...(previous ?? {}),\n optedOutAt: FieldValue.serverTimestamp(),\n resubscribedAt: null,\n }\n }\n for (const id of fields.resume) {\n const previous = stored[id]\n if (!previous) continue\n const state = readTopicSubscriptionState(previous)\n if (state === 'pending') {\n if (fields.confirmPending !== false) {\n topics[id] = { ...previous, confirmedAt: Date.now() }\n }\n continue\n }\n topics[id] = previous['resubscribedAt']\n ? previous\n : { ...previous, resubscribedAt: FieldValue.serverTimestamp() }\n }\n transaction.set(\n ref,\n {\n email: fields.email,\n // The whole map, not a merge of one key: a topic the recipient\n // rejoined has to lose its live status, and a dotted merge cannot\n // express \"these and no others\" for a map whose keys are data.\n topics: { ...stored, ...topics },\n updatedAt: FieldValue.serverTimestamp(),\n ...(existing.exists\n ? {}\n : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n })\n}\n\n/**\n * Lifts the opt-outs the link site's consent-group SIBLINGS hold on streams\n * the person has just asked for back, so a stream rejoined here is rejoined\n * from the whole sender — the send paths read a sibling's opt-out as this\n * site's own.\n *\n * Only a live opt-out is touched, and it is lifted the way\n * `writeTopicOptOuts` lifts one: `resubscribedAt` stamped onto the entry,\n * which stays as the evidence that the opt-out was honored while it stood. A\n * sibling's pending confirmation is left alone — it is that site's question,\n * not a refusal of the sender. Nothing is read or written for a site alone.\n */\nasync function resumeTopicsAcrossGroup(\n firestore: any,\n group: ConsentGroup,\n key: string,\n topicIds: readonly string[],\n): Promise<void> {\n const siblings = consentGroupOptOutHosts(group).slice(1)\n if (!siblings.length || !topicIds.length) return\n await Promise.all(\n siblings.map(async (id) => {\n const ref = firestore\n .collection('hosts')\n .doc(id)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n await firestore.runTransaction(async (transaction: any) => {\n const existing = await transaction.get(ref)\n if (!existing.exists) return\n const stored = (existing.get('topics') ?? {}) as Record<\n string,\n Record<string, unknown>\n >\n const lifted: Record<string, unknown> = {}\n for (const topicId of topicIds) {\n const previous = stored[topicId]\n if (readTopicSubscriptionState(previous) !== 'opted-out') continue\n lifted[topicId] = {\n ...previous,\n resubscribedAt: FieldValue.serverTimestamp(),\n }\n }\n if (!Object.keys(lifted).length) return\n transaction.set(\n ref,\n {\n topics: { ...stored, ...lifted },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n }),\n )\n}\n\n/**\n * Reopens ONE stream for a signed-in account (AGL-3305) — this plugin's side\n * of `plugin-email-streams`, which the console asks when an account's answer\n * about a stream turns back to yes. The caller has already proven the\n * mailbox; see the seam for why that is the caller's job.\n *\n * Two cases, and the second is the reason this lives here:\n *\n * - The address left just this stream: its opt-out is lifted, exactly as\n * re-ticking the box on the preference page lifts it.\n * - The address left EVERYTHING: every other active stream becomes an\n * opt-out first, and only then is the whole-site unsubscribe lifted —\n * through the same guard the resubscribe link uses. They asked for one\n * stream back, not for the newsletter and the promotions they also left,\n * and writing the opt-outs before the lift means there is no moment in\n * which the whole catalog is mailable.\n *\n * A pending confirmation is left pending. The preference page confirms by a\n * tick because the tick came through a link delivered to the mailbox; this\n * request came from the console, which is proof of an account, not of a\n * click in the mailbox the confirmation was sent to.\n *\n * A bounce, a complaint, an erasure or a staff hold is `held`, and nothing is\n * written: none of them is a preference, so no switch lifts them.\n */\nexport async function rejoinStreamForAccount(\n request: PluginEmailStreamRejoinRequest,\n firestore: any = firebaseAdmin.app().firestore(),\n): Promise<PluginEmailStreamRejoinResult> {\n const key = suppressionKeyFor(request.email)\n if (!key) return { status: 'held', reason: 'unusable-address' }\n // The sender the stream is rejoined FROM — the site's consent group, whose\n // records hold its mail as its own do.\n const group = await loadConsentGroup(request.hostId)\n const state = await readSubscriptionState(firestore, group, key)\n if (state.protectedRecord) {\n return { status: 'held', reason: state.protectedReason ?? 'held' }\n }\n const email = String(request.email).trim().toLowerCase()\n if (!state.suppressed) {\n await writeTopicOptOuts(firestore, request.hostId, key, {\n email,\n optOut: [],\n resume: [request.topicId],\n confirmPending: false,\n })\n await resumeTopicsAcrossGroup(firestore, group, key, [request.topicId])\n return { status: 'rejoined', releasedSuppression: false, keptLeft: 0 }\n }\n const others = activeEmailTopics(await loadTopicCatalog(firestore, request.hostId, true))\n .map((topic) => topic.id)\n .filter((id) => id !== request.topicId)\n await writeTopicOptOuts(firestore, request.hostId, key, {\n email,\n optOut: others,\n resume: [request.topicId],\n confirmPending: false,\n })\n await resumeTopicsAcrossGroup(firestore, group, key, [request.topicId])\n if (!(await releaseSiteSuppression(firestore, group, key))) {\n // Turned into a bounce or a complaint between the read and the lift.\n return { status: 'held', reason: 'protected' }\n }\n return { status: 'rejoined', releasedSuppression: true, keptLeft: others.length }\n}\n\n/** One topic row: a checkbox, its name and its description. */\nfunction topicRow(\n topic: EmailTopic,\n checked: boolean,\n highlighted: boolean,\n /**\n * Asked to confirm and has not.\n *\n * The box is EMPTY for a pending topic, because empty is the truth: the\n * send path refuses this stream until it is confirmed, and a ticked box\n * over a stream nothing will send would be the page telling a lie the\n * recipient can only discover by waiting for mail that never comes. The\n * note beside it is what turns \"not ticked\" from a puzzle into an answer,\n * and ticking it here confirms — see `writeTopicOptOuts`.\n */\n pending = false,\n pal: EmailPalette = PAL,\n): string {\n return (\n `<label style=\"display:flex;gap:12px;align-items:flex-start;padding:14px 0;` +\n `border-top:1px solid ${PAL.divider};cursor:pointer\">` +\n `<input type=\"checkbox\" name=\"topic:${escapeHtml(topic.id)}\" value=\"on\"` +\n (checked ? ' checked' : '') +\n ' style=\"margin:2px 0 0;width:18px;height:18px;flex:none\">' +\n '<span style=\"flex:1\">' +\n `<span style=\"display:block;font-size:14px;font-weight:600;color:${PAL.ink}\">` +\n escapeHtml(topic.name) +\n (highlighted\n ? `<span style=\"margin-left:8px;font-size:11px;font-weight:600;` +\n `text-transform:uppercase;letter-spacing:.04em;color:${pal.link}\">` +\n 'This email</span>'\n : '') +\n '</span>' +\n (pending\n ? `<span style=\"display:block;margin-top:2px;font-size:13px;line-height:1.45;` +\n `color:${PAL.muted}\">Waiting for you to confirm — tick this and save ` +\n 'to start receiving it.</span>'\n : '') +\n (topic.description\n ? `<span style=\"display:block;margin-top:2px;font-size:13px;line-height:1.45;` +\n `color:${PAL.muted}\">${escapeHtml(topic.description)}</span>`\n : '') +\n '</span></label>'\n )\n}\n\n/**\n * HOW OFTEN — the half of the preference center that shipped without.\n *\n * `docs/specs/email-competitive-gaps.md` G10: the frequency CAP shipped and\n * this did not, so a recipient who wanted the same mail less often had two\n * options and one of them was the spam button.\n *\n * Radio buttons rather than a select, and every option written out. The whole\n * value of the control is that somebody skimming a footer link can see, in\n * one glance, that \"less\" is available at all — a collapsed select says only\n * that there is a setting.\n *\n * The default option is named (\"As they come\") rather than left as the empty\n * choice, because a radio group whose default is unlabeled reads as a\n * question the recipient has not answered, and answering it is not something\n * this page should require of somebody who came here to uncheck one box.\n */\nfunction cadenceFieldset(current: MarketingCadence): string {\n const option = (value: MarketingCadence, label: string): string =>\n `<label style=\"display:flex;gap:12px;align-items:center;padding:10px 0;cursor:pointer\">` +\n `<input type=\"radio\" name=\"cadence\" value=\"${escapeHtml(value)}\"` +\n (value === current ? ' checked' : '') +\n ' style=\"margin:0;width:18px;height:18px;flex:none\">' +\n `<span style=\"font-size:14px;color:${PAL.ink}\">${label}</span></label>`\n return (\n `<div style=\"border-top:1px solid ${PAL.divider};padding-top:18px;margin-top:6px\">` +\n `<div style=\"font-size:14px;font-weight:600;color:${PAL.ink};margin-bottom:2px\">` +\n 'How often' +\n '</div>' +\n `<div style=\"font-size:13px;line-height:1.45;color:${PAL.muted};margin-bottom:6px\">` +\n 'This applies to everything above. Nothing is canceled — messages just ' +\n 'wait until the next one is due.' +\n '</div>' +\n option('all', 'As they come') +\n option('daily', 'At most one a day') +\n option('weekly', 'At most one a week') +\n option('monthly', 'At most one a month') +\n '</div>'\n )\n}\n\n/** The preference page's body. */\nfunction preferencesFormBody(args: {\n email: string\n query: string\n topics: EmailTopic[]\n state: SubscriptionState\n topicId: string\n brand: EmailPageBrand\n /** Who the choices are made about — see {@link pageSender}. */\n sender: EmailPageSender\n}): string {\n const { email, query, topics, state, topicId, brand, sender } = args\n const pal = brand.pal\n // A bounce or a complaint is not a preference, so the page does not pretend\n // the recipient can edit their way out of one. Shown instead of the form\n // rather than beside it: a form whose submit cannot take effect is worse\n // than no form.\n if (state.protectedRecord) return protectedAddressBody()\n const current = resolveCampaignTopic(topicId, topics)\n const action = `/api/email/preferences?${escapeHtml(query)}`\n return (\n heading('Email preferences') +\n paragraph(\n `Choose what <strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong> should keep receiving from ` +\n `<strong style=\"color:${PAL.ink}\">${sender.name}</strong>. ` +\n 'Unticked emails stop; everything else carries on.',\n 8,\n ) +\n reachParagraph(sender, 8) +\n (state.suppressed\n ? paragraph(\n 'You are currently unsubscribed from everything. Tick anything ' +\n 'below to start receiving it again.',\n 8,\n )\n : '') +\n `<form method=\"post\" action=\"${action}\">` +\n topics\n .map((topic) =>\n topicRow(\n topic,\n // A whole-site suppression outranks the per-topic record, so an\n // unsubscribed recipient sees every box empty — which is the state\n // they are actually in, and the state the form must round-trip. An\n // unconfirmed topic is empty for the same reason: the send path\n // refuses it, so a ticked box would not be what is true.\n !state.suppressed &&\n !state.optedOut.has(topic.id) &&\n !state.pending.has(topic.id),\n topic.id === current.id,\n !state.suppressed && state.pending.has(topic.id),\n pal,\n ),\n )\n .join('') +\n /*\n * HOW OFTEN, inside the same form as WHAT.\n *\n * The alternative to letting somebody choose \"monthly\" is letting them\n * choose \"report spam\", and on a shared sending domain under `p=reject`\n * that choice is charged to every other tenant. It sits under the topics\n * because it is the smaller decision of the two and a recipient who has\n * already found the thing they wanted to stop should not have to read\n * past a frequency question to stop it.\n */\n cadenceFieldset(state.cadence) +\n `<div style=\"border-top:1px solid ${PAL.divider};padding-top:20px;margin-top:6px\">` +\n submitButton('Save my preferences', { pal }) +\n '</div></form>' +\n // A SECOND form, not a second button in the first one. Sharing the form\n // would submit the checkbox state along with the \"everything\" action, so a\n // browser that fell back to the first submit button — or a user pressing\n // Return in the form — would send an ambiguous request. Two forms make the\n // two intentions two requests.\n `<form method=\"post\" action=\"${action}\" style=\"margin-top:12px\">` +\n '<input type=\"hidden\" name=\"action\" value=\"all\">' +\n '<button type=\"submit\" style=\"font:inherit;font-size:13px;font-weight:600;' +\n `padding:10px 20px;border:1px solid ${PAL.divider};border-radius:8px;` +\n `background:transparent;color:${PAL.muted};cursor:pointer;width:100%\">` +\n 'Unsubscribe from everything</button></form>'\n )\n}\n\n/** What the result page tells the recipient actually changed. */\nfunction changeSummary(args: {\n email: string\n keep: string[]\n drop: EmailTopic[]\n topics: EmailTopic[]\n /** A declared group is named; a site alone is \"this site\". */\n sender: EmailPageSender\n}): string {\n const address = `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n args.email,\n )}</strong>`\n const from = args.sender.reach ? args.sender.name : 'this site'\n if (!args.drop.length) {\n return `${address} keeps receiving everything ${from} sends.`\n }\n const names = args.drop\n .map((topic) => escapeHtml(topic.name))\n .join(', ')\n if (!args.keep.length) {\n return (\n `${address} has been unsubscribed from ${names} — everything ` +\n `${from} currently sends.`\n )\n }\n return `${address} will stop receiving ${names}, and keeps the rest.`\n}\n\n/**\n * `email/confirm` — the click that turns a pending subscription into a real\n * one (`docs/specs/email-competitive-gaps.md` P8).\n *\n * Same signed-link shape as its three siblings and the same safe-GET /\n * mutating-POST split, which matters here for exactly the reason it mattered\n * to the unsubscribe: a security gateway fetching every URL in the message\n * would otherwise confirm the subscription on the recipient's behalf, and a\n * confirmation nobody made is the one thing a double opt-in exists to\n * prevent. A prescanner following this link renders a page and changes\n * nothing.\n *\n * The subject it verifies is the confirmation form — see\n * `signedConfirmSubject` for why a topic without a campaign needs one — and\n * it is checked through the same comparison every other link goes through.\n */\nconst confirmHandler: PluginApiHandler = async (req, res) => {\n const method = String(req.method ?? 'GET').toUpperCase()\n if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {\n res.setHeader('Allow', 'GET, POST')\n return void res.status(405).send('Method not allowed')\n }\n\n const params = readParams(req)\n const secret = linkSecret()\n if (!params.hostId || !params.email || !params.signature || !secret) {\n return void res.status(400).send('Invalid confirmation link')\n }\n if (!signatureMatches({ ...params, secret, purpose: 'confirm' })) {\n return void res.status(403).send('Invalid confirmation link')\n }\n if (!suppressionKeyFor(params.email)) {\n return void res.status(400).send('Invalid confirmation link')\n }\n const { hostId, email, topicId } = params\n const query = signedQuery(params)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const [catalog, brand] = await Promise.all([\n loadTopicCatalog(firestore, hostId),\n loadHostBrand(hostId),\n ])\n const topic = resolveCampaignTopic(topicId, catalog)\n\n if (method !== 'POST') {\n // SAFE. A prescanner lands here and confirms nothing.\n return void sendPage(\n res,\n page(\n heading('Confirm your subscription') +\n paragraph(\n `Confirm that <strong style=\"color:${PAL.ink}\">${escapeHtml(\n email,\n )}</strong> should receive ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n topic.name,\n )}</strong> from ` +\n `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n brand.name,\n )}</strong>.`,\n ) +\n `<form method=\"post\" action=\"/api/email/confirm?${escapeHtml(\n query,\n )}\">` +\n submitButton('Yes, subscribe me', { pal: brand.pal }) +\n '</form>',\n 420,\n brand,\n ),\n )\n }\n\n const outcome = await confirmTopicSubscription(hostId, email, topicId)\n return void sendPage(\n res,\n page(confirmationBody(outcome, topic.name, brand.pal), 420, brand),\n )\n } catch (error) {\n console.error(error)\n return void res.status(500).send('Confirmation failed — please try again')\n }\n}\n\n/**\n * What each outcome tells the person in front of it.\n *\n * Every arm names what is TRUE rather than what went wrong. Somebody who\n * clicked an expired link has not made a mistake, and somebody who clicked\n * twice has not either — telling either of them \"invalid\" would read as the\n * subscription having failed when the first case needs a fresh signup and the\n * second is already done.\n */\nfunction confirmationBody(\n outcome: ConfirmTopicResult,\n topicName: string,\n pal: EmailPalette = PAL,\n): string {\n const stream = `<strong style=\"color:${PAL.ink}\">${escapeHtml(\n topicName,\n )}</strong>`\n switch (outcome) {\n case 'confirmed':\n return (\n successBadge(pal) +\n heading(\"You're subscribed\") +\n paragraph(`You'll start receiving ${stream} from this site.`, 0)\n )\n case 'already-confirmed':\n return (\n successBadge(pal) +\n heading('Already confirmed') +\n paragraph(`${stream} is already on its way to you.`, 0)\n )\n case 'expired':\n return (\n heading('This link has expired') +\n paragraph(\n `Confirmation links are good for three days. Sign up again and ` +\n `we'll send a fresh one — you are not subscribed to ${stream} in ` +\n 'the meantime.',\n 0,\n )\n )\n case 'opted-out':\n return (\n heading(\"Can't subscribe this address\") +\n paragraph(\n `This address asked to stop receiving ${stream} from this site, so ` +\n 'a confirmation link cannot put it back. Sign up again if that ' +\n 'was not what you meant.',\n 0,\n )\n )\n default:\n return (\n heading('Nothing to confirm') +\n paragraph(\n `There is no pending request for ${stream} at this address. If you ` +\n 'meant to subscribe, sign up on the site.',\n 0,\n )\n )\n }\n}\n\n/** Registers the email plugin's public API routes (AGL-396). */\nexport function registerEmailApi(): void {\n registerPluginApiRoute('email/unsubscribe', unsubscribeHandler)\n registerPluginApiRoute('email/resubscribe', resubscribeHandler)\n registerPluginApiRoute('email/preferences', preferencesHandler)\n registerPluginApiRoute('email/confirm', confirmHandler)\n}\n\n/*\n * The CONSOLE half of the same `email` prefix, kept in its own module.\n *\n * Two audiences, one entry point: the tenant loads this file for\n * `registerEmailApi` (the signed unsubscribe links a recipient clicks, no\n * session behind them), and the console loads it for\n * `registerEmailConsoleApi` (list membership, behind an org-wide role). The\n * manifest generator resolves both surfaces through `@aglyn/plugins-email/server`,\n * so this re-export is what makes the console half reachable — a second entry\n * point would be a second thing to keep in step with plugins.config.json.\n */\nexport {\n registerEmailConsoleApi,\n emailListMembersAddHandler,\n emailListMembersPreviewHandler,\n emailListRulePreviewHandler,\n CONSOLE_ADD_SOURCE,\n LIST_MEMBER_BATCH_MAX,\n} from './server-console'\n"],"names":["registerPluginApiRoute","activeEmailTopics","mergeEmailTopics","normalizeEmailTopic","readTopicSubscriptionState","resolveCampaignTopic","EMAIL_TOPICS_COLLECTION","TOPIC_OPT_OUTS_SUBCOLLECTION","consentGroupOptOutHosts","soloConsentGroup","confirmTopicSubscription","consentGroupForSite","EMAIL_FREQUENCY_SUBCOLLECTION","firebaseAdmin","mirrorPlatformResubscribe","mirrorPlatformUnsubscribe","resolveCampaignSendRef","resolveOrgIdForHost","setMarketingCadence","UNSUBSCRIBE_SUPPRESSION_REASON","stampRecordEmailState","normalizeMarketingCadence","escapeHtml","FieldValue","heading","isCampaignPathId","page","paragraph","PAL","PLATFORM_EMAIL_BRAND","readParams","resolveEmailPageBrand","sendPage","signatureMatches","signedQuery","submitButton","successBadge","suppressionKeyFor","linkSecret","process","env","EMAIL_UNSUBSCRIBE_SECRET","CRON_SECRET","openSignedLink","req","params","secret","refuse","status","refusal","key","hostId","email","signature","BRAND_READ_TIMEOUT_MS","loadHostBrand","timer","firestore","app","snapshot","Promise","race","collection","doc","get","resolve","setTimeout","exists","data","$id","error","console","clearTimeout","loadConsentGroup","waitMs","alone","resolving","pageSender","brand","group","declared","name","reach","hostIds","length","reachParagraph","sender","gap","unsubscribeHandler","res","method","String","toUpperCase","setHeader","send","opened","campaignId","topicId","query","all","ink","pal","link","created","writeSiteSuppression","countSendUnsubscribe","left","orgId","sendRef","sendId","update","increment","fields","ref","runTransaction","transaction","existing","set","reason","suppressedAt","serverTimestamp","createdAt","merge","state","atMs","Date","now","source","detail","resubscribeHandler","accent","groupRead","released","then","resolved","releaseSiteSuppression","protectedAddressBody","via","own","siblings","map","id","ownSnapshot","siblingSnapshots","some","delete","filter","_ref","index","preferencesHandler","body","catalog","loadTopicCatalog","readSubscriptionState","topics","preferencesFormBody","keep","Set","topic","drop","has","writeTopicOptOuts","optOut","resume","resumeTopicsAcrossGroup","cadence","cadenceStored","stillBlocked","size","changeSummary","cadenceSentence","strict","stored","docs","sites","hostRef","catch","optedOut","pending","suppressed","protectedReason","cadenceSetAtMs","Number","NEGATIVE_INFINITY","forEach","suppression","optOuts","frequency","record","Object","entries","add","setAtMs","isFinite","protectedRecord","previous","optedOutAt","resubscribedAt","confirmPending","confirmedAt","updatedAt","topicIds","slice","lifted","keys","rejoinStreamForAccount","request","trim","toLowerCase","releasedSuppression","keptLeft","others","topicRow","checked","highlighted","divider","muted","description","cadenceFieldset","current","option","value","label","args","action","join","address","from","names","confirmHandler","purpose","outcome","confirmationBody","topicName","stream","registerEmailApi","registerEmailConsoleApi","emailListMembersAddHandler","emailListMembersPreviewHandler","emailListRulePreviewHandler","CONSOLE_ADD_SOURCE","LIST_MEMBER_BATCH_MAX"],"mappings":"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"}
|