@aglyn/tenant-data-admin 1.0.0-beta.176 → 1.0.0-beta.184
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/lib/server/account-acquisition.d.ts +84 -0
- package/src/lib/server/account-acquisition.js +107 -0
- package/src/lib/server/account-acquisition.js.map +1 -0
- package/src/lib/server/admin-audit.js +5 -1
- package/src/lib/server/admin-audit.js.map +1 -1
- package/src/lib/server/duplicate-resource.js +8 -1
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +3 -1
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/email-marketing-gate.d.ts +14 -0
- package/src/lib/server/email-marketing-gate.js +132 -17
- package/src/lib/server/email-marketing-gate.js.map +1 -1
- package/src/lib/server/email-sender-reputation.d.ts +11 -0
- package/src/lib/server/email-sender-reputation.js +4 -1
- package/src/lib/server/email-sender-reputation.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +58 -7
- package/src/lib/server/email-suppression.js +113 -28
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/erase.d.ts +2 -0
- package/src/lib/server/erase.js +7 -0
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/first-party-hosts.d.ts +61 -0
- package/src/lib/server/first-party-hosts.js +110 -0
- package/src/lib/server/first-party-hosts.js.map +1 -0
- package/src/lib/server/first-touch-handoff.d.ts +51 -0
- package/src/lib/server/first-touch-handoff.js +88 -0
- package/src/lib/server/first-touch-handoff.js.map +1 -0
- package/src/lib/server/first-touch-route.d.ts +48 -0
- package/src/lib/server/first-touch-route.js +164 -0
- package/src/lib/server/first-touch-route.js.map +1 -0
- package/src/lib/server/host-email-tokens.js +10 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/live-page-usage.d.ts +33 -9
- package/src/lib/server/live-page-usage.js +26 -8
- package/src/lib/server/live-page-usage.js.map +1 -1
- package/src/lib/server/organizations.js +9 -0
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/personal-data-export.js +11 -0
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +157 -8
- package/src/lib/server/platform-marketing-consent.js +240 -21
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/sending-domains.js +2 -2
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/tracking-hosts.d.ts +98 -0
- package/src/lib/server/tracking-hosts.js +264 -0
- package/src/lib/server/tracking-hosts.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +1 -1
- package/src/lib/server/upsert-contact.js +6 -0
- package/src/lib/server/upsert-contact.js.map +1 -1
|
@@ -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 const sendable = await filterTopicSendable(\n options.hostId,\n topicId,\n [email],\n options.firestore,\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,MAAMC,WAAW,MAAMlC,oBACrBoB,QAAQjB,MAAM,EACd8B,SACA;QAAC7B;KAAM,EACPgB,QAAQf,SAAS;IAEnB,OAAO6B,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.\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"}
|
|
@@ -117,6 +117,7 @@
|
|
|
117
117
|
* mail stays on every audience they were on and is mailed again next month.
|
|
118
118
|
*/
|
|
119
119
|
import { type MarketingCadence, type MarketingSendGateRequest, type MarketingSendGateVerdict } from '@aglyn/shared-util-email';
|
|
120
|
+
import type { ConsentGroup } from '@aglyn/aglyn/app-utils/consent-groups';
|
|
120
121
|
/**
|
|
121
122
|
* Where the per-recipient window lives.
|
|
122
123
|
*
|
|
@@ -292,6 +293,13 @@ export declare function recordMarketingSends(hostId: string, emails: readonly st
|
|
|
292
293
|
* Keyed and read with one `getAll`, matching its three neighbors: one round
|
|
293
294
|
* trip bounded by the size of the send, and no composite index to go missing.
|
|
294
295
|
*
|
|
296
|
+
* ## "This site" means its consent group
|
|
297
|
+
*
|
|
298
|
+
* `group` is the sending site's consent group, and the pace is the one the
|
|
299
|
+
* person asked THE GROUP for — see {@link groupPace}. Omitted, the site alone,
|
|
300
|
+
* which is also the whole answer for an org that declared no group: one
|
|
301
|
+
* `getAll` over the same documents it always read.
|
|
302
|
+
*
|
|
295
303
|
* ## Fails OPEN, like the topic filter beside it
|
|
296
304
|
*
|
|
297
305
|
* A cadence is a PACE, not a stop. Guessing wrong on an unreadable counter
|
|
@@ -303,6 +311,7 @@ export declare function recordMarketingSends(hostId: string, emails: readonly st
|
|
|
303
311
|
export declare function filterCadenceSendable(hostId: string, emails: readonly string[], options?: {
|
|
304
312
|
nowMs?: number;
|
|
305
313
|
firestore?: any;
|
|
314
|
+
group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null;
|
|
306
315
|
}): Promise<string[]>;
|
|
307
316
|
/**
|
|
308
317
|
* The gate itself, exported so it can be exercised without installing it.
|
|
@@ -311,6 +320,11 @@ export declare function filterCadenceSendable(hostId: string, emails: readonly s
|
|
|
311
320
|
* permanent and the frequency record must not count a message that was never
|
|
312
321
|
* going to leave — a suppressed address whose window kept growing would stay
|
|
313
322
|
* capped for a day after being released.
|
|
323
|
+
*
|
|
324
|
+
* Every opt-out below is read across the sender's consent group
|
|
325
|
+
* (`request.consentHostIds`) — the suppression list, the stream, and the
|
|
326
|
+
* pace — because a person who left one site of a declared group left the
|
|
327
|
+
* sender. The frequency ceiling and the sunset stay the sending site's own.
|
|
314
328
|
*/
|
|
315
329
|
export declare function marketingSendVerdict(request: MarketingSendGateRequest, options?: {
|
|
316
330
|
nowMs?: number;
|
|
@@ -115,10 +115,10 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
115
115
|
* stays exactly where they were, and the next window mails them again. The
|
|
116
116
|
* recipient's own cadence is the same shape — a person who asked for monthly
|
|
117
117
|
* mail stays on every audience they were on and is mailed again next month.
|
|
118
|
-
*/ import { getMarketingSendGate, marketingCadenceVerdict, marketingFrequencyVerdict, marketingSunsetDays, marketingSunsetVerdict, normalizeMarketingCadence, setMarketingSendGate } from "@aglyn/shared-util-email";
|
|
118
|
+
*/ import { DEFAULT_MARKETING_CADENCE, getMarketingSendGate, marketingCadenceVerdict, marketingFrequencyVerdict, marketingSunsetDays, marketingSunsetVerdict, normalizeMarketingCadence, setMarketingSendGate } from "@aglyn/shared-util-email";
|
|
119
119
|
import firebaseAdmin from "./firebase-admin.js";
|
|
120
120
|
import { readPersonEngagement } from "./email-delivery-log.js";
|
|
121
|
-
import { emailSuppressionKey, filterSendableForHost, filterTopicSendable } from "./email-suppression.js";
|
|
121
|
+
import { emailSuppressionKey, filterSendableForHost, filterTopicSendable, getAllAcrossSites, optOutHostIds } from "./email-suppression.js";
|
|
122
122
|
import { buildUnsubscribeUrl } from "./email-unsubscribe-link.js";
|
|
123
123
|
const defaultFirestore = ()=>firebaseAdmin.app().firestore();
|
|
124
124
|
/**
|
|
@@ -177,6 +177,48 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
|
|
|
177
177
|
cadence: normalizeMarketingCadence(snapshot.get('cadence'))
|
|
178
178
|
};
|
|
179
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* The pace a person asked a CONSENT GROUP for, and when the group last mailed
|
|
182
|
+
* them, from the counter documents of the sites in it that hold one.
|
|
183
|
+
*
|
|
184
|
+
* Each site's preference page writes its own counter, and to the person every
|
|
185
|
+
* one of those pages was the same sender's — so the choice made MOST
|
|
186
|
+
* RECENTLY, on whichever site, is the one in force, and a later "as they
|
|
187
|
+
* come" undoes an earlier "monthly" exactly as it would on one site. The last
|
|
188
|
+
* send is the newest across the group, because one a week from a sender is
|
|
189
|
+
* one a week whichever of its sites sent it.
|
|
190
|
+
*
|
|
191
|
+
* Only the pace is pooled. The frequency ceiling and the sunset stay each
|
|
192
|
+
* site's own: they are controls this platform keeps over a sender's volume,
|
|
193
|
+
* not a request the person made.
|
|
194
|
+
*
|
|
195
|
+
* `snapshots` are documents that exist, the sending site's first, and it
|
|
196
|
+
* keeps a tie. A single snapshot answers exactly what `stateFromSnapshot`
|
|
197
|
+
* answers for it, which is what lets a site alone run through here unchanged.
|
|
198
|
+
*/ function groupPace(snapshots) {
|
|
199
|
+
let cadence = DEFAULT_MARKETING_CADENCE;
|
|
200
|
+
let chosenAtMs = Number.NEGATIVE_INFINITY;
|
|
201
|
+
let lastSentAtMs = null;
|
|
202
|
+
for (const snapshot of snapshots){
|
|
203
|
+
const state = stateFromSnapshot(snapshot);
|
|
204
|
+
if (state.lastSentAtMs !== null && (lastSentAtMs === null || state.lastSentAtMs > lastSentAtMs)) {
|
|
205
|
+
lastSentAtMs = state.lastSentAtMs;
|
|
206
|
+
}
|
|
207
|
+
// A counter the sends wrote carries no choice; it is not a vote for
|
|
208
|
+
// "as they come", it is no vote at all.
|
|
209
|
+
if (snapshot.get('cadence') == null) continue;
|
|
210
|
+
const setAtMs = Number(snapshot.get('cadenceSetAtMs'));
|
|
211
|
+
const atMs = Number.isFinite(setAtMs) ? setAtMs : 0;
|
|
212
|
+
if (atMs > chosenAtMs) {
|
|
213
|
+
chosenAtMs = atMs;
|
|
214
|
+
cadence = state.cadence;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
cadence,
|
|
219
|
+
lastSentAtMs
|
|
220
|
+
};
|
|
221
|
+
}
|
|
180
222
|
function frequencyDoc(hostId, emailKey, firestore) {
|
|
181
223
|
return (firestore != null ? firestore : defaultFirestore()).collection('hosts').doc(hostId).collection(EMAIL_FREQUENCY_SUBCOLLECTION).doc(emailKey);
|
|
182
224
|
}
|
|
@@ -328,6 +370,13 @@ function frequencyDoc(hostId, emailKey, firestore) {
|
|
|
328
370
|
* Keyed and read with one `getAll`, matching its three neighbors: one round
|
|
329
371
|
* trip bounded by the size of the send, and no composite index to go missing.
|
|
330
372
|
*
|
|
373
|
+
* ## "This site" means its consent group
|
|
374
|
+
*
|
|
375
|
+
* `group` is the sending site's consent group, and the pace is the one the
|
|
376
|
+
* person asked THE GROUP for — see {@link groupPace}. Omitted, the site alone,
|
|
377
|
+
* which is also the whole answer for an org that declared no group: one
|
|
378
|
+
* `getAll` over the same documents it always read.
|
|
379
|
+
*
|
|
331
380
|
* ## Fails OPEN, like the topic filter beside it
|
|
332
381
|
*
|
|
333
382
|
* A cadence is a PACE, not a stop. Guessing wrong on an unreadable counter
|
|
@@ -340,6 +389,7 @@ function frequencyDoc(hostId, emailKey, firestore) {
|
|
|
340
389
|
if (!emails.length || !hostId) return [
|
|
341
390
|
...emails
|
|
342
391
|
];
|
|
392
|
+
const hostIds = optOutHostIds(hostId, options == null ? void 0 : options.group);
|
|
343
393
|
const nowMs = (_ref = options == null ? void 0 : options.nowMs) != null ? _ref : Date.now();
|
|
344
394
|
// An unkeyable address carries no counter, so it has expressed no pace. It
|
|
345
395
|
// is dropped from the LOOKUP and kept in the answer, exactly as the topic
|
|
@@ -358,19 +408,18 @@ function frequencyDoc(hostId, emailKey, firestore) {
|
|
|
358
408
|
try {
|
|
359
409
|
var _ref1;
|
|
360
410
|
const db = (_ref1 = options == null ? void 0 : options.firestore) != null ? _ref1 : defaultFirestore();
|
|
361
|
-
const
|
|
362
|
-
const snapshots = await db.getAll(...lookups.map((entry)=>counters.doc(entry.key)));
|
|
411
|
+
const bySite = await getAllAcrossSites(db, hostIds, EMAIL_FREQUENCY_SUBCOLLECTION, lookups.map((entry)=>entry.key));
|
|
363
412
|
const holding = new Set();
|
|
364
413
|
lookups.forEach((entry, index)=>{
|
|
365
|
-
const
|
|
366
|
-
if (!
|
|
414
|
+
const held = bySite.map((snapshots)=>snapshots[index]).filter((snapshot)=>snapshot == null ? void 0 : snapshot.exists);
|
|
415
|
+
if (!held.length) return;
|
|
367
416
|
// The same decoder the per-message path uses, so the two cannot
|
|
368
|
-
// disagree about one document. The sunset's `firstSentAtMs`
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
const
|
|
373
|
-
const verdict = marketingCadenceVerdict(
|
|
417
|
+
// disagree about one document. The sunset's `firstSentAtMs` is
|
|
418
|
+
// deliberately not consulted: a campaign is exempt from that refusal,
|
|
419
|
+
// so subtracting on it here would remove people from a count the gate
|
|
420
|
+
// is going to mail anyway.
|
|
421
|
+
const pace = groupPace(held);
|
|
422
|
+
const verdict = marketingCadenceVerdict(pace.cadence, pace.lastSentAtMs, nowMs);
|
|
374
423
|
if (!verdict.allowed) holding.add(entry.email);
|
|
375
424
|
});
|
|
376
425
|
return emails.filter((email)=>!holding.has(email));
|
|
@@ -381,6 +430,64 @@ function frequencyDoc(hostId, emailKey, firestore) {
|
|
|
381
430
|
];
|
|
382
431
|
}
|
|
383
432
|
}
|
|
433
|
+
/**
|
|
434
|
+
* The consent group a gate request was made by, or `null` for the site alone.
|
|
435
|
+
*
|
|
436
|
+
* The sender resolved it — `MarketingSendContext.consentHostIds`, from the org
|
|
437
|
+
* it already held — so the gate spends no org read on it. A list that does
|
|
438
|
+
* not name the sending site is not a group resolved for it, and reads as the
|
|
439
|
+
* site alone: the gate must never throw, because `sendEmail` answers a gate
|
|
440
|
+
* that throws by sending ungated.
|
|
441
|
+
*/ function requestConsentGroup(request) {
|
|
442
|
+
const hostIds = request.consentHostIds;
|
|
443
|
+
if (!Array.isArray(hostIds) || !hostIds.includes(request.hostId)) return null;
|
|
444
|
+
return {
|
|
445
|
+
hostId: request.hostId,
|
|
446
|
+
hostIds: hostIds.filter((id)=>typeof id === 'string' && id)
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The sending site's counter, and the pace its consent group was asked for.
|
|
451
|
+
*
|
|
452
|
+
* A site alone is {@link readMarketingFrequencyState}, the one `get` it has
|
|
453
|
+
* always been. A group reads every member's counter in one `getAll`: the
|
|
454
|
+
* sending site's supplies the window and the first send, which stay its own,
|
|
455
|
+
* and all of them together supply the pace — see {@link groupPace}.
|
|
456
|
+
*
|
|
457
|
+
* Fails OPEN, for the reason {@link readMarketingFrequencyState} gives.
|
|
458
|
+
*/ async function readGroupFrequency(hostIds, email, firestore) {
|
|
459
|
+
if (hostIds.length === 1) {
|
|
460
|
+
const state = await readMarketingFrequencyState(hostIds[0], email, firestore);
|
|
461
|
+
return {
|
|
462
|
+
state,
|
|
463
|
+
pace: {
|
|
464
|
+
cadence: state.cadence,
|
|
465
|
+
lastSentAtMs: state.lastSentAtMs
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
const unread = {
|
|
470
|
+
state: _extends({}, NO_RECORD),
|
|
471
|
+
pace: {
|
|
472
|
+
cadence: NO_RECORD.cadence,
|
|
473
|
+
lastSentAtMs: null
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
const key = emailSuppressionKey(email);
|
|
477
|
+
if (!key) return unread;
|
|
478
|
+
try {
|
|
479
|
+
const db = firestore != null ? firestore : defaultFirestore();
|
|
480
|
+
const snapshots = await db.getAll(...hostIds.map((id)=>frequencyDoc(id, key, db)));
|
|
481
|
+
const own = snapshots[0];
|
|
482
|
+
return {
|
|
483
|
+
state: (own == null ? void 0 : own.exists) ? stateFromSnapshot(own) : _extends({}, NO_RECORD),
|
|
484
|
+
pace: groupPace(snapshots.filter((snapshot)=>snapshot == null ? void 0 : snapshot.exists))
|
|
485
|
+
};
|
|
486
|
+
} catch (error) {
|
|
487
|
+
console.error('[email-marketing] frequency read failed; allowing', error);
|
|
488
|
+
return unread;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
384
491
|
/**
|
|
385
492
|
* The gate itself, exported so it can be exercised without installing it.
|
|
386
493
|
*
|
|
@@ -388,13 +495,19 @@ function frequencyDoc(hostId, emailKey, firestore) {
|
|
|
388
495
|
* permanent and the frequency record must not count a message that was never
|
|
389
496
|
* going to leave — a suppressed address whose window kept growing would stay
|
|
390
497
|
* capped for a day after being released.
|
|
498
|
+
*
|
|
499
|
+
* Every opt-out below is read across the sender's consent group
|
|
500
|
+
* (`request.consentHostIds`) — the suppression list, the stream, and the
|
|
501
|
+
* pace — because a person who left one site of a declared group left the
|
|
502
|
+
* sender. The frequency ceiling and the sunset stay the sending site's own.
|
|
391
503
|
*/ export async function marketingSendVerdict(request, options) {
|
|
392
504
|
var _ref, _request_email, _request_topicId;
|
|
393
505
|
const nowMs = (_ref = options == null ? void 0 : options.nowMs) != null ? _ref : Date.now();
|
|
394
506
|
const email = String((_request_email = request.email) != null ? _request_email : '').trim().toLowerCase();
|
|
507
|
+
const group = requestConsentGroup(request);
|
|
395
508
|
const sendable = await filterSendableForHost(request.hostId, [
|
|
396
509
|
email
|
|
397
|
-
], options == null ? void 0 : options.firestore);
|
|
510
|
+
], options == null ? void 0 : options.firestore, group);
|
|
398
511
|
if (!sendable.length) {
|
|
399
512
|
return {
|
|
400
513
|
allowed: false,
|
|
@@ -463,7 +576,7 @@ function frequencyDoc(hostId, emailKey, firestore) {
|
|
|
463
576
|
*/ if (topicId) {
|
|
464
577
|
const onTopic = await filterTopicSendable(request.hostId, topicId, [
|
|
465
578
|
email
|
|
466
|
-
], options == null ? void 0 : options.firestore);
|
|
579
|
+
], options == null ? void 0 : options.firestore, group);
|
|
467
580
|
if (!onTopic.length) {
|
|
468
581
|
/*
|
|
469
582
|
* ⛔ A REFUSAL AND NOTHING ELSE, as with the pace refusals below.
|
|
@@ -484,7 +597,9 @@ function frequencyDoc(hostId, emailKey, firestore) {
|
|
|
484
597
|
// ONE read, and every refusal below is answered from it — except the
|
|
485
598
|
// sunset's engagement half, which is a different document and is fetched
|
|
486
599
|
// only when a window is configured and nothing above has already refused.
|
|
487
|
-
|
|
600
|
+
// For a consent group it is one `getAll`, and only the pace is pooled.
|
|
601
|
+
const hostIds = optOutHostIds(request.hostId, group);
|
|
602
|
+
const { state, pace } = await readGroupFrequency(hostIds, email, options == null ? void 0 : options.firestore);
|
|
488
603
|
/*
|
|
489
604
|
* THE RECIPIENT'S OWN REQUEST, first among the three pace refusals and the
|
|
490
605
|
* only one not subject to `capped`.
|
|
@@ -514,7 +629,7 @@ function frequencyDoc(hostId, emailKey, firestore) {
|
|
|
514
629
|
* carries no `marketing` context, so nothing on its path reaches this
|
|
515
630
|
* function. {@link filterCadenceSendable} is where it is asked there, and
|
|
516
631
|
* asked as a filter so the recipient count reflects it before Send.
|
|
517
|
-
*/ const cadence = marketingCadenceVerdict(
|
|
632
|
+
*/ const cadence = marketingCadenceVerdict(pace.cadence, pace.lastSentAtMs, nowMs);
|
|
518
633
|
if (!cadence.allowed) {
|
|
519
634
|
/*
|
|
520
635
|
* ⛔ A REFUSAL AND NOTHING ELSE, exactly as below.
|
|
@@ -527,7 +642,7 @@ function frequencyDoc(hostId, emailKey, firestore) {
|
|
|
527
642
|
*/ return {
|
|
528
643
|
allowed: false,
|
|
529
644
|
refusal: 'cadence-limited',
|
|
530
|
-
detail: `This address asked this site for no more than one marketing ` + `message ${CADENCE_PHRASES[
|
|
645
|
+
detail: `This address asked ${hostIds.length > 1 ? 'this site’s consent group' : 'this site'} for no more than one marketing ` + `message ${CADENCE_PHRASES[pace.cadence]}. The next one may go on ` + `${new Date(cadence.nextAllowedAtMs).toISOString()}.`,
|
|
531
646
|
unsubscribeUrl,
|
|
532
647
|
oneClickUrl
|
|
533
648
|
};
|
|
@@ -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 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 firebaseAdmin from './firebase-admin'\nimport { readPersonEngagement } from './email-delivery-log'\nimport {\n emailSuppressionKey,\n filterSendableForHost,\n filterTopicSendable,\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\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 * ## 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?: { nowMs?: number; firestore?: any },\n): Promise<string[]> {\n if (!emails.length || !hostId) return [...emails]\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 counters = db\n .collection('hosts')\n .doc(hostId)\n .collection(EMAIL_FREQUENCY_SUBCOLLECTION)\n const snapshots = await db.getAll(\n ...lookups.map((entry) => counters.doc(entry.key)),\n )\n const holding = new Set<string>()\n lookups.forEach((entry, index) => {\n const snapshot = snapshots[index]\n if (!snapshot?.exists) return\n // The same decoder the per-message path uses, so the two cannot\n // disagree about one document. The sunset's `firstSentAtMs` comes back\n // with it and is deliberately unused: a campaign is exempt from that\n // refusal, so subtracting on it here would remove people from a count\n // the gate is going to mail anyway.\n const state = stateFromSnapshot(snapshot)\n const verdict = marketingCadenceVerdict(\n state.cadence,\n state.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 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 */\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 sendable = await filterSendableForHost(\n request.hostId,\n [email],\n options?.firestore,\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 )\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 const state = await readMarketingFrequencyState(\n request.hostId,\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 state.cadence,\n state.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 this site for no more than one marketing ` +\n `message ${CADENCE_PHRASES[state.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":["getMarketingSendGate","marketingCadenceVerdict","marketingFrequencyVerdict","marketingSunsetDays","marketingSunsetVerdict","normalizeMarketingCadence","setMarketingSendGate","firebaseAdmin","readPersonEngagement","emailSuppressionKey","filterSendableForHost","filterTopicSendable","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","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","lookups","push","counters","snapshots","getAll","entry","holding","Set","forEach","index","state","verdict","allowed","add","filter","has","marketingSendVerdict","request","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`, 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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"}
|
|
@@ -120,6 +120,17 @@ export interface SenderReputationWindow extends EmailReputationCounts {
|
|
|
120
120
|
* questions about the same workspace is a round trip nobody has to pay.
|
|
121
121
|
*/
|
|
122
122
|
claimedToday: number;
|
|
123
|
+
/**
|
|
124
|
+
* Campaign messages accepted TODAY and YESTERDAY (UTC), summed.
|
|
125
|
+
*
|
|
126
|
+
* What the List-Unsubscribe bulk guard counts as "the last 24 hours"
|
|
127
|
+
* (AGL-3307). The counters are per calendar day, so a rolling 24 hours is
|
|
128
|
+
* not available — and two days is a superset of it: it never counts fewer
|
|
129
|
+
* messages than the trailing 24 hours held, so the guard can only err
|
|
130
|
+
* toward carrying the header. Read off the window's own documents, at no
|
|
131
|
+
* extra cost.
|
|
132
|
+
*/
|
|
133
|
+
acceptedLastTwoDays: number;
|
|
123
134
|
/** Days the window covers. */
|
|
124
135
|
windowDays: number;
|
|
125
136
|
/** True when the window could not be read and everything below is zero. */
|