@aglyn/tenant-data-admin 1.0.0-beta.190 → 1.0.0-beta.192
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 +7 -7
- package/src/lib/server/email-flow-gate.js +2 -1
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/email-marketing-gate.d.ts +3 -1
- package/src/lib/server/email-marketing-gate.js +10 -7
- package/src/lib/server/email-marketing-gate.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +5 -3
- package/src/lib/server/email-suppression.js +18 -8
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-topic-confirmation.d.ts +9 -1
- package/src/lib/server/email-topic-confirmation.js +18 -5
- package/src/lib/server/email-topic-confirmation.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-data-admin",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.192",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.192",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.192",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.192",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.192",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.192",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.192",
|
|
34
34
|
"@msgpack/msgpack": "^3.1.3",
|
|
35
35
|
"@swc/helpers": "0.5.23",
|
|
36
36
|
"sharp": "^0.35.3",
|
|
@@ -152,7 +152,8 @@ import { orgDataQueryForHost } from "./organizations.js";
|
|
|
152
152
|
const topicId = flowEmailTopicId(options.topicId, options.scope);
|
|
153
153
|
if (!topicId) return null;
|
|
154
154
|
// Across the same group: a person who left this stream on a sibling site
|
|
155
|
-
// left it from the sender this automation mails as
|
|
155
|
+
// left it from the sender this automation mails as — and, where the org
|
|
156
|
+
// turned the switch on, a sibling's pending confirmation holds it too.
|
|
156
157
|
const sendable = await filterTopicSendable(options.hostId, topicId, [
|
|
157
158
|
email
|
|
158
159
|
], options.firestore, group);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-flow-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE TWO GATES A FLOW EMAIL OWES THAT A REPLY DOES NOT.\n *\n * `sendEmail`'s marketing seam already asks the three questions every\n * merchant-triggered message owes — both suppression lists, the per-person\n * frequency ceiling, and an unsubscribe mechanism — at one chokepoint, for\n * any caller that declares `marketing: { hostId, siteBase }`. Two more belong\n * to a message a CAMPAIGN would owe and an immediate reply would not:\n *\n * 1. **The consent split.** Does this person have a basis to be mailed\n * marketing by this site, under the org's own policy?\n * 2. **The topic filter.** Have they left this particular stream?\n *\n * ## Why a flow email is on the campaign's side of that line\n *\n * `marketing-send.ts` draws it precisely: a message is marketing when the\n * merchant decided to mail them, rather than because the recipient just did\n * something. An action's immediate `sendEmail` is the second — the visitor\n * submitted the form a moment ago and the reply is the response to it, which\n * is why that path stays priority `transactional`.\n *\n * A step that runs three days later is the first. The person did one thing\n * once; everything after the wait is the merchant's schedule, not the\n * recipient's act. So a flow email is checked here, and the answer is the\n * same one a campaign would get for the same person.\n *\n * ## The middle ground, which used to be nothing at all\n *\n * An immediate reply used to ask NEITHER question, and that was too little.\n * The recipient comes out of the event payload — an anonymous visitor's write\n * on the collect route — so \"the recipient just did something\" is an\n * assumption about who typed the address, not a fact the path establishes.\n * Somebody with a recorded refusal on this site received merchant-authored\n * content whenever a third party entered their address in a form.\n *\n * It cannot ask the full question either: the default policy is `strict`, and\n * under it an address with no record is withheld — which is every first-time\n * visitor. {@link FlowEmailScope} is where that line is drawn.\n *\n * ## What this deliberately does NOT do\n *\n * It compares no count against any limit. A flow send is metered on the cost\n * meter like every other automated message and is refused by no plan quota —\n * `emailSendsPerMonth` governs campaigns, which are the discretionary send a\n * customer can see, delay and argue with. Making a sequence stop mid-way\n * through somebody's welcome series because a monthly figure moved would be a\n * capacity limit enforced at the person, which is the shape this codebase\n * refuses everywhere else.\n */\n\nimport {\n consentGroupForHost,\n flowEmailTopicId,\n readMarketingBasis,\n marketingConsentDecision,\n resolveMarketingConsentPolicy,\n} from '@aglyn/aglyn/server'\nimport firebaseAdmin from './firebase-admin'\nimport { findContactByEmail } from './contact-email-index'\nimport { filterTopicSendable } from './email-suppression'\nimport { orgDataQueryForHost } from './organizations'\n\n/** Why a flow's email was not sent. Null when it may go. */\nexport type FlowEmailRefusal =\n /** No basis to mail this person marketing, under the org's policy. */\n | 'consent-withheld'\n /** They have left the stream this message belongs to. */\n | 'topic-unsubscribed'\n\n/**\n * WHICH OF THE TWO QUESTIONS THIS MESSAGE HAS TO ANSWER.\n *\n * The distinction the module header draws, made into a parameter, because the\n * middle ground it leaves out is where a real defect lived: an immediate step\n * asked NEITHER question, so an address with a recorded refusal on this site\n * received merchant-authored content whenever somebody typed it into a form.\n *\n * `'scheduled'` — a step after a `wait`, or anything else that goes out on\n * the merchant's schedule. The org's full consent policy applies, and an\n * unnamed stream resolves to the default one, exactly as a campaign's does.\n *\n * `'immediate'` — a reply to an act the recipient just performed. Only a\n * STATED refusal stops it, and only a stream the step actually named.\n *\n * ## Why an immediate reply cannot take the full policy\n *\n * {@link DEFAULT_MARKETING_CONSENT_POLICY} is `strict`, which is what an org\n * that has never opened the setting has. Under it an address with no record\n * is withheld — and a first-time visitor filling in a form is exactly an\n * address with no record. Applying the split there would refuse the\n * auto-response to almost every new visitor on almost every site, which is a\n * far larger failure than the one being fixed.\n *\n * A stated refusal is different in kind and needs no policy to read it: the\n * person ticked the box that says do not market to me. `declined` is withheld\n * under every mode, so this narrows to the one answer that is the person's\n * own words rather than the org's configuration.\n *\n * The topic half narrows the same way. Defaulting an unnamed stream to\n * \"Promotions and offers\" would let a promotions opt-out silence a reply to a\n * contact form, and a form reply is not a promotion. A step that NAMES a\n * stream has said what it is, and is filtered on it.\n */\nexport type FlowEmailScope = 'scheduled' | 'immediate'\n\n/**\n * The person's own record, from the two silos that hold a consent basis for\n * somebody an automation can reach.\n *\n * `contacts` first because it is the canonical person store and the one the\n * consent capture surfaces write; `leads` second because a welcome series\n * fires on a sign-up that may not have produced a contact yet. Two keyed\n * reads at most, and only on a message that is about to be sent — the\n * expensive shape would be reading these on a beat, for people no step is\n * mailing.\n *\n * The contact is found through the org's address index narrowed to this\n * site (AGL-2633): a basis recorded on a record that was later merged into\n * another is the survivor's basis now, and the address the message goes to\n * may be the one that became an alternate.\n *\n * An address in NEITHER silo reads as record-less, which is what\n * `readMarketingBasis(null)` describes: `unrecorded`, with no capture date.\n * Under a `forward` policy that grandfathers and under `strict` it is\n * withheld — the same answer a campaign gives the same address, which is the\n * property that matters.\n */\nasync function readPersonRecord(\n hostId: string,\n email: string,\n firestore?: any,\n): Promise<Record<string, unknown> | null> {\n const db = firestore ?? firebaseAdmin.app().firestore()\n try {\n const { ref } = await orgDataQueryForHost(hostId, 'contacts')\n const contact = await findContactByEmail(ref, email, { hostId })\n if (contact) return contact.data() as Record<string, unknown>\n } catch (error) {\n console.error('[flow-email] contact lookup failed', hostId, error)\n }\n try {\n /*\n * SCOPED (AGL-3275). The collection is org-wide now, so an unnarrowed\n * `where('email', ...)` would answer this site with a sibling brand's\n * lead — and this function decides whether to MAIL the person it finds.\n * `orgDataQueryForHost` applies the same `visibleTo` clause the contact\n * lookup above already goes through.\n */\n const { query } = await orgDataQueryForHost(hostId, 'leads')\n const lead = (await query.where('email', '==', email).limit(1).get()).docs[0]\n if (lead) return lead.data() as Record<string, unknown>\n } catch (error) {\n console.error('[flow-email] lead lookup failed', hostId, error)\n }\n return null\n}\n\n/**\n * May this site mail this person this flow's message?\n *\n * FAILS CLOSED on consent and OPEN on topic, which is the asymmetry\n * `filterTopicSendable` already argues one layer down: a missing consent\n * basis is the whole question, where a topic preference is a narrower fact\n * whose lookup failing is no reason to withhold a message the person has a\n * basis to receive.\n *\n * @param org the owning org's document data, already read by the caller's\n * entitlement gate. Passed rather than re-read: the executor holds\n * it for the whole run, and the policy is the only thing needed\n * from it.\n */\nexport async function flowEmailRefusal(options: {\n hostId: string\n email: string\n topicId?: string | null\n org?: unknown\n firestore?: any\n /** See {@link FlowEmailScope}. Defaults to the stricter `'scheduled'`. */\n scope?: FlowEmailScope\n}): Promise<FlowEmailRefusal | null> {\n const immediate = options.scope === 'immediate'\n const email = String(options.email ?? '')\n .trim()\n .toLowerCase()\n if (!email) return 'consent-withheld'\n\n const policy = resolveMarketingConsentPolicy(\n (options.org as Record<string, unknown> | undefined)?.[\n 'marketingConsentPolicy'\n ],\n )\n const record = await readPersonRecord(\n options.hostId,\n email,\n options.firestore,\n )\n /*\n * The group the SEND belongs to, not the site alone. Three sites declared\n * as one sender share a basis, and an automation running on any of them is\n * that sender — resolved from the org the caller already read.\n */\n const group = consentGroupForHost(\n (options.org as Record<string, unknown> | undefined) ?? null,\n options.hostId,\n )\n const decision = marketingConsentDecision(\n readMarketingBasis(record, group),\n policy,\n )\n /*\n * An immediate reply is stopped by the person's own `declined` and by\n * nothing else the policy decides. Every other `withheld` reason —\n * `no-basis`, `not-grandfathered`, `other-host` — is a statement about\n * configuration and capture history, and under the default `strict` mode\n * the first of them describes every new visitor.\n */\n if (\n decision.verdict === 'withheld' &&\n (!immediate || decision.reason === 'declined')\n ) {\n return 'consent-withheld'\n }\n\n const topicId = flowEmailTopicId(options.topicId, options.scope)\n if (!topicId) return null\n // Across the same group: a person who left this stream on a sibling site\n // left it from the sender this automation mails as.\n const sendable = await filterTopicSendable(\n options.hostId,\n topicId,\n [email],\n options.firestore,\n group,\n )\n return sendable.length ? null : 'topic-unsubscribed'\n}\n"],"names":["consentGroupForHost","flowEmailTopicId","readMarketingBasis","marketingConsentDecision","resolveMarketingConsentPolicy","firebaseAdmin","findContactByEmail","filterTopicSendable","orgDataQueryForHost","readPersonRecord","hostId","email","firestore","db","app","ref","contact","data","error","console","query","lead","where","limit","get","docs","flowEmailRefusal","options","immediate","scope","String","trim","toLowerCase","policy","org","record","group","decision","verdict","reason","topicId","sendable","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgDC,GAED,SACEA,mBAAmB,EACnBC,gBAAgB,EAChBC,kBAAkB,EAClBC,wBAAwB,EACxBC,6BAA6B,QACxB,sBAAqB;AAC5B,OAAOC,mBAAmB,sBAAkB;AAC5C,SAASC,kBAAkB,QAAQ,2BAAuB;AAC1D,SAASC,mBAAmB,QAAQ,yBAAqB;AACzD,SAASC,mBAAmB,QAAQ,qBAAiB;AA6CrD;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,eAAeC,iBACbC,MAAc,EACdC,KAAa,EACbC,SAAe;IAEf,MAAMC,KAAKD,oBAAAA,YAAaP,cAAcS,GAAG,GAAGF,SAAS;IACrD,IAAI;QACF,MAAM,EAAEG,GAAG,EAAE,GAAG,MAAMP,oBAAoBE,QAAQ;QAClD,MAAMM,UAAU,MAAMV,mBAAmBS,KAAKJ,OAAO;YAAED;QAAO;QAC9D,IAAIM,SAAS,OAAOA,QAAQC,IAAI;IAClC,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,sCAAsCR,QAAQQ;IAC9D;IACA,IAAI;QACF;;;;;;KAMC,GACD,MAAM,EAAEE,KAAK,EAAE,GAAG,MAAMZ,oBAAoBE,QAAQ;QACpD,MAAMW,OAAO,AAAC,CAAA,MAAMD,MAAME,KAAK,CAAC,SAAS,MAAMX,OAAOY,KAAK,CAAC,GAAGC,GAAG,EAAC,EAAGC,IAAI,CAAC,EAAE;QAC7E,IAAIJ,MAAM,OAAOA,KAAKJ,IAAI;IAC5B,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,mCAAmCR,QAAQQ;IAC3D;IACA,OAAO;AACT;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeQ,iBAAiBC,OAQtC;QAEsBA,gBAqBlBA;QAfAA;IAPH,MAAMC,YAAYD,QAAQE,KAAK,KAAK;IACpC,MAAMlB,QAAQmB,QAAOH,iBAAAA,QAAQhB,KAAK,YAAbgB,iBAAiB,IACnCI,IAAI,GACJC,WAAW;IACd,IAAI,CAACrB,OAAO,OAAO;IAEnB,MAAMsB,SAAS7B,+BACZuB,gBAAAA,QAAQO,GAAG,qBAAZ,AAACP,aAAqD,CACpD,yBACD;IAEH,MAAMQ,SAAS,MAAM1B,iBACnBkB,QAAQjB,MAAM,EACdC,OACAgB,QAAQf,SAAS;IAEnB;;;;GAIC,GACD,MAAMwB,QAAQpC,qBACX2B,eAAAA,QAAQO,GAAG,YAAXP,eAAuD,MACxDA,QAAQjB,MAAM;IAEhB,MAAM2B,WAAWlC,yBACfD,mBAAmBiC,QAAQC,QAC3BH;IAEF;;;;;;GAMC,GACD,IACEI,SAASC,OAAO,KAAK,cACpB,CAAA,CAACV,aAAaS,SAASE,MAAM,KAAK,UAAS,GAC5C;QACA,OAAO;IACT;IAEA,MAAMC,UAAUvC,iBAAiB0B,QAAQa,OAAO,EAAEb,QAAQE,KAAK;IAC/D,IAAI,CAACW,SAAS,OAAO;IACrB,yEAAyE;IACzE,oDAAoD;IACpD,MAAMC,WAAW,MAAMlC,oBACrBoB,QAAQjB,MAAM,EACd8B,SACA;QAAC7B;KAAM,EACPgB,QAAQf,SAAS,EACjBwB;IAEF,OAAOK,SAASC,MAAM,GAAG,OAAO;AAClC"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-flow-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE TWO GATES A FLOW EMAIL OWES THAT A REPLY DOES NOT.\n *\n * `sendEmail`'s marketing seam already asks the three questions every\n * merchant-triggered message owes — both suppression lists, the per-person\n * frequency ceiling, and an unsubscribe mechanism — at one chokepoint, for\n * any caller that declares `marketing: { hostId, siteBase }`. Two more belong\n * to a message a CAMPAIGN would owe and an immediate reply would not:\n *\n * 1. **The consent split.** Does this person have a basis to be mailed\n * marketing by this site, under the org's own policy?\n * 2. **The topic filter.** Have they left this particular stream?\n *\n * ## Why a flow email is on the campaign's side of that line\n *\n * `marketing-send.ts` draws it precisely: a message is marketing when the\n * merchant decided to mail them, rather than because the recipient just did\n * something. An action's immediate `sendEmail` is the second — the visitor\n * submitted the form a moment ago and the reply is the response to it, which\n * is why that path stays priority `transactional`.\n *\n * A step that runs three days later is the first. The person did one thing\n * once; everything after the wait is the merchant's schedule, not the\n * recipient's act. So a flow email is checked here, and the answer is the\n * same one a campaign would get for the same person.\n *\n * ## The middle ground, which used to be nothing at all\n *\n * An immediate reply used to ask NEITHER question, and that was too little.\n * The recipient comes out of the event payload — an anonymous visitor's write\n * on the collect route — so \"the recipient just did something\" is an\n * assumption about who typed the address, not a fact the path establishes.\n * Somebody with a recorded refusal on this site received merchant-authored\n * content whenever a third party entered their address in a form.\n *\n * It cannot ask the full question either: the default policy is `strict`, and\n * under it an address with no record is withheld — which is every first-time\n * visitor. {@link FlowEmailScope} is where that line is drawn.\n *\n * ## What this deliberately does NOT do\n *\n * It compares no count against any limit. A flow send is metered on the cost\n * meter like every other automated message and is refused by no plan quota —\n * `emailSendsPerMonth` governs campaigns, which are the discretionary send a\n * customer can see, delay and argue with. Making a sequence stop mid-way\n * through somebody's welcome series because a monthly figure moved would be a\n * capacity limit enforced at the person, which is the shape this codebase\n * refuses everywhere else.\n */\n\nimport {\n consentGroupForHost,\n flowEmailTopicId,\n readMarketingBasis,\n marketingConsentDecision,\n resolveMarketingConsentPolicy,\n} from '@aglyn/aglyn/server'\nimport firebaseAdmin from './firebase-admin'\nimport { findContactByEmail } from './contact-email-index'\nimport { filterTopicSendable } from './email-suppression'\nimport { orgDataQueryForHost } from './organizations'\n\n/** Why a flow's email was not sent. Null when it may go. */\nexport type FlowEmailRefusal =\n /** No basis to mail this person marketing, under the org's policy. */\n | 'consent-withheld'\n /** They have left the stream this message belongs to. */\n | 'topic-unsubscribed'\n\n/**\n * WHICH OF THE TWO QUESTIONS THIS MESSAGE HAS TO ANSWER.\n *\n * The distinction the module header draws, made into a parameter, because the\n * middle ground it leaves out is where a real defect lived: an immediate step\n * asked NEITHER question, so an address with a recorded refusal on this site\n * received merchant-authored content whenever somebody typed it into a form.\n *\n * `'scheduled'` — a step after a `wait`, or anything else that goes out on\n * the merchant's schedule. The org's full consent policy applies, and an\n * unnamed stream resolves to the default one, exactly as a campaign's does.\n *\n * `'immediate'` — a reply to an act the recipient just performed. Only a\n * STATED refusal stops it, and only a stream the step actually named.\n *\n * ## Why an immediate reply cannot take the full policy\n *\n * {@link DEFAULT_MARKETING_CONSENT_POLICY} is `strict`, which is what an org\n * that has never opened the setting has. Under it an address with no record\n * is withheld — and a first-time visitor filling in a form is exactly an\n * address with no record. Applying the split there would refuse the\n * auto-response to almost every new visitor on almost every site, which is a\n * far larger failure than the one being fixed.\n *\n * A stated refusal is different in kind and needs no policy to read it: the\n * person ticked the box that says do not market to me. `declined` is withheld\n * under every mode, so this narrows to the one answer that is the person's\n * own words rather than the org's configuration.\n *\n * The topic half narrows the same way. Defaulting an unnamed stream to\n * \"Promotions and offers\" would let a promotions opt-out silence a reply to a\n * contact form, and a form reply is not a promotion. A step that NAMES a\n * stream has said what it is, and is filtered on it.\n */\nexport type FlowEmailScope = 'scheduled' | 'immediate'\n\n/**\n * The person's own record, from the two silos that hold a consent basis for\n * somebody an automation can reach.\n *\n * `contacts` first because it is the canonical person store and the one the\n * consent capture surfaces write; `leads` second because a welcome series\n * fires on a sign-up that may not have produced a contact yet. Two keyed\n * reads at most, and only on a message that is about to be sent — the\n * expensive shape would be reading these on a beat, for people no step is\n * mailing.\n *\n * The contact is found through the org's address index narrowed to this\n * site (AGL-2633): a basis recorded on a record that was later merged into\n * another is the survivor's basis now, and the address the message goes to\n * may be the one that became an alternate.\n *\n * An address in NEITHER silo reads as record-less, which is what\n * `readMarketingBasis(null)` describes: `unrecorded`, with no capture date.\n * Under a `forward` policy that grandfathers and under `strict` it is\n * withheld — the same answer a campaign gives the same address, which is the\n * property that matters.\n */\nasync function readPersonRecord(\n hostId: string,\n email: string,\n firestore?: any,\n): Promise<Record<string, unknown> | null> {\n const db = firestore ?? firebaseAdmin.app().firestore()\n try {\n const { ref } = await orgDataQueryForHost(hostId, 'contacts')\n const contact = await findContactByEmail(ref, email, { hostId })\n if (contact) return contact.data() as Record<string, unknown>\n } catch (error) {\n console.error('[flow-email] contact lookup failed', hostId, error)\n }\n try {\n /*\n * SCOPED (AGL-3275). The collection is org-wide now, so an unnarrowed\n * `where('email', ...)` would answer this site with a sibling brand's\n * lead — and this function decides whether to MAIL the person it finds.\n * `orgDataQueryForHost` applies the same `visibleTo` clause the contact\n * lookup above already goes through.\n */\n const { query } = await orgDataQueryForHost(hostId, 'leads')\n const lead = (await query.where('email', '==', email).limit(1).get()).docs[0]\n if (lead) return lead.data() as Record<string, unknown>\n } catch (error) {\n console.error('[flow-email] lead lookup failed', hostId, error)\n }\n return null\n}\n\n/**\n * May this site mail this person this flow's message?\n *\n * FAILS CLOSED on consent and OPEN on topic, which is the asymmetry\n * `filterTopicSendable` already argues one layer down: a missing consent\n * basis is the whole question, where a topic preference is a narrower fact\n * whose lookup failing is no reason to withhold a message the person has a\n * basis to receive.\n *\n * @param org the owning org's document data, already read by the caller's\n * entitlement gate. Passed rather than re-read: the executor holds\n * it for the whole run, and the policy is the only thing needed\n * from it.\n */\nexport async function flowEmailRefusal(options: {\n hostId: string\n email: string\n topicId?: string | null\n org?: unknown\n firestore?: any\n /** See {@link FlowEmailScope}. Defaults to the stricter `'scheduled'`. */\n scope?: FlowEmailScope\n}): Promise<FlowEmailRefusal | null> {\n const immediate = options.scope === 'immediate'\n const email = String(options.email ?? '')\n .trim()\n .toLowerCase()\n if (!email) return 'consent-withheld'\n\n const policy = resolveMarketingConsentPolicy(\n (options.org as Record<string, unknown> | undefined)?.[\n 'marketingConsentPolicy'\n ],\n )\n const record = await readPersonRecord(\n options.hostId,\n email,\n options.firestore,\n )\n /*\n * The group the SEND belongs to, not the site alone. Three sites declared\n * as one sender share a basis, and an automation running on any of them is\n * that sender — resolved from the org the caller already read.\n */\n const group = consentGroupForHost(\n (options.org as Record<string, unknown> | undefined) ?? null,\n options.hostId,\n )\n const decision = marketingConsentDecision(\n readMarketingBasis(record, group),\n policy,\n )\n /*\n * An immediate reply is stopped by the person's own `declined` and by\n * nothing else the policy decides. Every other `withheld` reason —\n * `no-basis`, `not-grandfathered`, `other-host` — is a statement about\n * configuration and capture history, and under the default `strict` mode\n * the first of them describes every new visitor.\n */\n if (\n decision.verdict === 'withheld' &&\n (!immediate || decision.reason === 'declined')\n ) {\n return 'consent-withheld'\n }\n\n const topicId = flowEmailTopicId(options.topicId, options.scope)\n if (!topicId) return null\n // Across the same group: a person who left this stream on a sibling site\n // left it from the sender this automation mails as — and, where the org\n // turned the switch on, a sibling's pending confirmation holds it too.\n const sendable = await filterTopicSendable(\n options.hostId,\n topicId,\n [email],\n options.firestore,\n group,\n )\n return sendable.length ? null : 'topic-unsubscribed'\n}\n"],"names":["consentGroupForHost","flowEmailTopicId","readMarketingBasis","marketingConsentDecision","resolveMarketingConsentPolicy","firebaseAdmin","findContactByEmail","filterTopicSendable","orgDataQueryForHost","readPersonRecord","hostId","email","firestore","db","app","ref","contact","data","error","console","query","lead","where","limit","get","docs","flowEmailRefusal","options","immediate","scope","String","trim","toLowerCase","policy","org","record","group","decision","verdict","reason","topicId","sendable","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgDC,GAED,SACEA,mBAAmB,EACnBC,gBAAgB,EAChBC,kBAAkB,EAClBC,wBAAwB,EACxBC,6BAA6B,QACxB,sBAAqB;AAC5B,OAAOC,mBAAmB,sBAAkB;AAC5C,SAASC,kBAAkB,QAAQ,2BAAuB;AAC1D,SAASC,mBAAmB,QAAQ,yBAAqB;AACzD,SAASC,mBAAmB,QAAQ,qBAAiB;AA6CrD;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,eAAeC,iBACbC,MAAc,EACdC,KAAa,EACbC,SAAe;IAEf,MAAMC,KAAKD,oBAAAA,YAAaP,cAAcS,GAAG,GAAGF,SAAS;IACrD,IAAI;QACF,MAAM,EAAEG,GAAG,EAAE,GAAG,MAAMP,oBAAoBE,QAAQ;QAClD,MAAMM,UAAU,MAAMV,mBAAmBS,KAAKJ,OAAO;YAAED;QAAO;QAC9D,IAAIM,SAAS,OAAOA,QAAQC,IAAI;IAClC,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,sCAAsCR,QAAQQ;IAC9D;IACA,IAAI;QACF;;;;;;KAMC,GACD,MAAM,EAAEE,KAAK,EAAE,GAAG,MAAMZ,oBAAoBE,QAAQ;QACpD,MAAMW,OAAO,AAAC,CAAA,MAAMD,MAAME,KAAK,CAAC,SAAS,MAAMX,OAAOY,KAAK,CAAC,GAAGC,GAAG,EAAC,EAAGC,IAAI,CAAC,EAAE;QAC7E,IAAIJ,MAAM,OAAOA,KAAKJ,IAAI;IAC5B,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,mCAAmCR,QAAQQ;IAC3D;IACA,OAAO;AACT;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeQ,iBAAiBC,OAQtC;QAEsBA,gBAqBlBA;QAfAA;IAPH,MAAMC,YAAYD,QAAQE,KAAK,KAAK;IACpC,MAAMlB,QAAQmB,QAAOH,iBAAAA,QAAQhB,KAAK,YAAbgB,iBAAiB,IACnCI,IAAI,GACJC,WAAW;IACd,IAAI,CAACrB,OAAO,OAAO;IAEnB,MAAMsB,SAAS7B,+BACZuB,gBAAAA,QAAQO,GAAG,qBAAZ,AAACP,aAAqD,CACpD,yBACD;IAEH,MAAMQ,SAAS,MAAM1B,iBACnBkB,QAAQjB,MAAM,EACdC,OACAgB,QAAQf,SAAS;IAEnB;;;;GAIC,GACD,MAAMwB,QAAQpC,qBACX2B,eAAAA,QAAQO,GAAG,YAAXP,eAAuD,MACxDA,QAAQjB,MAAM;IAEhB,MAAM2B,WAAWlC,yBACfD,mBAAmBiC,QAAQC,QAC3BH;IAEF;;;;;;GAMC,GACD,IACEI,SAASC,OAAO,KAAK,cACpB,CAAA,CAACV,aAAaS,SAASE,MAAM,KAAK,UAAS,GAC5C;QACA,OAAO;IACT;IAEA,MAAMC,UAAUvC,iBAAiB0B,QAAQa,OAAO,EAAEb,QAAQE,KAAK;IAC/D,IAAI,CAACW,SAAS,OAAO;IACrB,yEAAyE;IACzE,wEAAwE;IACxE,uEAAuE;IACvE,MAAMC,WAAW,MAAMlC,oBACrBoB,QAAQjB,MAAM,EACd8B,SACA;QAAC7B;KAAM,EACPgB,QAAQf,SAAS,EACjBwB;IAEF,OAAOK,SAASC,MAAM,GAAG,OAAO;AAClC"}
|
|
@@ -324,7 +324,9 @@ export declare function filterCadenceSendable(hostId: string, emails: readonly s
|
|
|
324
324
|
* Every opt-out below is read across the sender's consent group
|
|
325
325
|
* (`request.consentHostIds`) — the suppression list, the stream, and the
|
|
326
326
|
* pace — because a person who left one site of a declared group left the
|
|
327
|
-
* sender.
|
|
327
|
+
* sender. A sibling's pending confirmation holds the stream too when the
|
|
328
|
+
* request says the group waits (`request.consentAwaitsConfirmation`). The
|
|
329
|
+
* frequency ceiling and the sunset stay the sending site's own.
|
|
328
330
|
*/
|
|
329
331
|
export declare function marketingSendVerdict(request: MarketingSendGateRequest, options?: {
|
|
330
332
|
nowMs?: number;
|
|
@@ -433,17 +433,18 @@ function frequencyDoc(hostId, emailKey, firestore) {
|
|
|
433
433
|
/**
|
|
434
434
|
* The consent group a gate request was made by, or `null` for the site alone.
|
|
435
435
|
*
|
|
436
|
-
* The sender resolved it — `MarketingSendContext.consentHostIds
|
|
437
|
-
* it already held — so the gate
|
|
438
|
-
*
|
|
439
|
-
* site alone: the gate must never
|
|
440
|
-
* that throws by sending ungated.
|
|
436
|
+
* The sender resolved it — `MarketingSendContext.consentHostIds` and
|
|
437
|
+
* `consentAwaitsConfirmation`, from the org it already held — so the gate
|
|
438
|
+
* spends no org read on it. A list that does not name the sending site is not
|
|
439
|
+
* a group resolved for it, and reads as the site alone: the gate must never
|
|
440
|
+
* throw, because `sendEmail` answers a gate that throws by sending ungated.
|
|
441
441
|
*/ function requestConsentGroup(request) {
|
|
442
442
|
const hostIds = request.consentHostIds;
|
|
443
443
|
if (!Array.isArray(hostIds) || !hostIds.includes(request.hostId)) return null;
|
|
444
444
|
return {
|
|
445
445
|
hostId: request.hostId,
|
|
446
|
-
hostIds: hostIds.filter((id)=>typeof id === 'string' && id)
|
|
446
|
+
hostIds: hostIds.filter((id)=>typeof id === 'string' && id),
|
|
447
|
+
awaitsConfirmation: request.consentAwaitsConfirmation === true
|
|
447
448
|
};
|
|
448
449
|
}
|
|
449
450
|
/**
|
|
@@ -499,7 +500,9 @@ function frequencyDoc(hostId, emailKey, firestore) {
|
|
|
499
500
|
* Every opt-out below is read across the sender's consent group
|
|
500
501
|
* (`request.consentHostIds`) — the suppression list, the stream, and the
|
|
501
502
|
* pace — because a person who left one site of a declared group left the
|
|
502
|
-
* sender.
|
|
503
|
+
* sender. A sibling's pending confirmation holds the stream too when the
|
|
504
|
+
* request says the group waits (`request.consentAwaitsConfirmation`). The
|
|
505
|
+
* frequency ceiling and the sunset stay the sending site's own.
|
|
503
506
|
*/ export async function marketingSendVerdict(request, options) {
|
|
504
507
|
var _ref, _request_email, _request_topicId;
|
|
505
508
|
const nowMs = (_ref = options == null ? void 0 : options.nowMs) != null ? _ref : Date.now();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-marketing-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * MARKETING MAIL — the durable half.\n *\n * `marketing-send.ts` in `@aglyn/shared-util-email` states the policy and\n * holds the injection seam; this answers the question, because answering it\n * needs Firestore and that library may not hold the Admin SDK.\n *\n * Five things, in the order they cost:\n *\n * 1. **Both suppression lists**, through the one shared helper. A person who\n * unsubscribed from this site, hard-bounced anywhere in the product, or\n * pressed \"report spam\" is not mailed. Asked first because it is the only\n * one whose answer is permanent.\n * 2. **The pace the RECIPIENT asked for** on the preference page, if they\n * asked for one.\n * 3. **An engagement sunset**, when an operator has configured a window —\n * off by default, and it costs a read only when it is on.\n * 4. **A frequency ceiling**, per recipient per site, over a rolling day.\n * 5. **The signed unsubscribe URL**, so the message carries a way out.\n *\n * ## The order of the three pace refusals, and who each one is for\n *\n * Suppression is first and needs no argument: its answer is permanent, and\n * the frequency record must not count a message that was never going to\n * leave. The other three are all refusals about PACE, and they are ordered by\n * whose refusal it is, then by what asking costs.\n *\n * **The recipient's own cadence is second — above both platform controls.**\n * It is the only one of the three a PERSON asked for; the sunset is an\n * inference this platform drew about them and the ceiling is a guard against\n * the merchant. When more than one applies, the honest answer to \"why did\n * this not send\" is the fact somebody stated, not the guess we made. It is\n * also the cheapest to ask: it is already on the counter document the ceiling\n * reads, so a recipient who asked for monthly mail never pays for the\n * sunset's engagement read. And it is the only one that binds a campaign, so\n * putting it first makes the campaign path and the automated paths evaluate\n * the same refusals in the same order rather than interleaving one control a\n * campaign skips with one it does not.\n *\n * **The sunset is third, above the ceiling.** Its refusal is TERMINAL and the\n * ceiling's is not: a sweep defers a `frequency-capped` message and retries\n * it, so reporting the retryable refusal for a person the sunset would refuse\n * anyway means the same doomed row comes back on every beat. That argument\n * does not reach past the cadence, because a cadence gap is a day, a week or\n * a month — a sweep retries such a row once per interval, not once per beat.\n *\n * ## Which refusals bind a campaign\n *\n * `MarketingSendContext.capped` is `false` for a campaign, and it governs\n * exactly two of the four:\n *\n * | Refusal | A campaign is |\n * | ------------------ | ------------- |\n * | `suppressed` | **bound** |\n * | `cadence-limited` | **bound** |\n * | `unengaged` | exempt (`capped`) |\n * | `frequency-capped` | exempt (`capped`) |\n *\n * The two exemptions share one reason: a campaign is a merchant's reviewed,\n * one-shot act with a recipient count on screen before they press Send, and a\n * platform control that silently removed people from that number would make\n * it a lie. That reason is about a control the merchant did not ask for and\n * cannot see. It does not reach a request the RECIPIENT made — a campaign\n * that overrode the preference page would make it a form recording a choice\n * nothing honors, which is ignoring an unsubscribe one notch quieter.\n *\n * A campaign COUNTS toward the ceiling either way; it is exempt from the\n * refusal, never from the counting.\n *\n * ## The counter is a WINDOW, not a total\n *\n * `hosts/{hostId}/emailFrequency/{sha256(address)}` holds the instants of the\n * marketing messages this site sent this person, trimmed to the window on\n * every touch. A rolling window needs the instants; a running total would\n * need a reset nobody triggers, and a per-day bucket would let a merchant\n * send the whole ceiling at 23:55 and the whole ceiling again at 00:05.\n *\n * The record is bounded by the cap — a handful of numbers — because\n * `marketingFrequencyVerdict` returns the trimmed window and this writes back\n * only that. It is server-owned: the address is hashed exactly as the two\n * suppression lists hash it, and no client writes or reads it.\n *\n * ## The check and the write are not one transaction, on purpose\n *\n * Two concurrent marketing sends to the same person can both read a window\n * with room and both append, so the cap can be exceeded by the number of\n * messages genuinely in flight at that instant. That is accepted rather than\n * papered over: a transaction per recipient would double the round trips on a\n * path that is already one awaited HTTP POST per person, and being off by one\n * on a runaway guard costs a recipient one extra message. Being slow costs\n * every recipient.\n *\n * ## Never a reduction of the person or their data\n *\n * A cap here refuses a SEND. It never removes a contact, never unsubscribes\n * anybody, never trims an audience and never deletes a counter row belonging\n * to somebody. `over-limit.ts` states the rule for capacity limits and this is\n * the same instrument pointed at time: the message does not go, the person\n * stays exactly where they were, and the next window mails them again. The\n * recipient's own cadence is the same shape — a person who asked for monthly\n * mail stays on every audience they were on and is mailed again next month.\n */\n\nimport {\n DEFAULT_MARKETING_CADENCE,\n getMarketingSendGate,\n marketingCadenceVerdict,\n marketingFrequencyVerdict,\n marketingSunsetDays,\n marketingSunsetVerdict,\n normalizeMarketingCadence,\n setMarketingSendGate,\n type MarketingCadence,\n type MarketingSendGateRequest,\n type MarketingSendGateVerdict,\n} from '@aglyn/shared-util-email'\nimport type { ConsentGroup } from '@aglyn/aglyn/app-utils/consent-groups'\nimport firebaseAdmin from './firebase-admin'\nimport { readPersonEngagement } from './email-delivery-log'\nimport {\n emailSuppressionKey,\n filterSendableForHost,\n filterTopicSendable,\n getAllAcrossSites,\n optOutHostIds,\n} from './email-suppression'\nimport { buildUnsubscribeUrl } from './email-unsubscribe-link'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * Where the per-recipient window lives.\n *\n * Under the host rather than the org: the ceiling is \"how much mail this SITE\n * sends one person\", which is the unit a recipient experiences and the unit\n * the unsubscribe link is scoped to. An org-wide counter would let one site's\n * newsletter suppress a sibling site's order-related marketing, and the two\n * sites may be unrelated brands.\n */\nexport const EMAIL_FREQUENCY_SUBCOLLECTION = 'emailFrequency'\n\n/** How each cadence reads inside a refusal sentence. */\nconst CADENCE_PHRASES: Record<MarketingCadence, string> = {\n all: 'at this pace',\n daily: 'a day',\n weekly: 'a week',\n monthly: 'a month',\n}\n\n/** The stored window. */\nexport interface EmailFrequencyRecord {\n /** The address, in the clear, lowercased. The id is its hash. */\n email: string\n /** Marketing send instants, trimmed to the window on every touch. */\n sentAtMs: number[]\n /**\n * When this site FIRST sent this person marketing mail.\n *\n * Written once and never again, and it rides here rather than in a store of\n * its own because this document is already written on every marketing send:\n * carrying the field costs nothing, and a second per-recipient document\n * would be a write per send per person for one number.\n *\n * The sunset is what reads it — see `marketingSunsetVerdict`. A person we\n * have not been mailing for longer than the sunset window cannot have been\n * quiet for longer than the sunset window, so this is the field that keeps\n * a new subscriber from being refused for having nothing on record yet.\n *\n * Null for every row written before this field existed, which reads as \"no\n * record\" and therefore refuses nobody. That is the conservative direction\n * for a field introduced after the data, and it self-heals: the row gains\n * the stamp on this site's next marketing send to that person, and the\n * clock starts from then rather than from a past nobody recorded.\n */\n firstSentAtMs?: number\n /**\n * The most recent marketing send, NEVER trimmed.\n *\n * `sentAtMs` is a rolling day, so after a quiet day it is empty and cannot\n * answer \"when did this site last mail this person\" — which is the only\n * question a weekly or monthly cadence asks. One number rather than a\n * window kept for a month.\n *\n * The bookends of the same relationship: `firstSentAtMs` never moves and\n * this one moves on every send, so between them they answer both \"how long\n * have we been mailing this person\" and \"how long since we last did\".\n */\n lastSentAtMs?: number\n /**\n * How often the RECIPIENT asked to hear from this site.\n *\n * Stored on the counter document rather than beside the topic opt-outs, and\n * that is the whole reason it costs nothing: the gate already reads this\n * document on every marketing message, so honoring the preference adds no\n * round trip to any send. It also puts the preference in the one document\n * that is already about how much mail this site sends this person.\n *\n * Absent on every record written before it existed, which reads as no\n * expressed preference — see `normalizeMarketingCadence`.\n */\n cadence?: MarketingCadence\n /** When the recipient chose it, for the record that the request was made. */\n cadenceSetAtMs?: number\n}\n\n/**\n * What one recipient's counter document says, with its defaults applied.\n *\n * Everything the gate asks of Firestore about this person on this site, in\n * one shape, because it is fetched in one round trip — see\n * {@link readMarketingFrequencyState}.\n */\nexport interface MarketingFrequencyState {\n /** Send instants inside the rolling window. */\n window: number[]\n /** When this site first mailed them, or `null` for no record. */\n firstSentAtMs: number | null\n /** The most recent send, or `null` for somebody never mailed. */\n lastSentAtMs: number | null\n /** The recipient's chosen pace. */\n cadence: MarketingCadence\n}\n\n/** What an unreadable, absent or unkeyable counter reads as. */\nconst NO_RECORD: MarketingFrequencyState = {\n window: [],\n firstSentAtMs: null,\n lastSentAtMs: null,\n cadence: 'all',\n}\n\n/**\n * One counter snapshot, decoded.\n *\n * Shared by {@link readMarketingFrequencyState} and\n * {@link filterCadenceSendable} so the two cannot come to different\n * conclusions about the same document — in particular about the\n * `lastSentAtMs` fallback, where a per-message answer and a per-campaign\n * answer that disagreed would refuse a recipient on one path and mail them on\n * the other.\n */\nfunction stateFromSnapshot(snapshot: any): MarketingFrequencyState {\n const stored = snapshot.get('sentAtMs')\n const window = Array.isArray(stored)\n ? stored.map((at: unknown) => Number(at))\n : []\n const first = Number(snapshot.get('firstSentAtMs'))\n const last = Number(snapshot.get('lastSentAtMs'))\n return {\n window,\n firstSentAtMs: Number.isFinite(first) && first > 0 ? first : null,\n /*\n * The stored instant, or the newest entry still inside the window.\n *\n * The fallback is what makes this work on every record written before\n * `lastSentAtMs` existed: those carry a window and nothing else, and\n * reading `null` for them would let a monthly cadence pass on the first\n * message after this ships for anybody mailed in the last day.\n *\n * `firstSentAtMs` deliberately has NO such fallback. The window would be\n * the wrong answer for it in exactly the direction that matters: a\n * relationship dated from a send inside the last day is younger than any\n * sunset window, so the sunset could never fire — and a relationship\n * dated from the oldest entry in a rolling DAY is not the first send\n * either. Absent means \"no record\", and no record refuses nobody.\n */\n lastSentAtMs: Number.isFinite(last)\n ? last\n : window.length\n ? Math.max(...window)\n : null,\n cadence: normalizeMarketingCadence(snapshot.get('cadence')),\n }\n}\n\n/** What the recipient's own pace decides on: their choice and the last send. */\ninterface MarketingPace {\n cadence: MarketingCadence\n lastSentAtMs: number | null\n}\n\n/**\n * The pace a person asked a CONSENT GROUP for, and when the group last mailed\n * them, from the counter documents of the sites in it that hold one.\n *\n * Each site's preference page writes its own counter, and to the person every\n * one of those pages was the same sender's — so the choice made MOST\n * RECENTLY, on whichever site, is the one in force, and a later \"as they\n * come\" undoes an earlier \"monthly\" exactly as it would on one site. The last\n * send is the newest across the group, because one a week from a sender is\n * one a week whichever of its sites sent it.\n *\n * Only the pace is pooled. The frequency ceiling and the sunset stay each\n * site's own: they are controls this platform keeps over a sender's volume,\n * not a request the person made.\n *\n * `snapshots` are documents that exist, the sending site's first, and it\n * keeps a tie. A single snapshot answers exactly what `stateFromSnapshot`\n * answers for it, which is what lets a site alone run through here unchanged.\n */\nfunction groupPace(snapshots: readonly any[]): MarketingPace {\n let cadence: MarketingCadence = DEFAULT_MARKETING_CADENCE\n let chosenAtMs = Number.NEGATIVE_INFINITY\n let lastSentAtMs: number | null = null\n for (const snapshot of snapshots) {\n const state = stateFromSnapshot(snapshot)\n if (\n state.lastSentAtMs !== null &&\n (lastSentAtMs === null || state.lastSentAtMs > lastSentAtMs)\n ) {\n lastSentAtMs = state.lastSentAtMs\n }\n // A counter the sends wrote carries no choice; it is not a vote for\n // \"as they come\", it is no vote at all.\n if (snapshot.get('cadence') == null) continue\n const setAtMs = Number(snapshot.get('cadenceSetAtMs'))\n const atMs = Number.isFinite(setAtMs) ? setAtMs : 0\n if (atMs > chosenAtMs) {\n chosenAtMs = atMs\n cadence = state.cadence\n }\n }\n return { cadence, lastSentAtMs }\n}\n\nfunction frequencyDoc(\n hostId: string,\n emailKey: string,\n firestore?: any,\n): FirebaseFirestore.DocumentReference {\n return (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .collection(EMAIL_FREQUENCY_SUBCOLLECTION)\n .doc(emailKey)\n}\n\n/**\n * The instants inside the window for one recipient of one site's mail.\n *\n * FAILS OPEN — an empty window, meaning \"nothing recorded, so there is room\".\n * The opposite of {@link filterSendableForHost}, and the difference is what\n * each answer costs when it is wrong. A suppression read that fails open\n * mails somebody who told us to stop; a frequency read that fails closed\n * refuses a message nobody objected to, over a counter that is a runaway\n * guard rather than a consent record.\n */\nexport async function readMarketingFrequency(\n hostId: string,\n email: string,\n firestore?: any,\n): Promise<number[]> {\n return (await readMarketingFrequencyState(hostId, email, firestore)).window\n}\n\n/**\n * The whole counter document — the window, the first send, the last send and\n * the recipient's chosen pace — in ONE round trip.\n *\n * **One read, four facts, and that is a requirement rather than a tidiness.**\n * Three separate refusals in {@link marketingSendVerdict} read this document,\n * and a `get` per refusal would be three awaited round trips on a path that\n * is already one awaited HTTP POST per recipient. Which is also why the\n * recipient's cadence is stored HERE rather than beside the topic opt-outs:\n * honoring a preference this gate has to consult on every marketing message\n * has to cost nothing, and riding on a document already being read is the\n * only shape that does. See {@link EmailFrequencyRecord.cadence}.\n *\n * FAILS OPEN on every field, and in the same direction each time: an empty\n * window means \"there is room\", a null `firstSentAtMs` means \"no record\", a\n * null `lastSentAtMs` means \"never mailed\", and an unreadable cadence reads\n * as no preference expressed. An unreadable counter is not evidence that\n * somebody asked for less, and it is not evidence that they have gone cold —\n * the opposite of {@link filterSendableForHost}, for the reason\n * {@link readMarketingFrequency} states.\n */\nexport async function readMarketingFrequencyState(\n hostId: string,\n email: string,\n firestore?: any,\n): Promise<MarketingFrequencyState> {\n const key = emailSuppressionKey(email)\n if (!key) return { ...NO_RECORD }\n try {\n const snapshot = await frequencyDoc(hostId, key, firestore).get()\n if (!snapshot.exists) return { ...NO_RECORD }\n return stateFromSnapshot(snapshot)\n } catch (error) {\n console.error('[email-marketing] frequency read failed; allowing', error)\n return { ...NO_RECORD }\n }\n}\n\n/**\n * Records the pace a recipient asked for.\n *\n * Written from the preference page, which is unauthenticated and reached by a\n * signed link — so the CALLER has already proved the request is this\n * address's. A merge, because the document is the send counter and this must\n * not disturb the window it shares with.\n *\n * Never throws: a preference that failed to store is a page that should say\n * so, not a 500 on a screen a recipient reached in order to leave.\n *\n * @returns whether it was stored.\n */\nexport async function setMarketingCadence(\n hostId: string,\n email: string,\n cadence: MarketingCadence,\n options?: { nowMs?: number; firestore?: any },\n): Promise<boolean> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId) return false\n try {\n await frequencyDoc(hostId, key, options?.firestore).set(\n {\n email: String(email).trim().toLowerCase(),\n cadence: normalizeMarketingCadence(cadence),\n cadenceSetAtMs: options?.nowMs ?? Date.now(),\n },\n { merge: true },\n )\n return true\n } catch (error) {\n console.error('[email-marketing] cadence write failed', error)\n return false\n }\n}\n\n/**\n * Records marketing messages against the recipients' windows.\n *\n * Takes a LIST because the campaign sender records a whole batch at once: it\n * is exempt from the refusal (a reviewed, one-shot act whose recipient count\n * is on screen before it goes) but not from the counting, since a campaign is\n * most of the mail a person receives from a site. Doing that per recipient\n * inside the send loop would add a round trip to each of up to 500 sequential\n * sends; doing it here adds one batch after them.\n *\n * Never throws. A lost counter increment means one recipient's ceiling is\n * measured a message low, which is not worth failing a delivered send over.\n */\nexport async function recordMarketingSends(\n hostId: string,\n emails: readonly string[],\n options?: { nowMs?: number; firestore?: any },\n): Promise<number> {\n const nowMs = options?.nowMs ?? Date.now()\n const keyed = new Map<string, string>()\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key) keyed.set(key, String(email).trim().toLowerCase())\n }\n if (!keyed.size || !hostId) return 0\n let recorded = 0\n try {\n const db = options?.firestore ?? defaultFirestore()\n for (const [key, email] of keyed) {\n const ref = frequencyDoc(hostId, key, db)\n const existing: MarketingFrequencyState = await ref\n .get()\n .then((snapshot: any) =>\n snapshot.exists ? stateFromSnapshot(snapshot) : { ...NO_RECORD },\n )\n .catch(() => ({ ...NO_RECORD }))\n const window = marketingFrequencyVerdict(\n [...existing.window, nowMs],\n nowMs,\n )\n await ref.set(\n {\n email,\n sentAtMs: window.inWindow,\n lastSentAtMs: nowMs,\n // Write-once, unlike `lastSentAtMs` beside it. Overwriting it would\n // restart the sunset clock on every send, which would make the\n // sunset unreachable — a person whose relationship is always \"as\n // old as the last message\" is never older than the window.\n ...(existing.firstSentAtMs ? {} : { firstSentAtMs: nowMs }),\n },\n { merge: true },\n )\n recorded += 1\n }\n } catch (error) {\n console.error('[email-marketing] frequency record failed', error)\n }\n return recorded\n}\n\n/**\n * The subset of `emails` that has NOT asked this site for mail less often\n * than right now.\n *\n * The fourth filter a campaign passes, after the platform suppression list,\n * the site's own and the topic opt-outs — and the only one that is not in\n * `email-suppression.ts` beside those three, because it reads the counter\n * document this module owns and that module is this module's dependency.\n *\n * {@link marketingSendVerdict} enforces the same rule per message, for the\n * senders that reach the gate. THIS is the campaign path's copy, and it is\n * the enforcement there rather than only a count: a campaign carries no\n * `marketing` context — it mints its own unsubscribe URL upstream — so the\n * gate is not on its path at all, and a rule asked only there would not be\n * asked of the sender that produces most of a person's mail.\n *\n * Answering it as a FILTER rather than per message is also the only placement\n * that keeps the composer honest. The argument for exempting a campaign from\n * the platform ceiling is that a control which silently removed people from a\n * reviewed one-shot send would make the number on screen a lie; subtracting\n * a request the recipient actually made, where every other refusal is already\n * subtracted, is what keeps this one from having that problem.\n *\n * Keyed and read with one `getAll`, matching its three neighbors: one round\n * trip bounded by the size of the send, and no composite index to go missing.\n *\n * ## \"This site\" means its consent group\n *\n * `group` is the sending site's consent group, and the pace is the one the\n * person asked THE GROUP for — see {@link groupPace}. Omitted, the site alone,\n * which is also the whole answer for an org that declared no group: one\n * `getAll` over the same documents it always read.\n *\n * ## Fails OPEN, like the topic filter beside it\n *\n * A cadence is a PACE, not a stop. Guessing wrong on an unreadable counter\n * costs one recipient one message sooner than they asked for; guessing wrong\n * the other way withholds a whole campaign on a transient read failure. The\n * two suppression lists have already refused everybody who asked us to stop\n * entirely, so nobody who said \"no\" reaches this line.\n */\nexport async function filterCadenceSendable(\n hostId: string,\n emails: readonly string[],\n options?: {\n nowMs?: number\n firestore?: any\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null\n },\n): Promise<string[]> {\n if (!emails.length || !hostId) return [...emails]\n const hostIds = optOutHostIds(hostId, options?.group)\n const nowMs = options?.nowMs ?? Date.now()\n // An unkeyable address carries no counter, so it has expressed no pace. It\n // is dropped from the LOOKUP and kept in the answer, exactly as the topic\n // filter keeps one: the stricter filters above have already had their say.\n const lookups: Array<{ email: string; key: string }> = []\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key) lookups.push({ email, key })\n }\n if (!lookups.length) return [...emails]\n try {\n const db = options?.firestore ?? defaultFirestore()\n const bySite = await getAllAcrossSites(\n db,\n hostIds,\n EMAIL_FREQUENCY_SUBCOLLECTION,\n lookups.map((entry) => entry.key),\n )\n const holding = new Set<string>()\n lookups.forEach((entry, index) => {\n const held = bySite\n .map((snapshots) => snapshots[index])\n .filter((snapshot) => snapshot?.exists)\n if (!held.length) return\n // The same decoder the per-message path uses, so the two cannot\n // disagree about one document. The sunset's `firstSentAtMs` is\n // deliberately not consulted: a campaign is exempt from that refusal,\n // so subtracting on it here would remove people from a count the gate\n // is going to mail anyway.\n const pace = groupPace(held)\n const verdict = marketingCadenceVerdict(\n pace.cadence,\n pace.lastSentAtMs,\n nowMs,\n )\n if (!verdict.allowed) holding.add(entry.email)\n })\n return emails.filter((email) => !holding.has(email))\n } catch (error) {\n console.error(\n '[email-marketing] cadence lookup failed; failing open',\n error,\n )\n return [...emails]\n }\n}\n\n/**\n * The consent group a gate request was made by, or `null` for the site alone.\n *\n * The sender resolved it — `MarketingSendContext.consentHostIds`, from the org\n * it already held — so the gate spends no org read on it. A list that does\n * not name the sending site is not a group resolved for it, and reads as the\n * site alone: the gate must never throw, because `sendEmail` answers a gate\n * that throws by sending ungated.\n */\nfunction requestConsentGroup(\n request: MarketingSendGateRequest,\n): Pick<ConsentGroup, 'hostId' | 'hostIds'> | null {\n const hostIds = request.consentHostIds\n if (!Array.isArray(hostIds) || !hostIds.includes(request.hostId)) return null\n return {\n hostId: request.hostId,\n hostIds: hostIds.filter((id) => typeof id === 'string' && id),\n }\n}\n\n/**\n * The sending site's counter, and the pace its consent group was asked for.\n *\n * A site alone is {@link readMarketingFrequencyState}, the one `get` it has\n * always been. A group reads every member's counter in one `getAll`: the\n * sending site's supplies the window and the first send, which stay its own,\n * and all of them together supply the pace — see {@link groupPace}.\n *\n * Fails OPEN, for the reason {@link readMarketingFrequencyState} gives.\n */\nasync function readGroupFrequency(\n hostIds: readonly string[],\n email: string,\n firestore?: any,\n): Promise<{ state: MarketingFrequencyState; pace: MarketingPace }> {\n if (hostIds.length === 1) {\n const state = await readMarketingFrequencyState(hostIds[0], email, firestore)\n return {\n state,\n pace: { cadence: state.cadence, lastSentAtMs: state.lastSentAtMs },\n }\n }\n const unread = {\n state: { ...NO_RECORD },\n pace: { cadence: NO_RECORD.cadence, lastSentAtMs: null },\n }\n const key = emailSuppressionKey(email)\n if (!key) return unread\n try {\n const db = firestore ?? defaultFirestore()\n const snapshots = await db.getAll(\n ...hostIds.map((id) => frequencyDoc(id, key, db)),\n )\n const own = snapshots[0]\n return {\n state: own?.exists ? stateFromSnapshot(own) : { ...NO_RECORD },\n pace: groupPace(snapshots.filter((snapshot: any) => snapshot?.exists)),\n }\n } catch (error) {\n console.error('[email-marketing] frequency read failed; allowing', error)\n return unread\n }\n}\n\n/**\n * The gate itself, exported so it can be exercised without installing it.\n *\n * ORDER MATTERS. The suppression check is first because its answer is\n * permanent and the frequency record must not count a message that was never\n * going to leave — a suppressed address whose window kept growing would stay\n * capped for a day after being released.\n *\n * Every opt-out below is read across the sender's consent group\n * (`request.consentHostIds`) — the suppression list, the stream, and the\n * pace — because a person who left one site of a declared group left the\n * sender. The frequency ceiling and the sunset stay the sending site's own.\n */\nexport async function marketingSendVerdict(\n request: MarketingSendGateRequest,\n options?: { nowMs?: number; firestore?: any },\n): Promise<MarketingSendGateVerdict> {\n const nowMs = options?.nowMs ?? Date.now()\n const email = String(request.email ?? '')\n .trim()\n .toLowerCase()\n const group = requestConsentGroup(request)\n const sendable = await filterSendableForHost(\n request.hostId,\n [email],\n options?.firestore,\n group,\n )\n if (!sendable.length) {\n return {\n allowed: false,\n refusal: 'suppressed',\n detail:\n 'This address has unsubscribed, bounced permanently, or reported a ' +\n 'message as spam.',\n }\n }\n\n // Normalized once, and read by both the links below and the topic check\n // further down: two spellings of \"which stream is this\" is how a link comes\n // to carry a topic the gate did not check.\n const topicId = String(request.topicId ?? '').trim()\n\n /*\n * TWO URLS OVER ONE SIGNATURE, the same split `campaign-send.ts` makes and\n * for the same reason (RFC 8058).\n *\n * `oneClickUrl` is what `List-Unsubscribe` names: a mailbox provider POSTs\n * it with nobody present and expects the act to have happened when it reads\n * the 200, so it points at the route whose POST writes immediately.\n *\n * `unsubscribeUrl` is the link a PERSON clicks in the footer, and it points\n * at the preference page. Every caller on this path — the abandoned-cart\n * sweep, the restock notice, the newsletter welcome, the automation step —\n * names a topic, and a footer pointing at the one-click route gives the\n * recipient of one of those exactly one choice: stop hearing from this site\n * entirely. The page offers leaving that one stream instead, with\n * \"Unsubscribe from everything\" still on it.\n *\n * The topic rides both links, so the page opens on the stream the message\n * belonged to rather than on a list the recipient has to search.\n */\n const link = {\n siteBase: request.siteBase,\n hostId: request.hostId,\n email,\n ...(topicId ? { topicId } : {}),\n }\n const unsubscribeUrl = buildUnsubscribeUrl({\n ...link,\n surface: 'preferences',\n })\n const oneClickUrl = buildUnsubscribeUrl({ ...link, surface: 'one-click' })\n\n /*\n * THE STREAM THIS MESSAGE BELONGS TO — the third list, and the narrowest.\n *\n * ONLY when the caller named one. An absent topic is not \"the default\n * topic\": it is a message that belongs to no stream, and there is nothing\n * for a person to have left. That is what keeps this check off the mail it\n * must never touch — a receipt, a password reset, a booking confirmation,\n * none of which declare a `marketing` context at all and none of which name\n * a stream if they somehow did.\n *\n * After the suppression lists and before the counter read below, matching\n * the order `campaign-send.ts` filters in, for two reasons that agree. It\n * is the weaker fact, and the weaker fact should never be the one that\n * decides — a person who unticked \"Promotions and offers\" is still a\n * subscriber, where a person on either suppression list is not. And it is a\n * TERMINAL refusal, so answering it before the counter read means a\n * recipient this message was never going to reach costs one lookup rather\n * than three.\n *\n * Fails OPEN, because `filterTopicSendable` does: a topic preference is a\n * narrower fact than a suppression, and a read that failed for an unrelated\n * reason is no reason to withhold a message from somebody the two lists\n * above already cleared.\n */\n if (topicId) {\n const onTopic = await filterTopicSendable(\n request.hostId,\n topicId,\n [email],\n options?.firestore,\n group,\n )\n if (!onTopic.length) {\n /*\n * ⛔ A REFUSAL AND NOTHING ELSE, as with the pace refusals below.\n *\n * No suppression is written, no membership changes, and the frequency\n * window is not appended to — a message that never left must not count\n * against what this person has received. They stay on every other\n * stream they subscribe to, and the next message on one of those goes.\n */\n return {\n allowed: false,\n refusal: 'topic-unsubscribed',\n detail:\n 'This address has left the email topic this message belongs to. ' +\n 'They still receive the other streams from this site.',\n unsubscribeUrl,\n oneClickUrl,\n }\n }\n }\n\n // ONE read, and every refusal below is answered from it — except the\n // sunset's engagement half, which is a different document and is fetched\n // only when a window is configured and nothing above has already refused.\n // For a consent group it is one `getAll`, and only the pace is pooled.\n const hostIds = optOutHostIds(request.hostId, group)\n const { state, pace } = await readGroupFrequency(\n hostIds,\n email,\n options?.firestore,\n )\n\n /*\n * THE RECIPIENT'S OWN REQUEST, first among the three pace refusals and the\n * only one not subject to `capped`.\n *\n * `capped: false` exempts a campaign from the platform CEILING and from the\n * SUNSET, because a control the merchant did not ask for and cannot see,\n * silently removing people from a reviewed one-shot send, would make the\n * recipient count on screen a lie. That argument does not reach this one: a\n * person used the preference page to ask this site for less mail, and a\n * campaign that overrode them would make that page a form that records a\n * request nobody honors — the same failure as ignoring an unsubscribe, one\n * notch quieter.\n *\n * Above the sunset for three reasons. It is the only one of the three a\n * PERSON asked for, and when both apply the honest answer to \"why did this\n * not send\" is the fact somebody stated rather than the inference we drew.\n * It is answered entirely from the counter document already in hand, so a\n * recipient who asked for monthly mail never pays for the sunset's second\n * read. And it is the one refusal a campaign is bound by, so asking it\n * first gives the campaign path and the automated paths the same order.\n *\n * The sunset's terminality argument does not out-rank any of that: a sweep\n * that defers this row retries it once per cadence interval — a day, a week\n * or a month — not once per beat, which is what that argument is about.\n *\n * A campaign is bound by the rule but not by THIS enforcement of it: it\n * carries no `marketing` context, so nothing on its path reaches this\n * function. {@link filterCadenceSendable} is where it is asked there, and\n * asked as a filter so the recipient count reflects it before Send.\n */\n const cadence = marketingCadenceVerdict(\n pace.cadence,\n pace.lastSentAtMs,\n nowMs,\n )\n if (!cadence.allowed) {\n /*\n * ⛔ A REFUSAL AND NOTHING ELSE, exactly as below.\n *\n * Nobody is unsubscribed, no membership changes, no contact is touched,\n * and the frequency window is not appended to — a message that never left\n * must not count against what this person has received. Somebody who\n * asked for monthly mail stays on every audience they were on and is\n * mailed again next month.\n */\n return {\n allowed: false,\n refusal: 'cadence-limited',\n detail:\n `This address asked ${\n hostIds.length > 1 ? 'this site’s consent group' : 'this site'\n } for no more than one marketing ` +\n `message ${CADENCE_PHRASES[pace.cadence]}. The next one may go on ` +\n `${new Date(cadence.nextAllowedAtMs).toISOString()}.`,\n unsubscribeUrl,\n oneClickUrl,\n }\n }\n\n /*\n * THE SUNSET, ahead of the frequency ceiling.\n *\n * Ordered above the ceiling because its refusal is TERMINAL and the\n * ceiling's is not: a sweep defers a `frequency-capped` message and retries\n * it, so reporting the retryable refusal for a person the sunset would\n * refuse anyway means the same doomed row comes back on every beat.\n *\n * `request.capped` governs it, the same flag the ceiling reads, so a\n * campaign — a reviewed act with its recipient count on screen — is exempt\n * for the reason recorded on `MarketingSendContext.capped`. That leaves the\n * sunset governing the automated paths, which fire with no human present.\n *\n * The engagement read is the ONLY second round trip on this path, and it is\n * spent only when a window is configured, the caller is capped, and the\n * cadence above has already allowed. Off is the default, and off costs\n * nothing.\n */\n const sunsetDays = marketingSunsetDays()\n if (request.capped && sunsetDays > 0) {\n const engagement = await readPersonEngagement(email, options?.firestore)\n const sunset = marketingSunsetVerdict(\n {\n firstSentAtMs: state.firstSentAtMs,\n lastEngagedAtMs: engagement.lastEngagedAtMs,\n },\n nowMs,\n sunsetDays,\n )\n if (!sunset.allowed) {\n /*\n * ⛔ A REFUSAL AND NOTHING ELSE.\n *\n * No suppression is written, no membership is touched, no contact is\n * changed, and the frequency window below is not appended to — a\n * message that never left must not count against what this person has\n * received. The person is exactly where they were, and the next send\n * after they open anything goes.\n */\n return {\n allowed: false,\n refusal: 'unengaged',\n detail:\n `This address has not opened or clicked anything for ` +\n `${sunset.quietForDays} days, and this site has been mailing it ` +\n `for longer than the ${sunset.days}-day engagement window. It ` +\n `becomes mailable again as soon as they engage.`,\n unsubscribeUrl,\n oneClickUrl,\n }\n }\n }\n\n const verdict = marketingFrequencyVerdict(state.window, nowMs)\n if (request.capped && !verdict.allowed) {\n return {\n allowed: false,\n refusal: 'frequency-capped',\n detail:\n `This address has already received ${verdict.used} marketing ` +\n `messages from this site today (the ceiling is ${verdict.cap}).`,\n unsubscribeUrl,\n oneClickUrl,\n }\n }\n\n /*\n * The send is granted, so it is counted — including for a campaign, which\n * is exempt from the refusal above and not from this. `sentAtMs` therefore\n * measures what the recipient actually receives rather than only the part\n * of it a cap may stop, which is the difference between a ceiling and a\n * number that describes nothing.\n */\n const appended = marketingFrequencyVerdict(\n [...verdict.inWindow, nowMs],\n nowMs,\n )\n const key = emailSuppressionKey(email)\n if (key) {\n await frequencyDoc(request.hostId, key, options?.firestore)\n .set(\n {\n email,\n sentAtMs: appended.inWindow,\n lastSentAtMs: nowMs,\n // Write-once — see EmailFrequencyRecord.firstSentAtMs. Re-stamping\n // it on every send would keep the relationship permanently younger\n // than any sunset window, so the sunset could never fire.\n ...(state.firstSentAtMs ? {} : { firstSentAtMs: nowMs }),\n },\n { merge: true },\n )\n // Never blocks the send. A counter write that failed is a ceiling\n // measured one message low, and refusing delivery over it would let a\n // Firestore hiccup become an outage on a merchant's mail.\n .catch((error: unknown) => {\n console.error('[email-marketing] frequency write failed', error)\n })\n }\n\n return { allowed: true, unsubscribeUrl, oneClickUrl }\n}\n\n/**\n * Puts the gate on `sendEmail`'s path.\n *\n * **Called at module load**, from the bottom of this file, so that importing\n * `@aglyn/tenant-data-admin` anywhere is enough — every server surface in the\n * product already imports that barrel, and `export *` forces this module to\n * evaluate. The alternative, a call at each server entrypoint, is the\n * many-places-to-remember shape this codebase has rejected twice already.\n *\n * Idempotent: installing twice replaces the same closure with an equivalent\n * one, and the closure holds no state — the state is the Firestore document.\n */\nexport function installMarketingSendGate(): void {\n setMarketingSendGate((request) => marketingSendVerdict(request))\n}\n\n/** Whether a gate is installed. Reads the shared seam, not a local flag. */\nexport function isMarketingSendGateInstalled(): boolean {\n return getMarketingSendGate() !== null\n}\n\ninstallMarketingSendGate()\n"],"names":["DEFAULT_MARKETING_CADENCE","getMarketingSendGate","marketingCadenceVerdict","marketingFrequencyVerdict","marketingSunsetDays","marketingSunsetVerdict","normalizeMarketingCadence","setMarketingSendGate","firebaseAdmin","readPersonEngagement","emailSuppressionKey","filterSendableForHost","filterTopicSendable","getAllAcrossSites","optOutHostIds","buildUnsubscribeUrl","defaultFirestore","app","firestore","EMAIL_FREQUENCY_SUBCOLLECTION","CADENCE_PHRASES","all","daily","weekly","monthly","NO_RECORD","window","firstSentAtMs","lastSentAtMs","cadence","stateFromSnapshot","snapshot","stored","get","Array","isArray","map","at","Number","first","last","isFinite","length","Math","max","groupPace","snapshots","chosenAtMs","NEGATIVE_INFINITY","state","setAtMs","atMs","frequencyDoc","hostId","emailKey","collection","doc","readMarketingFrequency","email","readMarketingFrequencyState","key","exists","error","console","setMarketingCadence","options","set","String","trim","toLowerCase","cadenceSetAtMs","nowMs","Date","now","merge","recordMarketingSends","emails","keyed","Map","size","recorded","db","ref","existing","then","catch","sentAtMs","inWindow","filterCadenceSendable","hostIds","group","lookups","push","bySite","entry","holding","Set","forEach","index","held","filter","pace","verdict","allowed","add","has","requestConsentGroup","request","consentHostIds","includes","id","readGroupFrequency","unread","getAll","own","marketingSendVerdict","sendable","refusal","detail","topicId","link","siteBase","unsubscribeUrl","surface","oneClickUrl","onTopic","nextAllowedAtMs","toISOString","sunsetDays","capped","engagement","sunset","lastEngagedAtMs","quietForDays","days","used","cap","appended","installMarketingSendGate","isMarketingSendGateInstalled"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-marketing-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * MARKETING MAIL — the durable half.\n *\n * `marketing-send.ts` in `@aglyn/shared-util-email` states the policy and\n * holds the injection seam; this answers the question, because answering it\n * needs Firestore and that library may not hold the Admin SDK.\n *\n * Five things, in the order they cost:\n *\n * 1. **Both suppression lists**, through the one shared helper. A person who\n * unsubscribed from this site, hard-bounced anywhere in the product, or\n * pressed \"report spam\" is not mailed. Asked first because it is the only\n * one whose answer is permanent.\n * 2. **The pace the RECIPIENT asked for** on the preference page, if they\n * asked for one.\n * 3. **An engagement sunset**, when an operator has configured a window —\n * off by default, and it costs a read only when it is on.\n * 4. **A frequency ceiling**, per recipient per site, over a rolling day.\n * 5. **The signed unsubscribe URL**, so the message carries a way out.\n *\n * ## The order of the three pace refusals, and who each one is for\n *\n * Suppression is first and needs no argument: its answer is permanent, and\n * the frequency record must not count a message that was never going to\n * leave. The other three are all refusals about PACE, and they are ordered by\n * whose refusal it is, then by what asking costs.\n *\n * **The recipient's own cadence is second — above both platform controls.**\n * It is the only one of the three a PERSON asked for; the sunset is an\n * inference this platform drew about them and the ceiling is a guard against\n * the merchant. When more than one applies, the honest answer to \"why did\n * this not send\" is the fact somebody stated, not the guess we made. It is\n * also the cheapest to ask: it is already on the counter document the ceiling\n * reads, so a recipient who asked for monthly mail never pays for the\n * sunset's engagement read. And it is the only one that binds a campaign, so\n * putting it first makes the campaign path and the automated paths evaluate\n * the same refusals in the same order rather than interleaving one control a\n * campaign skips with one it does not.\n *\n * **The sunset is third, above the ceiling.** Its refusal is TERMINAL and the\n * ceiling's is not: a sweep defers a `frequency-capped` message and retries\n * it, so reporting the retryable refusal for a person the sunset would refuse\n * anyway means the same doomed row comes back on every beat. That argument\n * does not reach past the cadence, because a cadence gap is a day, a week or\n * a month — a sweep retries such a row once per interval, not once per beat.\n *\n * ## Which refusals bind a campaign\n *\n * `MarketingSendContext.capped` is `false` for a campaign, and it governs\n * exactly two of the four:\n *\n * | Refusal | A campaign is |\n * | ------------------ | ------------- |\n * | `suppressed` | **bound** |\n * | `cadence-limited` | **bound** |\n * | `unengaged` | exempt (`capped`) |\n * | `frequency-capped` | exempt (`capped`) |\n *\n * The two exemptions share one reason: a campaign is a merchant's reviewed,\n * one-shot act with a recipient count on screen before they press Send, and a\n * platform control that silently removed people from that number would make\n * it a lie. That reason is about a control the merchant did not ask for and\n * cannot see. It does not reach a request the RECIPIENT made — a campaign\n * that overrode the preference page would make it a form recording a choice\n * nothing honors, which is ignoring an unsubscribe one notch quieter.\n *\n * A campaign COUNTS toward the ceiling either way; it is exempt from the\n * refusal, never from the counting.\n *\n * ## The counter is a WINDOW, not a total\n *\n * `hosts/{hostId}/emailFrequency/{sha256(address)}` holds the instants of the\n * marketing messages this site sent this person, trimmed to the window on\n * every touch. A rolling window needs the instants; a running total would\n * need a reset nobody triggers, and a per-day bucket would let a merchant\n * send the whole ceiling at 23:55 and the whole ceiling again at 00:05.\n *\n * The record is bounded by the cap — a handful of numbers — because\n * `marketingFrequencyVerdict` returns the trimmed window and this writes back\n * only that. It is server-owned: the address is hashed exactly as the two\n * suppression lists hash it, and no client writes or reads it.\n *\n * ## The check and the write are not one transaction, on purpose\n *\n * Two concurrent marketing sends to the same person can both read a window\n * with room and both append, so the cap can be exceeded by the number of\n * messages genuinely in flight at that instant. That is accepted rather than\n * papered over: a transaction per recipient would double the round trips on a\n * path that is already one awaited HTTP POST per person, and being off by one\n * on a runaway guard costs a recipient one extra message. Being slow costs\n * every recipient.\n *\n * ## Never a reduction of the person or their data\n *\n * A cap here refuses a SEND. It never removes a contact, never unsubscribes\n * anybody, never trims an audience and never deletes a counter row belonging\n * to somebody. `over-limit.ts` states the rule for capacity limits and this is\n * the same instrument pointed at time: the message does not go, the person\n * stays exactly where they were, and the next window mails them again. The\n * recipient's own cadence is the same shape — a person who asked for monthly\n * mail stays on every audience they were on and is mailed again next month.\n */\n\nimport {\n DEFAULT_MARKETING_CADENCE,\n getMarketingSendGate,\n marketingCadenceVerdict,\n marketingFrequencyVerdict,\n marketingSunsetDays,\n marketingSunsetVerdict,\n normalizeMarketingCadence,\n setMarketingSendGate,\n type MarketingCadence,\n type MarketingSendGateRequest,\n type MarketingSendGateVerdict,\n} from '@aglyn/shared-util-email'\nimport type { ConsentGroup } from '@aglyn/aglyn/app-utils/consent-groups'\nimport firebaseAdmin from './firebase-admin'\nimport { readPersonEngagement } from './email-delivery-log'\nimport {\n emailSuppressionKey,\n filterSendableForHost,\n filterTopicSendable,\n getAllAcrossSites,\n optOutHostIds,\n} from './email-suppression'\nimport { buildUnsubscribeUrl } from './email-unsubscribe-link'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * Where the per-recipient window lives.\n *\n * Under the host rather than the org: the ceiling is \"how much mail this SITE\n * sends one person\", which is the unit a recipient experiences and the unit\n * the unsubscribe link is scoped to. An org-wide counter would let one site's\n * newsletter suppress a sibling site's order-related marketing, and the two\n * sites may be unrelated brands.\n */\nexport const EMAIL_FREQUENCY_SUBCOLLECTION = 'emailFrequency'\n\n/** How each cadence reads inside a refusal sentence. */\nconst CADENCE_PHRASES: Record<MarketingCadence, string> = {\n all: 'at this pace',\n daily: 'a day',\n weekly: 'a week',\n monthly: 'a month',\n}\n\n/** The stored window. */\nexport interface EmailFrequencyRecord {\n /** The address, in the clear, lowercased. The id is its hash. */\n email: string\n /** Marketing send instants, trimmed to the window on every touch. */\n sentAtMs: number[]\n /**\n * When this site FIRST sent this person marketing mail.\n *\n * Written once and never again, and it rides here rather than in a store of\n * its own because this document is already written on every marketing send:\n * carrying the field costs nothing, and a second per-recipient document\n * would be a write per send per person for one number.\n *\n * The sunset is what reads it — see `marketingSunsetVerdict`. A person we\n * have not been mailing for longer than the sunset window cannot have been\n * quiet for longer than the sunset window, so this is the field that keeps\n * a new subscriber from being refused for having nothing on record yet.\n *\n * Null for every row written before this field existed, which reads as \"no\n * record\" and therefore refuses nobody. That is the conservative direction\n * for a field introduced after the data, and it self-heals: the row gains\n * the stamp on this site's next marketing send to that person, and the\n * clock starts from then rather than from a past nobody recorded.\n */\n firstSentAtMs?: number\n /**\n * The most recent marketing send, NEVER trimmed.\n *\n * `sentAtMs` is a rolling day, so after a quiet day it is empty and cannot\n * answer \"when did this site last mail this person\" — which is the only\n * question a weekly or monthly cadence asks. One number rather than a\n * window kept for a month.\n *\n * The bookends of the same relationship: `firstSentAtMs` never moves and\n * this one moves on every send, so between them they answer both \"how long\n * have we been mailing this person\" and \"how long since we last did\".\n */\n lastSentAtMs?: number\n /**\n * How often the RECIPIENT asked to hear from this site.\n *\n * Stored on the counter document rather than beside the topic opt-outs, and\n * that is the whole reason it costs nothing: the gate already reads this\n * document on every marketing message, so honoring the preference adds no\n * round trip to any send. It also puts the preference in the one document\n * that is already about how much mail this site sends this person.\n *\n * Absent on every record written before it existed, which reads as no\n * expressed preference — see `normalizeMarketingCadence`.\n */\n cadence?: MarketingCadence\n /** When the recipient chose it, for the record that the request was made. */\n cadenceSetAtMs?: number\n}\n\n/**\n * What one recipient's counter document says, with its defaults applied.\n *\n * Everything the gate asks of Firestore about this person on this site, in\n * one shape, because it is fetched in one round trip — see\n * {@link readMarketingFrequencyState}.\n */\nexport interface MarketingFrequencyState {\n /** Send instants inside the rolling window. */\n window: number[]\n /** When this site first mailed them, or `null` for no record. */\n firstSentAtMs: number | null\n /** The most recent send, or `null` for somebody never mailed. */\n lastSentAtMs: number | null\n /** The recipient's chosen pace. */\n cadence: MarketingCadence\n}\n\n/** What an unreadable, absent or unkeyable counter reads as. */\nconst NO_RECORD: MarketingFrequencyState = {\n window: [],\n firstSentAtMs: null,\n lastSentAtMs: null,\n cadence: 'all',\n}\n\n/**\n * One counter snapshot, decoded.\n *\n * Shared by {@link readMarketingFrequencyState} and\n * {@link filterCadenceSendable} so the two cannot come to different\n * conclusions about the same document — in particular about the\n * `lastSentAtMs` fallback, where a per-message answer and a per-campaign\n * answer that disagreed would refuse a recipient on one path and mail them on\n * the other.\n */\nfunction stateFromSnapshot(snapshot: any): MarketingFrequencyState {\n const stored = snapshot.get('sentAtMs')\n const window = Array.isArray(stored)\n ? stored.map((at: unknown) => Number(at))\n : []\n const first = Number(snapshot.get('firstSentAtMs'))\n const last = Number(snapshot.get('lastSentAtMs'))\n return {\n window,\n firstSentAtMs: Number.isFinite(first) && first > 0 ? first : null,\n /*\n * The stored instant, or the newest entry still inside the window.\n *\n * The fallback is what makes this work on every record written before\n * `lastSentAtMs` existed: those carry a window and nothing else, and\n * reading `null` for them would let a monthly cadence pass on the first\n * message after this ships for anybody mailed in the last day.\n *\n * `firstSentAtMs` deliberately has NO such fallback. The window would be\n * the wrong answer for it in exactly the direction that matters: a\n * relationship dated from a send inside the last day is younger than any\n * sunset window, so the sunset could never fire — and a relationship\n * dated from the oldest entry in a rolling DAY is not the first send\n * either. Absent means \"no record\", and no record refuses nobody.\n */\n lastSentAtMs: Number.isFinite(last)\n ? last\n : window.length\n ? Math.max(...window)\n : null,\n cadence: normalizeMarketingCadence(snapshot.get('cadence')),\n }\n}\n\n/** What the recipient's own pace decides on: their choice and the last send. */\ninterface MarketingPace {\n cadence: MarketingCadence\n lastSentAtMs: number | null\n}\n\n/**\n * The pace a person asked a CONSENT GROUP for, and when the group last mailed\n * them, from the counter documents of the sites in it that hold one.\n *\n * Each site's preference page writes its own counter, and to the person every\n * one of those pages was the same sender's — so the choice made MOST\n * RECENTLY, on whichever site, is the one in force, and a later \"as they\n * come\" undoes an earlier \"monthly\" exactly as it would on one site. The last\n * send is the newest across the group, because one a week from a sender is\n * one a week whichever of its sites sent it.\n *\n * Only the pace is pooled. The frequency ceiling and the sunset stay each\n * site's own: they are controls this platform keeps over a sender's volume,\n * not a request the person made.\n *\n * `snapshots` are documents that exist, the sending site's first, and it\n * keeps a tie. A single snapshot answers exactly what `stateFromSnapshot`\n * answers for it, which is what lets a site alone run through here unchanged.\n */\nfunction groupPace(snapshots: readonly any[]): MarketingPace {\n let cadence: MarketingCadence = DEFAULT_MARKETING_CADENCE\n let chosenAtMs = Number.NEGATIVE_INFINITY\n let lastSentAtMs: number | null = null\n for (const snapshot of snapshots) {\n const state = stateFromSnapshot(snapshot)\n if (\n state.lastSentAtMs !== null &&\n (lastSentAtMs === null || state.lastSentAtMs > lastSentAtMs)\n ) {\n lastSentAtMs = state.lastSentAtMs\n }\n // A counter the sends wrote carries no choice; it is not a vote for\n // \"as they come\", it is no vote at all.\n if (snapshot.get('cadence') == null) continue\n const setAtMs = Number(snapshot.get('cadenceSetAtMs'))\n const atMs = Number.isFinite(setAtMs) ? setAtMs : 0\n if (atMs > chosenAtMs) {\n chosenAtMs = atMs\n cadence = state.cadence\n }\n }\n return { cadence, lastSentAtMs }\n}\n\nfunction frequencyDoc(\n hostId: string,\n emailKey: string,\n firestore?: any,\n): FirebaseFirestore.DocumentReference {\n return (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .collection(EMAIL_FREQUENCY_SUBCOLLECTION)\n .doc(emailKey)\n}\n\n/**\n * The instants inside the window for one recipient of one site's mail.\n *\n * FAILS OPEN — an empty window, meaning \"nothing recorded, so there is room\".\n * The opposite of {@link filterSendableForHost}, and the difference is what\n * each answer costs when it is wrong. A suppression read that fails open\n * mails somebody who told us to stop; a frequency read that fails closed\n * refuses a message nobody objected to, over a counter that is a runaway\n * guard rather than a consent record.\n */\nexport async function readMarketingFrequency(\n hostId: string,\n email: string,\n firestore?: any,\n): Promise<number[]> {\n return (await readMarketingFrequencyState(hostId, email, firestore)).window\n}\n\n/**\n * The whole counter document — the window, the first send, the last send and\n * the recipient's chosen pace — in ONE round trip.\n *\n * **One read, four facts, and that is a requirement rather than a tidiness.**\n * Three separate refusals in {@link marketingSendVerdict} read this document,\n * and a `get` per refusal would be three awaited round trips on a path that\n * is already one awaited HTTP POST per recipient. Which is also why the\n * recipient's cadence is stored HERE rather than beside the topic opt-outs:\n * honoring a preference this gate has to consult on every marketing message\n * has to cost nothing, and riding on a document already being read is the\n * only shape that does. See {@link EmailFrequencyRecord.cadence}.\n *\n * FAILS OPEN on every field, and in the same direction each time: an empty\n * window means \"there is room\", a null `firstSentAtMs` means \"no record\", a\n * null `lastSentAtMs` means \"never mailed\", and an unreadable cadence reads\n * as no preference expressed. An unreadable counter is not evidence that\n * somebody asked for less, and it is not evidence that they have gone cold —\n * the opposite of {@link filterSendableForHost}, for the reason\n * {@link readMarketingFrequency} states.\n */\nexport async function readMarketingFrequencyState(\n hostId: string,\n email: string,\n firestore?: any,\n): Promise<MarketingFrequencyState> {\n const key = emailSuppressionKey(email)\n if (!key) return { ...NO_RECORD }\n try {\n const snapshot = await frequencyDoc(hostId, key, firestore).get()\n if (!snapshot.exists) return { ...NO_RECORD }\n return stateFromSnapshot(snapshot)\n } catch (error) {\n console.error('[email-marketing] frequency read failed; allowing', error)\n return { ...NO_RECORD }\n }\n}\n\n/**\n * Records the pace a recipient asked for.\n *\n * Written from the preference page, which is unauthenticated and reached by a\n * signed link — so the CALLER has already proved the request is this\n * address's. A merge, because the document is the send counter and this must\n * not disturb the window it shares with.\n *\n * Never throws: a preference that failed to store is a page that should say\n * so, not a 500 on a screen a recipient reached in order to leave.\n *\n * @returns whether it was stored.\n */\nexport async function setMarketingCadence(\n hostId: string,\n email: string,\n cadence: MarketingCadence,\n options?: { nowMs?: number; firestore?: any },\n): Promise<boolean> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId) return false\n try {\n await frequencyDoc(hostId, key, options?.firestore).set(\n {\n email: String(email).trim().toLowerCase(),\n cadence: normalizeMarketingCadence(cadence),\n cadenceSetAtMs: options?.nowMs ?? Date.now(),\n },\n { merge: true },\n )\n return true\n } catch (error) {\n console.error('[email-marketing] cadence write failed', error)\n return false\n }\n}\n\n/**\n * Records marketing messages against the recipients' windows.\n *\n * Takes a LIST because the campaign sender records a whole batch at once: it\n * is exempt from the refusal (a reviewed, one-shot act whose recipient count\n * is on screen before it goes) but not from the counting, since a campaign is\n * most of the mail a person receives from a site. Doing that per recipient\n * inside the send loop would add a round trip to each of up to 500 sequential\n * sends; doing it here adds one batch after them.\n *\n * Never throws. A lost counter increment means one recipient's ceiling is\n * measured a message low, which is not worth failing a delivered send over.\n */\nexport async function recordMarketingSends(\n hostId: string,\n emails: readonly string[],\n options?: { nowMs?: number; firestore?: any },\n): Promise<number> {\n const nowMs = options?.nowMs ?? Date.now()\n const keyed = new Map<string, string>()\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key) keyed.set(key, String(email).trim().toLowerCase())\n }\n if (!keyed.size || !hostId) return 0\n let recorded = 0\n try {\n const db = options?.firestore ?? defaultFirestore()\n for (const [key, email] of keyed) {\n const ref = frequencyDoc(hostId, key, db)\n const existing: MarketingFrequencyState = await ref\n .get()\n .then((snapshot: any) =>\n snapshot.exists ? stateFromSnapshot(snapshot) : { ...NO_RECORD },\n )\n .catch(() => ({ ...NO_RECORD }))\n const window = marketingFrequencyVerdict(\n [...existing.window, nowMs],\n nowMs,\n )\n await ref.set(\n {\n email,\n sentAtMs: window.inWindow,\n lastSentAtMs: nowMs,\n // Write-once, unlike `lastSentAtMs` beside it. Overwriting it would\n // restart the sunset clock on every send, which would make the\n // sunset unreachable — a person whose relationship is always \"as\n // old as the last message\" is never older than the window.\n ...(existing.firstSentAtMs ? {} : { firstSentAtMs: nowMs }),\n },\n { merge: true },\n )\n recorded += 1\n }\n } catch (error) {\n console.error('[email-marketing] frequency record failed', error)\n }\n return recorded\n}\n\n/**\n * The subset of `emails` that has NOT asked this site for mail less often\n * than right now.\n *\n * The fourth filter a campaign passes, after the platform suppression list,\n * the site's own and the topic opt-outs — and the only one that is not in\n * `email-suppression.ts` beside those three, because it reads the counter\n * document this module owns and that module is this module's dependency.\n *\n * {@link marketingSendVerdict} enforces the same rule per message, for the\n * senders that reach the gate. THIS is the campaign path's copy, and it is\n * the enforcement there rather than only a count: a campaign carries no\n * `marketing` context — it mints its own unsubscribe URL upstream — so the\n * gate is not on its path at all, and a rule asked only there would not be\n * asked of the sender that produces most of a person's mail.\n *\n * Answering it as a FILTER rather than per message is also the only placement\n * that keeps the composer honest. The argument for exempting a campaign from\n * the platform ceiling is that a control which silently removed people from a\n * reviewed one-shot send would make the number on screen a lie; subtracting\n * a request the recipient actually made, where every other refusal is already\n * subtracted, is what keeps this one from having that problem.\n *\n * Keyed and read with one `getAll`, matching its three neighbors: one round\n * trip bounded by the size of the send, and no composite index to go missing.\n *\n * ## \"This site\" means its consent group\n *\n * `group` is the sending site's consent group, and the pace is the one the\n * person asked THE GROUP for — see {@link groupPace}. Omitted, the site alone,\n * which is also the whole answer for an org that declared no group: one\n * `getAll` over the same documents it always read.\n *\n * ## Fails OPEN, like the topic filter beside it\n *\n * A cadence is a PACE, not a stop. Guessing wrong on an unreadable counter\n * costs one recipient one message sooner than they asked for; guessing wrong\n * the other way withholds a whole campaign on a transient read failure. The\n * two suppression lists have already refused everybody who asked us to stop\n * entirely, so nobody who said \"no\" reaches this line.\n */\nexport async function filterCadenceSendable(\n hostId: string,\n emails: readonly string[],\n options?: {\n nowMs?: number\n firestore?: any\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null\n },\n): Promise<string[]> {\n if (!emails.length || !hostId) return [...emails]\n const hostIds = optOutHostIds(hostId, options?.group)\n const nowMs = options?.nowMs ?? Date.now()\n // An unkeyable address carries no counter, so it has expressed no pace. It\n // is dropped from the LOOKUP and kept in the answer, exactly as the topic\n // filter keeps one: the stricter filters above have already had their say.\n const lookups: Array<{ email: string; key: string }> = []\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key) lookups.push({ email, key })\n }\n if (!lookups.length) return [...emails]\n try {\n const db = options?.firestore ?? defaultFirestore()\n const bySite = await getAllAcrossSites(\n db,\n hostIds,\n EMAIL_FREQUENCY_SUBCOLLECTION,\n lookups.map((entry) => entry.key),\n )\n const holding = new Set<string>()\n lookups.forEach((entry, index) => {\n const held = bySite\n .map((snapshots) => snapshots[index])\n .filter((snapshot) => snapshot?.exists)\n if (!held.length) return\n // The same decoder the per-message path uses, so the two cannot\n // disagree about one document. The sunset's `firstSentAtMs` is\n // deliberately not consulted: a campaign is exempt from that refusal,\n // so subtracting on it here would remove people from a count the gate\n // is going to mail anyway.\n const pace = groupPace(held)\n const verdict = marketingCadenceVerdict(\n pace.cadence,\n pace.lastSentAtMs,\n nowMs,\n )\n if (!verdict.allowed) holding.add(entry.email)\n })\n return emails.filter((email) => !holding.has(email))\n } catch (error) {\n console.error(\n '[email-marketing] cadence lookup failed; failing open',\n error,\n )\n return [...emails]\n }\n}\n\n/**\n * The consent group a gate request was made by, or `null` for the site alone.\n *\n * The sender resolved it — `MarketingSendContext.consentHostIds` and\n * `consentAwaitsConfirmation`, from the org it already held — so the gate\n * spends no org read on it. A list that does not name the sending site is not\n * a group resolved for it, and reads as the site alone: the gate must never\n * throw, because `sendEmail` answers a gate that throws by sending ungated.\n */\nfunction requestConsentGroup(\n request: MarketingSendGateRequest,\n): Pick<ConsentGroup, 'hostId' | 'hostIds' | 'awaitsConfirmation'> | null {\n const hostIds = request.consentHostIds\n if (!Array.isArray(hostIds) || !hostIds.includes(request.hostId)) return null\n return {\n hostId: request.hostId,\n hostIds: hostIds.filter((id) => typeof id === 'string' && id),\n awaitsConfirmation: request.consentAwaitsConfirmation === true,\n }\n}\n\n/**\n * The sending site's counter, and the pace its consent group was asked for.\n *\n * A site alone is {@link readMarketingFrequencyState}, the one `get` it has\n * always been. A group reads every member's counter in one `getAll`: the\n * sending site's supplies the window and the first send, which stay its own,\n * and all of them together supply the pace — see {@link groupPace}.\n *\n * Fails OPEN, for the reason {@link readMarketingFrequencyState} gives.\n */\nasync function readGroupFrequency(\n hostIds: readonly string[],\n email: string,\n firestore?: any,\n): Promise<{ state: MarketingFrequencyState; pace: MarketingPace }> {\n if (hostIds.length === 1) {\n const state = await readMarketingFrequencyState(hostIds[0], email, firestore)\n return {\n state,\n pace: { cadence: state.cadence, lastSentAtMs: state.lastSentAtMs },\n }\n }\n const unread = {\n state: { ...NO_RECORD },\n pace: { cadence: NO_RECORD.cadence, lastSentAtMs: null },\n }\n const key = emailSuppressionKey(email)\n if (!key) return unread\n try {\n const db = firestore ?? defaultFirestore()\n const snapshots = await db.getAll(\n ...hostIds.map((id) => frequencyDoc(id, key, db)),\n )\n const own = snapshots[0]\n return {\n state: own?.exists ? stateFromSnapshot(own) : { ...NO_RECORD },\n pace: groupPace(snapshots.filter((snapshot: any) => snapshot?.exists)),\n }\n } catch (error) {\n console.error('[email-marketing] frequency read failed; allowing', error)\n return unread\n }\n}\n\n/**\n * The gate itself, exported so it can be exercised without installing it.\n *\n * ORDER MATTERS. The suppression check is first because its answer is\n * permanent and the frequency record must not count a message that was never\n * going to leave — a suppressed address whose window kept growing would stay\n * capped for a day after being released.\n *\n * Every opt-out below is read across the sender's consent group\n * (`request.consentHostIds`) — the suppression list, the stream, and the\n * pace — because a person who left one site of a declared group left the\n * sender. A sibling's pending confirmation holds the stream too when the\n * request says the group waits (`request.consentAwaitsConfirmation`). The\n * frequency ceiling and the sunset stay the sending site's own.\n */\nexport async function marketingSendVerdict(\n request: MarketingSendGateRequest,\n options?: { nowMs?: number; firestore?: any },\n): Promise<MarketingSendGateVerdict> {\n const nowMs = options?.nowMs ?? Date.now()\n const email = String(request.email ?? '')\n .trim()\n .toLowerCase()\n const group = requestConsentGroup(request)\n const sendable = await filterSendableForHost(\n request.hostId,\n [email],\n options?.firestore,\n group,\n )\n if (!sendable.length) {\n return {\n allowed: false,\n refusal: 'suppressed',\n detail:\n 'This address has unsubscribed, bounced permanently, or reported a ' +\n 'message as spam.',\n }\n }\n\n // Normalized once, and read by both the links below and the topic check\n // further down: two spellings of \"which stream is this\" is how a link comes\n // to carry a topic the gate did not check.\n const topicId = String(request.topicId ?? '').trim()\n\n /*\n * TWO URLS OVER ONE SIGNATURE, the same split `campaign-send.ts` makes and\n * for the same reason (RFC 8058).\n *\n * `oneClickUrl` is what `List-Unsubscribe` names: a mailbox provider POSTs\n * it with nobody present and expects the act to have happened when it reads\n * the 200, so it points at the route whose POST writes immediately.\n *\n * `unsubscribeUrl` is the link a PERSON clicks in the footer, and it points\n * at the preference page. Every caller on this path — the abandoned-cart\n * sweep, the restock notice, the newsletter welcome, the automation step —\n * names a topic, and a footer pointing at the one-click route gives the\n * recipient of one of those exactly one choice: stop hearing from this site\n * entirely. The page offers leaving that one stream instead, with\n * \"Unsubscribe from everything\" still on it.\n *\n * The topic rides both links, so the page opens on the stream the message\n * belonged to rather than on a list the recipient has to search.\n */\n const link = {\n siteBase: request.siteBase,\n hostId: request.hostId,\n email,\n ...(topicId ? { topicId } : {}),\n }\n const unsubscribeUrl = buildUnsubscribeUrl({\n ...link,\n surface: 'preferences',\n })\n const oneClickUrl = buildUnsubscribeUrl({ ...link, surface: 'one-click' })\n\n /*\n * THE STREAM THIS MESSAGE BELONGS TO — the third list, and the narrowest.\n *\n * ONLY when the caller named one. An absent topic is not \"the default\n * topic\": it is a message that belongs to no stream, and there is nothing\n * for a person to have left. That is what keeps this check off the mail it\n * must never touch — a receipt, a password reset, a booking confirmation,\n * none of which declare a `marketing` context at all and none of which name\n * a stream if they somehow did.\n *\n * After the suppression lists and before the counter read below, matching\n * the order `campaign-send.ts` filters in, for two reasons that agree. It\n * is the weaker fact, and the weaker fact should never be the one that\n * decides — a person who unticked \"Promotions and offers\" is still a\n * subscriber, where a person on either suppression list is not. And it is a\n * TERMINAL refusal, so answering it before the counter read means a\n * recipient this message was never going to reach costs one lookup rather\n * than three.\n *\n * Fails OPEN, because `filterTopicSendable` does: a topic preference is a\n * narrower fact than a suppression, and a read that failed for an unrelated\n * reason is no reason to withhold a message from somebody the two lists\n * above already cleared.\n */\n if (topicId) {\n const onTopic = await filterTopicSendable(\n request.hostId,\n topicId,\n [email],\n options?.firestore,\n group,\n )\n if (!onTopic.length) {\n /*\n * ⛔ A REFUSAL AND NOTHING ELSE, as with the pace refusals below.\n *\n * No suppression is written, no membership changes, and the frequency\n * window is not appended to — a message that never left must not count\n * against what this person has received. They stay on every other\n * stream they subscribe to, and the next message on one of those goes.\n */\n return {\n allowed: false,\n refusal: 'topic-unsubscribed',\n detail:\n 'This address has left the email topic this message belongs to. ' +\n 'They still receive the other streams from this site.',\n unsubscribeUrl,\n oneClickUrl,\n }\n }\n }\n\n // ONE read, and every refusal below is answered from it — except the\n // sunset's engagement half, which is a different document and is fetched\n // only when a window is configured and nothing above has already refused.\n // For a consent group it is one `getAll`, and only the pace is pooled.\n const hostIds = optOutHostIds(request.hostId, group)\n const { state, pace } = await readGroupFrequency(\n hostIds,\n email,\n options?.firestore,\n )\n\n /*\n * THE RECIPIENT'S OWN REQUEST, first among the three pace refusals and the\n * only one not subject to `capped`.\n *\n * `capped: false` exempts a campaign from the platform CEILING and from the\n * SUNSET, because a control the merchant did not ask for and cannot see,\n * silently removing people from a reviewed one-shot send, would make the\n * recipient count on screen a lie. That argument does not reach this one: a\n * person used the preference page to ask this site for less mail, and a\n * campaign that overrode them would make that page a form that records a\n * request nobody honors — the same failure as ignoring an unsubscribe, one\n * notch quieter.\n *\n * Above the sunset for three reasons. It is the only one of the three a\n * PERSON asked for, and when both apply the honest answer to \"why did this\n * not send\" is the fact somebody stated rather than the inference we drew.\n * It is answered entirely from the counter document already in hand, so a\n * recipient who asked for monthly mail never pays for the sunset's second\n * read. And it is the one refusal a campaign is bound by, so asking it\n * first gives the campaign path and the automated paths the same order.\n *\n * The sunset's terminality argument does not out-rank any of that: a sweep\n * that defers this row retries it once per cadence interval — a day, a week\n * or a month — not once per beat, which is what that argument is about.\n *\n * A campaign is bound by the rule but not by THIS enforcement of it: it\n * carries no `marketing` context, so nothing on its path reaches this\n * function. {@link filterCadenceSendable} is where it is asked there, and\n * asked as a filter so the recipient count reflects it before Send.\n */\n const cadence = marketingCadenceVerdict(\n pace.cadence,\n pace.lastSentAtMs,\n nowMs,\n )\n if (!cadence.allowed) {\n /*\n * ⛔ A REFUSAL AND NOTHING ELSE, exactly as below.\n *\n * Nobody is unsubscribed, no membership changes, no contact is touched,\n * and the frequency window is not appended to — a message that never left\n * must not count against what this person has received. Somebody who\n * asked for monthly mail stays on every audience they were on and is\n * mailed again next month.\n */\n return {\n allowed: false,\n refusal: 'cadence-limited',\n detail:\n `This address asked ${\n hostIds.length > 1 ? 'this site’s consent group' : 'this site'\n } for no more than one marketing ` +\n `message ${CADENCE_PHRASES[pace.cadence]}. The next one may go on ` +\n `${new Date(cadence.nextAllowedAtMs).toISOString()}.`,\n unsubscribeUrl,\n oneClickUrl,\n }\n }\n\n /*\n * THE SUNSET, ahead of the frequency ceiling.\n *\n * Ordered above the ceiling because its refusal is TERMINAL and the\n * ceiling's is not: a sweep defers a `frequency-capped` message and retries\n * it, so reporting the retryable refusal for a person the sunset would\n * refuse anyway means the same doomed row comes back on every beat.\n *\n * `request.capped` governs it, the same flag the ceiling reads, so a\n * campaign — a reviewed act with its recipient count on screen — is exempt\n * for the reason recorded on `MarketingSendContext.capped`. That leaves the\n * sunset governing the automated paths, which fire with no human present.\n *\n * The engagement read is the ONLY second round trip on this path, and it is\n * spent only when a window is configured, the caller is capped, and the\n * cadence above has already allowed. Off is the default, and off costs\n * nothing.\n */\n const sunsetDays = marketingSunsetDays()\n if (request.capped && sunsetDays > 0) {\n const engagement = await readPersonEngagement(email, options?.firestore)\n const sunset = marketingSunsetVerdict(\n {\n firstSentAtMs: state.firstSentAtMs,\n lastEngagedAtMs: engagement.lastEngagedAtMs,\n },\n nowMs,\n sunsetDays,\n )\n if (!sunset.allowed) {\n /*\n * ⛔ A REFUSAL AND NOTHING ELSE.\n *\n * No suppression is written, no membership is touched, no contact is\n * changed, and the frequency window below is not appended to — a\n * message that never left must not count against what this person has\n * received. The person is exactly where they were, and the next send\n * after they open anything goes.\n */\n return {\n allowed: false,\n refusal: 'unengaged',\n detail:\n `This address has not opened or clicked anything for ` +\n `${sunset.quietForDays} days, and this site has been mailing it ` +\n `for longer than the ${sunset.days}-day engagement window. It ` +\n `becomes mailable again as soon as they engage.`,\n unsubscribeUrl,\n oneClickUrl,\n }\n }\n }\n\n const verdict = marketingFrequencyVerdict(state.window, nowMs)\n if (request.capped && !verdict.allowed) {\n return {\n allowed: false,\n refusal: 'frequency-capped',\n detail:\n `This address has already received ${verdict.used} marketing ` +\n `messages from this site today (the ceiling is ${verdict.cap}).`,\n unsubscribeUrl,\n oneClickUrl,\n }\n }\n\n /*\n * The send is granted, so it is counted — including for a campaign, which\n * is exempt from the refusal above and not from this. `sentAtMs` therefore\n * measures what the recipient actually receives rather than only the part\n * of it a cap may stop, which is the difference between a ceiling and a\n * number that describes nothing.\n */\n const appended = marketingFrequencyVerdict(\n [...verdict.inWindow, nowMs],\n nowMs,\n )\n const key = emailSuppressionKey(email)\n if (key) {\n await frequencyDoc(request.hostId, key, options?.firestore)\n .set(\n {\n email,\n sentAtMs: appended.inWindow,\n lastSentAtMs: nowMs,\n // Write-once — see EmailFrequencyRecord.firstSentAtMs. Re-stamping\n // it on every send would keep the relationship permanently younger\n // than any sunset window, so the sunset could never fire.\n ...(state.firstSentAtMs ? {} : { firstSentAtMs: nowMs }),\n },\n { merge: true },\n )\n // Never blocks the send. A counter write that failed is a ceiling\n // measured one message low, and refusing delivery over it would let a\n // Firestore hiccup become an outage on a merchant's mail.\n .catch((error: unknown) => {\n console.error('[email-marketing] frequency write failed', error)\n })\n }\n\n return { allowed: true, unsubscribeUrl, oneClickUrl }\n}\n\n/**\n * Puts the gate on `sendEmail`'s path.\n *\n * **Called at module load**, from the bottom of this file, so that importing\n * `@aglyn/tenant-data-admin` anywhere is enough — every server surface in the\n * product already imports that barrel, and `export *` forces this module to\n * evaluate. The alternative, a call at each server entrypoint, is the\n * many-places-to-remember shape this codebase has rejected twice already.\n *\n * Idempotent: installing twice replaces the same closure with an equivalent\n * one, and the closure holds no state — the state is the Firestore document.\n */\nexport function installMarketingSendGate(): void {\n setMarketingSendGate((request) => marketingSendVerdict(request))\n}\n\n/** Whether a gate is installed. Reads the shared seam, not a local flag. */\nexport function isMarketingSendGateInstalled(): boolean {\n return getMarketingSendGate() !== null\n}\n\ninstallMarketingSendGate()\n"],"names":["DEFAULT_MARKETING_CADENCE","getMarketingSendGate","marketingCadenceVerdict","marketingFrequencyVerdict","marketingSunsetDays","marketingSunsetVerdict","normalizeMarketingCadence","setMarketingSendGate","firebaseAdmin","readPersonEngagement","emailSuppressionKey","filterSendableForHost","filterTopicSendable","getAllAcrossSites","optOutHostIds","buildUnsubscribeUrl","defaultFirestore","app","firestore","EMAIL_FREQUENCY_SUBCOLLECTION","CADENCE_PHRASES","all","daily","weekly","monthly","NO_RECORD","window","firstSentAtMs","lastSentAtMs","cadence","stateFromSnapshot","snapshot","stored","get","Array","isArray","map","at","Number","first","last","isFinite","length","Math","max","groupPace","snapshots","chosenAtMs","NEGATIVE_INFINITY","state","setAtMs","atMs","frequencyDoc","hostId","emailKey","collection","doc","readMarketingFrequency","email","readMarketingFrequencyState","key","exists","error","console","setMarketingCadence","options","set","String","trim","toLowerCase","cadenceSetAtMs","nowMs","Date","now","merge","recordMarketingSends","emails","keyed","Map","size","recorded","db","ref","existing","then","catch","sentAtMs","inWindow","filterCadenceSendable","hostIds","group","lookups","push","bySite","entry","holding","Set","forEach","index","held","filter","pace","verdict","allowed","add","has","requestConsentGroup","request","consentHostIds","includes","id","awaitsConfirmation","consentAwaitsConfirmation","readGroupFrequency","unread","getAll","own","marketingSendVerdict","sendable","refusal","detail","topicId","link","siteBase","unsubscribeUrl","surface","oneClickUrl","onTopic","nextAllowedAtMs","toISOString","sunsetDays","capped","engagement","sunset","lastEngagedAtMs","quietForDays","days","used","cap","appended","installMarketingSendGate","isMarketingSendGateInstalled"],"mappings":"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"}
|
|
@@ -480,10 +480,12 @@ export declare function filterSendableForHost(hostId: string, emails: readonly s
|
|
|
480
480
|
* has left it from the sender. `group` extends the read to every site in it,
|
|
481
481
|
* exactly as {@link filterSendableForHost} does for the suppression list.
|
|
482
482
|
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
483
|
+
* A sibling's REFUSAL always holds. A sibling's pending confirmation holds
|
|
484
|
+
* only when the group `awaitsConfirmation` — the org's switch, resolved into
|
|
485
|
+
* the group, so asking it reads nothing the refusal read did not already
|
|
486
|
+
* read. Off, the question stays the concern of the site whose form asked it.
|
|
485
487
|
*/
|
|
486
|
-
export declare function filterTopicSendable(hostId: string, topicId: string | null | undefined, emails: readonly string[], injectedFirestore?: any, group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null): Promise<string[]>;
|
|
488
|
+
export declare function filterTopicSendable(hostId: string, topicId: string | null | undefined, emails: readonly string[], injectedFirestore?: any, group?: Pick<ConsentGroup, 'hostId' | 'hostIds' | 'awaitsConfirmation'> | null): Promise<string[]>;
|
|
487
489
|
/**
|
|
488
490
|
* The staff queue, newest first, ordered by `suppressedAt` so a re-recorded or
|
|
489
491
|
* released entry surfaces again — the operator's question is "what changed",
|
|
@@ -89,7 +89,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
89
89
|
// `document-id.ts` gives at length: a barrel import resolves to whatever a
|
|
90
90
|
// spec's `jest.mock` happens to contain, and this module's neighbours are
|
|
91
91
|
// mocked in nearly every spec that touches them.
|
|
92
|
-
import { consentGroupOptOutHosts } from "@aglyn/aglyn/app-utils/consent-groups";
|
|
92
|
+
import { consentGroupOptOutHosts, consentGroupTopicState } from "@aglyn/aglyn/app-utils/consent-groups";
|
|
93
93
|
import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
|
|
94
94
|
import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
95
95
|
import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
|
|
@@ -417,9 +417,11 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
417
417
|
* All three per-site facts are read this way: the site suppression list (an
|
|
418
418
|
* unsubscribe, a hand-added opt-out, an erasure, and the bounces and
|
|
419
419
|
* complaints the platform list already carries), the topic opt-outs, and the
|
|
420
|
-
* recipient's cadence.
|
|
421
|
-
*
|
|
422
|
-
*
|
|
420
|
+
* recipient's cadence. A sibling's pending confirmation joins them only when
|
|
421
|
+
* the org turned its group's confirmation switch on — see
|
|
422
|
+
* {@link filterTopicSendable}. The group of one — every site of an org that
|
|
423
|
+
* declared nothing — reads exactly the documents these filters always read,
|
|
424
|
+
* in the same round trips.
|
|
423
425
|
*=========================================*/ /**
|
|
424
426
|
* The sites whose opt-out lists answer for a send made BY `hostId`, the
|
|
425
427
|
* sending site first.
|
|
@@ -580,8 +582,10 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
580
582
|
* has left it from the sender. `group` extends the read to every site in it,
|
|
581
583
|
* exactly as {@link filterSendableForHost} does for the suppression list.
|
|
582
584
|
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
+
* A sibling's REFUSAL always holds. A sibling's pending confirmation holds
|
|
586
|
+
* only when the group `awaitsConfirmation` — the org's switch, resolved into
|
|
587
|
+
* the group, so asking it reads nothing the refusal read did not already
|
|
588
|
+
* read. Off, the question stays the concern of the site whose form asked it.
|
|
585
589
|
*/ export async function filterTopicSendable(hostId, topicId, emails, injectedFirestore, group) {
|
|
586
590
|
const hostIds = optOutHostIds(hostId, group);
|
|
587
591
|
const topic = String(topicId != null ? topicId : '').trim();
|
|
@@ -605,7 +609,7 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
605
609
|
];
|
|
606
610
|
try {
|
|
607
611
|
const db = injectedFirestore != null ? injectedFirestore : defaultFirestore();
|
|
608
|
-
const
|
|
612
|
+
const bySite = await getAllAcrossSites(db, hostIds, TOPIC_OPT_OUTS_SUBCOLLECTION, lookups.map((entry)=>entry.key));
|
|
609
613
|
/*
|
|
610
614
|
* The shared state reader, never a field test written out here.
|
|
611
615
|
*
|
|
@@ -618,9 +622,15 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
618
622
|
var _snapshot_get;
|
|
619
623
|
return (snapshot == null ? void 0 : snapshot.exists) ? readTopicSubscriptionState(((_snapshot_get = snapshot.get('topics')) != null ? _snapshot_get : {})[topic]) : 'subscribed';
|
|
620
624
|
};
|
|
625
|
+
// The sending site first, as `optOutHostIds` ordered the reads — which
|
|
626
|
+
// is how the fold tells its own pending question from a sibling's.
|
|
627
|
+
const awaits = {
|
|
628
|
+
awaitsConfirmation: (group == null ? void 0 : group.awaitsConfirmation) === true
|
|
629
|
+
};
|
|
621
630
|
const gone = new Set();
|
|
622
631
|
lookups.forEach((entry, index)=>{
|
|
623
|
-
|
|
632
|
+
const states = bySite.map((snapshots)=>stateIn(snapshots[index]));
|
|
633
|
+
if (consentGroupTopicState(awaits, states) !== 'subscribed') {
|
|
624
634
|
gone.add(entry.email);
|
|
625
635
|
}
|
|
626
636
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-suppression.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The PLATFORM-WIDE email suppression list, `emailSuppressions/{emailKey}`\n * (AGL-2407).\n *\n * ## What was missing\n *\n * AGL-1918 (`7a8f3cd68`) made `email.bounced` (permanent) and\n * `email.complained` write a suppression — into `hosts/{hostId}/suppressions`,\n * found from a Resend tag that only `campaign-send.ts` stamped. That closed\n * the campaign half and could close no other, because there was nowhere to\n * file the rest:\n *\n * Every other sender in the product goes through the shared `sendEmail` —\n * invites,\n * password resets, verification, receipts, booking confirmations, the monthly\n * usage summary, the usage-alert fan-out, restock and abandoned-cart mail, the\n * merchant-authored workflow `sendEmail` step. A bounce on any of those\n * arrived at the webhook with no site to place it against and was answered\n * `200 {ignored:true}`. A dead address was re-mailed on every subsequent send,\n * forever, and a spam complaint had no effect on anything.\n *\n * That matters most exactly when it is hardest to fix: on Sept 1 the signup\n * door opens, and verification and invite mail — addressed by strangers typing\n * addresses — is the highest-bounce-rate mail we send, on the same Resend key\n * and the same From address as everything else.\n *\n * ## Why a sibling of `contactSuppressions` and not a channel on it\n *\n * `contact-suppression.ts` is the same idea for phones and every argument it\n * makes holds here: keyed on the identifier because that is what the sender\n * holds at send time; one `get()` by document id, no query and no composite\n * index that could go missing and fail the lookup open; a revocation is a\n * FIELD and not a delete, because the record is the evidence that the\n * suppression was honoured while it was in force.\n *\n * It is a separate collection for one concrete reason: the document id. That\n * list's key is the E.164 digits, and its docblock argues at length that a\n * phone number must NOT be hashed (NANP is ~10^10 values, so a hash buys the\n * appearance of de-identification and none of the substance). An email address\n * is not enumerable, `hosts/{hostId}/suppressions` already keys on\n * `sha256(email)`, and one collection holding two incompatible key spaces is\n * how a lookup comes to be performed against the wrong derivation. The\n * address is stored in the document in the clear regardless, exactly as the\n * per-host list stores it, so a staff reader still shows a human something\n * they can act on.\n *\n * ## Who reads it, and who deliberately does NOT\n *\n * AGL-1438's rule is that a QUOTA may only ever refuse a campaign, because\n * refusing a receipt or a password reset converts a billing event into an\n * outage on a customer's business. A suppression is not a quota, but the same\n * proportionality applies and lands in the same place:\n *\n * - **Bulk mail consults this list.** The monthly usage summary, the\n * usage-alert fan-out and the marketplace review fan-out to a publisher's\n * owners and admins go to a LIST of people who did not ask for that\n * particular message, on a schedule or on every submission, forever. They\n * are the sends that re-hit a dead mailbox every month and teach a mailbox\n * provider that `aglyn.com` does not listen.\n * - **Transactional mail does NOT.** A password reset, a verification, an\n * invite, a receipt or a booking confirmation answers something the human\n * just did. Refusing one because an address bounced or because somebody\n * once pressed \"report spam\" on an unrelated message would lock a real\n * customer out of their own account. The shared sender is therefore left\n * unconditional on purpose, and this is the note that says so.\n *\n * (Written throughout without a literal call expression, on purpose:\n * `email-send-metering-coverage.spec.ts` enumerates senders by grepping the\n * tree for one, and a prose mention here would enrol a module that sends\n * nothing into the AGL-1438 cost-meter sweep.)\n */\n\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\n// The leaf entry, not the `@aglyn/aglyn` barrel, for the reason\n// `document-id.ts` gives at length: a barrel import resolves to whatever a\n// spec's `jest.mock` happens to contain, and this module's neighbours are\n// mocked in nearly every spec that touches them.\nimport {\n consentGroupOptOutHosts,\n type ConsentGroup,\n} from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport firebaseAdmin from './firebase-admin'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\nexport const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions'\n\n/**\n * The PER-SITE list, `hosts/{hostId}/suppressions/{emailKey}` — unsubscribes\n * from one site's campaigns, plus the bounces and complaints that arrived\n * carrying that site's tag.\n *\n * Named here rather than at each call site because the two lists are read\n * together by {@link filterSendableForHost}, and a sender that knows one path\n * as a string literal and the other through this module is one rename away\n * from consulting a collection that does not exist and finding nobody\n * suppressed.\n */\nexport const HOST_SUPPRESSIONS_SUBCOLLECTION = 'suppressions'\n\n/**\n * Why an address is suppressed.\n *\n * `bounce` means PERMANENT only. Resend reports `data.bounce.type` as\n * `Permanent` or `Transient`, and a transient bounce is a full mailbox or a\n * greylisting server — recording one here would suppress a real recipient over\n * a temporary condition at their provider. The webhook makes that distinction;\n * this type only names the outcome.\n */\nexport type EmailSuppressionReason =\n /** Permanent bounce — the mailbox does not exist. */\n | 'bounce'\n /** The recipient pressed \"report spam\". */\n | 'complaint'\n /** Recorded by staff (a written request, a court order, a correction). */\n | 'staff'\n\n/**\n * The reasons that may be filed PLATFORM-WIDE, as a runtime set.\n *\n * ## The split this constant enforces\n *\n * A bounce and a complaint are facts about a MAILBOX and about the sending\n * domain every tenant's mail leaves by: the address does not exist for\n * anybody, and somebody who pressed \"report spam\" on `noreply@aglyn.com` has\n * told every sender behind that domain at once. Those belong everywhere,\n * immediately.\n *\n * An UNSUBSCRIBE is not that. It is a preference a person expressed to ONE\n * brand — the sender line they read, the newsletter they joined — and an\n * agency running twelve unrelated clients out of one account would, on a\n * platform-wide entry, stop mailing that person on behalf of eleven brands\n * they never heard from. So an unsubscribe lives only in\n * `hosts/{hostId}/suppressions`, and {@link suppressEmail} refuses it.\n *\n * ## Why a runtime refusal and not just the type\n *\n * {@link EmailSuppressionReason} already excludes it, and a type is not a\n * guard: every caller here builds its reason from a webhook payload or a\n * ternary, `as` casts exist, and the plugin API surface is untyped at the\n * boundary. The cost of the type being wrong once is an opt-out from one\n * brand silently applied to every brand in the account, which is invisible\n * from the console — the mail simply never arrives, and the merchant's own\n * suppression list does not mention it.\n */\nexport const PLATFORM_SUPPRESSION_REASONS: readonly EmailSuppressionReason[] = [\n 'bounce',\n 'complaint',\n 'staff',\n]\n\n/**\n * The reason a self-service opt-out is filed under, on the PER-SITE list.\n *\n * Named because three call sites compare against it — the unsubscribe writes\n * it, the resubscribe link refuses to reverse anything else, and the\n * preference page reads it to decide whether an address may opt back in. A\n * literal in three places is a literal that can be changed in two.\n */\nexport const UNSUBSCRIBE_SUPPRESSION_REASON = 'unsubscribe'\n\nexport interface EmailSuppressionRecord {\n /** The address, in the clear, lowercased. The id is its hash. */\n email: string\n reason: EmailSuppressionReason\n /**\n * The `context` tag the send carried (`'invite'`, `'usage-summary'`,\n * `'campaign'`, …), so a human reading the list can tell which of our\n * senders produced the address that died. Null when the send carried none.\n */\n context: string | null\n /** The site the failure was attributed to, when the send named one. */\n hostId: string | null\n /** Non-null once released. A released record does not suppress. */\n releasedAt: unknown | null\n /** What lifted it. Absent on records written before releases were typed. */\n releasedVia?: EmailReleaseChannel\n /** The site whose confirmed double opt-in lifted it, when one did. */\n releasedHostId?: string | null\n /** The stream that was confirmed, likewise. */\n releasedTopicId?: string | null\n}\n\n/**\n * Document id for an address: `sha256` of the lowercased, trimmed form.\n *\n * The SAME derivation `campaign-send.ts` uses for `hosts/{hostId}/suppressions`\n * — deliberately, so the two lists can never disagree about which document\n * describes which person.\n *\n * @returns the id, or `null` for anything that is not an address. Never a\n * best-guess id: suppressing the wrong person and believing you\n * suppressed the right one is worse than refusing.\n */\nexport function emailSuppressionKey(\n email: string | null | undefined,\n): string | null {\n /*\n * Delegated, not reimplemented. A second hash of the same address is how\n * this area got two derivations that agreed only by accident; `personKey`\n * is the one that normalizes and the one the unsubscribe route already\n * hashes through, so a suppression filed by either is found by both.\n */\n return personKey(email)\n}\n\nexport interface SuppressEmailInput {\n email: string\n reason: EmailSuppressionReason\n /** The `context` tag the failed send carried, when it carried one. */\n context?: string | null\n /** The site the failed send was attributed to, when it named one. */\n hostId?: string | null\n /**\n * Whether to say so on the person's record too (AGL-3245); on by default.\n * A sender that stamps a richer verdict of its own passes `false`.\n */\n stampRecord?: boolean\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n}\n\n/**\n * Record a platform-wide suppression. Idempotent by document id.\n *\n * `createdAt` is stamped only when the document is new, matching the per-host\n * list: a second bounce must not overwrite the moment the address first went\n * bad, because that is the date a human is told when they ask. `suppressedAt`\n * moves every time, so \"when did we last see this fail\" stays answerable.\n *\n * A previously released record is un-released — a fresh failure is a fresh\n * failure.\n *\n * @returns `created: false` when the address was already on the list.\n */\nexport async function suppressEmail(input: SuppressEmailInput): Promise<{\n key: string\n created: boolean\n}> {\n const key = emailSuppressionKey(input.email)\n if (!key) {\n throw new Error('[email-suppression] cannot key a suppression for that value')\n }\n if (!PLATFORM_SUPPRESSION_REASONS.includes(input.reason)) {\n throw new Error(\n `[email-suppression] ${input.reason} is a per-site preference and ` +\n 'cannot be filed platform-wide',\n )\n }\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n await ref.set(\n {\n email: String(input.email).trim().toLowerCase(),\n reason: input.reason,\n context: input.context ?? null,\n hostId: input.hostId ?? null,\n releasedAt: null,\n suppressedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n /*\n * The same verdict on the record the person reads (AGL-3245), through\n * whichever plugin keeps the workspace's records: a bounce or a complaint\n * on a send that named a site stamps the site's records. A send that\n * named none has no records to find, and a caller that stamps its own,\n * richer verdict — the sequence runtime, with the domain block and the\n * enrollment — says `stampRecord: false` and is left to it.\n */\n if (input.hostId && input.stampRecord !== false && input.reason !== 'staff') {\n await stampRecordEmailState({\n hostId: input.hostId,\n email: input.email,\n state: {\n status: input.reason === 'complaint' ? 'complained' : 'bounced',\n atMs: Date.now(),\n source: 'campaign',\n detail: input.context ? `Reported by the ${input.context} send.` : null,\n },\n })\n }\n return { key, created: !snapshot.exists }\n}\n\n/**\n * What lifted a suppression, recorded on the record it lifted.\n *\n * Written explicitly by both paths rather than left to \"absent means staff\".\n * The two are held to different rules — a staff release may lift any reason,\n * a confirmed opt-in may lift exactly one — so the audit answer to \"how did\n * this address get back on the domain\" has to be a stored fact and not an\n * inference from which field happens to be missing.\n */\nexport type EmailReleaseChannel =\n /** A staff correction through the admin suppressions surface. */\n | 'staff'\n /** A recipient completed a double opt-in from this address. */\n | 'double-opt-in'\n\n/**\n * Put an address back in circulation (a staff correction, or the person asking\n * to be re-added). The record is kept and marked released, never deleted —\n * same reasoning as `releasePhoneContact`: a deleted record cannot show that\n * the suppression was honored while it stood.\n *\n * Unconditional on the reason, and that is what separates it from\n * {@link releaseEmailForConfirmedOptIn}: a human with the staff role has read\n * the record and is accountable for the row, so they may lift a complaint —\n * a report filed against the wrong message, an address entered by mistake.\n * Nothing automatic gets that latitude.\n *\n * @returns false when there was no live record to release.\n */\nexport async function releaseEmail(input: {\n email: string\n releasedByUid?: string | null\n note?: string | null\n firestore?: any\n}): Promise<boolean> {\n const key = emailSuppressionKey(input.email)\n if (!key) return false\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n if (!snapshot.exists || snapshot.get('releasedAt')) return false\n await ref.set(\n {\n releasedAt: FieldValue.serverTimestamp(),\n releasedByUid: input.releasedByUid ?? null,\n releasedNote: input.note ?? null,\n releasedVia: 'staff' satisfies EmailReleaseChannel,\n },\n { merge: true },\n )\n return true\n}\n\n/**\n * The ONLY reason a completed round trip may lift, as a runtime set.\n *\n * ## Why a bounce is releasable\n *\n * A `bounce` record is a claim about a MAILBOX at a moment: mail addressed\n * here was permanently refused. Somebody clicking a confirmation link that\n * was delivered to that mailbox has refuted the claim with the only evidence\n * that could refute it — the message arrived, a human read it, and the round\n * trip closed. Leaving the record standing after that makes one transient\n * failure recorded as permanent — a mailbox that was full, a receiving server\n * that answered 550 while it was misconfigured — a life sentence no route can\n * undo.\n *\n * ## Why a complaint is NOT, and never will be\n *\n * `complaint` is not a deliverability fact. It is a person stating they do not\n * want this mail, and a round trip proves nothing about that statement — it\n * proves the mailbox works, which nobody doubted. Releasing one here would\n * make a public signup form into a laundry for spam complaints: submit the\n * complainant's address, they receive a confirmation (the shared sender is\n * transactional and consults no list), and one click anywhere in the chain\n * puts them back on the sending domain that the complaint was filed against.\n * The same reasoning refuses `staff`, which is a written request, a\n * correction, or a legal instruction, and is nobody's to reverse from a link.\n *\n * A runtime set and not just the type, for the reason\n * {@link PLATFORM_SUPPRESSION_REASONS} gives: reasons arrive from webhook\n * payloads through an untyped boundary, and the cost of the type being wrong\n * once here is a complaint silently laundered.\n */\nexport const OPT_IN_RELEASABLE_REASONS: readonly EmailSuppressionReason[] = [\n 'bounce',\n]\n\n/** What a completed double opt-in did to a platform suppression. */\nexport type ConfirmedOptInRelease =\n /** A live `bounce` record was lifted. The address is mailable again. */\n | 'released'\n /** Nothing was suppressed, or it was already released. */\n | 'nothing-to-release'\n /** A record stands that a round trip may not lift — see the constant. */\n | 'refused'\n /** The read or the write failed. The record, whatever it is, still stands. */\n | 'failed'\n\n/**\n * Lift a platform suppression that a completed double opt-in has disproved.\n *\n * THE ONE PLACE THE AUTOMATIC RULE IS STATED, mirroring\n * `releaseSiteSuppression` for the per-site list: every path that puts an\n * address back in circulation without a human deciding goes through here, so\n * there is one line to read and one line to change.\n *\n * ## Platform-wide, from one site's round trip\n *\n * The record being lifted asserts something host-independent — this mailbox\n * does not exist, learned anywhere in the product, including on transactional\n * mail that carried no site tag. A confirmation delivered to that mailbox\n * disproves it just as host-independently, so scoping the release to the\n * confirming site would leave a record standing that says the mailbox is dead\n * while we hold proof that it is not. The site and the topic are written onto\n * the record instead, which is what makes the release reviewable: an operator\n * reading the row sees which site's round trip lifted it and when.\n *\n * ## The per-site list is NOT touched\n *\n * `hosts/{hostId}/suppressions` mixes a deliverability fact with a stated\n * preference — an unsubscribe from that one site lives there too — and the\n * email plugin's `releaseSiteSuppression` is the single guarded path that\n * lifts one. Reaching around it from here would put a second, differently\n * reasoned releaser on a list whose whole protection is that there is one.\n *\n * Never throws: it runs inside a recipient's confirmation click, and a\n * Firestore failure must degrade to a suppression that stays in force, not to\n * a person told their confirmation failed.\n */\nexport async function releaseEmailForConfirmedOptIn(input: {\n email: string\n /** The site whose confirmation link was clicked, for the audit trail. */\n hostId: string\n /** The stream that was confirmed, likewise. */\n topicId: string\n firestore?: any\n}): Promise<ConfirmedOptInRelease> {\n const key = emailSuppressionKey(input.email)\n if (!key) return 'nothing-to-release'\n try {\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n if (!snapshot.exists || snapshot.get('releasedAt')) {\n return 'nothing-to-release'\n }\n if (!OPT_IN_RELEASABLE_REASONS.includes(snapshot.get('reason'))) {\n return 'refused'\n }\n await ref.set(\n {\n releasedAt: FieldValue.serverTimestamp(),\n // No human released this, so the field says so rather than naming\n // whoever last touched the record.\n releasedByUid: null,\n releasedVia: 'double-opt-in' satisfies EmailReleaseChannel,\n releasedHostId: input.hostId || null,\n releasedTopicId: input.topicId || null,\n },\n { merge: true },\n )\n return 'released'\n } catch (error) {\n console.error(\n '[email-suppression] opt-in release failed; suppression stands',\n error,\n )\n return 'failed'\n }\n}\n\n/**\n * The BULK-SEND gate. See the module note for which senders must call this and\n * which must not.\n *\n * FAILS CLOSED, like `isPhoneContactSuppressed`: a read that throws answers\n * `true`, meaning \"treat as suppressed\". The cost of failing closed here is\n * one delayed informational email — every caller is a cron that runs again —\n * and it is nearly free besides, because every one of them is already deep\n * inside a Firestore-backed sweep that a Firestore outage has stopped anyway.\n * The cost of failing open is another delivery attempt at a mailbox that has\n * already told us permanently that it does not exist, which is precisely the\n * behaviour a provider scores a sending domain on. Never \"fix\" a flaky read\n * here by returning false.\n */\nexport async function isEmailSuppressed(\n email: string | null | undefined,\n injectedFirestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(email)\n // An unusable value is one we cannot check against the list, so it is one we\n // must not send to. Same fail-closed rule.\n if (!key) return true\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_SUPPRESSIONS_COLLECTION)\n .doc(key)\n .get()\n if (!snapshot.exists) return false\n return !snapshot.get('releasedAt')\n } catch (error) {\n console.error('[email-suppression] lookup failed; failing closed', error)\n return true\n }\n}\n\n/**\n * The sendable subset of a recipient list, for the bulk senders.\n *\n * One `get()` per DISTINCT address rather than a query, so the check needs no\n * index and cannot fail open on a missing one. The list is deduplicated first\n * because the callers build it by fanning out over an org's owners and admins,\n * which routinely names the same person twice.\n */\nexport async function filterSuppressedEmails(\n emails: readonly string[],\n injectedFirestore?: any,\n): Promise<string[]> {\n const seen = new Set<string>()\n const candidates: string[] = []\n for (const email of emails) {\n const normalized = String(email ?? '')\n .trim()\n .toLowerCase()\n if (!normalized || seen.has(normalized)) continue\n seen.add(normalized)\n candidates.push(normalized)\n }\n const verdicts = await Promise.all(\n candidates.map((email) => isEmailSuppressed(email, injectedFirestore)),\n )\n return candidates.filter((_email, index) => !verdicts[index])\n}\n\n/*==========================================\n * THE SENDER A PERSON LEAVES IS THE CONSENT GROUP, NOT THE SITE.\n *\n * `consent-groups.ts` lets an org declare several sites ONE sender — a shop,\n * a booking page and a blog under one name, disclosed as one on every\n * capture form. To the person on the other end those are one sender, so\n * leaving any of them is leaving it: an unsubscribe filed by site A has to\n * withhold site B's mail too, or the recipient is made to unsubscribe once\n * per site of a sender they only ever knew as one.\n *\n * Every opt-out is written against the ONE site the person acted on and read\n * across the sending site's CURRENT group. Reading rather than copying on\n * write is what makes a site that joins a group inherit every refusal already\n * standing against its siblings, and it reaches every row already stored and\n * every link already sitting in an inbox — none of which a write-time fan-out\n * could.\n *\n * All three per-site facts are read this way: the site suppression list (an\n * unsubscribe, a hand-added opt-out, an erasure, and the bounces and\n * complaints the platform list already carries), the topic opt-outs, and the\n * recipient's cadence. The group of one — every site of an org that declared\n * nothing — reads exactly the documents these filters always read, in the\n * same round trips.\n *=========================================*/\n\n/**\n * The sites whose opt-out lists answer for a send made BY `hostId`, the\n * sending site first.\n *\n * The sending site's consent group: every site it names, because a refusal\n * standing on any of them is a refusal of the sender. Absent, or a group of\n * one, is the site alone.\n *\n * The group is the one `consentGroupForHost` resolved — never a set a caller\n * built from `org.hosts` or a shared domain, which would be inferring a\n * controller (see `consent-groups.ts`).\n *\n * Throws on a group resolved for a DIFFERENT site. That is a wiring defect no\n * runtime value produces, and the failure it would hide is one site's send\n * decided against another site's lists — the same refusal\n * `splitByMarketingConsent` makes over a consent map read for the wrong host.\n */\nexport function optOutHostIds(\n hostId: string,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null,\n): string[] {\n if (!group) return [hostId]\n if (group.hostId !== hostId) {\n throw new Error(\n `[email-suppression] a consent group resolved for ${group.hostId} ` +\n `cannot decide a send from ${hostId}`,\n )\n }\n return consentGroupOptOutHosts(group)\n}\n\n/**\n * One keyed `getAll` per site in `hostIds`, positionally aligned with `keys`.\n *\n * Per site rather than one call over every (site, address) pair, so each\n * round trip is exactly the size the single-site read always was — a batch\n * of a campaign — and a group multiplies the number of calls, run in\n * parallel, rather than the size of one. A group of one is one call, over\n * the same references in the same order.\n *\n * Rejects when any read does; each caller keeps its own posture on that.\n */\nexport async function getAllAcrossSites(\n db: any,\n hostIds: readonly string[],\n subcollection: string,\n keys: readonly string[],\n): Promise<any[][]> {\n return Promise.all(\n hostIds.map((id) => {\n const list = db.collection('hosts').doc(id).collection(subcollection)\n return db.getAll(...keys.map((key) => list.doc(key)))\n }),\n )\n}\n\n/**\n * The sendable subset for a send made in ONE SITE's name — BOTH lists.\n *\n * A per-site suppression says \"not from this site\". A platform suppression\n * says the address hard-bounced or somebody pressed \"report spam\", learned\n * anywhere in the product — including on transactional mail that carried no\n * site tag at all and could therefore never have reached the per-site list.\n * Consulting only the site's own list mails a known-dead or complaining\n * address from the one shared sending domain every tenant's mail leaves by,\n * which makes it every tenant's deliverability problem rather than one\n * merchant's.\n *\n * Composed from {@link filterSuppressedEmails} rather than reimplementing the\n * platform half: normalization, de-duplication and the fail-closed posture\n * live there, and a second copy of them is a second set of rules for two\n * senders to disagree about.\n *\n * ## \"This site\" means its consent group\n *\n * `group` is the sending site's consent group, and an entry on ANY of its\n * sites' lists withholds — see the section above. Every entry counts, whatever\n * its reason: an unsubscribe and a hand-added opt-out are the person leaving\n * the sender, an erasure is already written on every site of the workspace,\n * and a bounce or a complaint is on the platform list besides. Omitted, the\n * site alone, which is also the whole answer for an org that declared no\n * group.\n *\n * ## Both halves fail CLOSED\n *\n * A read that throws answers \"suppressed\". The platform half already does; the\n * per-site half matches it, because a list we could not read is not a list\n * that said this address is safe to mail. The cost of the other choice is a\n * message delivered to somebody who asked us to stop.\n *\n * ## One `getAll`, keyed, rather than a scan of the collection\n *\n * The per-site half looks up exactly the addresses being mailed, by document\n * id, in one round trip per site. Reading the whole collection instead —\n * which is what the campaign sender did — is bounded by however large the\n * collection has grown, so a site with more suppressions than the read window\n * fails OPEN on the remainder: the people most certain not to want the mail\n * are the ones a truncated read drops.\n */\nexport async function filterSendableForHost(\n hostId: string,\n emails: readonly string[],\n injectedFirestore?: any,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null,\n): Promise<string[]> {\n const hostIds = optOutHostIds(hostId, group)\n const platformSendable = await filterSuppressedEmails(\n emails,\n injectedFirestore,\n )\n // `getAll` rejects an empty reference list, and there is nothing to ask.\n if (!platformSendable.length) return []\n const db = injectedFirestore ?? defaultFirestore()\n // Every survivor of the platform half is keyable — `isEmailSuppressed`\n // answers `true` for an address it cannot key — so this narrows the type\n // rather than dropping anybody.\n const keyed: Array<{ email: string; key: string }> = []\n for (const email of platformSendable) {\n const key = emailSuppressionKey(email)\n if (key) keyed.push({ email, key })\n }\n if (!keyed.length) return []\n try {\n const bySite = await getAllAcrossSites(\n db,\n hostIds,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n keyed.map((entry) => entry.key),\n )\n return keyed\n .filter(\n (_entry, index) =>\n !bySite.some((snapshots) => snapshots[index]?.exists),\n )\n .map((entry) => entry.email)\n } catch (error) {\n console.error(\n '[email-suppression] per-site lookup failed; failing closed',\n error,\n )\n return []\n }\n}\n\n/**\n * The subset of `emails` that may be mailed about `topicId` on this site.\n *\n * The third filter a campaign passes, after the platform list and the site's\n * own: the two suppression lists answer \"may we mail this person at all\", and\n * this answers \"may we mail them about THIS\". A recipient who unticked\n * \"Promotions and offers\" on the preference page is not suppressed — they\n * still get the newsletter — so the fact cannot live on either suppression\n * list without meaning something it does not mean.\n *\n * ## Two ways to be excluded, and both are held here\n *\n * Somebody who LEFT the stream, and somebody who has been asked to confirm\n * joining it and has not. The second is what makes a double opt-in worth\n * recording: an unconfirmed subscriber has to be a real quarantine, not a\n * field the send path never looks at. Both come back from\n * `readTopicSubscriptionState`, which is the only place the three states are\n * decided.\n *\n * Keyed by {@link emailSuppressionKey} and read with one `getAll`, which is\n * both halves of the point: the same derivation as the two lists it runs\n * beside, so one person is one document id everywhere; and one round trip\n * bounded by the size of the send, so adding topics does not make a campaign\n * cost more to resolve.\n *\n * ## This one fails OPEN, and that is the opposite of its neighbors\n *\n * Every other filter in this module answers \"suppressed\" when a read throws,\n * because the cost of guessing wrong is mailing somebody who told us to stop.\n * Here the cost of guessing wrong in that direction is refusing to send a\n * newsletter somebody asked for, on a read that failed for an unrelated\n * reason — and the campaign has already passed both suppression lists, so\n * nobody who asked us to stop entirely can reach this line. A topic\n * preference is a narrower fact than a suppression and it gets the treatment\n * that matches. A caller that wants the strict posture already has it one\n * layer up.\n *\n * An empty `topicId` is a campaign from before topics existed, or one whose\n * topic was never resolved. It filters nobody: there is no stream to have\n * left.\n *\n * ## Leaving a stream is leaving it for the whole consent group\n *\n * The topic catalog is the org's, so \"Newsletter\" on one site of a declared\n * group is the same stream on its siblings, and a person who left it on one\n * has left it from the sender. `group` extends the read to every site in it,\n * exactly as {@link filterSendableForHost} does for the suppression list.\n *\n * Only a sibling's REFUSAL is read there. A pending confirmation stays the\n * concern of the site whose form asked for it, as it always was.\n */\nexport async function filterTopicSendable(\n hostId: string,\n topicId: string | null | undefined,\n emails: readonly string[],\n injectedFirestore?: any,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null,\n): Promise<string[]> {\n const hostIds = optOutHostIds(hostId, group)\n const topic = String(topicId ?? '').trim()\n if (!topic || !emails.length) return [...emails]\n // An unkeyable address cannot carry an opt-out record, so it cannot have\n // left this topic. It is dropped from the LOOKUP and kept in the answer —\n // the suppression filters above have already refused it on their own\n // stricter rule, so this one has no business refusing it a second time.\n const lookups: Array<{ email: string; key: string }> = []\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key) lookups.push({ email, key })\n }\n if (!lookups.length) return [...emails]\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const [own, ...siblings] = await getAllAcrossSites(\n db,\n hostIds,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n lookups.map((entry) => entry.key),\n )\n /*\n * The shared state reader, never a field test written out here.\n *\n * An entry means one of three things and only that function knows all\n * three. The shorthand this replaced — \"an entry with no\n * `resubscribedAt` is a live opt-out\" — reads a CONFIRMED double\n * opt-in, which carries `pendingAt` and `confirmedAt` and no\n * `resubscribedAt`, as somebody who left.\n */\n const stateIn = (snapshot: any) =>\n snapshot?.exists\n ? readTopicSubscriptionState((snapshot.get('topics') ?? {})[topic])\n : 'subscribed'\n const gone = new Set<string>()\n lookups.forEach((entry, index) => {\n if (\n stateIn(own[index]) !== 'subscribed' ||\n siblings.some((snapshots) => stateIn(snapshots[index]) === 'opted-out')\n ) {\n gone.add(entry.email)\n }\n })\n return emails.filter((email) => !gone.has(email))\n } catch (error) {\n console.error(\n '[email-suppression] topic opt-out lookup failed; failing open',\n error,\n )\n return [...emails]\n }\n}\n\n/*\n * THE FOURTH FILTER IS NOT IN THIS FILE, and where it is is forced.\n *\n * `filterCadenceSendable` — the subset that has not asked this site for mail\n * less often than right now — lives in `email-marketing-gate.ts`, because it\n * reads the per-recipient counter document that module owns and names. That\n * module already imports this one for the two suppression lists, so putting\n * the cadence filter here would close a cycle. A campaign's subtraction chain\n * therefore reads: this file's two lists, this file's topic opt-outs, then\n * that file's cadence.\n */\n\n/**\n * The staff queue, newest first, ordered by `suppressedAt` so a re-recorded or\n * released entry surfaces again — the operator's question is \"what changed\",\n * not \"what was first written\". Mirrors `listContactSuppressions`.\n */\nexport async function listEmailSuppressions(options?: {\n limit?: number\n /**\n * Where the NEXT page starts: the `suppressedAt` of the last row already\n * shown, as `seconds.nanoseconds`.\n *\n * The full timestamp rather than milliseconds, because `startAfter` skips\n * exactly the value it is given: a millisecond-truncated cursor sits BEFORE\n * the record it names, so that record would arrive again at the top of the\n * following page. Repeating a row is a smaller fault than skipping one and\n * neither is necessary.\n */\n startAfter?: string | null\n firestore?: any\n}): Promise<Array<EmailSuppressionRecord & { $id: string }>> {\n const db = options?.firestore ?? defaultFirestore()\n let query = db\n .collection(EMAIL_SUPPRESSIONS_COLLECTION)\n .orderBy('suppressedAt', 'desc')\n const cursor = suppressionCursorTimestamp(options?.startAfter)\n if (cursor) query = query.startAfter(cursor)\n const snapshot = await query\n .limit(Math.min(Math.max(options?.limit ?? 100, 1), 500))\n .get()\n return snapshot.docs.map((doc: any) => ({\n $id: doc.id,\n ...(doc.data() as EmailSuppressionRecord),\n }))\n}\n\n/**\n * The cursor a page hands back, from the last row on it.\n *\n * Null when the row carries no `suppressedAt` — every entry written since\n * AGL-1918 does, and `orderBy` has already excluded any that does not, so\n * this is the type narrowing rather than a case that occurs.\n */\nexport function suppressionCursorFrom(\n record: Record<string, any> | null | undefined,\n): string | null {\n const at = record?.['suppressedAt'] as\n | { seconds?: number; _seconds?: number; nanoseconds?: number; _nanoseconds?: number }\n | undefined\n const seconds = at?.seconds ?? at?._seconds\n if (typeof seconds !== 'number') return null\n const nanoseconds = at?.nanoseconds ?? at?._nanoseconds ?? 0\n return `${seconds}.${nanoseconds}`\n}\n\n/** The inverse, for the query. Invalid input yields no cursor, never a guess. */\nexport function suppressionCursorTimestamp(\n cursor: string | null | undefined,\n): Timestamp | null {\n const [rawSeconds, rawNanos] = String(cursor ?? '').split('.')\n const seconds = Number(rawSeconds)\n const nanoseconds = Number(rawNanos ?? 0)\n if (!Number.isFinite(seconds) || !seconds) return null\n return new Timestamp(seconds, Number.isFinite(nanoseconds) ? nanoseconds : 0)\n}\n\n/*==========================================\n * THE ERASURE ROW ON THE PER-SITE LIST (AGL-2623).\n *\n * A person erased from a workspace must not be quietly rebuilt by the next\n * form they fill in or the next order they place, and must not be mailed by\n * a campaign either. Both gates already read `hosts/{hostId}/suppressions`\n * — every campaign filter refuses an address with a row there — so the\n * erasure writes one row per site of the workspace rather than inventing a\n * third list, and the capture door reads the same row.\n *\n * The row carries NO address. Every other writer of this list stores the\n * email in the clear beside the hash, because a hub admin releasing an\n * unsubscribe needs to see who it was; an erasure row is the one whose\n * whole purpose is that the address is no longer held, so the id — the\n * hash — is the entire record, and a row that already held the address in\n * the clear has it removed. The hub's list renders such a row as an entry\n * with no address, which it already knows how to do.\n *\n * Per SITE, not platform-wide, on purpose: the erasure was asked of one\n * workspace, and a second workspace that knows the same person has a\n * relationship this request has no claim on. The platform list is for\n * bounces and complaints, which are facts about the address everywhere.\n *=========================================*/\n\n/** The `reason` an erasure row carries. Read by the capture door, below. */\nexport const HOST_ERASURE_SUPPRESSION_REASON = 'erasure'\n\n/**\n * Suppress an address on one site because the person was erased. Idempotent:\n * a second erasure of the same person merges onto the same row.\n */\nexport async function suppressEmailForHostErasure(input: {\n hostId: string\n email: string\n firestore?: any\n}): Promise<{ key: string; created: boolean } | null> {\n const key = emailSuppressionKey(input.email)\n if (!key) return null\n const db = input.firestore ?? defaultFirestore()\n const ref = db\n .collection('hosts')\n .doc(input.hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n const snapshot = await ref.get()\n await ref.set(\n {\n email: null,\n reason: HOST_ERASURE_SUPPRESSION_REASON,\n suppressedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n return { key, created: !snapshot.exists }\n}\n\n/**\n * Whether a site has erased this address, so a capture must not create a\n * contact for it. Only an ERASURE row refuses: an ordinary unsubscribe is a\n * mailing preference, and a person who unsubscribed and then bought\n * something is still a customer whose order the CRM should know about.\n *\n * Fails OPEN. A read that throws answers `false` and logs: the alternative\n * refuses every capture on the site for as long as the list is unreadable,\n * which turns a transient read failure into a silent drop of the site's\n * leads and orders. The window this leaves — a recreate during an outage\n * of the suppression read — is logged, and the next erasure run finds the\n * recreated record by the same address.\n */\nexport async function hostRefusesCaptureForErasure(\n hostId: string,\n email: string | null | undefined,\n injectedFirestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(email)\n if (!key) return false\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const snapshot = await db\n .collection('hosts')\n .doc(hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n .get()\n return (\n Boolean(snapshot?.exists) &&\n snapshot.get('reason') === HOST_ERASURE_SUPPRESSION_REASON\n )\n } catch (error) {\n console.error(\n '[email-suppression] erasure lookup failed; the capture proceeds',\n error,\n )\n return false\n }\n}\n"],"names":["FieldValue","Timestamp","consentGroupOptOutHosts","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","personKey","stampRecordEmailState","firebaseAdmin","defaultFirestore","app","firestore","EMAIL_SUPPRESSIONS_COLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","PLATFORM_SUPPRESSION_REASONS","UNSUBSCRIBE_SUPPRESSION_REASON","emailSuppressionKey","email","suppressEmail","input","key","Error","includes","reason","db","ref","collection","doc","snapshot","get","set","String","trim","toLowerCase","context","hostId","releasedAt","suppressedAt","serverTimestamp","exists","createdAt","merge","stampRecord","state","status","atMs","Date","now","source","detail","created","releaseEmail","releasedByUid","releasedNote","note","releasedVia","OPT_IN_RELEASABLE_REASONS","releaseEmailForConfirmedOptIn","releasedHostId","releasedTopicId","topicId","error","console","isEmailSuppressed","injectedFirestore","filterSuppressedEmails","emails","seen","Set","candidates","normalized","has","add","push","verdicts","Promise","all","map","filter","_email","index","optOutHostIds","group","getAllAcrossSites","hostIds","subcollection","keys","id","list","getAll","filterSendableForHost","platformSendable","length","keyed","bySite","entry","_entry","some","snapshots","filterTopicSendable","topic","lookups","own","siblings","stateIn","gone","forEach","listEmailSuppressions","options","query","orderBy","cursor","suppressionCursorTimestamp","startAfter","limit","Math","min","max","docs","$id","data","suppressionCursorFrom","record","at","seconds","_seconds","nanoseconds","_nanoseconds","rawSeconds","rawNanos","split","Number","isFinite","HOST_ERASURE_SUPPRESSION_REASON","suppressEmailForHostErasure","hostRefusesCaptureForErasure","Boolean"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-suppression.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The PLATFORM-WIDE email suppression list, `emailSuppressions/{emailKey}`\n * (AGL-2407).\n *\n * ## What was missing\n *\n * AGL-1918 (`7a8f3cd68`) made `email.bounced` (permanent) and\n * `email.complained` write a suppression — into `hosts/{hostId}/suppressions`,\n * found from a Resend tag that only `campaign-send.ts` stamped. That closed\n * the campaign half and could close no other, because there was nowhere to\n * file the rest:\n *\n * Every other sender in the product goes through the shared `sendEmail` —\n * invites,\n * password resets, verification, receipts, booking confirmations, the monthly\n * usage summary, the usage-alert fan-out, restock and abandoned-cart mail, the\n * merchant-authored workflow `sendEmail` step. A bounce on any of those\n * arrived at the webhook with no site to place it against and was answered\n * `200 {ignored:true}`. A dead address was re-mailed on every subsequent send,\n * forever, and a spam complaint had no effect on anything.\n *\n * That matters most exactly when it is hardest to fix: on Sept 1 the signup\n * door opens, and verification and invite mail — addressed by strangers typing\n * addresses — is the highest-bounce-rate mail we send, on the same Resend key\n * and the same From address as everything else.\n *\n * ## Why a sibling of `contactSuppressions` and not a channel on it\n *\n * `contact-suppression.ts` is the same idea for phones and every argument it\n * makes holds here: keyed on the identifier because that is what the sender\n * holds at send time; one `get()` by document id, no query and no composite\n * index that could go missing and fail the lookup open; a revocation is a\n * FIELD and not a delete, because the record is the evidence that the\n * suppression was honoured while it was in force.\n *\n * It is a separate collection for one concrete reason: the document id. That\n * list's key is the E.164 digits, and its docblock argues at length that a\n * phone number must NOT be hashed (NANP is ~10^10 values, so a hash buys the\n * appearance of de-identification and none of the substance). An email address\n * is not enumerable, `hosts/{hostId}/suppressions` already keys on\n * `sha256(email)`, and one collection holding two incompatible key spaces is\n * how a lookup comes to be performed against the wrong derivation. The\n * address is stored in the document in the clear regardless, exactly as the\n * per-host list stores it, so a staff reader still shows a human something\n * they can act on.\n *\n * ## Who reads it, and who deliberately does NOT\n *\n * AGL-1438's rule is that a QUOTA may only ever refuse a campaign, because\n * refusing a receipt or a password reset converts a billing event into an\n * outage on a customer's business. A suppression is not a quota, but the same\n * proportionality applies and lands in the same place:\n *\n * - **Bulk mail consults this list.** The monthly usage summary, the\n * usage-alert fan-out and the marketplace review fan-out to a publisher's\n * owners and admins go to a LIST of people who did not ask for that\n * particular message, on a schedule or on every submission, forever. They\n * are the sends that re-hit a dead mailbox every month and teach a mailbox\n * provider that `aglyn.com` does not listen.\n * - **Transactional mail does NOT.** A password reset, a verification, an\n * invite, a receipt or a booking confirmation answers something the human\n * just did. Refusing one because an address bounced or because somebody\n * once pressed \"report spam\" on an unrelated message would lock a real\n * customer out of their own account. The shared sender is therefore left\n * unconditional on purpose, and this is the note that says so.\n *\n * (Written throughout without a literal call expression, on purpose:\n * `email-send-metering-coverage.spec.ts` enumerates senders by grepping the\n * tree for one, and a prose mention here would enrol a module that sends\n * nothing into the AGL-1438 cost-meter sweep.)\n */\n\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\n// The leaf entry, not the `@aglyn/aglyn` barrel, for the reason\n// `document-id.ts` gives at length: a barrel import resolves to whatever a\n// spec's `jest.mock` happens to contain, and this module's neighbours are\n// mocked in nearly every spec that touches them.\nimport {\n consentGroupOptOutHosts,\n consentGroupTopicState,\n type ConsentGroup,\n} from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport firebaseAdmin from './firebase-admin'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\nexport const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions'\n\n/**\n * The PER-SITE list, `hosts/{hostId}/suppressions/{emailKey}` — unsubscribes\n * from one site's campaigns, plus the bounces and complaints that arrived\n * carrying that site's tag.\n *\n * Named here rather than at each call site because the two lists are read\n * together by {@link filterSendableForHost}, and a sender that knows one path\n * as a string literal and the other through this module is one rename away\n * from consulting a collection that does not exist and finding nobody\n * suppressed.\n */\nexport const HOST_SUPPRESSIONS_SUBCOLLECTION = 'suppressions'\n\n/**\n * Why an address is suppressed.\n *\n * `bounce` means PERMANENT only. Resend reports `data.bounce.type` as\n * `Permanent` or `Transient`, and a transient bounce is a full mailbox or a\n * greylisting server — recording one here would suppress a real recipient over\n * a temporary condition at their provider. The webhook makes that distinction;\n * this type only names the outcome.\n */\nexport type EmailSuppressionReason =\n /** Permanent bounce — the mailbox does not exist. */\n | 'bounce'\n /** The recipient pressed \"report spam\". */\n | 'complaint'\n /** Recorded by staff (a written request, a court order, a correction). */\n | 'staff'\n\n/**\n * The reasons that may be filed PLATFORM-WIDE, as a runtime set.\n *\n * ## The split this constant enforces\n *\n * A bounce and a complaint are facts about a MAILBOX and about the sending\n * domain every tenant's mail leaves by: the address does not exist for\n * anybody, and somebody who pressed \"report spam\" on `noreply@aglyn.com` has\n * told every sender behind that domain at once. Those belong everywhere,\n * immediately.\n *\n * An UNSUBSCRIBE is not that. It is a preference a person expressed to ONE\n * brand — the sender line they read, the newsletter they joined — and an\n * agency running twelve unrelated clients out of one account would, on a\n * platform-wide entry, stop mailing that person on behalf of eleven brands\n * they never heard from. So an unsubscribe lives only in\n * `hosts/{hostId}/suppressions`, and {@link suppressEmail} refuses it.\n *\n * ## Why a runtime refusal and not just the type\n *\n * {@link EmailSuppressionReason} already excludes it, and a type is not a\n * guard: every caller here builds its reason from a webhook payload or a\n * ternary, `as` casts exist, and the plugin API surface is untyped at the\n * boundary. The cost of the type being wrong once is an opt-out from one\n * brand silently applied to every brand in the account, which is invisible\n * from the console — the mail simply never arrives, and the merchant's own\n * suppression list does not mention it.\n */\nexport const PLATFORM_SUPPRESSION_REASONS: readonly EmailSuppressionReason[] = [\n 'bounce',\n 'complaint',\n 'staff',\n]\n\n/**\n * The reason a self-service opt-out is filed under, on the PER-SITE list.\n *\n * Named because three call sites compare against it — the unsubscribe writes\n * it, the resubscribe link refuses to reverse anything else, and the\n * preference page reads it to decide whether an address may opt back in. A\n * literal in three places is a literal that can be changed in two.\n */\nexport const UNSUBSCRIBE_SUPPRESSION_REASON = 'unsubscribe'\n\nexport interface EmailSuppressionRecord {\n /** The address, in the clear, lowercased. The id is its hash. */\n email: string\n reason: EmailSuppressionReason\n /**\n * The `context` tag the send carried (`'invite'`, `'usage-summary'`,\n * `'campaign'`, …), so a human reading the list can tell which of our\n * senders produced the address that died. Null when the send carried none.\n */\n context: string | null\n /** The site the failure was attributed to, when the send named one. */\n hostId: string | null\n /** Non-null once released. A released record does not suppress. */\n releasedAt: unknown | null\n /** What lifted it. Absent on records written before releases were typed. */\n releasedVia?: EmailReleaseChannel\n /** The site whose confirmed double opt-in lifted it, when one did. */\n releasedHostId?: string | null\n /** The stream that was confirmed, likewise. */\n releasedTopicId?: string | null\n}\n\n/**\n * Document id for an address: `sha256` of the lowercased, trimmed form.\n *\n * The SAME derivation `campaign-send.ts` uses for `hosts/{hostId}/suppressions`\n * — deliberately, so the two lists can never disagree about which document\n * describes which person.\n *\n * @returns the id, or `null` for anything that is not an address. Never a\n * best-guess id: suppressing the wrong person and believing you\n * suppressed the right one is worse than refusing.\n */\nexport function emailSuppressionKey(\n email: string | null | undefined,\n): string | null {\n /*\n * Delegated, not reimplemented. A second hash of the same address is how\n * this area got two derivations that agreed only by accident; `personKey`\n * is the one that normalizes and the one the unsubscribe route already\n * hashes through, so a suppression filed by either is found by both.\n */\n return personKey(email)\n}\n\nexport interface SuppressEmailInput {\n email: string\n reason: EmailSuppressionReason\n /** The `context` tag the failed send carried, when it carried one. */\n context?: string | null\n /** The site the failed send was attributed to, when it named one. */\n hostId?: string | null\n /**\n * Whether to say so on the person's record too (AGL-3245); on by default.\n * A sender that stamps a richer verdict of its own passes `false`.\n */\n stampRecord?: boolean\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n}\n\n/**\n * Record a platform-wide suppression. Idempotent by document id.\n *\n * `createdAt` is stamped only when the document is new, matching the per-host\n * list: a second bounce must not overwrite the moment the address first went\n * bad, because that is the date a human is told when they ask. `suppressedAt`\n * moves every time, so \"when did we last see this fail\" stays answerable.\n *\n * A previously released record is un-released — a fresh failure is a fresh\n * failure.\n *\n * @returns `created: false` when the address was already on the list.\n */\nexport async function suppressEmail(input: SuppressEmailInput): Promise<{\n key: string\n created: boolean\n}> {\n const key = emailSuppressionKey(input.email)\n if (!key) {\n throw new Error('[email-suppression] cannot key a suppression for that value')\n }\n if (!PLATFORM_SUPPRESSION_REASONS.includes(input.reason)) {\n throw new Error(\n `[email-suppression] ${input.reason} is a per-site preference and ` +\n 'cannot be filed platform-wide',\n )\n }\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n await ref.set(\n {\n email: String(input.email).trim().toLowerCase(),\n reason: input.reason,\n context: input.context ?? null,\n hostId: input.hostId ?? null,\n releasedAt: null,\n suppressedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n /*\n * The same verdict on the record the person reads (AGL-3245), through\n * whichever plugin keeps the workspace's records: a bounce or a complaint\n * on a send that named a site stamps the site's records. A send that\n * named none has no records to find, and a caller that stamps its own,\n * richer verdict — the sequence runtime, with the domain block and the\n * enrollment — says `stampRecord: false` and is left to it.\n */\n if (input.hostId && input.stampRecord !== false && input.reason !== 'staff') {\n await stampRecordEmailState({\n hostId: input.hostId,\n email: input.email,\n state: {\n status: input.reason === 'complaint' ? 'complained' : 'bounced',\n atMs: Date.now(),\n source: 'campaign',\n detail: input.context ? `Reported by the ${input.context} send.` : null,\n },\n })\n }\n return { key, created: !snapshot.exists }\n}\n\n/**\n * What lifted a suppression, recorded on the record it lifted.\n *\n * Written explicitly by both paths rather than left to \"absent means staff\".\n * The two are held to different rules — a staff release may lift any reason,\n * a confirmed opt-in may lift exactly one — so the audit answer to \"how did\n * this address get back on the domain\" has to be a stored fact and not an\n * inference from which field happens to be missing.\n */\nexport type EmailReleaseChannel =\n /** A staff correction through the admin suppressions surface. */\n | 'staff'\n /** A recipient completed a double opt-in from this address. */\n | 'double-opt-in'\n\n/**\n * Put an address back in circulation (a staff correction, or the person asking\n * to be re-added). The record is kept and marked released, never deleted —\n * same reasoning as `releasePhoneContact`: a deleted record cannot show that\n * the suppression was honored while it stood.\n *\n * Unconditional on the reason, and that is what separates it from\n * {@link releaseEmailForConfirmedOptIn}: a human with the staff role has read\n * the record and is accountable for the row, so they may lift a complaint —\n * a report filed against the wrong message, an address entered by mistake.\n * Nothing automatic gets that latitude.\n *\n * @returns false when there was no live record to release.\n */\nexport async function releaseEmail(input: {\n email: string\n releasedByUid?: string | null\n note?: string | null\n firestore?: any\n}): Promise<boolean> {\n const key = emailSuppressionKey(input.email)\n if (!key) return false\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n if (!snapshot.exists || snapshot.get('releasedAt')) return false\n await ref.set(\n {\n releasedAt: FieldValue.serverTimestamp(),\n releasedByUid: input.releasedByUid ?? null,\n releasedNote: input.note ?? null,\n releasedVia: 'staff' satisfies EmailReleaseChannel,\n },\n { merge: true },\n )\n return true\n}\n\n/**\n * The ONLY reason a completed round trip may lift, as a runtime set.\n *\n * ## Why a bounce is releasable\n *\n * A `bounce` record is a claim about a MAILBOX at a moment: mail addressed\n * here was permanently refused. Somebody clicking a confirmation link that\n * was delivered to that mailbox has refuted the claim with the only evidence\n * that could refute it — the message arrived, a human read it, and the round\n * trip closed. Leaving the record standing after that makes one transient\n * failure recorded as permanent — a mailbox that was full, a receiving server\n * that answered 550 while it was misconfigured — a life sentence no route can\n * undo.\n *\n * ## Why a complaint is NOT, and never will be\n *\n * `complaint` is not a deliverability fact. It is a person stating they do not\n * want this mail, and a round trip proves nothing about that statement — it\n * proves the mailbox works, which nobody doubted. Releasing one here would\n * make a public signup form into a laundry for spam complaints: submit the\n * complainant's address, they receive a confirmation (the shared sender is\n * transactional and consults no list), and one click anywhere in the chain\n * puts them back on the sending domain that the complaint was filed against.\n * The same reasoning refuses `staff`, which is a written request, a\n * correction, or a legal instruction, and is nobody's to reverse from a link.\n *\n * A runtime set and not just the type, for the reason\n * {@link PLATFORM_SUPPRESSION_REASONS} gives: reasons arrive from webhook\n * payloads through an untyped boundary, and the cost of the type being wrong\n * once here is a complaint silently laundered.\n */\nexport const OPT_IN_RELEASABLE_REASONS: readonly EmailSuppressionReason[] = [\n 'bounce',\n]\n\n/** What a completed double opt-in did to a platform suppression. */\nexport type ConfirmedOptInRelease =\n /** A live `bounce` record was lifted. The address is mailable again. */\n | 'released'\n /** Nothing was suppressed, or it was already released. */\n | 'nothing-to-release'\n /** A record stands that a round trip may not lift — see the constant. */\n | 'refused'\n /** The read or the write failed. The record, whatever it is, still stands. */\n | 'failed'\n\n/**\n * Lift a platform suppression that a completed double opt-in has disproved.\n *\n * THE ONE PLACE THE AUTOMATIC RULE IS STATED, mirroring\n * `releaseSiteSuppression` for the per-site list: every path that puts an\n * address back in circulation without a human deciding goes through here, so\n * there is one line to read and one line to change.\n *\n * ## Platform-wide, from one site's round trip\n *\n * The record being lifted asserts something host-independent — this mailbox\n * does not exist, learned anywhere in the product, including on transactional\n * mail that carried no site tag. A confirmation delivered to that mailbox\n * disproves it just as host-independently, so scoping the release to the\n * confirming site would leave a record standing that says the mailbox is dead\n * while we hold proof that it is not. The site and the topic are written onto\n * the record instead, which is what makes the release reviewable: an operator\n * reading the row sees which site's round trip lifted it and when.\n *\n * ## The per-site list is NOT touched\n *\n * `hosts/{hostId}/suppressions` mixes a deliverability fact with a stated\n * preference — an unsubscribe from that one site lives there too — and the\n * email plugin's `releaseSiteSuppression` is the single guarded path that\n * lifts one. Reaching around it from here would put a second, differently\n * reasoned releaser on a list whose whole protection is that there is one.\n *\n * Never throws: it runs inside a recipient's confirmation click, and a\n * Firestore failure must degrade to a suppression that stays in force, not to\n * a person told their confirmation failed.\n */\nexport async function releaseEmailForConfirmedOptIn(input: {\n email: string\n /** The site whose confirmation link was clicked, for the audit trail. */\n hostId: string\n /** The stream that was confirmed, likewise. */\n topicId: string\n firestore?: any\n}): Promise<ConfirmedOptInRelease> {\n const key = emailSuppressionKey(input.email)\n if (!key) return 'nothing-to-release'\n try {\n const db = input.firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key)\n const snapshot = await ref.get()\n if (!snapshot.exists || snapshot.get('releasedAt')) {\n return 'nothing-to-release'\n }\n if (!OPT_IN_RELEASABLE_REASONS.includes(snapshot.get('reason'))) {\n return 'refused'\n }\n await ref.set(\n {\n releasedAt: FieldValue.serverTimestamp(),\n // No human released this, so the field says so rather than naming\n // whoever last touched the record.\n releasedByUid: null,\n releasedVia: 'double-opt-in' satisfies EmailReleaseChannel,\n releasedHostId: input.hostId || null,\n releasedTopicId: input.topicId || null,\n },\n { merge: true },\n )\n return 'released'\n } catch (error) {\n console.error(\n '[email-suppression] opt-in release failed; suppression stands',\n error,\n )\n return 'failed'\n }\n}\n\n/**\n * The BULK-SEND gate. See the module note for which senders must call this and\n * which must not.\n *\n * FAILS CLOSED, like `isPhoneContactSuppressed`: a read that throws answers\n * `true`, meaning \"treat as suppressed\". The cost of failing closed here is\n * one delayed informational email — every caller is a cron that runs again —\n * and it is nearly free besides, because every one of them is already deep\n * inside a Firestore-backed sweep that a Firestore outage has stopped anyway.\n * The cost of failing open is another delivery attempt at a mailbox that has\n * already told us permanently that it does not exist, which is precisely the\n * behaviour a provider scores a sending domain on. Never \"fix\" a flaky read\n * here by returning false.\n */\nexport async function isEmailSuppressed(\n email: string | null | undefined,\n injectedFirestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(email)\n // An unusable value is one we cannot check against the list, so it is one we\n // must not send to. Same fail-closed rule.\n if (!key) return true\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_SUPPRESSIONS_COLLECTION)\n .doc(key)\n .get()\n if (!snapshot.exists) return false\n return !snapshot.get('releasedAt')\n } catch (error) {\n console.error('[email-suppression] lookup failed; failing closed', error)\n return true\n }\n}\n\n/**\n * The sendable subset of a recipient list, for the bulk senders.\n *\n * One `get()` per DISTINCT address rather than a query, so the check needs no\n * index and cannot fail open on a missing one. The list is deduplicated first\n * because the callers build it by fanning out over an org's owners and admins,\n * which routinely names the same person twice.\n */\nexport async function filterSuppressedEmails(\n emails: readonly string[],\n injectedFirestore?: any,\n): Promise<string[]> {\n const seen = new Set<string>()\n const candidates: string[] = []\n for (const email of emails) {\n const normalized = String(email ?? '')\n .trim()\n .toLowerCase()\n if (!normalized || seen.has(normalized)) continue\n seen.add(normalized)\n candidates.push(normalized)\n }\n const verdicts = await Promise.all(\n candidates.map((email) => isEmailSuppressed(email, injectedFirestore)),\n )\n return candidates.filter((_email, index) => !verdicts[index])\n}\n\n/*==========================================\n * THE SENDER A PERSON LEAVES IS THE CONSENT GROUP, NOT THE SITE.\n *\n * `consent-groups.ts` lets an org declare several sites ONE sender — a shop,\n * a booking page and a blog under one name, disclosed as one on every\n * capture form. To the person on the other end those are one sender, so\n * leaving any of them is leaving it: an unsubscribe filed by site A has to\n * withhold site B's mail too, or the recipient is made to unsubscribe once\n * per site of a sender they only ever knew as one.\n *\n * Every opt-out is written against the ONE site the person acted on and read\n * across the sending site's CURRENT group. Reading rather than copying on\n * write is what makes a site that joins a group inherit every refusal already\n * standing against its siblings, and it reaches every row already stored and\n * every link already sitting in an inbox — none of which a write-time fan-out\n * could.\n *\n * All three per-site facts are read this way: the site suppression list (an\n * unsubscribe, a hand-added opt-out, an erasure, and the bounces and\n * complaints the platform list already carries), the topic opt-outs, and the\n * recipient's cadence. A sibling's pending confirmation joins them only when\n * the org turned its group's confirmation switch on — see\n * {@link filterTopicSendable}. The group of one — every site of an org that\n * declared nothing — reads exactly the documents these filters always read,\n * in the same round trips.\n *=========================================*/\n\n/**\n * The sites whose opt-out lists answer for a send made BY `hostId`, the\n * sending site first.\n *\n * The sending site's consent group: every site it names, because a refusal\n * standing on any of them is a refusal of the sender. Absent, or a group of\n * one, is the site alone.\n *\n * The group is the one `consentGroupForHost` resolved — never a set a caller\n * built from `org.hosts` or a shared domain, which would be inferring a\n * controller (see `consent-groups.ts`).\n *\n * Throws on a group resolved for a DIFFERENT site. That is a wiring defect no\n * runtime value produces, and the failure it would hide is one site's send\n * decided against another site's lists — the same refusal\n * `splitByMarketingConsent` makes over a consent map read for the wrong host.\n */\nexport function optOutHostIds(\n hostId: string,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null,\n): string[] {\n if (!group) return [hostId]\n if (group.hostId !== hostId) {\n throw new Error(\n `[email-suppression] a consent group resolved for ${group.hostId} ` +\n `cannot decide a send from ${hostId}`,\n )\n }\n return consentGroupOptOutHosts(group)\n}\n\n/**\n * One keyed `getAll` per site in `hostIds`, positionally aligned with `keys`.\n *\n * Per site rather than one call over every (site, address) pair, so each\n * round trip is exactly the size the single-site read always was — a batch\n * of a campaign — and a group multiplies the number of calls, run in\n * parallel, rather than the size of one. A group of one is one call, over\n * the same references in the same order.\n *\n * Rejects when any read does; each caller keeps its own posture on that.\n */\nexport async function getAllAcrossSites(\n db: any,\n hostIds: readonly string[],\n subcollection: string,\n keys: readonly string[],\n): Promise<any[][]> {\n return Promise.all(\n hostIds.map((id) => {\n const list = db.collection('hosts').doc(id).collection(subcollection)\n return db.getAll(...keys.map((key) => list.doc(key)))\n }),\n )\n}\n\n/**\n * The sendable subset for a send made in ONE SITE's name — BOTH lists.\n *\n * A per-site suppression says \"not from this site\". A platform suppression\n * says the address hard-bounced or somebody pressed \"report spam\", learned\n * anywhere in the product — including on transactional mail that carried no\n * site tag at all and could therefore never have reached the per-site list.\n * Consulting only the site's own list mails a known-dead or complaining\n * address from the one shared sending domain every tenant's mail leaves by,\n * which makes it every tenant's deliverability problem rather than one\n * merchant's.\n *\n * Composed from {@link filterSuppressedEmails} rather than reimplementing the\n * platform half: normalization, de-duplication and the fail-closed posture\n * live there, and a second copy of them is a second set of rules for two\n * senders to disagree about.\n *\n * ## \"This site\" means its consent group\n *\n * `group` is the sending site's consent group, and an entry on ANY of its\n * sites' lists withholds — see the section above. Every entry counts, whatever\n * its reason: an unsubscribe and a hand-added opt-out are the person leaving\n * the sender, an erasure is already written on every site of the workspace,\n * and a bounce or a complaint is on the platform list besides. Omitted, the\n * site alone, which is also the whole answer for an org that declared no\n * group.\n *\n * ## Both halves fail CLOSED\n *\n * A read that throws answers \"suppressed\". The platform half already does; the\n * per-site half matches it, because a list we could not read is not a list\n * that said this address is safe to mail. The cost of the other choice is a\n * message delivered to somebody who asked us to stop.\n *\n * ## One `getAll`, keyed, rather than a scan of the collection\n *\n * The per-site half looks up exactly the addresses being mailed, by document\n * id, in one round trip per site. Reading the whole collection instead —\n * which is what the campaign sender did — is bounded by however large the\n * collection has grown, so a site with more suppressions than the read window\n * fails OPEN on the remainder: the people most certain not to want the mail\n * are the ones a truncated read drops.\n */\nexport async function filterSendableForHost(\n hostId: string,\n emails: readonly string[],\n injectedFirestore?: any,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null,\n): Promise<string[]> {\n const hostIds = optOutHostIds(hostId, group)\n const platformSendable = await filterSuppressedEmails(\n emails,\n injectedFirestore,\n )\n // `getAll` rejects an empty reference list, and there is nothing to ask.\n if (!platformSendable.length) return []\n const db = injectedFirestore ?? defaultFirestore()\n // Every survivor of the platform half is keyable — `isEmailSuppressed`\n // answers `true` for an address it cannot key — so this narrows the type\n // rather than dropping anybody.\n const keyed: Array<{ email: string; key: string }> = []\n for (const email of platformSendable) {\n const key = emailSuppressionKey(email)\n if (key) keyed.push({ email, key })\n }\n if (!keyed.length) return []\n try {\n const bySite = await getAllAcrossSites(\n db,\n hostIds,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n keyed.map((entry) => entry.key),\n )\n return keyed\n .filter(\n (_entry, index) =>\n !bySite.some((snapshots) => snapshots[index]?.exists),\n )\n .map((entry) => entry.email)\n } catch (error) {\n console.error(\n '[email-suppression] per-site lookup failed; failing closed',\n error,\n )\n return []\n }\n}\n\n/**\n * The subset of `emails` that may be mailed about `topicId` on this site.\n *\n * The third filter a campaign passes, after the platform list and the site's\n * own: the two suppression lists answer \"may we mail this person at all\", and\n * this answers \"may we mail them about THIS\". A recipient who unticked\n * \"Promotions and offers\" on the preference page is not suppressed — they\n * still get the newsletter — so the fact cannot live on either suppression\n * list without meaning something it does not mean.\n *\n * ## Two ways to be excluded, and both are held here\n *\n * Somebody who LEFT the stream, and somebody who has been asked to confirm\n * joining it and has not. The second is what makes a double opt-in worth\n * recording: an unconfirmed subscriber has to be a real quarantine, not a\n * field the send path never looks at. Both come back from\n * `readTopicSubscriptionState`, which is the only place the three states are\n * decided.\n *\n * Keyed by {@link emailSuppressionKey} and read with one `getAll`, which is\n * both halves of the point: the same derivation as the two lists it runs\n * beside, so one person is one document id everywhere; and one round trip\n * bounded by the size of the send, so adding topics does not make a campaign\n * cost more to resolve.\n *\n * ## This one fails OPEN, and that is the opposite of its neighbors\n *\n * Every other filter in this module answers \"suppressed\" when a read throws,\n * because the cost of guessing wrong is mailing somebody who told us to stop.\n * Here the cost of guessing wrong in that direction is refusing to send a\n * newsletter somebody asked for, on a read that failed for an unrelated\n * reason — and the campaign has already passed both suppression lists, so\n * nobody who asked us to stop entirely can reach this line. A topic\n * preference is a narrower fact than a suppression and it gets the treatment\n * that matches. A caller that wants the strict posture already has it one\n * layer up.\n *\n * An empty `topicId` is a campaign from before topics existed, or one whose\n * topic was never resolved. It filters nobody: there is no stream to have\n * left.\n *\n * ## Leaving a stream is leaving it for the whole consent group\n *\n * The topic catalog is the org's, so \"Newsletter\" on one site of a declared\n * group is the same stream on its siblings, and a person who left it on one\n * has left it from the sender. `group` extends the read to every site in it,\n * exactly as {@link filterSendableForHost} does for the suppression list.\n *\n * A sibling's REFUSAL always holds. A sibling's pending confirmation holds\n * only when the group `awaitsConfirmation` — the org's switch, resolved into\n * the group, so asking it reads nothing the refusal read did not already\n * read. Off, the question stays the concern of the site whose form asked it.\n */\nexport async function filterTopicSendable(\n hostId: string,\n topicId: string | null | undefined,\n emails: readonly string[],\n injectedFirestore?: any,\n group?: Pick<ConsentGroup, 'hostId' | 'hostIds' | 'awaitsConfirmation'> | null,\n): Promise<string[]> {\n const hostIds = optOutHostIds(hostId, group)\n const topic = String(topicId ?? '').trim()\n if (!topic || !emails.length) return [...emails]\n // An unkeyable address cannot carry an opt-out record, so it cannot have\n // left this topic. It is dropped from the LOOKUP and kept in the answer —\n // the suppression filters above have already refused it on their own\n // stricter rule, so this one has no business refusing it a second time.\n const lookups: Array<{ email: string; key: string }> = []\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key) lookups.push({ email, key })\n }\n if (!lookups.length) return [...emails]\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const bySite = await getAllAcrossSites(\n db,\n hostIds,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n lookups.map((entry) => entry.key),\n )\n /*\n * The shared state reader, never a field test written out here.\n *\n * An entry means one of three things and only that function knows all\n * three. The shorthand this replaced — \"an entry with no\n * `resubscribedAt` is a live opt-out\" — reads a CONFIRMED double\n * opt-in, which carries `pendingAt` and `confirmedAt` and no\n * `resubscribedAt`, as somebody who left.\n */\n const stateIn = (snapshot: any) =>\n snapshot?.exists\n ? readTopicSubscriptionState((snapshot.get('topics') ?? {})[topic])\n : 'subscribed'\n // The sending site first, as `optOutHostIds` ordered the reads — which\n // is how the fold tells its own pending question from a sibling's.\n const awaits = { awaitsConfirmation: group?.awaitsConfirmation === true }\n const gone = new Set<string>()\n lookups.forEach((entry, index) => {\n const states = bySite.map((snapshots) => stateIn(snapshots[index]))\n if (consentGroupTopicState(awaits, states) !== 'subscribed') {\n gone.add(entry.email)\n }\n })\n return emails.filter((email) => !gone.has(email))\n } catch (error) {\n console.error(\n '[email-suppression] topic opt-out lookup failed; failing open',\n error,\n )\n return [...emails]\n }\n}\n\n/*\n * THE FOURTH FILTER IS NOT IN THIS FILE, and where it is is forced.\n *\n * `filterCadenceSendable` — the subset that has not asked this site for mail\n * less often than right now — lives in `email-marketing-gate.ts`, because it\n * reads the per-recipient counter document that module owns and names. That\n * module already imports this one for the two suppression lists, so putting\n * the cadence filter here would close a cycle. A campaign's subtraction chain\n * therefore reads: this file's two lists, this file's topic opt-outs, then\n * that file's cadence.\n */\n\n/**\n * The staff queue, newest first, ordered by `suppressedAt` so a re-recorded or\n * released entry surfaces again — the operator's question is \"what changed\",\n * not \"what was first written\". Mirrors `listContactSuppressions`.\n */\nexport async function listEmailSuppressions(options?: {\n limit?: number\n /**\n * Where the NEXT page starts: the `suppressedAt` of the last row already\n * shown, as `seconds.nanoseconds`.\n *\n * The full timestamp rather than milliseconds, because `startAfter` skips\n * exactly the value it is given: a millisecond-truncated cursor sits BEFORE\n * the record it names, so that record would arrive again at the top of the\n * following page. Repeating a row is a smaller fault than skipping one and\n * neither is necessary.\n */\n startAfter?: string | null\n firestore?: any\n}): Promise<Array<EmailSuppressionRecord & { $id: string }>> {\n const db = options?.firestore ?? defaultFirestore()\n let query = db\n .collection(EMAIL_SUPPRESSIONS_COLLECTION)\n .orderBy('suppressedAt', 'desc')\n const cursor = suppressionCursorTimestamp(options?.startAfter)\n if (cursor) query = query.startAfter(cursor)\n const snapshot = await query\n .limit(Math.min(Math.max(options?.limit ?? 100, 1), 500))\n .get()\n return snapshot.docs.map((doc: any) => ({\n $id: doc.id,\n ...(doc.data() as EmailSuppressionRecord),\n }))\n}\n\n/**\n * The cursor a page hands back, from the last row on it.\n *\n * Null when the row carries no `suppressedAt` — every entry written since\n * AGL-1918 does, and `orderBy` has already excluded any that does not, so\n * this is the type narrowing rather than a case that occurs.\n */\nexport function suppressionCursorFrom(\n record: Record<string, any> | null | undefined,\n): string | null {\n const at = record?.['suppressedAt'] as\n | { seconds?: number; _seconds?: number; nanoseconds?: number; _nanoseconds?: number }\n | undefined\n const seconds = at?.seconds ?? at?._seconds\n if (typeof seconds !== 'number') return null\n const nanoseconds = at?.nanoseconds ?? at?._nanoseconds ?? 0\n return `${seconds}.${nanoseconds}`\n}\n\n/** The inverse, for the query. Invalid input yields no cursor, never a guess. */\nexport function suppressionCursorTimestamp(\n cursor: string | null | undefined,\n): Timestamp | null {\n const [rawSeconds, rawNanos] = String(cursor ?? '').split('.')\n const seconds = Number(rawSeconds)\n const nanoseconds = Number(rawNanos ?? 0)\n if (!Number.isFinite(seconds) || !seconds) return null\n return new Timestamp(seconds, Number.isFinite(nanoseconds) ? nanoseconds : 0)\n}\n\n/*==========================================\n * THE ERASURE ROW ON THE PER-SITE LIST (AGL-2623).\n *\n * A person erased from a workspace must not be quietly rebuilt by the next\n * form they fill in or the next order they place, and must not be mailed by\n * a campaign either. Both gates already read `hosts/{hostId}/suppressions`\n * — every campaign filter refuses an address with a row there — so the\n * erasure writes one row per site of the workspace rather than inventing a\n * third list, and the capture door reads the same row.\n *\n * The row carries NO address. Every other writer of this list stores the\n * email in the clear beside the hash, because a hub admin releasing an\n * unsubscribe needs to see who it was; an erasure row is the one whose\n * whole purpose is that the address is no longer held, so the id — the\n * hash — is the entire record, and a row that already held the address in\n * the clear has it removed. The hub's list renders such a row as an entry\n * with no address, which it already knows how to do.\n *\n * Per SITE, not platform-wide, on purpose: the erasure was asked of one\n * workspace, and a second workspace that knows the same person has a\n * relationship this request has no claim on. The platform list is for\n * bounces and complaints, which are facts about the address everywhere.\n *=========================================*/\n\n/** The `reason` an erasure row carries. Read by the capture door, below. */\nexport const HOST_ERASURE_SUPPRESSION_REASON = 'erasure'\n\n/**\n * Suppress an address on one site because the person was erased. Idempotent:\n * a second erasure of the same person merges onto the same row.\n */\nexport async function suppressEmailForHostErasure(input: {\n hostId: string\n email: string\n firestore?: any\n}): Promise<{ key: string; created: boolean } | null> {\n const key = emailSuppressionKey(input.email)\n if (!key) return null\n const db = input.firestore ?? defaultFirestore()\n const ref = db\n .collection('hosts')\n .doc(input.hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n const snapshot = await ref.get()\n await ref.set(\n {\n email: null,\n reason: HOST_ERASURE_SUPPRESSION_REASON,\n suppressedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n return { key, created: !snapshot.exists }\n}\n\n/**\n * Whether a site has erased this address, so a capture must not create a\n * contact for it. Only an ERASURE row refuses: an ordinary unsubscribe is a\n * mailing preference, and a person who unsubscribed and then bought\n * something is still a customer whose order the CRM should know about.\n *\n * Fails OPEN. A read that throws answers `false` and logs: the alternative\n * refuses every capture on the site for as long as the list is unreadable,\n * which turns a transient read failure into a silent drop of the site's\n * leads and orders. The window this leaves — a recreate during an outage\n * of the suppression read — is logged, and the next erasure run finds the\n * recreated record by the same address.\n */\nexport async function hostRefusesCaptureForErasure(\n hostId: string,\n email: string | null | undefined,\n injectedFirestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(email)\n if (!key) return false\n try {\n const db = injectedFirestore ?? defaultFirestore()\n const snapshot = await db\n .collection('hosts')\n .doc(hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n .get()\n return (\n Boolean(snapshot?.exists) &&\n snapshot.get('reason') === HOST_ERASURE_SUPPRESSION_REASON\n )\n } catch (error) {\n console.error(\n '[email-suppression] erasure lookup failed; the capture proceeds',\n error,\n )\n return false\n }\n}\n"],"names":["FieldValue","Timestamp","consentGroupOptOutHosts","consentGroupTopicState","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","personKey","stampRecordEmailState","firebaseAdmin","defaultFirestore","app","firestore","EMAIL_SUPPRESSIONS_COLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","PLATFORM_SUPPRESSION_REASONS","UNSUBSCRIBE_SUPPRESSION_REASON","emailSuppressionKey","email","suppressEmail","input","key","Error","includes","reason","db","ref","collection","doc","snapshot","get","set","String","trim","toLowerCase","context","hostId","releasedAt","suppressedAt","serverTimestamp","exists","createdAt","merge","stampRecord","state","status","atMs","Date","now","source","detail","created","releaseEmail","releasedByUid","releasedNote","note","releasedVia","OPT_IN_RELEASABLE_REASONS","releaseEmailForConfirmedOptIn","releasedHostId","releasedTopicId","topicId","error","console","isEmailSuppressed","injectedFirestore","filterSuppressedEmails","emails","seen","Set","candidates","normalized","has","add","push","verdicts","Promise","all","map","filter","_email","index","optOutHostIds","group","getAllAcrossSites","hostIds","subcollection","keys","id","list","getAll","filterSendableForHost","platformSendable","length","keyed","bySite","entry","_entry","some","snapshots","filterTopicSendable","topic","lookups","stateIn","awaits","awaitsConfirmation","gone","forEach","states","listEmailSuppressions","options","query","orderBy","cursor","suppressionCursorTimestamp","startAfter","limit","Math","min","max","docs","$id","data","suppressionCursorFrom","record","at","seconds","_seconds","nanoseconds","_nanoseconds","rawSeconds","rawNanos","split","Number","isFinite","HOST_ERASURE_SUPPRESSION_REASON","suppressEmailForHostErasure","hostRefusesCaptureForErasure","Boolean"],"mappings":"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"}
|
|
@@ -34,13 +34,21 @@ export type PendingConfirmationResult =
|
|
|
34
34
|
* Puts an address in the quarantine for one topic and returns the moment it
|
|
35
35
|
* entered, which is what the confirmation link is signed and dated from.
|
|
36
36
|
*
|
|
37
|
-
* ## Re-asking does not restamp
|
|
37
|
+
* ## Re-asking does not restamp — inside the window
|
|
38
38
|
*
|
|
39
39
|
* A second signup from the same address inside the window keeps the original
|
|
40
40
|
* `pendingAt`, so the 72-hour expiry measures from when we first asked. The
|
|
41
41
|
* alternative lets somebody — or a script — hold an address in a permanently
|
|
42
42
|
* fresh pending state by resubmitting a form, which is a way to keep a
|
|
43
43
|
* confirmation link alive indefinitely.
|
|
44
|
+
*
|
|
45
|
+
* Once the window has passed, a signup is a new question and is stamped now.
|
|
46
|
+
* An expired request admits nobody and its link no longer acts, so keeping
|
|
47
|
+
* its moment would date the new link from the old one — expired before it is
|
|
48
|
+
* sent — and "sign up again", which the expired-link page and the docs tell
|
|
49
|
+
* the person to do, could never succeed. With a consent group waiting on its
|
|
50
|
+
* sites' confirmations, that stranded request would hold the stream from
|
|
51
|
+
* every site of the group.
|
|
44
52
|
*/
|
|
45
53
|
export declare function recordPendingTopicConfirmation(hostId: string, email: string, topicId: string, options?: {
|
|
46
54
|
nowMs?: number;
|
|
@@ -52,13 +52,21 @@ function optOutDoc(hostId, key, firestore) {
|
|
|
52
52
|
* Puts an address in the quarantine for one topic and returns the moment it
|
|
53
53
|
* entered, which is what the confirmation link is signed and dated from.
|
|
54
54
|
*
|
|
55
|
-
* ## Re-asking does not restamp
|
|
55
|
+
* ## Re-asking does not restamp — inside the window
|
|
56
56
|
*
|
|
57
57
|
* A second signup from the same address inside the window keeps the original
|
|
58
58
|
* `pendingAt`, so the 72-hour expiry measures from when we first asked. The
|
|
59
59
|
* alternative lets somebody — or a script — hold an address in a permanently
|
|
60
60
|
* fresh pending state by resubmitting a form, which is a way to keep a
|
|
61
61
|
* confirmation link alive indefinitely.
|
|
62
|
+
*
|
|
63
|
+
* Once the window has passed, a signup is a new question and is stamped now.
|
|
64
|
+
* An expired request admits nobody and its link no longer acts, so keeping
|
|
65
|
+
* its moment would date the new link from the old one — expired before it is
|
|
66
|
+
* sent — and "sign up again", which the expired-link page and the docs tell
|
|
67
|
+
* the person to do, could never succeed. With a consent group waiting on its
|
|
68
|
+
* sites' confirmations, that stranded request would hold the stream from
|
|
69
|
+
* every site of the group.
|
|
62
70
|
*/ export async function recordPendingTopicConfirmation(hostId, email, topicId, options) {
|
|
63
71
|
var _ref, _ref1;
|
|
64
72
|
const key = emailSuppressionKey(email);
|
|
@@ -85,10 +93,15 @@ function optOutDoc(hostId, key, firestore) {
|
|
|
85
93
|
return;
|
|
86
94
|
}
|
|
87
95
|
if (state === 'pending') {
|
|
88
|
-
// Already in the quarantine. Keep the original moment
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
// Already in the quarantine. Keep the original moment while that
|
|
97
|
+
// request can still be confirmed — see above; past it, fall through
|
|
98
|
+
// and ask again.
|
|
99
|
+
const askedAtMs = Number(existing == null ? void 0 : existing.pendingAt) || nowMs;
|
|
100
|
+
if (!doubleOptInExpired(askedAtMs, nowMs)) {
|
|
101
|
+
result = 'pending';
|
|
102
|
+
pendingAtMs = askedAtMs;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
92
105
|
}
|
|
93
106
|
if (existing == null ? void 0 : existing.confirmedAt) {
|
|
94
107
|
result = 'already-subscribed';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-topic-confirmation.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * DOUBLE OPT-IN — the durable half.\n *\n * `email-topics.ts` in the framework states the policy and owns the three\n * states an entry can be in; this writes them, because writing them needs\n * Firestore.\n *\n * ## The record it writes is the one the send path already reads\n *\n * `hosts/{hostId}/topicOptOuts/{emailKey}` holds a map keyed by topic, and\n * `filterTopicSendable` reads it on every topic-filtered send. A pending\n * confirmation is therefore a THIRD state of a fact the send path already\n * consults rather than a new document nobody has a reason to look at — which\n * is the difference between ActiveCampaign's quarantine, where an unconfirmed\n * subscriber genuinely cannot be mailed, and Customer.io's recipe, whose own\n * documentation warns that it \"doesn't automatically check this attribute\n * before sending messages\".\n *\n * ## Nothing here is a deletion\n *\n * A confirmation stamps `confirmedAt` beside the `pendingAt` it answers,\n * rather than clearing the pending mark. The pair IS the evidence — when we\n * asked and when they answered — and it is the evidence a consent question\n * needs, which is the same argument `writeTopicOptOuts` makes for keeping an\n * opt-out entry after somebody rejoins.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n doubleOptInExpired,\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport { isEmailTopicId } from '@aglyn/aglyn/app-utils/email-topics'\nimport firebaseAdmin from './firebase-admin'\nimport {\n emailSuppressionKey,\n releaseEmailForConfirmedOptIn,\n} from './email-suppression'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\nfunction optOutDoc(hostId: string, key: string, firestore?: any) {\n return (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n}\n\n/** What asking for a confirmation did. */\nexport type PendingConfirmationResult =\n /** Recorded. The address is not mailable about this topic until confirmed. */\n | 'pending'\n /** Already confirmed, or never needed confirming. Nothing was written. */\n | 'already-subscribed'\n /**\n * They left this stream. Nothing was written and nothing may be sent.\n *\n * A signup form cannot re-arm mail to somebody who unsubscribed by asking\n * them again: the confirmation request is itself a message, and sending it\n * would be mailing somebody who told this site to stop.\n */\n | 'opted-out'\n /** Not an address, or not a topic id. */\n | 'unusable'\n\n/**\n * Puts an address in the quarantine for one topic and returns the moment it\n * entered, which is what the confirmation link is signed and dated from.\n *\n * ## Re-asking does not restamp\n *\n * A second signup from the same address inside the window keeps the original\n * `pendingAt`, so the 72-hour expiry measures from when we first asked. The\n * alternative lets somebody — or a script — hold an address in a permanently\n * fresh pending state by resubmitting a form, which is a way to keep a\n * confirmation link alive indefinitely.\n */\nexport async function recordPendingTopicConfirmation(\n hostId: string,\n email: string,\n topicId: string,\n options?: { nowMs?: number; firestore?: any },\n): Promise<{ result: PendingConfirmationResult; pendingAtMs: number | null }> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isEmailTopicId(topicId)) {\n return { result: 'unusable', pendingAtMs: null }\n }\n const nowMs = options?.nowMs ?? Date.now()\n const ref = optOutDoc(hostId, key, options?.firestore)\n const address = String(email).trim().toLowerCase()\n let result: PendingConfirmationResult = 'pending'\n let pendingAtMs: number | null = nowMs\n await (options?.firestore ?? defaultFirestore()).runTransaction(\n async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const existing = stored[topicId]\n const state = readTopicSubscriptionState(existing)\n if (state === 'opted-out') {\n result = 'opted-out'\n pendingAtMs = null\n return\n }\n if (state === 'pending') {\n // Already in the quarantine. Keep the original moment — see above.\n result = 'pending'\n pendingAtMs = Number(existing?.pendingAt) || nowMs\n return\n }\n if (existing?.confirmedAt) {\n result = 'already-subscribed'\n pendingAtMs = null\n return\n }\n transaction.set(\n ref,\n {\n email: address,\n topics: {\n ...stored,\n [topicId]: {\n ...(existing ?? {}),\n pendingAt: nowMs,\n confirmedAt: null,\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists\n ? {}\n : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n },\n )\n return { result, pendingAtMs }\n}\n\n/** What a click on a confirmation link did. */\nexport type ConfirmTopicResult =\n /** They are now a subscriber to this topic. */\n | 'confirmed'\n /** They already were, and a second click changes nothing. */\n | 'already-confirmed'\n /** The request is older than the window, so the link no longer acts. */\n | 'expired'\n /** They left this stream. A confirmation link may not undo that. */\n | 'opted-out'\n /** Nothing was pending for this address and topic. */\n | 'not-pending'\n /** Not an address, or not a topic id. */\n | 'unusable'\n\n/**\n * Honors a confirmation click.\n *\n * ## An expired link does not admit anybody, and does not clear anything\n *\n * `expired` leaves the record exactly as it was: still pending, still\n * unmailable. Expiry is a property of the LINK, and a lapse that quietly\n * admitted the address, or quietly cleared the request, would make the window\n * a delay somebody could wait out rather than a gate.\n *\n * ## A confirmation cannot reverse an unsubscribe\n *\n * Same rule as `releaseSiteSuppression`: the recipient's later, more explicit\n * act stands, and a link minted before it must not undo it.\n *\n * ## A confirmation DOES reverse a hard bounce\n *\n * The one thing a completed round trip is evidence of. A `bounce` entry on\n * the platform list claims mail addressed here is permanently refused, and a\n * click on a link that was delivered to that mailbox is the only evidence\n * that could refute it. Without this, an address recorded as permanently\n * bounced — often a full mailbox or a misconfigured receiving server that a\n * provider reported as permanent — can never re-subscribe by any route, and\n * the double opt-in it just completed would enrol somebody the send path then\n * silently drops.\n *\n * The refusal above and the release here are the same rule seen from two\n * sides: a stated intention stands and a measurement does not. Which reasons\n * qualify is decided by `OPT_IN_RELEASABLE_REASONS`, not here — an unsubscribe\n * or a spam complaint is never among them, or this function would be a way to\n * launder a complaint through a signup form.\n *\n * The release is best-effort and deliberately after the transaction: the\n * confirmation is what the person is waiting for, and a suppression list that\n * could not be written must not cost them the subscription they just made.\n */\nexport async function confirmTopicSubscription(\n hostId: string,\n email: string,\n topicId: string,\n options?: { nowMs?: number; firestore?: any },\n): Promise<ConfirmTopicResult> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isEmailTopicId(topicId)) return 'unusable'\n const nowMs = options?.nowMs ?? Date.now()\n const ref = optOutDoc(hostId, key, options?.firestore)\n /*\n * The verdict comes OUT of the transaction rather than through a closure\n * variable beside it. A transaction body can be retried, so a variable the\n * caller reads afterwards describes whichever attempt ran last rather than\n * the one that committed.\n */\n const result: ConfirmTopicResult = await (\n options?.firestore ?? defaultFirestore()\n ).runTransaction(async (transaction: any): Promise<ConfirmTopicResult> => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const existing = stored[topicId]\n const state = readTopicSubscriptionState(existing)\n if (state === 'opted-out') return 'opted-out'\n if (state === 'subscribed') {\n return existing?.confirmedAt ? 'already-confirmed' : 'not-pending'\n }\n if (doubleOptInExpired(Number(existing?.pendingAt), nowMs)) {\n return 'expired'\n }\n transaction.set(\n ref,\n {\n email: String(email).trim().toLowerCase(),\n topics: {\n ...stored,\n [topicId]: { ...(existing ?? {}), confirmedAt: nowMs },\n },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return 'confirmed'\n })\n if (result === 'confirmed') {\n await releaseEmailForConfirmedOptIn({\n email,\n hostId,\n topicId,\n ...(options?.firestore ? { firestore: options.firestore } : {}),\n })\n }\n return result\n}\n\n/**\n * Whether this site confirms new subscriptions by default.\n *\n * One host-document field, read at the moment somebody signs up and nowhere\n * else — the send path never asks, because by then the answer is already on\n * the person's own record as a pending mark or its absence. A read on every\n * send would be a read per recipient to re-derive a decision that was made\n * once.\n *\n * Fails to `false`, which is off. A default that a failed read could switch\n * ON would quarantine every new signup on a site whose owner never asked for\n * confirmations, and they would have no way to tell why their list stopped\n * growing.\n */\nexport async function siteRequiresDoubleOptIn(\n hostId: string,\n firestore?: any,\n): Promise<boolean> {\n if (!hostId) return false\n try {\n const snapshot = await (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .get()\n return snapshot.exists && snapshot.get('emailDoubleOptIn') === true\n } catch (error) {\n console.error('[email-topics] double opt-in setting read failed', error)\n return false\n }\n}\n\n/** What asking to leave one topic did. */\nexport type TopicOptOutResult =\n /** Recorded now. The address is not mailable about this topic. */\n | 'opted-out'\n /** They had already left it; the moment they left is kept. */\n | 'already-opted-out'\n /** Not an address, or not a topic id. Nothing was written. */\n | 'unusable'\n\n/**\n * Takes one address off one topic on one site (AGL-2981) — the opt-out a\n * sender that is not the preference page records: a sales email's\n * unsubscribe link, a reply that asks to be left alone.\n *\n * The same record the preference page writes (`writeTopicOptOuts` in the\n * email plugin) and the send path reads, on the same rules:\n *\n * - an address that already left keeps the moment it left — a second\n * request is not a later one;\n * - the entry is carried forward rather than replaced, so a confirmation\n * the person once gave stays on the record beside the opt-out;\n * - a rejoin stamped earlier is cleared, because leaving is now the more\n * recent act.\n *\n * A transaction, and its verdict comes out of it rather than through a\n * variable beside it: a retried attempt must not describe itself as the one\n * that committed.\n */\nexport async function recordTopicOptOut(\n hostId: string,\n email: string,\n topicId: string,\n options?: { firestore?: any },\n): Promise<TopicOptOutResult> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isEmailTopicId(topicId)) return 'unusable'\n const db = options?.firestore ?? defaultFirestore()\n const ref = optOutDoc(hostId, key, db)\n return db.runTransaction(async (transaction: any): Promise<TopicOptOutResult> => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const previous = stored[topicId]\n if (readTopicSubscriptionState(previous) === 'opted-out') return 'already-opted-out'\n transaction.set(\n ref,\n {\n email: String(email).trim().toLowerCase(),\n topics: {\n ...stored,\n [topicId]: {\n ...(previous ?? {}),\n optedOutAt: FieldValue.serverTimestamp(),\n resubscribedAt: null,\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n return 'opted-out'\n })\n}\n"],"names":["FieldValue","doubleOptInExpired","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","isEmailTopicId","firebaseAdmin","emailSuppressionKey","releaseEmailForConfirmedOptIn","defaultFirestore","app","firestore","optOutDoc","hostId","key","collection","doc","recordPendingTopicConfirmation","email","topicId","options","result","pendingAtMs","nowMs","Date","now","ref","address","String","trim","toLowerCase","runTransaction","transaction","snapshot","get","stored","exists","existing","state","Number","pendingAt","confirmedAt","set","topics","updatedAt","serverTimestamp","createdAt","merge","confirmTopicSubscription","siteRequiresDoubleOptIn","error","console","recordTopicOptOut","db","previous","optedOutAt","resubscribedAt"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-topic-confirmation.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * DOUBLE OPT-IN — the durable half.\n *\n * `email-topics.ts` in the framework states the policy and owns the three\n * states an entry can be in; this writes them, because writing them needs\n * Firestore.\n *\n * ## The record it writes is the one the send path already reads\n *\n * `hosts/{hostId}/topicOptOuts/{emailKey}` holds a map keyed by topic, and\n * `filterTopicSendable` reads it on every topic-filtered send. A pending\n * confirmation is therefore a THIRD state of a fact the send path already\n * consults rather than a new document nobody has a reason to look at — which\n * is the difference between ActiveCampaign's quarantine, where an unconfirmed\n * subscriber genuinely cannot be mailed, and Customer.io's recipe, whose own\n * documentation warns that it \"doesn't automatically check this attribute\n * before sending messages\".\n *\n * ## Nothing here is a deletion\n *\n * A confirmation stamps `confirmedAt` beside the `pendingAt` it answers,\n * rather than clearing the pending mark. The pair IS the evidence — when we\n * asked and when they answered — and it is the evidence a consent question\n * needs, which is the same argument `writeTopicOptOuts` makes for keeping an\n * opt-out entry after somebody rejoins.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n doubleOptInExpired,\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport { isEmailTopicId } from '@aglyn/aglyn/app-utils/email-topics'\nimport firebaseAdmin from './firebase-admin'\nimport {\n emailSuppressionKey,\n releaseEmailForConfirmedOptIn,\n} from './email-suppression'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\nfunction optOutDoc(hostId: string, key: string, firestore?: any) {\n return (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n}\n\n/** What asking for a confirmation did. */\nexport type PendingConfirmationResult =\n /** Recorded. The address is not mailable about this topic until confirmed. */\n | 'pending'\n /** Already confirmed, or never needed confirming. Nothing was written. */\n | 'already-subscribed'\n /**\n * They left this stream. Nothing was written and nothing may be sent.\n *\n * A signup form cannot re-arm mail to somebody who unsubscribed by asking\n * them again: the confirmation request is itself a message, and sending it\n * would be mailing somebody who told this site to stop.\n */\n | 'opted-out'\n /** Not an address, or not a topic id. */\n | 'unusable'\n\n/**\n * Puts an address in the quarantine for one topic and returns the moment it\n * entered, which is what the confirmation link is signed and dated from.\n *\n * ## Re-asking does not restamp — inside the window\n *\n * A second signup from the same address inside the window keeps the original\n * `pendingAt`, so the 72-hour expiry measures from when we first asked. The\n * alternative lets somebody — or a script — hold an address in a permanently\n * fresh pending state by resubmitting a form, which is a way to keep a\n * confirmation link alive indefinitely.\n *\n * Once the window has passed, a signup is a new question and is stamped now.\n * An expired request admits nobody and its link no longer acts, so keeping\n * its moment would date the new link from the old one — expired before it is\n * sent — and \"sign up again\", which the expired-link page and the docs tell\n * the person to do, could never succeed. With a consent group waiting on its\n * sites' confirmations, that stranded request would hold the stream from\n * every site of the group.\n */\nexport async function recordPendingTopicConfirmation(\n hostId: string,\n email: string,\n topicId: string,\n options?: { nowMs?: number; firestore?: any },\n): Promise<{ result: PendingConfirmationResult; pendingAtMs: number | null }> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isEmailTopicId(topicId)) {\n return { result: 'unusable', pendingAtMs: null }\n }\n const nowMs = options?.nowMs ?? Date.now()\n const ref = optOutDoc(hostId, key, options?.firestore)\n const address = String(email).trim().toLowerCase()\n let result: PendingConfirmationResult = 'pending'\n let pendingAtMs: number | null = nowMs\n await (options?.firestore ?? defaultFirestore()).runTransaction(\n async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const existing = stored[topicId]\n const state = readTopicSubscriptionState(existing)\n if (state === 'opted-out') {\n result = 'opted-out'\n pendingAtMs = null\n return\n }\n if (state === 'pending') {\n // Already in the quarantine. Keep the original moment while that\n // request can still be confirmed — see above; past it, fall through\n // and ask again.\n const askedAtMs = Number(existing?.pendingAt) || nowMs\n if (!doubleOptInExpired(askedAtMs, nowMs)) {\n result = 'pending'\n pendingAtMs = askedAtMs\n return\n }\n }\n if (existing?.confirmedAt) {\n result = 'already-subscribed'\n pendingAtMs = null\n return\n }\n transaction.set(\n ref,\n {\n email: address,\n topics: {\n ...stored,\n [topicId]: {\n ...(existing ?? {}),\n pendingAt: nowMs,\n confirmedAt: null,\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists\n ? {}\n : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n },\n )\n return { result, pendingAtMs }\n}\n\n/** What a click on a confirmation link did. */\nexport type ConfirmTopicResult =\n /** They are now a subscriber to this topic. */\n | 'confirmed'\n /** They already were, and a second click changes nothing. */\n | 'already-confirmed'\n /** The request is older than the window, so the link no longer acts. */\n | 'expired'\n /** They left this stream. A confirmation link may not undo that. */\n | 'opted-out'\n /** Nothing was pending for this address and topic. */\n | 'not-pending'\n /** Not an address, or not a topic id. */\n | 'unusable'\n\n/**\n * Honors a confirmation click.\n *\n * ## An expired link does not admit anybody, and does not clear anything\n *\n * `expired` leaves the record exactly as it was: still pending, still\n * unmailable. Expiry is a property of the LINK, and a lapse that quietly\n * admitted the address, or quietly cleared the request, would make the window\n * a delay somebody could wait out rather than a gate.\n *\n * ## A confirmation cannot reverse an unsubscribe\n *\n * Same rule as `releaseSiteSuppression`: the recipient's later, more explicit\n * act stands, and a link minted before it must not undo it.\n *\n * ## A confirmation DOES reverse a hard bounce\n *\n * The one thing a completed round trip is evidence of. A `bounce` entry on\n * the platform list claims mail addressed here is permanently refused, and a\n * click on a link that was delivered to that mailbox is the only evidence\n * that could refute it. Without this, an address recorded as permanently\n * bounced — often a full mailbox or a misconfigured receiving server that a\n * provider reported as permanent — can never re-subscribe by any route, and\n * the double opt-in it just completed would enrol somebody the send path then\n * silently drops.\n *\n * The refusal above and the release here are the same rule seen from two\n * sides: a stated intention stands and a measurement does not. Which reasons\n * qualify is decided by `OPT_IN_RELEASABLE_REASONS`, not here — an unsubscribe\n * or a spam complaint is never among them, or this function would be a way to\n * launder a complaint through a signup form.\n *\n * The release is best-effort and deliberately after the transaction: the\n * confirmation is what the person is waiting for, and a suppression list that\n * could not be written must not cost them the subscription they just made.\n */\nexport async function confirmTopicSubscription(\n hostId: string,\n email: string,\n topicId: string,\n options?: { nowMs?: number; firestore?: any },\n): Promise<ConfirmTopicResult> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isEmailTopicId(topicId)) return 'unusable'\n const nowMs = options?.nowMs ?? Date.now()\n const ref = optOutDoc(hostId, key, options?.firestore)\n /*\n * The verdict comes OUT of the transaction rather than through a closure\n * variable beside it. A transaction body can be retried, so a variable the\n * caller reads afterwards describes whichever attempt ran last rather than\n * the one that committed.\n */\n const result: ConfirmTopicResult = await (\n options?.firestore ?? defaultFirestore()\n ).runTransaction(async (transaction: any): Promise<ConfirmTopicResult> => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const existing = stored[topicId]\n const state = readTopicSubscriptionState(existing)\n if (state === 'opted-out') return 'opted-out'\n if (state === 'subscribed') {\n return existing?.confirmedAt ? 'already-confirmed' : 'not-pending'\n }\n if (doubleOptInExpired(Number(existing?.pendingAt), nowMs)) {\n return 'expired'\n }\n transaction.set(\n ref,\n {\n email: String(email).trim().toLowerCase(),\n topics: {\n ...stored,\n [topicId]: { ...(existing ?? {}), confirmedAt: nowMs },\n },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return 'confirmed'\n })\n if (result === 'confirmed') {\n await releaseEmailForConfirmedOptIn({\n email,\n hostId,\n topicId,\n ...(options?.firestore ? { firestore: options.firestore } : {}),\n })\n }\n return result\n}\n\n/**\n * Whether this site confirms new subscriptions by default.\n *\n * One host-document field, read at the moment somebody signs up and nowhere\n * else — the send path never asks, because by then the answer is already on\n * the person's own record as a pending mark or its absence. A read on every\n * send would be a read per recipient to re-derive a decision that was made\n * once.\n *\n * Fails to `false`, which is off. A default that a failed read could switch\n * ON would quarantine every new signup on a site whose owner never asked for\n * confirmations, and they would have no way to tell why their list stopped\n * growing.\n */\nexport async function siteRequiresDoubleOptIn(\n hostId: string,\n firestore?: any,\n): Promise<boolean> {\n if (!hostId) return false\n try {\n const snapshot = await (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .get()\n return snapshot.exists && snapshot.get('emailDoubleOptIn') === true\n } catch (error) {\n console.error('[email-topics] double opt-in setting read failed', error)\n return false\n }\n}\n\n/** What asking to leave one topic did. */\nexport type TopicOptOutResult =\n /** Recorded now. The address is not mailable about this topic. */\n | 'opted-out'\n /** They had already left it; the moment they left is kept. */\n | 'already-opted-out'\n /** Not an address, or not a topic id. Nothing was written. */\n | 'unusable'\n\n/**\n * Takes one address off one topic on one site (AGL-2981) — the opt-out a\n * sender that is not the preference page records: a sales email's\n * unsubscribe link, a reply that asks to be left alone.\n *\n * The same record the preference page writes (`writeTopicOptOuts` in the\n * email plugin) and the send path reads, on the same rules:\n *\n * - an address that already left keeps the moment it left — a second\n * request is not a later one;\n * - the entry is carried forward rather than replaced, so a confirmation\n * the person once gave stays on the record beside the opt-out;\n * - a rejoin stamped earlier is cleared, because leaving is now the more\n * recent act.\n *\n * A transaction, and its verdict comes out of it rather than through a\n * variable beside it: a retried attempt must not describe itself as the one\n * that committed.\n */\nexport async function recordTopicOptOut(\n hostId: string,\n email: string,\n topicId: string,\n options?: { firestore?: any },\n): Promise<TopicOptOutResult> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isEmailTopicId(topicId)) return 'unusable'\n const db = options?.firestore ?? defaultFirestore()\n const ref = optOutDoc(hostId, key, db)\n return db.runTransaction(async (transaction: any): Promise<TopicOptOutResult> => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const previous = stored[topicId]\n if (readTopicSubscriptionState(previous) === 'opted-out') return 'already-opted-out'\n transaction.set(\n ref,\n {\n email: String(email).trim().toLowerCase(),\n topics: {\n ...stored,\n [topicId]: {\n ...(previous ?? {}),\n optedOutAt: FieldValue.serverTimestamp(),\n resubscribedAt: null,\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n return 'opted-out'\n })\n}\n"],"names":["FieldValue","doubleOptInExpired","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","isEmailTopicId","firebaseAdmin","emailSuppressionKey","releaseEmailForConfirmedOptIn","defaultFirestore","app","firestore","optOutDoc","hostId","key","collection","doc","recordPendingTopicConfirmation","email","topicId","options","result","pendingAtMs","nowMs","Date","now","ref","address","String","trim","toLowerCase","runTransaction","transaction","snapshot","get","stored","exists","existing","state","askedAtMs","Number","pendingAt","confirmedAt","set","topics","updatedAt","serverTimestamp","createdAt","merge","confirmTopicSubscription","siteRequiresDoubleOptIn","error","console","recordTopicOptOut","db","previous","optedOutAt","resubscribedAt"],"mappings":"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"}
|