@aglyn/plugins-email 1.0.0-beta.161 → 1.0.0-beta.162
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/server.js +17 -0
- package/src/lib/server.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-email",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.162",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,22 +25,22 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-data-types": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-ui-jsx-forms": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-ui-theme": "1.0.0-beta.
|
|
38
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
39
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
40
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
41
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
42
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
43
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.162",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.162",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.162",
|
|
31
|
+
"@aglyn/shared-data-types": "1.0.0-beta.162",
|
|
32
|
+
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.162",
|
|
33
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.162",
|
|
34
|
+
"@aglyn/shared-ui-jsx-forms": "1.0.0-beta.162",
|
|
35
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.162",
|
|
36
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.162",
|
|
37
|
+
"@aglyn/shared-ui-theme": "1.0.0-beta.162",
|
|
38
|
+
"@aglyn/shared-util-email": "1.0.0-beta.162",
|
|
39
|
+
"@aglyn/shared-util-http": "1.0.0-beta.162",
|
|
40
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.162",
|
|
41
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.162",
|
|
42
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.162",
|
|
43
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.162",
|
|
44
44
|
"@swc/helpers": "0.5.23"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
package/src/lib/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
23
23
|
* pure function or a constant that lives in one leaf file.
|
|
24
24
|
*/ import { activeEmailTopics, mergeEmailTopics, normalizeEmailTopic, readTopicSubscriptionState, resolveCampaignTopic, EMAIL_TOPICS_COLLECTION, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
|
|
25
25
|
import { confirmTopicSubscription, EMAIL_FREQUENCY_SUBCOLLECTION, firebaseAdmin, resolveOrgIdForHost, setMarketingCadence, UNSUBSCRIBE_SUPPRESSION_REASON } from "@aglyn/tenant-data-admin";
|
|
26
|
+
import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
|
|
26
27
|
/*
|
|
27
28
|
* The pure cadence rule from the shared email library, where the SEND path
|
|
28
29
|
* reads it too. The preference page and the gate must agree about what
|
|
@@ -277,6 +278,22 @@ const unsubscribeHandler = async (req, res)=>{
|
|
|
277
278
|
merge: true
|
|
278
279
|
});
|
|
279
280
|
});
|
|
281
|
+
// The same verdict on the record the person is (AGL-3245), through
|
|
282
|
+
// whichever plugin keeps the workspace's records, so the record page says
|
|
283
|
+
// they left. A stamp that failed is logged by the seam and costs the
|
|
284
|
+
// unsubscribe nothing.
|
|
285
|
+
if (created) {
|
|
286
|
+
await stampRecordEmailState({
|
|
287
|
+
hostId,
|
|
288
|
+
email: fields.email,
|
|
289
|
+
state: {
|
|
290
|
+
status: 'unsubscribed',
|
|
291
|
+
atMs: Date.now(),
|
|
292
|
+
source: 'campaign',
|
|
293
|
+
detail: fields.campaignId ? 'Unsubscribed from a campaign email.' : 'Unsubscribed by the link.'
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
280
297
|
return created;
|
|
281
298
|
}
|
|
282
299
|
/**
|
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 resolveOrgIdForHost,\n setMarketingCadence,\n UNSUBSCRIBE_SUPPRESSION_REASON,\n type ConfirmTopicResult,\n} from '@aglyn/tenant-data-admin'\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.\n *\n * A merge-set would CREATE the campaign — a document holding one `stats`\n * map and nothing else — for an unsubscribe arriving after the merchant\n * deleted it, which is the fault the delivery webhook records against\n * this exact shape. `update()` refuses a missing document, which is the\n * behavior wanted: the count for a campaign nobody can open has no\n * reader.\n */\n if (created && isCampaignPathId(campaignId)) {\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('campaigns')\n .doc(campaignId)\n .update({ 'stats.unsubscribes': FieldValue.increment(1) })\n .catch(() => undefined)\n }\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 * 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 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 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 && isCampaignPathId(campaignId)) {\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('campaigns')\n .doc(campaignId)\n .update({ 'stats.unsubscribes': FieldValue.increment(1) })\n .catch(() => undefined)\n }\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 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): 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 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: { email: string; optOut: string[]; resume: string[] },\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 topics[id] = { ...previous, confirmedAt: Date.now() }\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/** 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","resolveOrgIdForHost","setMarketingCadence","UNSUBSCRIBE_SUPPRESSION_REASON","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","update","increment","catch","undefined","fields","ref","runTransaction","transaction","existing","set","reason","suppressedAt","serverTimestamp","createdAt","merge","resubscribeHandler","accent","released","releaseSiteSuppression","protectedAddressBody","delete","preferencesHandler","body","catalog","state","loadTopicCatalog","readSubscriptionState","topics","preferencesFormBody","keep","Set","map","topic","id","filter","drop","has","writeTopicOptOuts","optOut","resume","cadence","cadenceStored","stillBlocked","size","length","changeSummary","cadenceSentence","orgId","stored","docs","optOuts","hostRef","suppression","frequency","optedOut","pending","record","Object","entries","add","suppressed","protectedRecord","previous","optedOutAt","resubscribedAt","confirmedAt","Date","now","updatedAt","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 confirmTopicSubscription,\n EMAIL_FREQUENCY_SUBCOLLECTION,\n firebaseAdmin,\n resolveOrgIdForHost,\n setMarketingCadence,\n UNSUBSCRIBE_SUPPRESSION_REASON,\n type ConfirmTopicResult,\n} from '@aglyn/tenant-data-admin'\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.\n *\n * A merge-set would CREATE the campaign — a document holding one `stats`\n * map and nothing else — for an unsubscribe arriving after the merchant\n * deleted it, which is the fault the delivery webhook records against\n * this exact shape. `update()` refuses a missing document, which is the\n * behavior wanted: the count for a campaign nobody can open has no\n * reader.\n */\n if (created && isCampaignPathId(campaignId)) {\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('campaigns')\n .doc(campaignId)\n .update({ 'stats.unsubscribes': FieldValue.increment(1) })\n .catch(() => undefined)\n }\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 * 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 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 && isCampaignPathId(campaignId)) {\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('campaigns')\n .doc(campaignId)\n .update({ 'stats.unsubscribes': FieldValue.increment(1) })\n .catch(() => undefined)\n }\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 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): 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 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: { email: string; optOut: string[]; resume: string[] },\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 topics[id] = { ...previous, confirmedAt: Date.now() }\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/** 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","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","update","increment","catch","undefined","fields","ref","runTransaction","transaction","existing","set","reason","suppressedAt","serverTimestamp","createdAt","merge","state","atMs","Date","now","source","detail","resubscribeHandler","accent","released","releaseSiteSuppression","protectedAddressBody","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","orgId","stored","docs","optOuts","hostRef","suppression","frequency","optedOut","pending","record","Object","entries","add","suppressed","protectedRecord","previous","optedOutAt","resubscribedAt","confirmedAt","updatedAt","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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"}
|