@aglyn/tenant-data-admin 1.0.0-beta.175 → 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
|
@@ -163,6 +163,7 @@ function reputationRef(firestore, dayKey, orgId) {
|
|
|
163
163
|
bounced: 0,
|
|
164
164
|
complained: 0,
|
|
165
165
|
claimedToday: 0,
|
|
166
|
+
acceptedLastTwoDays: 0,
|
|
166
167
|
windowDays,
|
|
167
168
|
degraded: false
|
|
168
169
|
};
|
|
@@ -174,12 +175,14 @@ function reputationRef(firestore, dayKey, orgId) {
|
|
|
174
175
|
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
175
176
|
const dayKeys = reputationWindowDayKeys(now, windowDays);
|
|
176
177
|
const today = reputationDayKey(now);
|
|
178
|
+
const yesterday = reputationDayKey(now - DAY_MS);
|
|
177
179
|
const snapshots = await firestore.getAll(...dayKeys.map((dayKey)=>reputationRef(firestore, dayKey, orgId)));
|
|
178
180
|
return snapshots.reduce((totals, snapshot, index)=>_extends({}, totals, {
|
|
179
181
|
accepted: totals.accepted + storedCount(snapshot, 'accepted'),
|
|
180
182
|
bounced: totals.bounced + storedCount(snapshot, 'bounced'),
|
|
181
183
|
complained: totals.complained + storedCount(snapshot, 'complained'),
|
|
182
|
-
claimedToday: dayKeys[index] === today ? totals.claimedToday + storedCount(snapshot, 'claimed') : totals.claimedToday
|
|
184
|
+
claimedToday: dayKeys[index] === today ? totals.claimedToday + storedCount(snapshot, 'claimed') : totals.claimedToday,
|
|
185
|
+
acceptedLastTwoDays: dayKeys[index] === today || dayKeys[index] === yesterday ? totals.acceptedLastTwoDays + storedCount(snapshot, 'accepted') : totals.acceptedLastTwoDays
|
|
183
186
|
}), empty);
|
|
184
187
|
} catch (error) {
|
|
185
188
|
console.error('[sender-reputation] window unavailable — grading ok', error);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-sender-reputation.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 * PER-TENANT SENDING REPUTATION — the durable half.\n *\n * The policy is pure and lives in `@aglyn/shared-util-email`\n * (`sender-reputation.ts`); this adds the counters that make a rate a fact\n * rather than an intention, and the daily claim that paces a workspace's\n * first week.\n *\n * ## One document per workspace per day\n *\n * `rateLimits/emailRep_{YYYY-MM-DD}_{orgId}` holds four numbers:\n *\n * - `claimed` — campaign messages the day's ramp has granted. The PACING\n * counter, taken before a send and reconciled to what actually left.\n * - `accepted` — campaign messages that actually left. The DENOMINATOR every\n * rate divides by.\n * - `bounced`, `complained` — what the provider reported back.\n *\n * A day per document rather than a map of days on one, because the day is\n * what the TTL policy can sweep: `rateLimits` already carries an `expiresAt`\n * policy, and a document that ages out is a window that prunes itself. A map\n * of every day a workspace has ever sent would grow forever and would have to\n * be pruned by a job nobody would write.\n *\n * A per-ORG document rather than per-site, because the org is the tenant: the\n * plan, the monthly allowance and the hourly share are all keyed on it, and a\n * merchant with three sites on one domain is one sender to a mailbox\n * provider.\n *\n * The window document deliberately carries **no `lastAtMs`** — the\n * rate-limiter health probe queries this collection on that field, and a\n * per-day document in its range would compete with the degradation markers it\n * exists to find. The same reasoning as `sendRate_*` and `sendRateOrg_*`\n * beside it.\n *\n * ## Which day a bounce is filed under\n *\n * The day the EVENT arrived, not the day the message was sent. The webhook\n * knows the first and would need a per-message lookup for the second, and\n * over a seven-day window the two differ only at the edges — a bounce lands\n * within minutes and a complaint within hours or a day. Stated because the\n * rate is a ratio of two things counted on slightly different clocks, and a\n * reader comparing it against a provider's dashboard should know why they\n * will not agree to the last decimal.\n *\n * ## Failure posture: OPEN, and that is not the usual answer here\n *\n * An unreadable counter grades `ok` and grants the send, matching\n * `consumeEmailSendBudget` and `claimOrgEmailSendBudget`. The breaker is the\n * one control in this file that could refuse a paying customer's campaign,\n * and a refusal produced by a Firestore blip is an outage dressed as a policy\n * decision. The reputation risk it fails to catch is bounded by an hour of\n * sending; the refusal it would produce is not bounded by anything.\n */\n\nimport {\n EMAIL_REPUTATION_WINDOW_DAYS,\n emailRampVerdict,\n emailReputationVerdict,\n normalizeEmailReputationPolicy,\n orgDailyCampaignCeiling,\n reputationDayKey,\n reputationWindowDayKeys,\n daysBetween,\n type EmailRampVerdict,\n type EmailReputationCounts,\n type EmailReputationPolicy,\n type EmailReputationVerdict,\n} from '@aglyn/shared-util-email'\nimport { readEmailSendRateConfig } from './email-send-rate'\nimport { firebaseAdmin } from './firebase-admin'\nimport { RATE_LIMIT_COLLECTION } from './rate-limit-store'\n\n/** Id prefix for the per-org, per-day reputation documents. */\nexport const EMAIL_REPUTATION_DOC_PREFIX = 'emailRep_'\n\n/** One day in ms, named so the arithmetic below reads as arithmetic. */\nconst DAY_MS = 86_400_000\n\n/** The document id for one workspace's day. */\nexport function emailReputationDocId(dayKey: string, orgId: string): string {\n return `${EMAIL_REPUTATION_DOC_PREFIX}${dayKey}_${orgId}`\n}\n\n/** The counted fields, so a writer cannot invent a fifth by typo. */\nexport type EmailReputationCounter =\n | 'claimed'\n | 'accepted'\n | 'bounced'\n | 'complained'\n\nfunction reputationRef(firestore: any, dayKey: string, orgId: string): any {\n return firestore\n .collection(RATE_LIMIT_COLLECTION)\n .doc(emailReputationDocId(dayKey, orgId))\n}\n\n/** A stored count, clamped. A corrupt negative reads as 0, never as headroom. */\nfunction storedCount(snapshot: any, field: EmailReputationCounter): number {\n const value = Number((snapshot?.exists ? snapshot.get(field) : 0) ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * When a day's document may be swept.\n *\n * One window plus a day of slack past the end of the day it counts, so the\n * oldest day a rate still divides by is never deleted underneath the read\n * that needs it.\n */\nfunction reputationExpiresAt(dayStartMs: number): Date {\n return new Date(dayStartMs + (EMAIL_REPUTATION_WINDOW_DAYS + 2) * DAY_MS)\n}\n\n/** Midnight UTC of the day a timestamp falls in. */\nfunction dayStartMs(atMs: number): number {\n return Math.floor(atMs / DAY_MS) * DAY_MS\n}\n\n/**\n * Adds to one of a workspace's daily counters.\n *\n * **Never throws.** Reputation accounting runs after mail has gone out, or\n * inside a webhook the provider will retry — a counter write that failed must\n * not turn a delivered campaign into a 500 or a webhook into a retry storm.\n * `FieldValue.increment` rather than a transaction because nothing decides\n * anything from this write: the decision is made by a separate read, and an\n * increment is the contention-free way to record a fact.\n */\nexport async function recordEmailReputationEvent(options: {\n orgId: string\n counter: EmailReputationCounter\n count?: number\n atMs?: number\n firestore?: any\n}): Promise<void> {\n const orgId = String(options.orgId ?? '')\n const count = Math.floor(Number(options.count ?? 1))\n if (!orgId || !Number.isFinite(count) || count <= 0) return\n const atMs = Number(options.atMs ?? Date.now())\n const at = Number.isFinite(atMs) && atMs > 0 ? atMs : Date.now()\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n await reputationRef(firestore, reputationDayKey(at), orgId).set(\n {\n [options.counter]: firebaseAdmin.firestore.FieldValue.increment(count),\n orgId,\n dayKey: reputationDayKey(at),\n // NOT `lastAtMs` — see the storage note at the top of this file.\n expiresAt: reputationExpiresAt(dayStartMs(at)),\n },\n { merge: true },\n )\n } catch (error) {\n console.error('[sender-reputation] counter write failed', error)\n }\n}\n\n/** Campaign messages that actually left, for a workspace. The denominator. */\nexport function recordCampaignAccepted(\n orgId: string,\n count: number,\n options?: { atMs?: number; firestore?: any },\n): Promise<void> {\n return recordEmailReputationEvent({\n orgId,\n counter: 'accepted',\n count,\n ...options,\n })\n}\n\n/**\n * A permanent bounce or a spam complaint, attributed to the workspace that\n * sent it.\n *\n * The suppression the same event produces is a separate act and is not\n * conditional on this: an address comes off the list whether or not the\n * counter write lands.\n */\nexport function recordEmailReputationFailure(\n orgId: string,\n kind: 'bounce' | 'complaint',\n options?: { atMs?: number; firestore?: any },\n): Promise<void> {\n return recordEmailReputationEvent({\n orgId,\n counter: kind === 'complaint' ? 'complained' : 'bounced',\n count: 1,\n ...options,\n })\n}\n\n/** The counts a workspace's window holds, plus what the ramp needs. */\nexport interface SenderReputationWindow extends EmailReputationCounts {\n /**\n * Campaign messages the ramp has granted TODAY.\n *\n * Read here rather than by a second query because today's document is\n * already in this window: the ramp needs one number off it and the rates\n * need three off all of them, and two reads of one document to answer two\n * questions about the same workspace is a round trip nobody has to pay.\n */\n claimedToday: number\n /** Days the window covers. */\n windowDays: number\n /** True when the window could not be read and everything below is zero. */\n degraded: boolean\n}\n\n/**\n * Sums a workspace's last {@link EMAIL_REPUTATION_WINDOW_DAYS} days.\n *\n * One `getAll` over the window's day documents — seven reads per campaign,\n * not per message, and none at all for a workspace that has never sent. An\n * absent day is a quiet day and reads as zero.\n */\nexport async function readSenderReputationWindow(options: {\n orgId: string\n now?: number\n windowDays?: number\n firestore?: any\n}): Promise<SenderReputationWindow> {\n const windowDays = Math.max(\n 1,\n Math.floor(Number(options.windowDays ?? EMAIL_REPUTATION_WINDOW_DAYS)) ||\n EMAIL_REPUTATION_WINDOW_DAYS,\n )\n const empty: SenderReputationWindow = {\n accepted: 0,\n bounced: 0,\n complained: 0,\n claimedToday: 0,\n windowDays,\n degraded: false,\n }\n const orgId = String(options.orgId ?? '')\n if (!orgId) return empty\n const now = Number(options.now ?? Date.now())\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const dayKeys = reputationWindowDayKeys(now, windowDays)\n const today = reputationDayKey(now)\n const snapshots = await firestore.getAll(\n ...dayKeys.map((dayKey) => reputationRef(firestore, dayKey, orgId)),\n )\n return snapshots.reduce(\n (totals: SenderReputationWindow, snapshot: any, index: number) => ({\n ...totals,\n accepted: totals.accepted + storedCount(snapshot, 'accepted'),\n bounced: totals.bounced + storedCount(snapshot, 'bounced'),\n complained: totals.complained + storedCount(snapshot, 'complained'),\n claimedToday:\n dayKeys[index] === today\n ? totals.claimedToday + storedCount(snapshot, 'claimed')\n : totals.claimedToday,\n }),\n empty,\n )\n } catch (error) {\n console.error('[sender-reputation] window unavailable — grading ok', error)\n return { ...empty, degraded: true }\n }\n}\n\n/** A workspace's reputation, and whether its campaigns may go out. */\nexport interface SenderReputationRead extends EmailReputationVerdict {\n /** True when the window could not be read, so nothing was graded. */\n degraded: boolean\n /** The counts the grade was made from, so a caller can size a ramp too. */\n window: SenderReputationWindow\n}\n\n/**\n * Grades one workspace.\n *\n * The policy and the grace period come from the ORG DOCUMENT, which the\n * campaign sender has already read — passed in rather than re-fetched, so\n * this costs the window read and nothing else.\n */\nexport async function readSenderReputation(options: {\n orgId: string\n policy?: unknown\n reinstatedUntilMs?: unknown\n now?: number\n firestore?: any\n}): Promise<SenderReputationRead> {\n const now = Number(options.now ?? Date.now())\n const window = await readSenderReputationWindow({\n orgId: options.orgId,\n now,\n firestore: options.firestore,\n })\n const verdict = emailReputationVerdict({\n counts: window,\n // A degraded read grades against zeroes, which is `ok` by construction.\n // Stated rather than special-cased: the fail-open posture at the top of\n // this file is the reason, and a branch here would be a second place for\n // it to be got wrong.\n policy: normalizeEmailReputationPolicy(options.policy),\n windowDays: window.windowDays,\n reinstatedUntilMs: Number(options.reinstatedUntilMs ?? 0) || null,\n now,\n })\n return { ...verdict, degraded: window.degraded, window }\n}\n\n/**\n * The ramp step a workspace has EARNED, from a window it has already read.\n *\n * A thin wrapper over the pure policy that supplies the one number the policy\n * cannot know: what a graduated workspace's day is, which is a share of the\n * live platform ceiling and therefore moves with a staff ramp.\n */\nexport function resolveOrgEmailRamp(options: {\n /** Days since the workspace was created; null or absent graduates it. */\n ageDays: number | null | undefined\n /** Campaign messages it has delivered inside the window. */\n deliveredLifetime: number\n /** The live platform ceiling. */\n platformPerHour: number\n}): EmailRampVerdict {\n return emailRampVerdict({\n ageDays: options.ageDays,\n deliveredLifetime: options.deliveredLifetime,\n graduatedPerDay: orgDailyCampaignCeiling(options.platformPerHour),\n })\n}\n\n/** A workspace's ramp step, and whether the history behind it was readable. */\nexport interface OrgEmailRampRead {\n /** The step, from the age and the delivery history that resolved. */\n ramp: EmailRampVerdict\n /**\n * True when the ramp BINDS and the window it is sized from was unreadable,\n * so {@link ramp} is a floor rather than an answer.\n *\n * A caller that pages a batch — the campaign path — may treat this as the\n * step and carry on, because the floor is the lowest step and erring low\n * defers mail rather than sending it. A caller that admits or refuses ONE\n * message has no such third answer and must refuse: see\n * {@link readOrgEmailRamp}.\n */\n degraded: boolean\n}\n\n/**\n * A workspace's ramp step, resolved from its record and its history.\n *\n * The reads {@link resolveOrgEmailRamp} needs, in the order that pays for\n * the fewest of them. The platform ceiling comes first because it is process-\n * cached for fifteen seconds and therefore free; the seven-day window is a\n * `getAll` and is only paid when the ramp can still bind.\n *\n * ## A graduated workspace pays nothing\n *\n * Age alone decides graduation — delivery volume can only RAISE a step, never\n * reach past the last one — so a workspace past its first week is answered\n * from the org record with no window read at all. That is every existing\n * customer and every org whose record predates the creation timestamp, which\n * is to say almost every call.\n *\n * ## An unreadable window is `degraded`, and it is the caller's to answer\n *\n * The window read grades itself rather than throwing, and an unreadable one\n * reads as zero delivered — the LOWEST step, which is the conservative\n * direction for a ceiling. What it is not is a fact, so it is flagged: a\n * surface admitting one message at a time cannot defer the remainder to\n * tomorrow the way a campaign can, and a limiter with no answer must refuse\n * rather than admit.\n *\n * ## The platform flag parks this control\n *\n * `enabled: false` on the send-rate config graduates every workspace here,\n * exactly as it parks the hourly share and {@link claimOrgEmailSendDay} — one\n * switch, and it turns the ramp off everywhere it is consulted.\n */\nexport async function readOrgEmailRamp(options: {\n orgId: string\n /** The org record's `createdAt`, in whatever shape it was stored. */\n createdAt: unknown\n now?: number\n firestore?: any\n}): Promise<OrgEmailRampRead> {\n const now = Number(options.now ?? Date.now())\n const at = Number.isFinite(now) && now > 0 ? now : Date.now()\n const config = await readEmailSendRateConfig(\n options.firestore ? { firestore: options.firestore, now: at } : { now: at },\n )\n const platformPerHour = config.perHour\n if (!config.enabled) {\n // A parked control answers with the verdict an established workspace\n // gets, so a surface reading the step does not blank while it is off.\n const parked = resolveOrgEmailRamp({\n ageDays: null,\n deliveredLifetime: 0,\n platformPerHour,\n })\n return { ramp: parked, degraded: false }\n }\n const ageDays = orgAgeDays(options.createdAt, at)\n /*\n * The step this workspace would be on having delivered nothing. It is the\n * graduation test and the floor in one value: delivered volume only ever\n * raises a step, so a verdict that graduates here graduates at any volume,\n * and one that does not is the lowest step the workspace can be on.\n */\n const floor = resolveOrgEmailRamp({\n ageDays,\n deliveredLifetime: 0,\n platformPerHour,\n })\n if (floor.graduated) return { ramp: floor, degraded: false }\n const window = await readSenderReputationWindow({\n orgId: options.orgId,\n now: at,\n firestore: options.firestore,\n })\n if (window.degraded) return { ramp: floor, degraded: true }\n return {\n ramp: resolveOrgEmailRamp({\n ageDays,\n deliveredLifetime: window.accepted,\n platformPerHour,\n }),\n degraded: false,\n }\n}\n\n/** The answer to a per-day ramp claim. Every field is a stated number. */\nexport interface OrgEmailSendDayClaim {\n allowed: boolean\n /** Messages claimed today BEFORE this send. */\n used: number\n /** What this workspace may send today. */\n ceiling: number\n /** Headroom after this send, floored at 0. */\n remaining: number\n /** When the day rolls and a deferred campaign may go, ms. */\n retryAtMs: number\n /** The ramp decision the ceiling came from. */\n ramp: EmailRampVerdict\n /** True when the counter was unreachable and this failed open. */\n degraded: boolean\n /**\n * What to hand {@link reconcileOrgEmailSendDay} once the batch is done.\n * Null when nothing was claimed — a graduated workspace, a parked control,\n * or a refusal.\n */\n reservation: OrgEmailSendDayReservation | null\n}\n\n/** A claim on a day's ramp, so an undelivered remainder can be given back. */\nexport interface OrgEmailSendDayReservation {\n orgId: string\n dayKey: string\n claimed: number\n}\n\n/**\n * THE NEW-SENDER RAMP, ENFORCED.\n *\n * Claims `count` campaign messages against what this workspace may send\n * today, atomically. The transaction reads the counter and writes an ABSOLUTE\n * value derived from that read rather than an increment, for the reason\n * `reserveCampaignEmailSends` and `claimOrgEmailSendBudget` both do: the read\n * is the authority for the decision, and an increment proves nothing about\n * the value the decision was made from.\n *\n * The ceiling arrives as a resolved {@link EmailRampVerdict} rather than\n * being derived here, because the caller has already paid the window read\n * that decides which step the workspace is on — see\n * {@link resolveOrgEmailRamp}. A graduated workspace claims nothing at all:\n * the ramp does not bind it, and the hourly share that does has already been\n * claimed by the time this runs.\n *\n * **Campaigns only**, like the hourly claim beside it. This function is not\n * on `sendEmail`'s path and cannot be reached by a transactional message.\n *\n * A refused claim writes NOTHING — a campaign that resumes tomorrow must not\n * have spent tomorrow's budget on being told no.\n */\nexport async function claimOrgEmailSendDay(options: {\n orgId: string\n /** Messages this batch would send. */\n count: number\n /** What this workspace may send today, from {@link resolveOrgEmailRamp}. */\n ramp: EmailRampVerdict\n /** False parks the control, exactly as the platform governor's flag does. */\n enabled?: boolean\n now?: number\n firestore?: any\n}): Promise<OrgEmailSendDayClaim> {\n const now = Number(options.now ?? Date.now())\n const at = Number.isFinite(now) && now > 0 ? now : Date.now()\n const dayKey = reputationDayKey(at)\n const retryAtMs = dayStartMs(at) + DAY_MS\n const count = Math.max(0, Math.floor(Number(options.count) || 0))\n const orgId = String(options.orgId ?? '')\n const ramp = options.ramp\n\n const granted = (\n used: number,\n degraded: boolean,\n reservation: OrgEmailSendDayReservation | null,\n ): OrgEmailSendDayClaim => ({\n allowed: true,\n used,\n ceiling: ramp.perDay,\n remaining: Math.max(0, ramp.perDay - (used + count)),\n retryAtMs,\n ramp,\n degraded,\n reservation,\n })\n\n // A parked control still reports a real ceiling, so a surface reading this\n // does not blank while the control is off.\n if (options.enabled === false) return granted(0, false, null)\n // A graduated workspace is not paced by the ramp at all. No read, no write,\n // and no claim to give back.\n if (ramp.graduated) return granted(0, false, null)\n if (!orgId) return granted(0, true, null)\n\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const ref = reputationRef(firestore, dayKey, orgId)\n return await firestore.runTransaction(async (tx: any) => {\n const snapshot = await tx.get(ref)\n const used = storedCount(snapshot, 'claimed')\n if (used + count > ramp.perDay) {\n // A refused claim writes NOTHING.\n return {\n allowed: false,\n used,\n ceiling: ramp.perDay,\n remaining: Math.max(0, ramp.perDay - used),\n retryAtMs,\n ramp,\n degraded: false,\n reservation: null,\n }\n }\n tx.set(\n ref,\n {\n claimed: used + count,\n orgId,\n dayKey,\n expiresAt: reputationExpiresAt(dayStartMs(at)),\n },\n { merge: true },\n )\n return granted(used, false, { orgId, dayKey, claimed: count })\n })\n } catch (error) {\n console.error('[sender-reputation] day window unavailable — allowing', error)\n return granted(0, true, null)\n }\n}\n\n/**\n * Returns the undelivered part of a day's ramp claim.\n *\n * The hourly claim is deliberately never reconciled — its window is an hour\n * and an unrefunded remainder costs the org the rest of it. A DAY is long\n * enough that the same choice would cost a new workspace its whole first day\n * over one failed batch, which is the opposite of what a ramp is for.\n *\n * **Never throws** and never drives the counter below zero, exactly as\n * `reconcileCampaignSendReservation` does one surface over.\n */\nexport async function reconcileOrgEmailSendDay(\n reservation: OrgEmailSendDayReservation | null | undefined,\n delivered: number,\n firestore?: any,\n): Promise<void> {\n if (!reservation?.orgId) return\n const sent = Math.max(0, Math.floor(Number(delivered) || 0))\n const refund = Math.max(0, reservation.claimed - sent)\n if (refund <= 0) return\n try {\n const db = firestore ?? firebaseAdmin.app().firestore()\n const ref = reputationRef(db, reservation.dayKey, reservation.orgId)\n await db.runTransaction(async (tx: any) => {\n const snapshot = await tx.get(ref)\n const used = storedCount(snapshot, 'claimed')\n tx.set(ref, { claimed: Math.max(0, used - refund) }, { merge: true })\n })\n } catch (error) {\n console.error('[sender-reputation] day reconcile failed', error)\n }\n}\n\n/**\n * How old a workspace is, in days, from whatever its `createdAt` turns out to\n * be.\n *\n * Firestore hands back a `Timestamp`, a restore or a fixture can leave a\n * number, and an org written before the field existed has nothing. All three\n * are handled and the last one answers `null`, which\n * {@link claimOrgEmailSendDay} reads as graduated — the only safe direction,\n * for the reason stated there.\n */\nexport function orgAgeDays(\n createdAt: unknown,\n now: number = Date.now(),\n): number | null {\n const raw = createdAt as\n | { toMillis?: () => number; seconds?: number }\n | number\n | string\n | null\n | undefined\n let createdMs = 0\n if (typeof raw === 'number') createdMs = raw\n else if (typeof raw === 'string') createdMs = Date.parse(raw)\n else if (typeof raw?.toMillis === 'function') createdMs = raw.toMillis()\n else if (typeof raw?.seconds === 'number') createdMs = raw.seconds * 1000\n /*\n * The one check, and it has to answer `null` rather than 0. A creation date\n * that could not be read is an org whose record predates the field, which\n * is an EXISTING customer; `0` would say \"created today\" and ramp every\n * paying tenant on the platform down to the first step.\n */\n if (!Number.isFinite(createdMs) || createdMs <= 0) return null\n return daysBetween(createdMs, now)\n}\n\n/** Re-exported so a caller reads one module for the whole control. */\nexport type { EmailReputationPolicy, EmailRampVerdict }\n"],"names":["EMAIL_REPUTATION_WINDOW_DAYS","emailRampVerdict","emailReputationVerdict","normalizeEmailReputationPolicy","orgDailyCampaignCeiling","reputationDayKey","reputationWindowDayKeys","daysBetween","readEmailSendRateConfig","firebaseAdmin","RATE_LIMIT_COLLECTION","EMAIL_REPUTATION_DOC_PREFIX","DAY_MS","emailReputationDocId","dayKey","orgId","reputationRef","firestore","collection","doc","storedCount","snapshot","field","value","Number","exists","get","isFinite","Math","floor","reputationExpiresAt","dayStartMs","Date","atMs","recordEmailReputationEvent","options","String","count","now","at","app","set","counter","FieldValue","increment","expiresAt","merge","error","console","recordCampaignAccepted","recordEmailReputationFailure","kind","readSenderReputationWindow","windowDays","max","empty","accepted","bounced","complained","claimedToday","degraded","dayKeys","today","snapshots","getAll","map","reduce","totals","index","readSenderReputation","window","verdict","counts","policy","reinstatedUntilMs","resolveOrgEmailRamp","ageDays","deliveredLifetime","graduatedPerDay","platformPerHour","readOrgEmailRamp","config","perHour","enabled","parked","ramp","orgAgeDays","createdAt","graduated","claimOrgEmailSendDay","retryAtMs","granted","used","reservation","allowed","ceiling","perDay","remaining","ref","runTransaction","tx","claimed","reconcileOrgEmailSendDay","delivered","sent","refund","db","raw","createdMs","parse","toMillis","seconds"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-sender-reputation.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 * PER-TENANT SENDING REPUTATION — the durable half.\n *\n * The policy is pure and lives in `@aglyn/shared-util-email`\n * (`sender-reputation.ts`); this adds the counters that make a rate a fact\n * rather than an intention, and the daily claim that paces a workspace's\n * first week.\n *\n * ## One document per workspace per day\n *\n * `rateLimits/emailRep_{YYYY-MM-DD}_{orgId}` holds four numbers:\n *\n * - `claimed` — campaign messages the day's ramp has granted. The PACING\n * counter, taken before a send and reconciled to what actually left.\n * - `accepted` — campaign messages that actually left. The DENOMINATOR every\n * rate divides by.\n * - `bounced`, `complained` — what the provider reported back.\n *\n * A day per document rather than a map of days on one, because the day is\n * what the TTL policy can sweep: `rateLimits` already carries an `expiresAt`\n * policy, and a document that ages out is a window that prunes itself. A map\n * of every day a workspace has ever sent would grow forever and would have to\n * be pruned by a job nobody would write.\n *\n * A per-ORG document rather than per-site, because the org is the tenant: the\n * plan, the monthly allowance and the hourly share are all keyed on it, and a\n * merchant with three sites on one domain is one sender to a mailbox\n * provider.\n *\n * The window document deliberately carries **no `lastAtMs`** — the\n * rate-limiter health probe queries this collection on that field, and a\n * per-day document in its range would compete with the degradation markers it\n * exists to find. The same reasoning as `sendRate_*` and `sendRateOrg_*`\n * beside it.\n *\n * ## Which day a bounce is filed under\n *\n * The day the EVENT arrived, not the day the message was sent. The webhook\n * knows the first and would need a per-message lookup for the second, and\n * over a seven-day window the two differ only at the edges — a bounce lands\n * within minutes and a complaint within hours or a day. Stated because the\n * rate is a ratio of two things counted on slightly different clocks, and a\n * reader comparing it against a provider's dashboard should know why they\n * will not agree to the last decimal.\n *\n * ## Failure posture: OPEN, and that is not the usual answer here\n *\n * An unreadable counter grades `ok` and grants the send, matching\n * `consumeEmailSendBudget` and `claimOrgEmailSendBudget`. The breaker is the\n * one control in this file that could refuse a paying customer's campaign,\n * and a refusal produced by a Firestore blip is an outage dressed as a policy\n * decision. The reputation risk it fails to catch is bounded by an hour of\n * sending; the refusal it would produce is not bounded by anything.\n */\n\nimport {\n EMAIL_REPUTATION_WINDOW_DAYS,\n emailRampVerdict,\n emailReputationVerdict,\n normalizeEmailReputationPolicy,\n orgDailyCampaignCeiling,\n reputationDayKey,\n reputationWindowDayKeys,\n daysBetween,\n type EmailRampVerdict,\n type EmailReputationCounts,\n type EmailReputationPolicy,\n type EmailReputationVerdict,\n} from '@aglyn/shared-util-email'\nimport { readEmailSendRateConfig } from './email-send-rate'\nimport { firebaseAdmin } from './firebase-admin'\nimport { RATE_LIMIT_COLLECTION } from './rate-limit-store'\n\n/** Id prefix for the per-org, per-day reputation documents. */\nexport const EMAIL_REPUTATION_DOC_PREFIX = 'emailRep_'\n\n/** One day in ms, named so the arithmetic below reads as arithmetic. */\nconst DAY_MS = 86_400_000\n\n/** The document id for one workspace's day. */\nexport function emailReputationDocId(dayKey: string, orgId: string): string {\n return `${EMAIL_REPUTATION_DOC_PREFIX}${dayKey}_${orgId}`\n}\n\n/** The counted fields, so a writer cannot invent a fifth by typo. */\nexport type EmailReputationCounter =\n | 'claimed'\n | 'accepted'\n | 'bounced'\n | 'complained'\n\nfunction reputationRef(firestore: any, dayKey: string, orgId: string): any {\n return firestore\n .collection(RATE_LIMIT_COLLECTION)\n .doc(emailReputationDocId(dayKey, orgId))\n}\n\n/** A stored count, clamped. A corrupt negative reads as 0, never as headroom. */\nfunction storedCount(snapshot: any, field: EmailReputationCounter): number {\n const value = Number((snapshot?.exists ? snapshot.get(field) : 0) ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * When a day's document may be swept.\n *\n * One window plus a day of slack past the end of the day it counts, so the\n * oldest day a rate still divides by is never deleted underneath the read\n * that needs it.\n */\nfunction reputationExpiresAt(dayStartMs: number): Date {\n return new Date(dayStartMs + (EMAIL_REPUTATION_WINDOW_DAYS + 2) * DAY_MS)\n}\n\n/** Midnight UTC of the day a timestamp falls in. */\nfunction dayStartMs(atMs: number): number {\n return Math.floor(atMs / DAY_MS) * DAY_MS\n}\n\n/**\n * Adds to one of a workspace's daily counters.\n *\n * **Never throws.** Reputation accounting runs after mail has gone out, or\n * inside a webhook the provider will retry — a counter write that failed must\n * not turn a delivered campaign into a 500 or a webhook into a retry storm.\n * `FieldValue.increment` rather than a transaction because nothing decides\n * anything from this write: the decision is made by a separate read, and an\n * increment is the contention-free way to record a fact.\n */\nexport async function recordEmailReputationEvent(options: {\n orgId: string\n counter: EmailReputationCounter\n count?: number\n atMs?: number\n firestore?: any\n}): Promise<void> {\n const orgId = String(options.orgId ?? '')\n const count = Math.floor(Number(options.count ?? 1))\n if (!orgId || !Number.isFinite(count) || count <= 0) return\n const atMs = Number(options.atMs ?? Date.now())\n const at = Number.isFinite(atMs) && atMs > 0 ? atMs : Date.now()\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n await reputationRef(firestore, reputationDayKey(at), orgId).set(\n {\n [options.counter]: firebaseAdmin.firestore.FieldValue.increment(count),\n orgId,\n dayKey: reputationDayKey(at),\n // NOT `lastAtMs` — see the storage note at the top of this file.\n expiresAt: reputationExpiresAt(dayStartMs(at)),\n },\n { merge: true },\n )\n } catch (error) {\n console.error('[sender-reputation] counter write failed', error)\n }\n}\n\n/** Campaign messages that actually left, for a workspace. The denominator. */\nexport function recordCampaignAccepted(\n orgId: string,\n count: number,\n options?: { atMs?: number; firestore?: any },\n): Promise<void> {\n return recordEmailReputationEvent({\n orgId,\n counter: 'accepted',\n count,\n ...options,\n })\n}\n\n/**\n * A permanent bounce or a spam complaint, attributed to the workspace that\n * sent it.\n *\n * The suppression the same event produces is a separate act and is not\n * conditional on this: an address comes off the list whether or not the\n * counter write lands.\n */\nexport function recordEmailReputationFailure(\n orgId: string,\n kind: 'bounce' | 'complaint',\n options?: { atMs?: number; firestore?: any },\n): Promise<void> {\n return recordEmailReputationEvent({\n orgId,\n counter: kind === 'complaint' ? 'complained' : 'bounced',\n count: 1,\n ...options,\n })\n}\n\n/** The counts a workspace's window holds, plus what the ramp needs. */\nexport interface SenderReputationWindow extends EmailReputationCounts {\n /**\n * Campaign messages the ramp has granted TODAY.\n *\n * Read here rather than by a second query because today's document is\n * already in this window: the ramp needs one number off it and the rates\n * need three off all of them, and two reads of one document to answer two\n * questions about the same workspace is a round trip nobody has to pay.\n */\n claimedToday: number\n /**\n * Campaign messages accepted TODAY and YESTERDAY (UTC), summed.\n *\n * What the List-Unsubscribe bulk guard counts as \"the last 24 hours\"\n * (AGL-3307). The counters are per calendar day, so a rolling 24 hours is\n * not available — and two days is a superset of it: it never counts fewer\n * messages than the trailing 24 hours held, so the guard can only err\n * toward carrying the header. Read off the window's own documents, at no\n * extra cost.\n */\n acceptedLastTwoDays: number\n /** Days the window covers. */\n windowDays: number\n /** True when the window could not be read and everything below is zero. */\n degraded: boolean\n}\n\n/**\n * Sums a workspace's last {@link EMAIL_REPUTATION_WINDOW_DAYS} days.\n *\n * One `getAll` over the window's day documents — seven reads per campaign,\n * not per message, and none at all for a workspace that has never sent. An\n * absent day is a quiet day and reads as zero.\n */\nexport async function readSenderReputationWindow(options: {\n orgId: string\n now?: number\n windowDays?: number\n firestore?: any\n}): Promise<SenderReputationWindow> {\n const windowDays = Math.max(\n 1,\n Math.floor(Number(options.windowDays ?? EMAIL_REPUTATION_WINDOW_DAYS)) ||\n EMAIL_REPUTATION_WINDOW_DAYS,\n )\n const empty: SenderReputationWindow = {\n accepted: 0,\n bounced: 0,\n complained: 0,\n claimedToday: 0,\n acceptedLastTwoDays: 0,\n windowDays,\n degraded: false,\n }\n const orgId = String(options.orgId ?? '')\n if (!orgId) return empty\n const now = Number(options.now ?? Date.now())\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const dayKeys = reputationWindowDayKeys(now, windowDays)\n const today = reputationDayKey(now)\n const yesterday = reputationDayKey(now - DAY_MS)\n const snapshots = await firestore.getAll(\n ...dayKeys.map((dayKey) => reputationRef(firestore, dayKey, orgId)),\n )\n return snapshots.reduce(\n (totals: SenderReputationWindow, snapshot: any, index: number) => ({\n ...totals,\n accepted: totals.accepted + storedCount(snapshot, 'accepted'),\n bounced: totals.bounced + storedCount(snapshot, 'bounced'),\n complained: totals.complained + storedCount(snapshot, 'complained'),\n claimedToday:\n dayKeys[index] === today\n ? totals.claimedToday + storedCount(snapshot, 'claimed')\n : totals.claimedToday,\n acceptedLastTwoDays:\n dayKeys[index] === today || dayKeys[index] === yesterday\n ? totals.acceptedLastTwoDays + storedCount(snapshot, 'accepted')\n : totals.acceptedLastTwoDays,\n }),\n empty,\n )\n } catch (error) {\n console.error('[sender-reputation] window unavailable — grading ok', error)\n return { ...empty, degraded: true }\n }\n}\n\n/** A workspace's reputation, and whether its campaigns may go out. */\nexport interface SenderReputationRead extends EmailReputationVerdict {\n /** True when the window could not be read, so nothing was graded. */\n degraded: boolean\n /** The counts the grade was made from, so a caller can size a ramp too. */\n window: SenderReputationWindow\n}\n\n/**\n * Grades one workspace.\n *\n * The policy and the grace period come from the ORG DOCUMENT, which the\n * campaign sender has already read — passed in rather than re-fetched, so\n * this costs the window read and nothing else.\n */\nexport async function readSenderReputation(options: {\n orgId: string\n policy?: unknown\n reinstatedUntilMs?: unknown\n now?: number\n firestore?: any\n}): Promise<SenderReputationRead> {\n const now = Number(options.now ?? Date.now())\n const window = await readSenderReputationWindow({\n orgId: options.orgId,\n now,\n firestore: options.firestore,\n })\n const verdict = emailReputationVerdict({\n counts: window,\n // A degraded read grades against zeroes, which is `ok` by construction.\n // Stated rather than special-cased: the fail-open posture at the top of\n // this file is the reason, and a branch here would be a second place for\n // it to be got wrong.\n policy: normalizeEmailReputationPolicy(options.policy),\n windowDays: window.windowDays,\n reinstatedUntilMs: Number(options.reinstatedUntilMs ?? 0) || null,\n now,\n })\n return { ...verdict, degraded: window.degraded, window }\n}\n\n/**\n * The ramp step a workspace has EARNED, from a window it has already read.\n *\n * A thin wrapper over the pure policy that supplies the one number the policy\n * cannot know: what a graduated workspace's day is, which is a share of the\n * live platform ceiling and therefore moves with a staff ramp.\n */\nexport function resolveOrgEmailRamp(options: {\n /** Days since the workspace was created; null or absent graduates it. */\n ageDays: number | null | undefined\n /** Campaign messages it has delivered inside the window. */\n deliveredLifetime: number\n /** The live platform ceiling. */\n platformPerHour: number\n}): EmailRampVerdict {\n return emailRampVerdict({\n ageDays: options.ageDays,\n deliveredLifetime: options.deliveredLifetime,\n graduatedPerDay: orgDailyCampaignCeiling(options.platformPerHour),\n })\n}\n\n/** A workspace's ramp step, and whether the history behind it was readable. */\nexport interface OrgEmailRampRead {\n /** The step, from the age and the delivery history that resolved. */\n ramp: EmailRampVerdict\n /**\n * True when the ramp BINDS and the window it is sized from was unreadable,\n * so {@link ramp} is a floor rather than an answer.\n *\n * A caller that pages a batch — the campaign path — may treat this as the\n * step and carry on, because the floor is the lowest step and erring low\n * defers mail rather than sending it. A caller that admits or refuses ONE\n * message has no such third answer and must refuse: see\n * {@link readOrgEmailRamp}.\n */\n degraded: boolean\n}\n\n/**\n * A workspace's ramp step, resolved from its record and its history.\n *\n * The reads {@link resolveOrgEmailRamp} needs, in the order that pays for\n * the fewest of them. The platform ceiling comes first because it is process-\n * cached for fifteen seconds and therefore free; the seven-day window is a\n * `getAll` and is only paid when the ramp can still bind.\n *\n * ## A graduated workspace pays nothing\n *\n * Age alone decides graduation — delivery volume can only RAISE a step, never\n * reach past the last one — so a workspace past its first week is answered\n * from the org record with no window read at all. That is every existing\n * customer and every org whose record predates the creation timestamp, which\n * is to say almost every call.\n *\n * ## An unreadable window is `degraded`, and it is the caller's to answer\n *\n * The window read grades itself rather than throwing, and an unreadable one\n * reads as zero delivered — the LOWEST step, which is the conservative\n * direction for a ceiling. What it is not is a fact, so it is flagged: a\n * surface admitting one message at a time cannot defer the remainder to\n * tomorrow the way a campaign can, and a limiter with no answer must refuse\n * rather than admit.\n *\n * ## The platform flag parks this control\n *\n * `enabled: false` on the send-rate config graduates every workspace here,\n * exactly as it parks the hourly share and {@link claimOrgEmailSendDay} — one\n * switch, and it turns the ramp off everywhere it is consulted.\n */\nexport async function readOrgEmailRamp(options: {\n orgId: string\n /** The org record's `createdAt`, in whatever shape it was stored. */\n createdAt: unknown\n now?: number\n firestore?: any\n}): Promise<OrgEmailRampRead> {\n const now = Number(options.now ?? Date.now())\n const at = Number.isFinite(now) && now > 0 ? now : Date.now()\n const config = await readEmailSendRateConfig(\n options.firestore ? { firestore: options.firestore, now: at } : { now: at },\n )\n const platformPerHour = config.perHour\n if (!config.enabled) {\n // A parked control answers with the verdict an established workspace\n // gets, so a surface reading the step does not blank while it is off.\n const parked = resolveOrgEmailRamp({\n ageDays: null,\n deliveredLifetime: 0,\n platformPerHour,\n })\n return { ramp: parked, degraded: false }\n }\n const ageDays = orgAgeDays(options.createdAt, at)\n /*\n * The step this workspace would be on having delivered nothing. It is the\n * graduation test and the floor in one value: delivered volume only ever\n * raises a step, so a verdict that graduates here graduates at any volume,\n * and one that does not is the lowest step the workspace can be on.\n */\n const floor = resolveOrgEmailRamp({\n ageDays,\n deliveredLifetime: 0,\n platformPerHour,\n })\n if (floor.graduated) return { ramp: floor, degraded: false }\n const window = await readSenderReputationWindow({\n orgId: options.orgId,\n now: at,\n firestore: options.firestore,\n })\n if (window.degraded) return { ramp: floor, degraded: true }\n return {\n ramp: resolveOrgEmailRamp({\n ageDays,\n deliveredLifetime: window.accepted,\n platformPerHour,\n }),\n degraded: false,\n }\n}\n\n/** The answer to a per-day ramp claim. Every field is a stated number. */\nexport interface OrgEmailSendDayClaim {\n allowed: boolean\n /** Messages claimed today BEFORE this send. */\n used: number\n /** What this workspace may send today. */\n ceiling: number\n /** Headroom after this send, floored at 0. */\n remaining: number\n /** When the day rolls and a deferred campaign may go, ms. */\n retryAtMs: number\n /** The ramp decision the ceiling came from. */\n ramp: EmailRampVerdict\n /** True when the counter was unreachable and this failed open. */\n degraded: boolean\n /**\n * What to hand {@link reconcileOrgEmailSendDay} once the batch is done.\n * Null when nothing was claimed — a graduated workspace, a parked control,\n * or a refusal.\n */\n reservation: OrgEmailSendDayReservation | null\n}\n\n/** A claim on a day's ramp, so an undelivered remainder can be given back. */\nexport interface OrgEmailSendDayReservation {\n orgId: string\n dayKey: string\n claimed: number\n}\n\n/**\n * THE NEW-SENDER RAMP, ENFORCED.\n *\n * Claims `count` campaign messages against what this workspace may send\n * today, atomically. The transaction reads the counter and writes an ABSOLUTE\n * value derived from that read rather than an increment, for the reason\n * `reserveCampaignEmailSends` and `claimOrgEmailSendBudget` both do: the read\n * is the authority for the decision, and an increment proves nothing about\n * the value the decision was made from.\n *\n * The ceiling arrives as a resolved {@link EmailRampVerdict} rather than\n * being derived here, because the caller has already paid the window read\n * that decides which step the workspace is on — see\n * {@link resolveOrgEmailRamp}. A graduated workspace claims nothing at all:\n * the ramp does not bind it, and the hourly share that does has already been\n * claimed by the time this runs.\n *\n * **Campaigns only**, like the hourly claim beside it. This function is not\n * on `sendEmail`'s path and cannot be reached by a transactional message.\n *\n * A refused claim writes NOTHING — a campaign that resumes tomorrow must not\n * have spent tomorrow's budget on being told no.\n */\nexport async function claimOrgEmailSendDay(options: {\n orgId: string\n /** Messages this batch would send. */\n count: number\n /** What this workspace may send today, from {@link resolveOrgEmailRamp}. */\n ramp: EmailRampVerdict\n /** False parks the control, exactly as the platform governor's flag does. */\n enabled?: boolean\n now?: number\n firestore?: any\n}): Promise<OrgEmailSendDayClaim> {\n const now = Number(options.now ?? Date.now())\n const at = Number.isFinite(now) && now > 0 ? now : Date.now()\n const dayKey = reputationDayKey(at)\n const retryAtMs = dayStartMs(at) + DAY_MS\n const count = Math.max(0, Math.floor(Number(options.count) || 0))\n const orgId = String(options.orgId ?? '')\n const ramp = options.ramp\n\n const granted = (\n used: number,\n degraded: boolean,\n reservation: OrgEmailSendDayReservation | null,\n ): OrgEmailSendDayClaim => ({\n allowed: true,\n used,\n ceiling: ramp.perDay,\n remaining: Math.max(0, ramp.perDay - (used + count)),\n retryAtMs,\n ramp,\n degraded,\n reservation,\n })\n\n // A parked control still reports a real ceiling, so a surface reading this\n // does not blank while the control is off.\n if (options.enabled === false) return granted(0, false, null)\n // A graduated workspace is not paced by the ramp at all. No read, no write,\n // and no claim to give back.\n if (ramp.graduated) return granted(0, false, null)\n if (!orgId) return granted(0, true, null)\n\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const ref = reputationRef(firestore, dayKey, orgId)\n return await firestore.runTransaction(async (tx: any) => {\n const snapshot = await tx.get(ref)\n const used = storedCount(snapshot, 'claimed')\n if (used + count > ramp.perDay) {\n // A refused claim writes NOTHING.\n return {\n allowed: false,\n used,\n ceiling: ramp.perDay,\n remaining: Math.max(0, ramp.perDay - used),\n retryAtMs,\n ramp,\n degraded: false,\n reservation: null,\n }\n }\n tx.set(\n ref,\n {\n claimed: used + count,\n orgId,\n dayKey,\n expiresAt: reputationExpiresAt(dayStartMs(at)),\n },\n { merge: true },\n )\n return granted(used, false, { orgId, dayKey, claimed: count })\n })\n } catch (error) {\n console.error('[sender-reputation] day window unavailable — allowing', error)\n return granted(0, true, null)\n }\n}\n\n/**\n * Returns the undelivered part of a day's ramp claim.\n *\n * The hourly claim is deliberately never reconciled — its window is an hour\n * and an unrefunded remainder costs the org the rest of it. A DAY is long\n * enough that the same choice would cost a new workspace its whole first day\n * over one failed batch, which is the opposite of what a ramp is for.\n *\n * **Never throws** and never drives the counter below zero, exactly as\n * `reconcileCampaignSendReservation` does one surface over.\n */\nexport async function reconcileOrgEmailSendDay(\n reservation: OrgEmailSendDayReservation | null | undefined,\n delivered: number,\n firestore?: any,\n): Promise<void> {\n if (!reservation?.orgId) return\n const sent = Math.max(0, Math.floor(Number(delivered) || 0))\n const refund = Math.max(0, reservation.claimed - sent)\n if (refund <= 0) return\n try {\n const db = firestore ?? firebaseAdmin.app().firestore()\n const ref = reputationRef(db, reservation.dayKey, reservation.orgId)\n await db.runTransaction(async (tx: any) => {\n const snapshot = await tx.get(ref)\n const used = storedCount(snapshot, 'claimed')\n tx.set(ref, { claimed: Math.max(0, used - refund) }, { merge: true })\n })\n } catch (error) {\n console.error('[sender-reputation] day reconcile failed', error)\n }\n}\n\n/**\n * How old a workspace is, in days, from whatever its `createdAt` turns out to\n * be.\n *\n * Firestore hands back a `Timestamp`, a restore or a fixture can leave a\n * number, and an org written before the field existed has nothing. All three\n * are handled and the last one answers `null`, which\n * {@link claimOrgEmailSendDay} reads as graduated — the only safe direction,\n * for the reason stated there.\n */\nexport function orgAgeDays(\n createdAt: unknown,\n now: number = Date.now(),\n): number | null {\n const raw = createdAt as\n | { toMillis?: () => number; seconds?: number }\n | number\n | string\n | null\n | undefined\n let createdMs = 0\n if (typeof raw === 'number') createdMs = raw\n else if (typeof raw === 'string') createdMs = Date.parse(raw)\n else if (typeof raw?.toMillis === 'function') createdMs = raw.toMillis()\n else if (typeof raw?.seconds === 'number') createdMs = raw.seconds * 1000\n /*\n * The one check, and it has to answer `null` rather than 0. A creation date\n * that could not be read is an org whose record predates the field, which\n * is an EXISTING customer; `0` would say \"created today\" and ramp every\n * paying tenant on the platform down to the first step.\n */\n if (!Number.isFinite(createdMs) || createdMs <= 0) return null\n return daysBetween(createdMs, now)\n}\n\n/** Re-exported so a caller reads one module for the whole control. */\nexport type { EmailReputationPolicy, EmailRampVerdict }\n"],"names":["EMAIL_REPUTATION_WINDOW_DAYS","emailRampVerdict","emailReputationVerdict","normalizeEmailReputationPolicy","orgDailyCampaignCeiling","reputationDayKey","reputationWindowDayKeys","daysBetween","readEmailSendRateConfig","firebaseAdmin","RATE_LIMIT_COLLECTION","EMAIL_REPUTATION_DOC_PREFIX","DAY_MS","emailReputationDocId","dayKey","orgId","reputationRef","firestore","collection","doc","storedCount","snapshot","field","value","Number","exists","get","isFinite","Math","floor","reputationExpiresAt","dayStartMs","Date","atMs","recordEmailReputationEvent","options","String","count","now","at","app","set","counter","FieldValue","increment","expiresAt","merge","error","console","recordCampaignAccepted","recordEmailReputationFailure","kind","readSenderReputationWindow","windowDays","max","empty","accepted","bounced","complained","claimedToday","acceptedLastTwoDays","degraded","dayKeys","today","yesterday","snapshots","getAll","map","reduce","totals","index","readSenderReputation","window","verdict","counts","policy","reinstatedUntilMs","resolveOrgEmailRamp","ageDays","deliveredLifetime","graduatedPerDay","platformPerHour","readOrgEmailRamp","config","perHour","enabled","parked","ramp","orgAgeDays","createdAt","graduated","claimOrgEmailSendDay","retryAtMs","granted","used","reservation","allowed","ceiling","perDay","remaining","ref","runTransaction","tx","claimed","reconcileOrgEmailSendDay","delivered","sent","refund","db","raw","createdMs","parse","toMillis","seconds"],"mappings":"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"}
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
* nothing into the AGL-1438 cost-meter sweep.)
|
|
87
87
|
*/
|
|
88
88
|
import { Timestamp } from 'firebase-admin/firestore';
|
|
89
|
+
import { type ConsentGroup } from '@aglyn/aglyn/app-utils/consent-groups';
|
|
89
90
|
export declare const EMAIL_SUPPRESSIONS_COLLECTION = "emailSuppressions";
|
|
90
91
|
/**
|
|
91
92
|
* The PER-SITE list, `hosts/{hostId}/suppressions/{emailKey}` — unsubscribes
|
|
@@ -357,6 +358,36 @@ export declare function isEmailSuppressed(email: string | null | undefined, inje
|
|
|
357
358
|
* which routinely names the same person twice.
|
|
358
359
|
*/
|
|
359
360
|
export declare function filterSuppressedEmails(emails: readonly string[], injectedFirestore?: any): Promise<string[]>;
|
|
361
|
+
/**
|
|
362
|
+
* The sites whose opt-out lists answer for a send made BY `hostId`, the
|
|
363
|
+
* sending site first.
|
|
364
|
+
*
|
|
365
|
+
* The sending site's consent group: every site it names, because a refusal
|
|
366
|
+
* standing on any of them is a refusal of the sender. Absent, or a group of
|
|
367
|
+
* one, is the site alone.
|
|
368
|
+
*
|
|
369
|
+
* The group is the one `consentGroupForHost` resolved — never a set a caller
|
|
370
|
+
* built from `org.hosts` or a shared domain, which would be inferring a
|
|
371
|
+
* controller (see `consent-groups.ts`).
|
|
372
|
+
*
|
|
373
|
+
* Throws on a group resolved for a DIFFERENT site. That is a wiring defect no
|
|
374
|
+
* runtime value produces, and the failure it would hide is one site's send
|
|
375
|
+
* decided against another site's lists — the same refusal
|
|
376
|
+
* `splitByMarketingConsent` makes over a consent map read for the wrong host.
|
|
377
|
+
*/
|
|
378
|
+
export declare function optOutHostIds(hostId: string, group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null): string[];
|
|
379
|
+
/**
|
|
380
|
+
* One keyed `getAll` per site in `hostIds`, positionally aligned with `keys`.
|
|
381
|
+
*
|
|
382
|
+
* Per site rather than one call over every (site, address) pair, so each
|
|
383
|
+
* round trip is exactly the size the single-site read always was — a batch
|
|
384
|
+
* of a campaign — and a group multiplies the number of calls, run in
|
|
385
|
+
* parallel, rather than the size of one. A group of one is one call, over
|
|
386
|
+
* the same references in the same order.
|
|
387
|
+
*
|
|
388
|
+
* Rejects when any read does; each caller keeps its own posture on that.
|
|
389
|
+
*/
|
|
390
|
+
export declare function getAllAcrossSites(db: any, hostIds: readonly string[], subcollection: string, keys: readonly string[]): Promise<any[][]>;
|
|
360
391
|
/**
|
|
361
392
|
* The sendable subset for a send made in ONE SITE's name — BOTH lists.
|
|
362
393
|
*
|
|
@@ -374,6 +405,16 @@ export declare function filterSuppressedEmails(emails: readonly string[], inject
|
|
|
374
405
|
* live there, and a second copy of them is a second set of rules for two
|
|
375
406
|
* senders to disagree about.
|
|
376
407
|
*
|
|
408
|
+
* ## "This site" means its consent group
|
|
409
|
+
*
|
|
410
|
+
* `group` is the sending site's consent group, and an entry on ANY of its
|
|
411
|
+
* sites' lists withholds — see the section above. Every entry counts, whatever
|
|
412
|
+
* its reason: an unsubscribe and a hand-added opt-out are the person leaving
|
|
413
|
+
* the sender, an erasure is already written on every site of the workspace,
|
|
414
|
+
* and a bounce or a complaint is on the platform list besides. Omitted, the
|
|
415
|
+
* site alone, which is also the whole answer for an org that declared no
|
|
416
|
+
* group.
|
|
417
|
+
*
|
|
377
418
|
* ## Both halves fail CLOSED
|
|
378
419
|
*
|
|
379
420
|
* A read that throws answers "suppressed". The platform half already does; the
|
|
@@ -384,13 +425,13 @@ export declare function filterSuppressedEmails(emails: readonly string[], inject
|
|
|
384
425
|
* ## One `getAll`, keyed, rather than a scan of the collection
|
|
385
426
|
*
|
|
386
427
|
* The per-site half looks up exactly the addresses being mailed, by document
|
|
387
|
-
* id, in one round trip. Reading the whole collection instead —
|
|
388
|
-
* the campaign sender did — is bounded by however large the
|
|
389
|
-
* grown, so a site with more suppressions than the read window
|
|
390
|
-
* the remainder: the people most certain not to want the mail
|
|
391
|
-
* truncated read drops.
|
|
428
|
+
* id, in one round trip per site. Reading the whole collection instead —
|
|
429
|
+
* which is what the campaign sender did — is bounded by however large the
|
|
430
|
+
* collection has grown, so a site with more suppressions than the read window
|
|
431
|
+
* fails OPEN on the remainder: the people most certain not to want the mail
|
|
432
|
+
* are the ones a truncated read drops.
|
|
392
433
|
*/
|
|
393
|
-
export declare function filterSendableForHost(hostId: string, emails: readonly string[], injectedFirestore?: any): Promise<string[]>;
|
|
434
|
+
export declare function filterSendableForHost(hostId: string, emails: readonly string[], injectedFirestore?: any, group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null): Promise<string[]>;
|
|
394
435
|
/**
|
|
395
436
|
* The subset of `emails` that may be mailed about `topicId` on this site.
|
|
396
437
|
*
|
|
@@ -431,8 +472,18 @@ export declare function filterSendableForHost(hostId: string, emails: readonly s
|
|
|
431
472
|
* An empty `topicId` is a campaign from before topics existed, or one whose
|
|
432
473
|
* topic was never resolved. It filters nobody: there is no stream to have
|
|
433
474
|
* left.
|
|
475
|
+
*
|
|
476
|
+
* ## Leaving a stream is leaving it for the whole consent group
|
|
477
|
+
*
|
|
478
|
+
* The topic catalog is the org's, so "Newsletter" on one site of a declared
|
|
479
|
+
* group is the same stream on its siblings, and a person who left it on one
|
|
480
|
+
* has left it from the sender. `group` extends the read to every site in it,
|
|
481
|
+
* exactly as {@link filterSendableForHost} does for the suppression list.
|
|
482
|
+
*
|
|
483
|
+
* Only a sibling's REFUSAL is read there. A pending confirmation stays the
|
|
484
|
+
* concern of the site whose form asked for it, as it always was.
|
|
434
485
|
*/
|
|
435
|
-
export declare function filterTopicSendable(hostId: string, topicId: string | null | undefined, emails: readonly string[], injectedFirestore?: any): Promise<string[]>;
|
|
486
|
+
export declare function filterTopicSendable(hostId: string, topicId: string | null | undefined, emails: readonly string[], injectedFirestore?: any, group?: Pick<ConsentGroup, 'hostId' | 'hostIds'> | null): Promise<string[]>;
|
|
436
487
|
/**
|
|
437
488
|
* The staff queue, newest first, ordered by `suppressedAt` so a re-recorded or
|
|
438
489
|
* released entry surfaces again — the operator's question is "what changed",
|
|
@@ -89,6 +89,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
89
89
|
// `document-id.ts` gives at length: a barrel import resolves to whatever a
|
|
90
90
|
// spec's `jest.mock` happens to contain, and this module's neighbours are
|
|
91
91
|
// mocked in nearly every spec that touches them.
|
|
92
|
+
import { consentGroupOptOutHosts } from "@aglyn/aglyn/app-utils/consent-groups";
|
|
92
93
|
import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
|
|
93
94
|
import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
94
95
|
import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
|
|
@@ -396,6 +397,70 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
396
397
|
const verdicts = await Promise.all(candidates.map((email)=>isEmailSuppressed(email, injectedFirestore)));
|
|
397
398
|
return candidates.filter((_email, index)=>!verdicts[index]);
|
|
398
399
|
}
|
|
400
|
+
/*==========================================
|
|
401
|
+
* THE SENDER A PERSON LEAVES IS THE CONSENT GROUP, NOT THE SITE.
|
|
402
|
+
*
|
|
403
|
+
* `consent-groups.ts` lets an org declare several sites ONE sender — a shop,
|
|
404
|
+
* a booking page and a blog under one name, disclosed as one on every
|
|
405
|
+
* capture form. To the person on the other end those are one sender, so
|
|
406
|
+
* leaving any of them is leaving it: an unsubscribe filed by site A has to
|
|
407
|
+
* withhold site B's mail too, or the recipient is made to unsubscribe once
|
|
408
|
+
* per site of a sender they only ever knew as one.
|
|
409
|
+
*
|
|
410
|
+
* Every opt-out is written against the ONE site the person acted on and read
|
|
411
|
+
* across the sending site's CURRENT group. Reading rather than copying on
|
|
412
|
+
* write is what makes a site that joins a group inherit every refusal already
|
|
413
|
+
* standing against its siblings, and it reaches every row already stored and
|
|
414
|
+
* every link already sitting in an inbox — none of which a write-time fan-out
|
|
415
|
+
* could.
|
|
416
|
+
*
|
|
417
|
+
* All three per-site facts are read this way: the site suppression list (an
|
|
418
|
+
* unsubscribe, a hand-added opt-out, an erasure, and the bounces and
|
|
419
|
+
* complaints the platform list already carries), the topic opt-outs, and the
|
|
420
|
+
* recipient's cadence. The group of one — every site of an org that declared
|
|
421
|
+
* nothing — reads exactly the documents these filters always read, in the
|
|
422
|
+
* same round trips.
|
|
423
|
+
*=========================================*/ /**
|
|
424
|
+
* The sites whose opt-out lists answer for a send made BY `hostId`, the
|
|
425
|
+
* sending site first.
|
|
426
|
+
*
|
|
427
|
+
* The sending site's consent group: every site it names, because a refusal
|
|
428
|
+
* standing on any of them is a refusal of the sender. Absent, or a group of
|
|
429
|
+
* one, is the site alone.
|
|
430
|
+
*
|
|
431
|
+
* The group is the one `consentGroupForHost` resolved — never a set a caller
|
|
432
|
+
* built from `org.hosts` or a shared domain, which would be inferring a
|
|
433
|
+
* controller (see `consent-groups.ts`).
|
|
434
|
+
*
|
|
435
|
+
* Throws on a group resolved for a DIFFERENT site. That is a wiring defect no
|
|
436
|
+
* runtime value produces, and the failure it would hide is one site's send
|
|
437
|
+
* decided against another site's lists — the same refusal
|
|
438
|
+
* `splitByMarketingConsent` makes over a consent map read for the wrong host.
|
|
439
|
+
*/ export function optOutHostIds(hostId, group) {
|
|
440
|
+
if (!group) return [
|
|
441
|
+
hostId
|
|
442
|
+
];
|
|
443
|
+
if (group.hostId !== hostId) {
|
|
444
|
+
throw new Error(`[email-suppression] a consent group resolved for ${group.hostId} ` + `cannot decide a send from ${hostId}`);
|
|
445
|
+
}
|
|
446
|
+
return consentGroupOptOutHosts(group);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* One keyed `getAll` per site in `hostIds`, positionally aligned with `keys`.
|
|
450
|
+
*
|
|
451
|
+
* Per site rather than one call over every (site, address) pair, so each
|
|
452
|
+
* round trip is exactly the size the single-site read always was — a batch
|
|
453
|
+
* of a campaign — and a group multiplies the number of calls, run in
|
|
454
|
+
* parallel, rather than the size of one. A group of one is one call, over
|
|
455
|
+
* the same references in the same order.
|
|
456
|
+
*
|
|
457
|
+
* Rejects when any read does; each caller keeps its own posture on that.
|
|
458
|
+
*/ export async function getAllAcrossSites(db, hostIds, subcollection, keys) {
|
|
459
|
+
return Promise.all(hostIds.map((id)=>{
|
|
460
|
+
const list = db.collection('hosts').doc(id).collection(subcollection);
|
|
461
|
+
return db.getAll(...keys.map((key)=>list.doc(key)));
|
|
462
|
+
}));
|
|
463
|
+
}
|
|
399
464
|
/**
|
|
400
465
|
* The sendable subset for a send made in ONE SITE's name — BOTH lists.
|
|
401
466
|
*
|
|
@@ -413,6 +478,16 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
413
478
|
* live there, and a second copy of them is a second set of rules for two
|
|
414
479
|
* senders to disagree about.
|
|
415
480
|
*
|
|
481
|
+
* ## "This site" means its consent group
|
|
482
|
+
*
|
|
483
|
+
* `group` is the sending site's consent group, and an entry on ANY of its
|
|
484
|
+
* sites' lists withholds — see the section above. Every entry counts, whatever
|
|
485
|
+
* its reason: an unsubscribe and a hand-added opt-out are the person leaving
|
|
486
|
+
* the sender, an erasure is already written on every site of the workspace,
|
|
487
|
+
* and a bounce or a complaint is on the platform list besides. Omitted, the
|
|
488
|
+
* site alone, which is also the whole answer for an org that declared no
|
|
489
|
+
* group.
|
|
490
|
+
*
|
|
416
491
|
* ## Both halves fail CLOSED
|
|
417
492
|
*
|
|
418
493
|
* A read that throws answers "suppressed". The platform half already does; the
|
|
@@ -423,17 +498,17 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
423
498
|
* ## One `getAll`, keyed, rather than a scan of the collection
|
|
424
499
|
*
|
|
425
500
|
* The per-site half looks up exactly the addresses being mailed, by document
|
|
426
|
-
* id, in one round trip. Reading the whole collection instead —
|
|
427
|
-
* the campaign sender did — is bounded by however large the
|
|
428
|
-
* grown, so a site with more suppressions than the read window
|
|
429
|
-
* the remainder: the people most certain not to want the mail
|
|
430
|
-
* truncated read drops.
|
|
431
|
-
*/ export async function filterSendableForHost(hostId, emails, injectedFirestore) {
|
|
501
|
+
* id, in one round trip per site. Reading the whole collection instead —
|
|
502
|
+
* which is what the campaign sender did — is bounded by however large the
|
|
503
|
+
* collection has grown, so a site with more suppressions than the read window
|
|
504
|
+
* fails OPEN on the remainder: the people most certain not to want the mail
|
|
505
|
+
* are the ones a truncated read drops.
|
|
506
|
+
*/ export async function filterSendableForHost(hostId, emails, injectedFirestore, group) {
|
|
507
|
+
const hostIds = optOutHostIds(hostId, group);
|
|
432
508
|
const platformSendable = await filterSuppressedEmails(emails, injectedFirestore);
|
|
433
509
|
// `getAll` rejects an empty reference list, and there is nothing to ask.
|
|
434
510
|
if (!platformSendable.length) return [];
|
|
435
511
|
const db = injectedFirestore != null ? injectedFirestore : defaultFirestore();
|
|
436
|
-
const hostList = db.collection('hosts').doc(hostId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION);
|
|
437
512
|
// Every survivor of the platform half is keyable — `isEmailSuppressed`
|
|
438
513
|
// answers `true` for an address it cannot key — so this narrows the type
|
|
439
514
|
// rather than dropping anybody.
|
|
@@ -447,11 +522,11 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
447
522
|
}
|
|
448
523
|
if (!keyed.length) return [];
|
|
449
524
|
try {
|
|
450
|
-
const
|
|
451
|
-
return keyed.filter((_entry, index)=>{
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
525
|
+
const bySite = await getAllAcrossSites(db, hostIds, HOST_SUPPRESSIONS_SUBCOLLECTION, keyed.map((entry)=>entry.key));
|
|
526
|
+
return keyed.filter((_entry, index)=>!bySite.some((snapshots)=>{
|
|
527
|
+
var _snapshots_index;
|
|
528
|
+
return (_snapshots_index = snapshots[index]) == null ? void 0 : _snapshots_index.exists;
|
|
529
|
+
})).map((entry)=>entry.email);
|
|
455
530
|
} catch (error) {
|
|
456
531
|
console.error('[email-suppression] per-site lookup failed; failing closed', error);
|
|
457
532
|
return [];
|
|
@@ -497,7 +572,18 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
497
572
|
* An empty `topicId` is a campaign from before topics existed, or one whose
|
|
498
573
|
* topic was never resolved. It filters nobody: there is no stream to have
|
|
499
574
|
* left.
|
|
500
|
-
|
|
575
|
+
*
|
|
576
|
+
* ## Leaving a stream is leaving it for the whole consent group
|
|
577
|
+
*
|
|
578
|
+
* The topic catalog is the org's, so "Newsletter" on one site of a declared
|
|
579
|
+
* group is the same stream on its siblings, and a person who left it on one
|
|
580
|
+
* has left it from the sender. `group` extends the read to every site in it,
|
|
581
|
+
* exactly as {@link filterSendableForHost} does for the suppression list.
|
|
582
|
+
*
|
|
583
|
+
* Only a sibling's REFUSAL is read there. A pending confirmation stays the
|
|
584
|
+
* concern of the site whose form asked for it, as it always was.
|
|
585
|
+
*/ export async function filterTopicSendable(hostId, topicId, emails, injectedFirestore, group) {
|
|
586
|
+
const hostIds = optOutHostIds(hostId, group);
|
|
501
587
|
const topic = String(topicId != null ? topicId : '').trim();
|
|
502
588
|
if (!topic || !emails.length) return [
|
|
503
589
|
...emails
|
|
@@ -519,23 +605,22 @@ export const EMAIL_SUPPRESSIONS_COLLECTION = 'emailSuppressions';
|
|
|
519
605
|
];
|
|
520
606
|
try {
|
|
521
607
|
const db = injectedFirestore != null ? injectedFirestore : defaultFirestore();
|
|
522
|
-
const
|
|
523
|
-
|
|
608
|
+
const [own, ...siblings] = await getAllAcrossSites(db, hostIds, TOPIC_OPT_OUTS_SUBCOLLECTION, lookups.map((entry)=>entry.key));
|
|
609
|
+
/*
|
|
610
|
+
* The shared state reader, never a field test written out here.
|
|
611
|
+
*
|
|
612
|
+
* An entry means one of three things and only that function knows all
|
|
613
|
+
* three. The shorthand this replaced — "an entry with no
|
|
614
|
+
* `resubscribedAt` is a live opt-out" — reads a CONFIRMED double
|
|
615
|
+
* opt-in, which carries `pendingAt` and `confirmedAt` and no
|
|
616
|
+
* `resubscribedAt`, as somebody who left.
|
|
617
|
+
*/ const stateIn = (snapshot)=>{
|
|
618
|
+
var _snapshot_get;
|
|
619
|
+
return (snapshot == null ? void 0 : snapshot.exists) ? readTopicSubscriptionState(((_snapshot_get = snapshot.get('topics')) != null ? _snapshot_get : {})[topic]) : 'subscribed';
|
|
620
|
+
};
|
|
524
621
|
const gone = new Set();
|
|
525
622
|
lookups.forEach((entry, index)=>{
|
|
526
|
-
|
|
527
|
-
const snapshot = snapshots[index];
|
|
528
|
-
if (!(snapshot == null ? void 0 : snapshot.exists)) return;
|
|
529
|
-
/*
|
|
530
|
-
* The shared state reader, never a field test written out here.
|
|
531
|
-
*
|
|
532
|
-
* An entry means one of three things and only that function knows all
|
|
533
|
-
* three. The shorthand this replaced — "an entry with no
|
|
534
|
-
* `resubscribedAt` is a live opt-out" — reads a CONFIRMED double
|
|
535
|
-
* opt-in, which carries `pendingAt` and `confirmedAt` and no
|
|
536
|
-
* `resubscribedAt`, as somebody who left.
|
|
537
|
-
*/ const record = ((_snapshot_get = snapshot.get('topics')) != null ? _snapshot_get : {})[topic];
|
|
538
|
-
if (readTopicSubscriptionState(record) !== 'subscribed') {
|
|
623
|
+
if (stateIn(own[index]) !== 'subscribed' || siblings.some((snapshots)=>stateIn(snapshots[index]) === 'opted-out')) {
|
|
539
624
|
gone.add(entry.email);
|
|
540
625
|
}
|
|
541
626
|
});
|