@aglyn/shared-util-email 1.0.0-beta.189 → 1.0.0-beta.191
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/shared-util-email",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.191",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
28
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.191",
|
|
29
|
+
"@aglyn/shared-util-http": "1.0.0-beta.191",
|
|
30
30
|
"@msgpack/msgpack": "^3.1.3",
|
|
31
31
|
"@swc/helpers": "0.5.23"
|
|
32
32
|
},
|
|
@@ -116,6 +116,18 @@ export interface MarketingSendContext {
|
|
|
116
116
|
* mails somebody who unsubscribed from the site next door.
|
|
117
117
|
*/
|
|
118
118
|
consentHostIds: readonly string[];
|
|
119
|
+
/**
|
|
120
|
+
* Whether a confirmation one of {@link consentHostIds} is still waiting on
|
|
121
|
+
* holds this message too — `consentGroupForHost(org, hostId)
|
|
122
|
+
* .awaitsConfirmation`, from the same resolved group. `false` for a site in
|
|
123
|
+
* no declared group, and for every org that has not turned the switch on.
|
|
124
|
+
*
|
|
125
|
+
* The sending site's own pending confirmation holds the message either way;
|
|
126
|
+
* this decides only a sibling's. Required beside {@link consentHostIds} for
|
|
127
|
+
* the reason that one is: a sender that could leave it out would mail
|
|
128
|
+
* somebody the org asked every site of the group to wait on.
|
|
129
|
+
*/
|
|
130
|
+
consentAwaitsConfirmation: boolean;
|
|
119
131
|
}
|
|
120
132
|
/** What the gate is asked, once per marketing message. */
|
|
121
133
|
export interface MarketingSendGateRequest {
|
|
@@ -139,6 +151,11 @@ export interface MarketingSendGateRequest {
|
|
|
139
151
|
* not name `hostId`, is the site alone.
|
|
140
152
|
*/
|
|
141
153
|
consentHostIds?: readonly string[];
|
|
154
|
+
/**
|
|
155
|
+
* See {@link MarketingSendContext.consentAwaitsConfirmation}. Only `true`
|
|
156
|
+
* lets a sibling's pending confirmation hold the message.
|
|
157
|
+
*/
|
|
158
|
+
consentAwaitsConfirmation?: boolean;
|
|
142
159
|
}
|
|
143
160
|
/** Why a marketing message was not sent, or `null` when it may go. */
|
|
144
161
|
export type MarketingSendRefusal =
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/marketing-send.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 policy half.\n *\n * A message is MARKETING when a merchant's audience receives it because the\n * merchant decided to mail them, rather than because the recipient just did\n * something. Campaigns are the obvious case; so are a member post, an\n * abandoned-cart reminder, a back-in-stock alert and a merchant-authored\n * workflow email.\n *\n * ## The three things marketing mail owes, and where they were\n *\n * Before this module they were owed by every bulk sender and discharged by\n * exactly one of them:\n *\n * 1. **An unsubscribe mechanism.** `List-Unsubscribe` plus\n * `List-Unsubscribe-Post` is what Gmail's and Yahoo's bulk-sender rules\n * ask for, and a visible link is what CAN-SPAM asks for. The campaign\n * sender adds both; the shared `sendEmail` added neither, so four\n * merchant-triggered bulk paths mailed people with no way out.\n * 2. **A suppression check.** An address that hard-bounced or pressed\n * \"report spam\" must not be mailed again. Those four paths consulted\n * neither list.\n * 3. **A ceiling on how much one person receives.** There was none at any\n * scope, so a single recipient could take a campaign, a cart reminder, a\n * restock alert, a member post and a workflow email in one day.\n *\n * All three are the same shape — a question asked once, per recipient, at the\n * moment a marketing message is about to leave — so they are one seam rather\n * than three, and `sendEmail` asks it whenever a caller declares\n * {@link MarketingSendContext}.\n *\n * ## Why the gate is injected rather than imported\n *\n * The same split as the send-rate governor beside it. Answering the question\n * needs Firestore: two suppression collections and a per-recipient counter.\n * The durable half therefore lives in `@aglyn/tenant-data-admin`, which is\n * the only layer that may hold the Admin SDK — this library is `scope:shared`\n * and the dependency edge already runs the other way. So the gate reaches\n * `sendEmail` through {@link setMarketingSendGate}.\n *\n * Nothing installed means UNGATED, not refused: a unit test, a preview build\n * and a self-host that never installs one all keep sending. A control that\n * turns its own absence into an outage is worse than the burst it guards.\n *\n * ## Why this file holds no crypto and mints no URL\n *\n * The unsubscribe URL is an HMAC over the recipient's address, and\n * `node:crypto` cannot ride into a browser bundle — console components import\n * this library's barrel. The gate returns the minted URL instead, from the\n * server layer that already signs one.\n */\n\n/*\n * `send-rate.ts` imports nothing, so this cannot be half of a cycle — which\n * matters, because this module is imported by `send-email.ts` alongside that\n * one and a cycle between the two would resolve differently under swc and\n * jest.\n */\nimport { escapeEmailHtml } from './email-render'\nimport { resolveSendPriority, type EmailSendPriority } from './send-rate'\nimport { listUnsubscribeHeaders } from './list-unsubscribe'\n\n/** What a marketing send declares about itself. */\nexport interface MarketingSendContext {\n /** The site whose audience this is. Both suppression lists key on it. */\n hostId: string\n /**\n * The site's public origin, for the unsubscribe link.\n *\n * The CALLER resolves it — through `hostPublicOrigin`, from the host\n * document — because the shared subdomain policy lives in the framework\n * library and neither this module nor the gate may import it. Empty when a\n * host has no custom domain and no subdomain, which is a site nobody can\n * reach; the send then carries no unsubscribe URL and says so in the logs\n * rather than minting a link that resolves to nothing.\n */\n siteBase: string\n /**\n * An unsubscribe URL the caller has already minted, and the campaign it\n * belongs to.\n *\n * The campaign sender builds its own, because the same URL has to reach the\n * designed template as an `{{unsubscribeUrl}}` merge value long before the\n * message is handed over. Supplied, it is used verbatim and nothing is\n * minted — one link per message, whoever made it.\n */\n unsubscribeUrl?: string\n /**\n * The `List-Unsubscribe` URL, when the caller minted its own pair.\n *\n * Separate from {@link unsubscribeUrl} because the two are read by\n * different readers. This one is POSTed by a mailbox provider with nobody\n * present, so it must name the route that writes on POST; the other is\n * clicked by a person, so it names the preference page. Absent, the header\n * falls back to `unsubscribeUrl` — which is right for a caller that minted\n * only one link, and wrong only for one that minted a page and did not say\n * so.\n */\n oneClickUrl?: string\n /**\n * Whether a frequency cap may refuse this message.\n *\n * `false` for a campaign. A campaign is a merchant's deliberate, reviewed\n * act with a recipient count on screen before they press Send; a cap that\n * silently removed people from it would make that number a lie, and a\n * one-shot send has nowhere to hold the remainder for later. So a campaign\n * COUNTS toward what a recipient has received — it is most of the load a\n * person feels — and yields the refusal to the automated paths, which fire\n * with no human present and are the ones that stack.\n *\n * Defaults to true: an unmarked caller is a machine.\n */\n capped?: boolean\n /**\n * The STREAM this message belongs to, so a recipient who left that stream\n * is not mailed it.\n *\n * ## Absent refuses nobody, and that is the whole safety property\n *\n * A topic opt-out governs marketing STREAMS — \"Promotions and offers\",\n * \"Newsletter\". It is not a thing anybody can untick off a receipt, a\n * password reset or a booking confirmation, and a gate that refused those\n * on a topic preference would be the failure mode this control is most\n * capable of causing. Two guards make that unreachable rather than\n * unlikely:\n *\n * 1. transactional mail declares no `marketing` context at all, so it\n * never reaches the gate; and\n * 2. a marketing caller that names no stream gets no topic refusal here.\n * `filterTopicSendable` already reads an empty topic that way — there\n * is no stream to have left — and this preserves it rather than\n * defaulting to one.\n *\n * So the check binds exactly the senders that declare what they are, which\n * is the same polarity `isMarketingMessage` chose: enumerate what is\n * restricted, and a forgotten caller sends rather than silently drops.\n *\n * ## Why the gate asks it at all, when the consent split does not\n *\n * The two used to travel together in `email-flow-gate.ts`, and they are not\n * the same kind of fact. The consent split is the ORG's policy over its own\n * audience, which is why it stays a caller-side question. A topic opt-out\n * is the RECIPIENT talking to the platform, recorded by the same preference\n * page that records their cadence and reached by the same\n * `List-Unsubscribe` link — so it belongs with the suppression and cadence\n * checks, at the one chokepoint every marketing message crosses.\n */\n topicId?: string\n /**\n * Every site in the sending site's CONSENT GROUP, the sending site included\n * — `consentGroupForHost(org, hostId).hostIds`, from the org the caller\n * already holds. `[hostId]` for a site in no declared group.\n *\n * An org may declare several sites one sender, and a person who left one\n * of them left the sender: the gate withholds this message on an\n * unsubscribe, a stream left, or a pace asked for on ANY of these sites.\n *\n * Required, and resolved by the caller rather than by the gate, for two\n * reasons that agree. The gate would otherwise spend an org read on every\n * marketing message to learn what every caller here has already read; and\n * a sender that could leave it out would leave it out, which silently\n * mails somebody who unsubscribed from the site next door.\n */\n consentHostIds: readonly string[]\n}\n\n/** What the gate is asked, once per marketing message. */\nexport interface MarketingSendGateRequest {\n hostId: string\n siteBase: string\n /** The single recipient. A marketing message addresses exactly one person. */\n email: string\n /** The caller's `context` label, for the log line on a refusal. */\n context?: string\n /**\n * The stream this message belongs to, or absent for a message that belongs\n * to none. See {@link MarketingSendContext.topicId} — absent refuses\n * nobody.\n */\n topicId?: string\n /** Whether a frequency cap may refuse — see {@link MarketingSendContext}. */\n capped: boolean\n /**\n * The sender's consent group — see\n * {@link MarketingSendContext.consentHostIds}. Absent, or a list that does\n * not name `hostId`, is the site alone.\n */\n consentHostIds?: readonly string[]\n}\n\n/** Why a marketing message was not sent, or `null` when it may go. */\nexport type MarketingSendRefusal =\n /** On a suppression list: unsubscribed, hard-bounced, or a complaint. */\n | 'suppressed'\n /**\n * They have left the STREAM this message belongs to.\n *\n * Kept apart from `suppressed` even though both are terminal for this\n * message, because they describe different people: somebody on a\n * suppression list has left the site, and somebody here has left one of its\n * streams and still wants the others. A merchant reading why a send shrank\n * has a different thing to do about each, and only one of them is a list\n * that has to be rebuilt.\n *\n * NOT retryable — the condition clears when the person re-subscribes, not\n * when time passes.\n */\n | 'topic-unsubscribed'\n /** This person has already received their ceiling from this site. */\n | 'frequency-capped'\n /**\n * The RECIPIENT asked for less than this, and it has not been long enough.\n *\n * Kept apart from `frequency-capped` even though both are refusals about\n * pace, because they are refusals on behalf of different people: the\n * ceiling protects a shared sending domain from a merchant, and this\n * carries out a request the person on the other end made on the preference\n * page. Reporting them as one would make \"why did this not send\" answerable\n * only by guessing, and it is the merchant-facing half of a promise the\n * product made to a recipient.\n */\n | 'cadence-limited'\n /**\n * This site has been mailing this person for longer than the sunset window\n * and nothing in that window says they are still listening.\n *\n * NOT retryable by a sweep — the condition does not clear on a schedule,\n * it clears when the person engages. See {@link marketingSunsetVerdict}.\n */\n | 'unengaged'\n\nexport interface MarketingSendGateVerdict {\n allowed: boolean\n /** Set when `allowed` is false. */\n refusal?: MarketingSendRefusal\n /** Human-readable, for the log and the `detail` on the result. */\n detail?: string\n /**\n * The signed opt-out URL a PERSON clicks, when the gate could mint one.\n * Points at the preference page, where the stream this message belongs to\n * is one of the things they can stop instead of all of it. Absent leaves\n * the message without unsubscribe headers, which is a misconfiguration to\n * fix and not a reason to refuse mail.\n */\n unsubscribeUrl?: string\n /**\n * The same signature over the one-click route, for `List-Unsubscribe`.\n *\n * A mailbox provider POSTs that header with no human present and expects\n * the act to have happened when it reads the 200, so it must never name a\n * page of checkboxes somebody has to submit. Absent, the header falls back\n * to {@link unsubscribeUrl}.\n */\n oneClickUrl?: string\n}\n\n/**\n * Whether a message is MARKETING, derived from what it already carries.\n *\n * ## Nothing new to remember, on purpose\n *\n * The obvious design is a `kind: 'marketing' | 'transactional'` option on\n * `sendEmail`. It is rejected for the reason the `from` override was deleted\n * and the `context` tag was derived rather than threaded: twenty call sites\n * cannot each be relied on to set a field, and the twenty-first is the one that\n * does not. The consequence of forgetting here is a merchant's campaign leaving\n * on the pooled identity that carries every other site's password resets — a\n * failure nobody sees until the pool stops delivering.\n *\n * So the answer is read off two things a marketing send is ALREADY obliged to\n * carry, neither of which is optional and neither of which was added for this:\n *\n * 1. **`marketing`** — the context object. A message that declares it gets the\n * RFC 8058 header pair, the suppression check and the frequency cap, so a\n * marketing sender cannot omit it and still be correct; it would be shipping\n * mail with no unsubscribe link. Four of the five marketing senders in the\n * tree take this arm.\n * 2. **`priority === 'campaign'`** — which `resolveSendPriority` derives from\n * `context: 'campaign'`. The campaign sender mints its own unsubscribe\n * headers upstream and so passes no `marketing` context, but it is the one\n * sender that cannot avoid this label: the hourly governor is allowed to\n * refuse a campaign, and a campaign that hid from the priority would be\n * hiding from that too.\n *\n * A sender would have to defeat BOTH — no unsubscribe context and no campaign\n * priority — to reach the pool with promotional mail, and a message in that\n * state is already broken in ways its author would notice.\n *\n * ## Polarity\n *\n * A message matching neither is transactional, which is the permissive answer,\n * and that is deliberate — the same choice `resolveSendPriority` makes for the\n * same reason. Enumerating what is RESTRICTED means a forgotten caller sends a\n * receipt that goes out. Enumerating what is PERMITTED means a forgotten caller\n * drops one, and a dropped password reset is the failure you learn about from a\n * support ticket.\n */\nexport function isMarketingMessage(options: {\n marketing?: unknown\n priority?: string | null\n context?: string | null\n}): boolean {\n if (options?.marketing) return true\n // `context` is consulted only through the same rule the governor uses, so a\n // context that stops meaning \"campaign\" cannot leave the two disagreeing.\n return resolveSendPriority(\n options?.context ?? undefined,\n (options?.priority as EmailSendPriority) || undefined,\n ) === 'campaign'\n}\n\nexport type MarketingSendGate = (\n request: MarketingSendGateRequest,\n) => Promise<MarketingSendGateVerdict>\n\nlet installedGate: MarketingSendGate | null = null\n\n/** Installs the durable gate. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setMarketingSendGate(gate: MarketingSendGate | null): void {\n installedGate = gate\n}\n\n/** The installed gate, or null when nothing has been installed. */\nexport function getMarketingSendGate(): MarketingSendGate | null {\n return installedGate\n}\n\n/** Test seam: forget any installed gate. */\nexport function resetMarketingSendGateForTests(): void {\n installedGate = null\n}\n\n/**\n * The rolling window a frequency cap counts over. One day, because that is\n * the unit a recipient experiences (\"this shop mailed me four times today\")\n * and the unit every published vendor cap is expressed in.\n */\nexport const MARKETING_FREQUENCY_WINDOW_MS = 86_400_000\n\n/**\n * How many marketing messages one person may receive from one site inside the\n * window, by default.\n *\n * **A runaway guard, not a marketing policy.** It is deliberately above what\n * an ordinary merchant produces: the worst legitimate day is a campaign plus\n * a cart reminder plus a restock alert plus a member post, which is four. Set\n * lower and the first thing it would refuse is a real message somebody meant\n * to send; set higher and it stops describing a ceiling at all. What it does\n * remove is the unbounded case — a member post that mails 200 subscribers per\n * click with no limit on clicks, and a workflow whose email step fires on\n * every anonymous form submission.\n *\n * The same number for every plan. This is a deliverability control on a\n * shared sending domain, so it protects every tenant from every other tenant\n * and cannot be something one plan buys its way past.\n */\nexport const MARKETING_FREQUENCY_DEFAULT_PER_WINDOW = 5\n\n/** Floor and ceiling on a configured cap — a typo guard, not a policy. */\nexport const MARKETING_FREQUENCY_MIN_PER_WINDOW = 1\nexport const MARKETING_FREQUENCY_MAX_PER_WINDOW = 1_000\n\n/**\n * The live cap.\n *\n * Read from the environment so a self-host operator can set their own —\n * `docs/design` states that every dependency is configurable — and read per\n * call rather than captured at module load, matching `getEmailConfig`: these\n * run in serverless handlers where the module may be evaluated during a build.\n *\n * An unparseable or out-of-range value falls back to the default rather than\n * throwing or disabling the cap. A control that a typo can switch off is not\n * a control.\n */\nexport function marketingFrequencyCap(): number {\n const raw = Number(process.env.AGLYN_EMAIL_MARKETING_CAP_PER_DAY)\n if (!Number.isFinite(raw)) return MARKETING_FREQUENCY_DEFAULT_PER_WINDOW\n const whole = Math.floor(raw)\n if (\n whole < MARKETING_FREQUENCY_MIN_PER_WINDOW ||\n whole > MARKETING_FREQUENCY_MAX_PER_WINDOW\n ) {\n return MARKETING_FREQUENCY_DEFAULT_PER_WINDOW\n }\n return whole\n}\n\n/**\n * Whether one more message fits, given what this person has already received.\n *\n * Pure, so the decision is testable without a Firestore harness — the same\n * split `emailSendRateVerdict` makes for the platform hour.\n *\n * @param recentSendsAtMs every marketing send to this person from this site,\n * newest or oldest first, in any order.\n * @returns the verdict and the trimmed window, so the caller writes back only\n * what still counts instead of growing the record forever.\n */\nexport function marketingFrequencyVerdict(\n recentSendsAtMs: readonly number[],\n nowMs: number,\n cap: number = marketingFrequencyCap(),\n): { allowed: boolean; used: number; cap: number; inWindow: number[] } {\n const floor = nowMs - MARKETING_FREQUENCY_WINDOW_MS\n const inWindow = recentSendsAtMs\n .filter((at) => Number.isFinite(at) && at > floor && at <= nowMs)\n .sort((a, b) => a - b)\n // Keep the NEWEST when a record has somehow grown past the cap. The\n // oldest entries are the ones about to leave the window anyway, so\n // dropping them loses the least information about when this person is\n // mailable again.\n .slice(-Math.max(cap, MARKETING_FREQUENCY_MIN_PER_WINDOW))\n return {\n allowed: inWindow.length < cap,\n used: inWindow.length,\n cap,\n inWindow,\n }\n}\n\n/*==========================================\n * ENGAGEMENT-BASED SUNSETTING.\n *\n * Stop mailing an address that has gone quiet. The whole design fits in one\n * sentence and three refusals it must never become.\n *\n * ## ⛔ It refuses a SEND. It never reduces a person.\n *\n * `over-limit.ts` states the rule for capacity and this is the same\n * instrument pointed at attention rather than at count. A sunset does NOT\n * unsubscribe anybody, does NOT remove them from a list, does NOT delete a\n * contact and does NOT write a suppression. Every one of those would be a\n * ceiling performing a deletion, and every one of them is irreversible in a\n * way the condition that triggered it is not.\n *\n * ## It is reversible without anybody doing anything\n *\n * The only state is two timestamps: when this site first mailed this person,\n * and when this person last engaged with our mail. A person who opens or\n * clicks anything moves the second one, and the very next send finds them\n * inside the window and mailable. Nothing has to be undone, because nothing\n * was done.\n *\n * ## The three ways it could refuse somebody it should not, and the guards\n *\n * 1. **A brand-new subscriber has no engagement yet.** So the window is\n * measured from when we STARTED mailing them, and somebody we have not\n * been mailing for longer than the window is never refused, however\n * little they have engaged.\n * 2. **We might have no record at all.** An unknown `firstSentAtMs` refuses\n * nobody. Missing evidence is not evidence of absence, and the reading\n * that mails somebody once more is the recoverable one.\n * 3. **A campaign is a reviewed act with its recipient count on screen.**\n * Sunsetting yields to it exactly as the frequency ceiling does, through\n * the same `capped` flag, so the number a merchant read before pressing\n * Send stays true. It governs the automated paths, which fire with no\n * human present.\n *\n * ## Opens are a weak signal and this leans on the broader one anyway\n *\n * The industry lesson is to sunset on clicks, because Apple's Mail Privacy\n * Protection prefetches images and inflates opens. That argument is about\n * choosing an audience. This is a refusal, and for a refusal the weaker,\n * more generous signal is the correct one: counting an open as engagement\n * refuses FEWER people. The audience rules\n * (`DynamicListEngagement`) keep opens and clicks apart so a merchant\n * segmenting on engagement can lean on clicks; this deliberately does not.\n *=========================================*/\n\n/** Floor and ceiling on a configured sunset window — a typo guard. */\nexport const MARKETING_SUNSET_MIN_DAYS = 30\nexport const MARKETING_SUNSET_MAX_DAYS = 3_650\n\n/**\n * The window, in days, or 0 for OFF.\n *\n * **Off unless an operator turns it on**, and that default is the honest one:\n * no compared vendor automates this, so a platform that silently stopped\n * mailing a merchant's quiet subscribers would be doing something none of\n * their previous tools did and none of their recipients asked for. The value\n * is a number of days rather than a boolean because the only interesting\n * question about a sunset is where it starts.\n *\n * Read per call rather than captured at module load, matching the frequency\n * cap beside it: these run in serverless handlers where the module may be\n * evaluated during a build.\n *\n * An unparseable or out-of-range value reads as OFF rather than falling back\n * to a default. This is the opposite of {@link marketingFrequencyCap}'s\n * handling and deliberately so — a typo there weakens a guard that is on by\n * default, and a typo here would ENABLE a refusal nobody asked for.\n */\nexport function marketingSunsetDays(): number {\n const raw = Number(process.env.AGLYN_EMAIL_SUNSET_AFTER_DAYS)\n if (!Number.isFinite(raw)) return 0\n const whole = Math.floor(raw)\n if (whole < MARKETING_SUNSET_MIN_DAYS || whole > MARKETING_SUNSET_MAX_DAYS) {\n return 0\n }\n return whole\n}\n\n/** What the sunset needs to know about one person. */\nexport interface MarketingSunsetFacts {\n /**\n * When this site first sent this person marketing mail, or null when we\n * have no record. Null never refuses.\n */\n firstSentAtMs: number | null\n /** When they last opened or clicked any of our mail, or null for never. */\n lastEngagedAtMs: number | null\n}\n\n/**\n * Whether this person has gone quiet for longer than the window.\n *\n * Pure, so the decision is testable without a Firestore harness — the same\n * split {@link marketingFrequencyVerdict} makes.\n *\n * @param days 0 disables the sunset entirely and this always allows.\n */\nexport function marketingSunsetVerdict(\n facts: MarketingSunsetFacts,\n nowMs: number,\n days: number = marketingSunsetDays(),\n): { allowed: boolean; days: number; quietForDays: number | null } {\n if (!days || days <= 0) {\n return { allowed: true, days: 0, quietForDays: null }\n }\n const floor = nowMs - days * 86_400_000\n const first = Number(facts.firstSentAtMs ?? 0)\n // Guard 1 and 2 together: no record, or a relationship younger than the\n // window, allows. A person cannot have been quiet for longer than we have\n // been mailing them.\n if (!Number.isFinite(first) || first <= 0 || first >= floor) {\n return { allowed: true, days, quietForDays: null }\n }\n const engaged = Number(facts.lastEngagedAtMs ?? 0)\n if (Number.isFinite(engaged) && engaged > 0 && engaged >= floor) {\n return { allowed: true, days, quietForDays: null }\n }\n const since = Number.isFinite(engaged) && engaged > 0 ? engaged : first\n return {\n allowed: false,\n days,\n quietForDays: Math.floor((nowMs - since) / 86_400_000),\n }\n}\n\n/**\n * HOW OFTEN THE RECIPIENT ASKED TO HEAR FROM THIS SITE.\n *\n * `docs/specs/email-competitive-gaps.md` G10 shipped its cap half and left\n * this one: unsubscribe was all-or-nothing plus, since topics, per-stream —\n * and a recipient who wanted the same mail LESS OFTEN still had only two\n * levers, one of which is the spam button. On a shared sending domain under\n * `p=reject` that button is charged to every other tenant, which is what\n * makes \"monthly\" a platform control wearing a courtesy's clothes.\n *\n * ## A minimum interval, not a second rolling window\n *\n * {@link marketingFrequencyVerdict} counts messages inside a day because the\n * thing it guards against is a burst. This guards against a DRIP, and the\n * question a drip asks is \"how long since the last one\" — one stored instant,\n * not a window that would have to be kept for a month to answer a monthly\n * choice. Two instruments, because they are two different questions.\n *\n * ## New values, so the vocabulary is chosen rather than inherited\n *\n * `'all'` is the default and the absence: a record with no cadence, and every\n * record written before this existed, means the person has expressed no\n * preference — which is not the same as having asked for everything, but is\n * the only reading that does not silently withhold mail from people who never\n * chose.\n */\nexport type MarketingCadence = 'all' | 'daily' | 'weekly' | 'monthly'\n\n/** The default: no expressed preference, so only the platform ceiling binds. */\nexport const DEFAULT_MARKETING_CADENCE: MarketingCadence = 'all'\n\n/**\n * The minimum gap each choice asks for, in millis.\n *\n * Calendar-naive on purpose. \"At most one a week\" is a promise about pace,\n * and honoring it as seven days from the last message is both what the words\n * say and what a recipient can check; anchoring it to a calendar week would\n * let two messages land on a Sunday and a Monday and still be \"one a week\".\n */\nexport const MARKETING_CADENCE_INTERVAL_MS: Record<MarketingCadence, number> = {\n all: 0,\n daily: 86_400_000,\n weekly: 7 * 86_400_000,\n monthly: 30 * 86_400_000,\n}\n\n/** How each choice reads, wherever one is named to a person or an operator. */\nexport const MARKETING_CADENCE_LABELS: Record<MarketingCadence, string> = {\n all: 'As they come',\n daily: 'At most one a day',\n weekly: 'At most one a week',\n monthly: 'At most one a month',\n}\n\n/**\n * Coerces a stored or submitted value to a cadence.\n *\n * Everything unrecognized becomes {@link DEFAULT_MARKETING_CADENCE}. The\n * direction matters and is the opposite of the consent policy's: a malformed\n * consent value must not become a way to switch enforcement off, because its\n * failure mode is mail to somebody who declined. A malformed cadence falling\n * to `'monthly'` would withhold mail from everybody whose record got\n * corrupted, and nobody asked for that either — so an unreadable preference\n * reads as no preference, and the person keeps whatever the ceiling allows.\n */\nexport function normalizeMarketingCadence(value: unknown): MarketingCadence {\n return value === 'daily' || value === 'weekly' || value === 'monthly'\n ? value\n : DEFAULT_MARKETING_CADENCE\n}\n\n/**\n * Whether enough time has passed for one more message at this pace.\n *\n * Pure, so the rule can be asserted without a Firestore harness — the same\n * split {@link marketingFrequencyVerdict} makes.\n *\n * @param lastSentAtMs when this site last sent this person marketing mail, or\n * `null` for somebody it has never mailed. Never mailed always allows:\n * a cadence is a gap between messages and there is no first gap.\n * @returns the verdict and, on a refusal, the instant the next message may go\n * — so a caller that defers has something to defer UNTIL rather than\n * a retry loop that discovers the answer by asking again.\n */\nexport function marketingCadenceVerdict(\n cadence: MarketingCadence,\n lastSentAtMs: number | null | undefined,\n nowMs: number,\n): { allowed: boolean; cadence: MarketingCadence; nextAllowedAtMs: number } {\n const interval = MARKETING_CADENCE_INTERVAL_MS[cadence] ?? 0\n const last = Number(lastSentAtMs)\n if (!interval || !Number.isFinite(last) || last <= 0) {\n return { allowed: true, cadence, nextAllowedAtMs: nowMs }\n }\n const nextAllowedAtMs = last + interval\n /*\n * A stored instant in the FUTURE allows rather than refusing until it\n * passes. Clocks disagree across processes and a record written a few\n * seconds ahead would otherwise hold a recipient's mail for a whole\n * interval, which is a much larger error than the one it would prevent.\n */\n if (last > nowMs) return { allowed: true, cadence, nextAllowedAtMs: nowMs }\n return { allowed: nowMs >= nextAllowedAtMs, cadence, nextAllowedAtMs }\n}\n\n/**\n * The RFC 8058 header pair.\n *\n * Both or neither: `List-Unsubscribe` alone does not satisfy the bulk-sender\n * rules, and `List-Unsubscribe-Post` without a URL to post to advertises a\n * verb nothing serves.\n */\nexport function unsubscribeHeaders(\n unsubscribeUrl: string,\n): Record<string, string> {\n if (!unsubscribeUrl) return {}\n return listUnsubscribeHeaders({ url: unsubscribeUrl })\n}\n\n/**\n * What the visible opt-out is CALLED, in every part of every message.\n *\n * The link opens the preference page, where leaving one stream is a choice\n * alongside leaving all of them — and a footer that says only \"Unsubscribe\"\n * is the only place a recipient would have learned that, so it never gets\n * said. The word \"unsubscribe\" stays in the line because that is what a\n * recipient scans a footer for.\n *\n * Named once and shared with `renderRecipientEmail`, which writes this same\n * line into a campaign's text part: two spellings of one sentence is how the\n * idempotency checks below come to append a second footer to a message that\n * already had one.\n */\nexport const UNSUBSCRIBE_FOOTER_LABEL =\n 'Choose which emails you get, or unsubscribe'\n\n/**\n * The visible opt-out, appended to the plain-text part.\n *\n * The headers are for the mailbox provider; this is for the person. CAN-SPAM\n * asks for a mechanism the recipient can see and use, and a header is neither\n * — most clients render no control for it at all unless the sender's domain\n * reputation is high enough for the provider to offer one.\n *\n * Idempotent by URL: a body that already carries the link is returned\n * untouched, so a sender that writes its own footer does not get two.\n */\nexport function appendUnsubscribeText(\n text: string,\n unsubscribeUrl: string,\n): string {\n if (!unsubscribeUrl || (text && text.includes(unsubscribeUrl))) return text\n return `${text ?? ''}\\n\\n—\\n${UNSUBSCRIBE_FOOTER_LABEL}: ${unsubscribeUrl}`\n}\n\n/**\n * The same visible opt-out for the HTML part.\n *\n * Appended to whatever the sender produced rather than woven into it, because\n * the HTML may be a merchant-designed template this module knows nothing\n * about. A designed template that already renders `{{unsubscribeUrl}}`\n * carries the URL, so the check below leaves it alone and the merchant's own\n * placement wins.\n *\n * ⚠️ That check has to look for the ESCAPED URL as well. A signed opt-out\n * link carries `&` between its query parameters, and a renderer putting it\n * into an `href` escapes it — so `renderEmailHtml` emits `…&sig=…` and a\n * plain `includes` of the unescaped URL matches nothing. Every designed\n * template in the product goes through that renderer, which means the\n * merchants who DID place the token were the ones getting two footers.\n *\n * Styles are inline and literal because this is email HTML: mail clients\n * strip `<style>` blocks and support no CSS variables, so a theme token\n * cannot reach the wire.\n */\nexport function appendUnsubscribeHtml(\n html: string,\n unsubscribeUrl: string,\n): string {\n if (!unsubscribeUrl) return html\n if (\n html &&\n (html.includes(unsubscribeUrl) ||\n html.includes(escapeEmailHtml(unsubscribeUrl)))\n ) {\n return html\n }\n const footer =\n '<div style=\"margin:24px auto 0;max-width:600px;padding:16px 24px;' +\n \"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,\" +\n 'Helvetica,Arial,sans-serif;font-size:12px;line-height:1.5;' +\n 'color:#616161;text-align:center\">' +\n `<a href=\"${escapeEmailHtml(unsubscribeUrl)}\" style=\"color:#616161\">` +\n `${UNSUBSCRIBE_FOOTER_LABEL}</a>` +\n '</div>'\n return `${html ?? ''}${footer}`\n}\n"],"names":["escapeEmailHtml","resolveSendPriority","listUnsubscribeHeaders","isMarketingMessage","options","marketing","context","undefined","priority","installedGate","setMarketingSendGate","gate","getMarketingSendGate","resetMarketingSendGateForTests","MARKETING_FREQUENCY_WINDOW_MS","MARKETING_FREQUENCY_DEFAULT_PER_WINDOW","MARKETING_FREQUENCY_MIN_PER_WINDOW","MARKETING_FREQUENCY_MAX_PER_WINDOW","marketingFrequencyCap","raw","Number","process","env","AGLYN_EMAIL_MARKETING_CAP_PER_DAY","isFinite","whole","Math","floor","marketingFrequencyVerdict","recentSendsAtMs","nowMs","cap","inWindow","filter","at","sort","a","b","slice","max","allowed","length","used","MARKETING_SUNSET_MIN_DAYS","MARKETING_SUNSET_MAX_DAYS","marketingSunsetDays","AGLYN_EMAIL_SUNSET_AFTER_DAYS","marketingSunsetVerdict","facts","days","quietForDays","first","firstSentAtMs","engaged","lastEngagedAtMs","since","DEFAULT_MARKETING_CADENCE","MARKETING_CADENCE_INTERVAL_MS","all","daily","weekly","monthly","MARKETING_CADENCE_LABELS","normalizeMarketingCadence","value","marketingCadenceVerdict","cadence","lastSentAtMs","interval","last","nextAllowedAtMs","unsubscribeHeaders","unsubscribeUrl","url","UNSUBSCRIBE_FOOTER_LABEL","appendUnsubscribeText","text","includes","appendUnsubscribeHtml","html","footer"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/marketing-send.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 policy half.\n *\n * A message is MARKETING when a merchant's audience receives it because the\n * merchant decided to mail them, rather than because the recipient just did\n * something. Campaigns are the obvious case; so are a member post, an\n * abandoned-cart reminder, a back-in-stock alert and a merchant-authored\n * workflow email.\n *\n * ## The three things marketing mail owes, and where they were\n *\n * Before this module they were owed by every bulk sender and discharged by\n * exactly one of them:\n *\n * 1. **An unsubscribe mechanism.** `List-Unsubscribe` plus\n * `List-Unsubscribe-Post` is what Gmail's and Yahoo's bulk-sender rules\n * ask for, and a visible link is what CAN-SPAM asks for. The campaign\n * sender adds both; the shared `sendEmail` added neither, so four\n * merchant-triggered bulk paths mailed people with no way out.\n * 2. **A suppression check.** An address that hard-bounced or pressed\n * \"report spam\" must not be mailed again. Those four paths consulted\n * neither list.\n * 3. **A ceiling on how much one person receives.** There was none at any\n * scope, so a single recipient could take a campaign, a cart reminder, a\n * restock alert, a member post and a workflow email in one day.\n *\n * All three are the same shape — a question asked once, per recipient, at the\n * moment a marketing message is about to leave — so they are one seam rather\n * than three, and `sendEmail` asks it whenever a caller declares\n * {@link MarketingSendContext}.\n *\n * ## Why the gate is injected rather than imported\n *\n * The same split as the send-rate governor beside it. Answering the question\n * needs Firestore: two suppression collections and a per-recipient counter.\n * The durable half therefore lives in `@aglyn/tenant-data-admin`, which is\n * the only layer that may hold the Admin SDK — this library is `scope:shared`\n * and the dependency edge already runs the other way. So the gate reaches\n * `sendEmail` through {@link setMarketingSendGate}.\n *\n * Nothing installed means UNGATED, not refused: a unit test, a preview build\n * and a self-host that never installs one all keep sending. A control that\n * turns its own absence into an outage is worse than the burst it guards.\n *\n * ## Why this file holds no crypto and mints no URL\n *\n * The unsubscribe URL is an HMAC over the recipient's address, and\n * `node:crypto` cannot ride into a browser bundle — console components import\n * this library's barrel. The gate returns the minted URL instead, from the\n * server layer that already signs one.\n */\n\n/*\n * `send-rate.ts` imports nothing, so this cannot be half of a cycle — which\n * matters, because this module is imported by `send-email.ts` alongside that\n * one and a cycle between the two would resolve differently under swc and\n * jest.\n */\nimport { escapeEmailHtml } from './email-render'\nimport { resolveSendPriority, type EmailSendPriority } from './send-rate'\nimport { listUnsubscribeHeaders } from './list-unsubscribe'\n\n/** What a marketing send declares about itself. */\nexport interface MarketingSendContext {\n /** The site whose audience this is. Both suppression lists key on it. */\n hostId: string\n /**\n * The site's public origin, for the unsubscribe link.\n *\n * The CALLER resolves it — through `hostPublicOrigin`, from the host\n * document — because the shared subdomain policy lives in the framework\n * library and neither this module nor the gate may import it. Empty when a\n * host has no custom domain and no subdomain, which is a site nobody can\n * reach; the send then carries no unsubscribe URL and says so in the logs\n * rather than minting a link that resolves to nothing.\n */\n siteBase: string\n /**\n * An unsubscribe URL the caller has already minted, and the campaign it\n * belongs to.\n *\n * The campaign sender builds its own, because the same URL has to reach the\n * designed template as an `{{unsubscribeUrl}}` merge value long before the\n * message is handed over. Supplied, it is used verbatim and nothing is\n * minted — one link per message, whoever made it.\n */\n unsubscribeUrl?: string\n /**\n * The `List-Unsubscribe` URL, when the caller minted its own pair.\n *\n * Separate from {@link unsubscribeUrl} because the two are read by\n * different readers. This one is POSTed by a mailbox provider with nobody\n * present, so it must name the route that writes on POST; the other is\n * clicked by a person, so it names the preference page. Absent, the header\n * falls back to `unsubscribeUrl` — which is right for a caller that minted\n * only one link, and wrong only for one that minted a page and did not say\n * so.\n */\n oneClickUrl?: string\n /**\n * Whether a frequency cap may refuse this message.\n *\n * `false` for a campaign. A campaign is a merchant's deliberate, reviewed\n * act with a recipient count on screen before they press Send; a cap that\n * silently removed people from it would make that number a lie, and a\n * one-shot send has nowhere to hold the remainder for later. So a campaign\n * COUNTS toward what a recipient has received — it is most of the load a\n * person feels — and yields the refusal to the automated paths, which fire\n * with no human present and are the ones that stack.\n *\n * Defaults to true: an unmarked caller is a machine.\n */\n capped?: boolean\n /**\n * The STREAM this message belongs to, so a recipient who left that stream\n * is not mailed it.\n *\n * ## Absent refuses nobody, and that is the whole safety property\n *\n * A topic opt-out governs marketing STREAMS — \"Promotions and offers\",\n * \"Newsletter\". It is not a thing anybody can untick off a receipt, a\n * password reset or a booking confirmation, and a gate that refused those\n * on a topic preference would be the failure mode this control is most\n * capable of causing. Two guards make that unreachable rather than\n * unlikely:\n *\n * 1. transactional mail declares no `marketing` context at all, so it\n * never reaches the gate; and\n * 2. a marketing caller that names no stream gets no topic refusal here.\n * `filterTopicSendable` already reads an empty topic that way — there\n * is no stream to have left — and this preserves it rather than\n * defaulting to one.\n *\n * So the check binds exactly the senders that declare what they are, which\n * is the same polarity `isMarketingMessage` chose: enumerate what is\n * restricted, and a forgotten caller sends rather than silently drops.\n *\n * ## Why the gate asks it at all, when the consent split does not\n *\n * The two used to travel together in `email-flow-gate.ts`, and they are not\n * the same kind of fact. The consent split is the ORG's policy over its own\n * audience, which is why it stays a caller-side question. A topic opt-out\n * is the RECIPIENT talking to the platform, recorded by the same preference\n * page that records their cadence and reached by the same\n * `List-Unsubscribe` link — so it belongs with the suppression and cadence\n * checks, at the one chokepoint every marketing message crosses.\n */\n topicId?: string\n /**\n * Every site in the sending site's CONSENT GROUP, the sending site included\n * — `consentGroupForHost(org, hostId).hostIds`, from the org the caller\n * already holds. `[hostId]` for a site in no declared group.\n *\n * An org may declare several sites one sender, and a person who left one\n * of them left the sender: the gate withholds this message on an\n * unsubscribe, a stream left, or a pace asked for on ANY of these sites.\n *\n * Required, and resolved by the caller rather than by the gate, for two\n * reasons that agree. The gate would otherwise spend an org read on every\n * marketing message to learn what every caller here has already read; and\n * a sender that could leave it out would leave it out, which silently\n * mails somebody who unsubscribed from the site next door.\n */\n consentHostIds: readonly string[]\n /**\n * Whether a confirmation one of {@link consentHostIds} is still waiting on\n * holds this message too — `consentGroupForHost(org, hostId)\n * .awaitsConfirmation`, from the same resolved group. `false` for a site in\n * no declared group, and for every org that has not turned the switch on.\n *\n * The sending site's own pending confirmation holds the message either way;\n * this decides only a sibling's. Required beside {@link consentHostIds} for\n * the reason that one is: a sender that could leave it out would mail\n * somebody the org asked every site of the group to wait on.\n */\n consentAwaitsConfirmation: boolean\n}\n\n/** What the gate is asked, once per marketing message. */\nexport interface MarketingSendGateRequest {\n hostId: string\n siteBase: string\n /** The single recipient. A marketing message addresses exactly one person. */\n email: string\n /** The caller's `context` label, for the log line on a refusal. */\n context?: string\n /**\n * The stream this message belongs to, or absent for a message that belongs\n * to none. See {@link MarketingSendContext.topicId} — absent refuses\n * nobody.\n */\n topicId?: string\n /** Whether a frequency cap may refuse — see {@link MarketingSendContext}. */\n capped: boolean\n /**\n * The sender's consent group — see\n * {@link MarketingSendContext.consentHostIds}. Absent, or a list that does\n * not name `hostId`, is the site alone.\n */\n consentHostIds?: readonly string[]\n /**\n * See {@link MarketingSendContext.consentAwaitsConfirmation}. Only `true`\n * lets a sibling's pending confirmation hold the message.\n */\n consentAwaitsConfirmation?: boolean\n}\n\n/** Why a marketing message was not sent, or `null` when it may go. */\nexport type MarketingSendRefusal =\n /** On a suppression list: unsubscribed, hard-bounced, or a complaint. */\n | 'suppressed'\n /**\n * They have left the STREAM this message belongs to.\n *\n * Kept apart from `suppressed` even though both are terminal for this\n * message, because they describe different people: somebody on a\n * suppression list has left the site, and somebody here has left one of its\n * streams and still wants the others. A merchant reading why a send shrank\n * has a different thing to do about each, and only one of them is a list\n * that has to be rebuilt.\n *\n * NOT retryable — the condition clears when the person re-subscribes, not\n * when time passes.\n */\n | 'topic-unsubscribed'\n /** This person has already received their ceiling from this site. */\n | 'frequency-capped'\n /**\n * The RECIPIENT asked for less than this, and it has not been long enough.\n *\n * Kept apart from `frequency-capped` even though both are refusals about\n * pace, because they are refusals on behalf of different people: the\n * ceiling protects a shared sending domain from a merchant, and this\n * carries out a request the person on the other end made on the preference\n * page. Reporting them as one would make \"why did this not send\" answerable\n * only by guessing, and it is the merchant-facing half of a promise the\n * product made to a recipient.\n */\n | 'cadence-limited'\n /**\n * This site has been mailing this person for longer than the sunset window\n * and nothing in that window says they are still listening.\n *\n * NOT retryable by a sweep — the condition does not clear on a schedule,\n * it clears when the person engages. See {@link marketingSunsetVerdict}.\n */\n | 'unengaged'\n\nexport interface MarketingSendGateVerdict {\n allowed: boolean\n /** Set when `allowed` is false. */\n refusal?: MarketingSendRefusal\n /** Human-readable, for the log and the `detail` on the result. */\n detail?: string\n /**\n * The signed opt-out URL a PERSON clicks, when the gate could mint one.\n * Points at the preference page, where the stream this message belongs to\n * is one of the things they can stop instead of all of it. Absent leaves\n * the message without unsubscribe headers, which is a misconfiguration to\n * fix and not a reason to refuse mail.\n */\n unsubscribeUrl?: string\n /**\n * The same signature over the one-click route, for `List-Unsubscribe`.\n *\n * A mailbox provider POSTs that header with no human present and expects\n * the act to have happened when it reads the 200, so it must never name a\n * page of checkboxes somebody has to submit. Absent, the header falls back\n * to {@link unsubscribeUrl}.\n */\n oneClickUrl?: string\n}\n\n/**\n * Whether a message is MARKETING, derived from what it already carries.\n *\n * ## Nothing new to remember, on purpose\n *\n * The obvious design is a `kind: 'marketing' | 'transactional'` option on\n * `sendEmail`. It is rejected for the reason the `from` override was deleted\n * and the `context` tag was derived rather than threaded: twenty call sites\n * cannot each be relied on to set a field, and the twenty-first is the one that\n * does not. The consequence of forgetting here is a merchant's campaign leaving\n * on the pooled identity that carries every other site's password resets — a\n * failure nobody sees until the pool stops delivering.\n *\n * So the answer is read off two things a marketing send is ALREADY obliged to\n * carry, neither of which is optional and neither of which was added for this:\n *\n * 1. **`marketing`** — the context object. A message that declares it gets the\n * RFC 8058 header pair, the suppression check and the frequency cap, so a\n * marketing sender cannot omit it and still be correct; it would be shipping\n * mail with no unsubscribe link. Four of the five marketing senders in the\n * tree take this arm.\n * 2. **`priority === 'campaign'`** — which `resolveSendPriority` derives from\n * `context: 'campaign'`. The campaign sender mints its own unsubscribe\n * headers upstream and so passes no `marketing` context, but it is the one\n * sender that cannot avoid this label: the hourly governor is allowed to\n * refuse a campaign, and a campaign that hid from the priority would be\n * hiding from that too.\n *\n * A sender would have to defeat BOTH — no unsubscribe context and no campaign\n * priority — to reach the pool with promotional mail, and a message in that\n * state is already broken in ways its author would notice.\n *\n * ## Polarity\n *\n * A message matching neither is transactional, which is the permissive answer,\n * and that is deliberate — the same choice `resolveSendPriority` makes for the\n * same reason. Enumerating what is RESTRICTED means a forgotten caller sends a\n * receipt that goes out. Enumerating what is PERMITTED means a forgotten caller\n * drops one, and a dropped password reset is the failure you learn about from a\n * support ticket.\n */\nexport function isMarketingMessage(options: {\n marketing?: unknown\n priority?: string | null\n context?: string | null\n}): boolean {\n if (options?.marketing) return true\n // `context` is consulted only through the same rule the governor uses, so a\n // context that stops meaning \"campaign\" cannot leave the two disagreeing.\n return resolveSendPriority(\n options?.context ?? undefined,\n (options?.priority as EmailSendPriority) || undefined,\n ) === 'campaign'\n}\n\nexport type MarketingSendGate = (\n request: MarketingSendGateRequest,\n) => Promise<MarketingSendGateVerdict>\n\nlet installedGate: MarketingSendGate | null = null\n\n/** Installs the durable gate. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setMarketingSendGate(gate: MarketingSendGate | null): void {\n installedGate = gate\n}\n\n/** The installed gate, or null when nothing has been installed. */\nexport function getMarketingSendGate(): MarketingSendGate | null {\n return installedGate\n}\n\n/** Test seam: forget any installed gate. */\nexport function resetMarketingSendGateForTests(): void {\n installedGate = null\n}\n\n/**\n * The rolling window a frequency cap counts over. One day, because that is\n * the unit a recipient experiences (\"this shop mailed me four times today\")\n * and the unit every published vendor cap is expressed in.\n */\nexport const MARKETING_FREQUENCY_WINDOW_MS = 86_400_000\n\n/**\n * How many marketing messages one person may receive from one site inside the\n * window, by default.\n *\n * **A runaway guard, not a marketing policy.** It is deliberately above what\n * an ordinary merchant produces: the worst legitimate day is a campaign plus\n * a cart reminder plus a restock alert plus a member post, which is four. Set\n * lower and the first thing it would refuse is a real message somebody meant\n * to send; set higher and it stops describing a ceiling at all. What it does\n * remove is the unbounded case — a member post that mails 200 subscribers per\n * click with no limit on clicks, and a workflow whose email step fires on\n * every anonymous form submission.\n *\n * The same number for every plan. This is a deliverability control on a\n * shared sending domain, so it protects every tenant from every other tenant\n * and cannot be something one plan buys its way past.\n */\nexport const MARKETING_FREQUENCY_DEFAULT_PER_WINDOW = 5\n\n/** Floor and ceiling on a configured cap — a typo guard, not a policy. */\nexport const MARKETING_FREQUENCY_MIN_PER_WINDOW = 1\nexport const MARKETING_FREQUENCY_MAX_PER_WINDOW = 1_000\n\n/**\n * The live cap.\n *\n * Read from the environment so a self-host operator can set their own —\n * `docs/design` states that every dependency is configurable — and read per\n * call rather than captured at module load, matching `getEmailConfig`: these\n * run in serverless handlers where the module may be evaluated during a build.\n *\n * An unparseable or out-of-range value falls back to the default rather than\n * throwing or disabling the cap. A control that a typo can switch off is not\n * a control.\n */\nexport function marketingFrequencyCap(): number {\n const raw = Number(process.env.AGLYN_EMAIL_MARKETING_CAP_PER_DAY)\n if (!Number.isFinite(raw)) return MARKETING_FREQUENCY_DEFAULT_PER_WINDOW\n const whole = Math.floor(raw)\n if (\n whole < MARKETING_FREQUENCY_MIN_PER_WINDOW ||\n whole > MARKETING_FREQUENCY_MAX_PER_WINDOW\n ) {\n return MARKETING_FREQUENCY_DEFAULT_PER_WINDOW\n }\n return whole\n}\n\n/**\n * Whether one more message fits, given what this person has already received.\n *\n * Pure, so the decision is testable without a Firestore harness — the same\n * split `emailSendRateVerdict` makes for the platform hour.\n *\n * @param recentSendsAtMs every marketing send to this person from this site,\n * newest or oldest first, in any order.\n * @returns the verdict and the trimmed window, so the caller writes back only\n * what still counts instead of growing the record forever.\n */\nexport function marketingFrequencyVerdict(\n recentSendsAtMs: readonly number[],\n nowMs: number,\n cap: number = marketingFrequencyCap(),\n): { allowed: boolean; used: number; cap: number; inWindow: number[] } {\n const floor = nowMs - MARKETING_FREQUENCY_WINDOW_MS\n const inWindow = recentSendsAtMs\n .filter((at) => Number.isFinite(at) && at > floor && at <= nowMs)\n .sort((a, b) => a - b)\n // Keep the NEWEST when a record has somehow grown past the cap. The\n // oldest entries are the ones about to leave the window anyway, so\n // dropping them loses the least information about when this person is\n // mailable again.\n .slice(-Math.max(cap, MARKETING_FREQUENCY_MIN_PER_WINDOW))\n return {\n allowed: inWindow.length < cap,\n used: inWindow.length,\n cap,\n inWindow,\n }\n}\n\n/*==========================================\n * ENGAGEMENT-BASED SUNSETTING.\n *\n * Stop mailing an address that has gone quiet. The whole design fits in one\n * sentence and three refusals it must never become.\n *\n * ## ⛔ It refuses a SEND. It never reduces a person.\n *\n * `over-limit.ts` states the rule for capacity and this is the same\n * instrument pointed at attention rather than at count. A sunset does NOT\n * unsubscribe anybody, does NOT remove them from a list, does NOT delete a\n * contact and does NOT write a suppression. Every one of those would be a\n * ceiling performing a deletion, and every one of them is irreversible in a\n * way the condition that triggered it is not.\n *\n * ## It is reversible without anybody doing anything\n *\n * The only state is two timestamps: when this site first mailed this person,\n * and when this person last engaged with our mail. A person who opens or\n * clicks anything moves the second one, and the very next send finds them\n * inside the window and mailable. Nothing has to be undone, because nothing\n * was done.\n *\n * ## The three ways it could refuse somebody it should not, and the guards\n *\n * 1. **A brand-new subscriber has no engagement yet.** So the window is\n * measured from when we STARTED mailing them, and somebody we have not\n * been mailing for longer than the window is never refused, however\n * little they have engaged.\n * 2. **We might have no record at all.** An unknown `firstSentAtMs` refuses\n * nobody. Missing evidence is not evidence of absence, and the reading\n * that mails somebody once more is the recoverable one.\n * 3. **A campaign is a reviewed act with its recipient count on screen.**\n * Sunsetting yields to it exactly as the frequency ceiling does, through\n * the same `capped` flag, so the number a merchant read before pressing\n * Send stays true. It governs the automated paths, which fire with no\n * human present.\n *\n * ## Opens are a weak signal and this leans on the broader one anyway\n *\n * The industry lesson is to sunset on clicks, because Apple's Mail Privacy\n * Protection prefetches images and inflates opens. That argument is about\n * choosing an audience. This is a refusal, and for a refusal the weaker,\n * more generous signal is the correct one: counting an open as engagement\n * refuses FEWER people. The audience rules\n * (`DynamicListEngagement`) keep opens and clicks apart so a merchant\n * segmenting on engagement can lean on clicks; this deliberately does not.\n *=========================================*/\n\n/** Floor and ceiling on a configured sunset window — a typo guard. */\nexport const MARKETING_SUNSET_MIN_DAYS = 30\nexport const MARKETING_SUNSET_MAX_DAYS = 3_650\n\n/**\n * The window, in days, or 0 for OFF.\n *\n * **Off unless an operator turns it on**, and that default is the honest one:\n * no compared vendor automates this, so a platform that silently stopped\n * mailing a merchant's quiet subscribers would be doing something none of\n * their previous tools did and none of their recipients asked for. The value\n * is a number of days rather than a boolean because the only interesting\n * question about a sunset is where it starts.\n *\n * Read per call rather than captured at module load, matching the frequency\n * cap beside it: these run in serverless handlers where the module may be\n * evaluated during a build.\n *\n * An unparseable or out-of-range value reads as OFF rather than falling back\n * to a default. This is the opposite of {@link marketingFrequencyCap}'s\n * handling and deliberately so — a typo there weakens a guard that is on by\n * default, and a typo here would ENABLE a refusal nobody asked for.\n */\nexport function marketingSunsetDays(): number {\n const raw = Number(process.env.AGLYN_EMAIL_SUNSET_AFTER_DAYS)\n if (!Number.isFinite(raw)) return 0\n const whole = Math.floor(raw)\n if (whole < MARKETING_SUNSET_MIN_DAYS || whole > MARKETING_SUNSET_MAX_DAYS) {\n return 0\n }\n return whole\n}\n\n/** What the sunset needs to know about one person. */\nexport interface MarketingSunsetFacts {\n /**\n * When this site first sent this person marketing mail, or null when we\n * have no record. Null never refuses.\n */\n firstSentAtMs: number | null\n /** When they last opened or clicked any of our mail, or null for never. */\n lastEngagedAtMs: number | null\n}\n\n/**\n * Whether this person has gone quiet for longer than the window.\n *\n * Pure, so the decision is testable without a Firestore harness — the same\n * split {@link marketingFrequencyVerdict} makes.\n *\n * @param days 0 disables the sunset entirely and this always allows.\n */\nexport function marketingSunsetVerdict(\n facts: MarketingSunsetFacts,\n nowMs: number,\n days: number = marketingSunsetDays(),\n): { allowed: boolean; days: number; quietForDays: number | null } {\n if (!days || days <= 0) {\n return { allowed: true, days: 0, quietForDays: null }\n }\n const floor = nowMs - days * 86_400_000\n const first = Number(facts.firstSentAtMs ?? 0)\n // Guard 1 and 2 together: no record, or a relationship younger than the\n // window, allows. A person cannot have been quiet for longer than we have\n // been mailing them.\n if (!Number.isFinite(first) || first <= 0 || first >= floor) {\n return { allowed: true, days, quietForDays: null }\n }\n const engaged = Number(facts.lastEngagedAtMs ?? 0)\n if (Number.isFinite(engaged) && engaged > 0 && engaged >= floor) {\n return { allowed: true, days, quietForDays: null }\n }\n const since = Number.isFinite(engaged) && engaged > 0 ? engaged : first\n return {\n allowed: false,\n days,\n quietForDays: Math.floor((nowMs - since) / 86_400_000),\n }\n}\n\n/**\n * HOW OFTEN THE RECIPIENT ASKED TO HEAR FROM THIS SITE.\n *\n * `docs/specs/email-competitive-gaps.md` G10 shipped its cap half and left\n * this one: unsubscribe was all-or-nothing plus, since topics, per-stream —\n * and a recipient who wanted the same mail LESS OFTEN still had only two\n * levers, one of which is the spam button. On a shared sending domain under\n * `p=reject` that button is charged to every other tenant, which is what\n * makes \"monthly\" a platform control wearing a courtesy's clothes.\n *\n * ## A minimum interval, not a second rolling window\n *\n * {@link marketingFrequencyVerdict} counts messages inside a day because the\n * thing it guards against is a burst. This guards against a DRIP, and the\n * question a drip asks is \"how long since the last one\" — one stored instant,\n * not a window that would have to be kept for a month to answer a monthly\n * choice. Two instruments, because they are two different questions.\n *\n * ## New values, so the vocabulary is chosen rather than inherited\n *\n * `'all'` is the default and the absence: a record with no cadence, and every\n * record written before this existed, means the person has expressed no\n * preference — which is not the same as having asked for everything, but is\n * the only reading that does not silently withhold mail from people who never\n * chose.\n */\nexport type MarketingCadence = 'all' | 'daily' | 'weekly' | 'monthly'\n\n/** The default: no expressed preference, so only the platform ceiling binds. */\nexport const DEFAULT_MARKETING_CADENCE: MarketingCadence = 'all'\n\n/**\n * The minimum gap each choice asks for, in millis.\n *\n * Calendar-naive on purpose. \"At most one a week\" is a promise about pace,\n * and honoring it as seven days from the last message is both what the words\n * say and what a recipient can check; anchoring it to a calendar week would\n * let two messages land on a Sunday and a Monday and still be \"one a week\".\n */\nexport const MARKETING_CADENCE_INTERVAL_MS: Record<MarketingCadence, number> = {\n all: 0,\n daily: 86_400_000,\n weekly: 7 * 86_400_000,\n monthly: 30 * 86_400_000,\n}\n\n/** How each choice reads, wherever one is named to a person or an operator. */\nexport const MARKETING_CADENCE_LABELS: Record<MarketingCadence, string> = {\n all: 'As they come',\n daily: 'At most one a day',\n weekly: 'At most one a week',\n monthly: 'At most one a month',\n}\n\n/**\n * Coerces a stored or submitted value to a cadence.\n *\n * Everything unrecognized becomes {@link DEFAULT_MARKETING_CADENCE}. The\n * direction matters and is the opposite of the consent policy's: a malformed\n * consent value must not become a way to switch enforcement off, because its\n * failure mode is mail to somebody who declined. A malformed cadence falling\n * to `'monthly'` would withhold mail from everybody whose record got\n * corrupted, and nobody asked for that either — so an unreadable preference\n * reads as no preference, and the person keeps whatever the ceiling allows.\n */\nexport function normalizeMarketingCadence(value: unknown): MarketingCadence {\n return value === 'daily' || value === 'weekly' || value === 'monthly'\n ? value\n : DEFAULT_MARKETING_CADENCE\n}\n\n/**\n * Whether enough time has passed for one more message at this pace.\n *\n * Pure, so the rule can be asserted without a Firestore harness — the same\n * split {@link marketingFrequencyVerdict} makes.\n *\n * @param lastSentAtMs when this site last sent this person marketing mail, or\n * `null` for somebody it has never mailed. Never mailed always allows:\n * a cadence is a gap between messages and there is no first gap.\n * @returns the verdict and, on a refusal, the instant the next message may go\n * — so a caller that defers has something to defer UNTIL rather than\n * a retry loop that discovers the answer by asking again.\n */\nexport function marketingCadenceVerdict(\n cadence: MarketingCadence,\n lastSentAtMs: number | null | undefined,\n nowMs: number,\n): { allowed: boolean; cadence: MarketingCadence; nextAllowedAtMs: number } {\n const interval = MARKETING_CADENCE_INTERVAL_MS[cadence] ?? 0\n const last = Number(lastSentAtMs)\n if (!interval || !Number.isFinite(last) || last <= 0) {\n return { allowed: true, cadence, nextAllowedAtMs: nowMs }\n }\n const nextAllowedAtMs = last + interval\n /*\n * A stored instant in the FUTURE allows rather than refusing until it\n * passes. Clocks disagree across processes and a record written a few\n * seconds ahead would otherwise hold a recipient's mail for a whole\n * interval, which is a much larger error than the one it would prevent.\n */\n if (last > nowMs) return { allowed: true, cadence, nextAllowedAtMs: nowMs }\n return { allowed: nowMs >= nextAllowedAtMs, cadence, nextAllowedAtMs }\n}\n\n/**\n * The RFC 8058 header pair.\n *\n * Both or neither: `List-Unsubscribe` alone does not satisfy the bulk-sender\n * rules, and `List-Unsubscribe-Post` without a URL to post to advertises a\n * verb nothing serves.\n */\nexport function unsubscribeHeaders(\n unsubscribeUrl: string,\n): Record<string, string> {\n if (!unsubscribeUrl) return {}\n return listUnsubscribeHeaders({ url: unsubscribeUrl })\n}\n\n/**\n * What the visible opt-out is CALLED, in every part of every message.\n *\n * The link opens the preference page, where leaving one stream is a choice\n * alongside leaving all of them — and a footer that says only \"Unsubscribe\"\n * is the only place a recipient would have learned that, so it never gets\n * said. The word \"unsubscribe\" stays in the line because that is what a\n * recipient scans a footer for.\n *\n * Named once and shared with `renderRecipientEmail`, which writes this same\n * line into a campaign's text part: two spellings of one sentence is how the\n * idempotency checks below come to append a second footer to a message that\n * already had one.\n */\nexport const UNSUBSCRIBE_FOOTER_LABEL =\n 'Choose which emails you get, or unsubscribe'\n\n/**\n * The visible opt-out, appended to the plain-text part.\n *\n * The headers are for the mailbox provider; this is for the person. CAN-SPAM\n * asks for a mechanism the recipient can see and use, and a header is neither\n * — most clients render no control for it at all unless the sender's domain\n * reputation is high enough for the provider to offer one.\n *\n * Idempotent by URL: a body that already carries the link is returned\n * untouched, so a sender that writes its own footer does not get two.\n */\nexport function appendUnsubscribeText(\n text: string,\n unsubscribeUrl: string,\n): string {\n if (!unsubscribeUrl || (text && text.includes(unsubscribeUrl))) return text\n return `${text ?? ''}\\n\\n—\\n${UNSUBSCRIBE_FOOTER_LABEL}: ${unsubscribeUrl}`\n}\n\n/**\n * The same visible opt-out for the HTML part.\n *\n * Appended to whatever the sender produced rather than woven into it, because\n * the HTML may be a merchant-designed template this module knows nothing\n * about. A designed template that already renders `{{unsubscribeUrl}}`\n * carries the URL, so the check below leaves it alone and the merchant's own\n * placement wins.\n *\n * ⚠️ That check has to look for the ESCAPED URL as well. A signed opt-out\n * link carries `&` between its query parameters, and a renderer putting it\n * into an `href` escapes it — so `renderEmailHtml` emits `…&sig=…` and a\n * plain `includes` of the unescaped URL matches nothing. Every designed\n * template in the product goes through that renderer, which means the\n * merchants who DID place the token were the ones getting two footers.\n *\n * Styles are inline and literal because this is email HTML: mail clients\n * strip `<style>` blocks and support no CSS variables, so a theme token\n * cannot reach the wire.\n */\nexport function appendUnsubscribeHtml(\n html: string,\n unsubscribeUrl: string,\n): string {\n if (!unsubscribeUrl) return html\n if (\n html &&\n (html.includes(unsubscribeUrl) ||\n html.includes(escapeEmailHtml(unsubscribeUrl)))\n ) {\n return html\n }\n const footer =\n '<div style=\"margin:24px auto 0;max-width:600px;padding:16px 24px;' +\n \"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,\" +\n 'Helvetica,Arial,sans-serif;font-size:12px;line-height:1.5;' +\n 'color:#616161;text-align:center\">' +\n `<a href=\"${escapeEmailHtml(unsubscribeUrl)}\" style=\"color:#616161\">` +\n `${UNSUBSCRIBE_FOOTER_LABEL}</a>` +\n '</div>'\n return `${html ?? ''}${footer}`\n}\n"],"names":["escapeEmailHtml","resolveSendPriority","listUnsubscribeHeaders","isMarketingMessage","options","marketing","context","undefined","priority","installedGate","setMarketingSendGate","gate","getMarketingSendGate","resetMarketingSendGateForTests","MARKETING_FREQUENCY_WINDOW_MS","MARKETING_FREQUENCY_DEFAULT_PER_WINDOW","MARKETING_FREQUENCY_MIN_PER_WINDOW","MARKETING_FREQUENCY_MAX_PER_WINDOW","marketingFrequencyCap","raw","Number","process","env","AGLYN_EMAIL_MARKETING_CAP_PER_DAY","isFinite","whole","Math","floor","marketingFrequencyVerdict","recentSendsAtMs","nowMs","cap","inWindow","filter","at","sort","a","b","slice","max","allowed","length","used","MARKETING_SUNSET_MIN_DAYS","MARKETING_SUNSET_MAX_DAYS","marketingSunsetDays","AGLYN_EMAIL_SUNSET_AFTER_DAYS","marketingSunsetVerdict","facts","days","quietForDays","first","firstSentAtMs","engaged","lastEngagedAtMs","since","DEFAULT_MARKETING_CADENCE","MARKETING_CADENCE_INTERVAL_MS","all","daily","weekly","monthly","MARKETING_CADENCE_LABELS","normalizeMarketingCadence","value","marketingCadenceVerdict","cadence","lastSentAtMs","interval","last","nextAllowedAtMs","unsubscribeHeaders","unsubscribeUrl","url","UNSUBSCRIBE_FOOTER_LABEL","appendUnsubscribeText","text","includes","appendUnsubscribeHtml","html","footer"],"mappings":"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"}
|
package/src/lib/send-email.js
CHANGED
|
@@ -363,7 +363,8 @@ function normalizeRecipients(to) {
|
|
|
363
363
|
}, options.marketing.topicId ? {
|
|
364
364
|
topicId: options.marketing.topicId
|
|
365
365
|
} : {}, {
|
|
366
|
-
consentHostIds: options.marketing.consentHostIds
|
|
366
|
+
consentHostIds: options.marketing.consentHostIds,
|
|
367
|
+
consentAwaitsConfirmation: options.marketing.consentAwaitsConfirmation
|
|
367
368
|
}));
|
|
368
369
|
} catch (error) {
|
|
369
370
|
/*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/send-email.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 Resend send endpoint. Every outbound application email in Aglyn goes\n * through here — invites, receipts, usage summaries, campaigns, staff alerts.\n *\n * Auth email (verification, password reset) is Firebase's job and does NOT\n * come through this module.\n */\nimport {\n type EmailSendPriority,\n emailSendRateWindowStartMs,\n getEmailSendGovernor,\n isRefusablePriority,\n resolveSendPriority,\n} from './send-rate'\nimport { renderTextEmailHtml } from './text-email-html'\nimport {\n sendingIdentityRefusal,\n pooledMarketingRefusal,\n type SendingIdentityAudience,\n type SendingIdentityVerdict,\n} from './sending-domain'\nimport {\n appendUnsubscribeHtml,\n appendUnsubscribeText,\n getMarketingSendGate,\n isMarketingMessage,\n unsubscribeHeaders,\n type MarketingSendContext,\n} from './marketing-send'\n\nexport const RESEND_SEND_ENDPOINT = 'https://api.resend.com/emails'\n\n/** A Resend delivery tag, used for webhook attribution (AGL-268). */\nexport interface EmailTag {\n name: string\n value: string\n}\n\nexport interface SendEmailOptions {\n /** One or more recipient addresses. */\n to: string | string[]\n subject: string\n /** Plain-text body. Supply at least one of `text` or `html`. */\n text?: string\n /**\n * HTML body. Supply at least one of `text` or `html`.\n *\n * Omitted, one is synthesized from `text` so the message always carries an\n * HTML part — a text-only message has no anchors, so its links are inert in\n * the inbox and Resend's click tracking has nothing to rewrite. See\n * `text-email-html.ts`.\n */\n html?: string\n /** Extra MIME headers, e.g. `List-Unsubscribe`. */\n headers?: Record<string, string>\n /** Delivery tags for the opens/clicks webhook. */\n tags?: EmailTag[]\n replyTo?: string | string[]\n /*\n * THERE IS NO `from`.\n *\n * There was: a raw override of the configured sender, subordinate to a\n * resolved identity but winning over everything else, verified against\n * nothing. Every address this function can send from now comes from one of\n * exactly two places — the deployment's own `USAGE_EMAIL_FROM`, or a\n * {@link SendingIdentityVerdict} the server resolved from a document — and\n * neither is reachable from a request body.\n *\n * Deleting it rather than guarding it is what makes that a property instead\n * of a habit. A guard would have to be written correctly at each of the\n * ninety-odd call sites, or once here and then trusted; an option that does\n * not exist cannot be passed by the next sender, and `resolveSendingIdentity`\n * becomes the whole of the answer to \"as whom does this leave\".\n *\n * The resolution below reads no `from` from `options` either, so this is not\n * only a compile-time close. Marketplace plugin bundles reach `sendEmail` as\n * JavaScript and are typechecked against nothing.\n */\n /**\n * White-label display name for the sender (White-Label Phase 1). Replaces\n * only the display name in front of the verified address — the address\n * itself is never taken from the caller, so this cannot forge a different\n * sender. Callers pass `resolveBrandingProfile(org).fromName` here so an\n * agency's mail reads as their brand instead of \"Aglyn\".\n */\n fromName?: string\n /**\n * The server-resolved sending identity for this message, from\n * `resolveSendingIdentity`.\n *\n * Supplied, it decides the address and it may refuse the send outright —\n * `fromName` is subordinate to it, because a verdict is the answer to \"may\n * this leave, and as whom\" and a display name is not. Omitted, every\n * existing caller keeps the behavior it had: the configured platform\n * identity with an optional display name.\n *\n * Callers resolve it from the ORG DOCUMENT, never from request input. An\n * address assembled from a request body is a `From:` override wearing a new\n * name, and the invariant `applyFromName` exists to hold is that the\n * address cannot move off a verified identity.\n */\n sendingIdentity?: SendingIdentityVerdict | null\n /**\n * Whose mail this is — see {@link SendingIdentityAudience}.\n *\n * `tenant` says the message belongs to a SITE, and it makes the platform\n * sender unreachable: a tenant message with no resolved identity is refused\n * rather than sent from `aglyn.com`. Pair it with `sendingIdentity` from\n * `hostSendingIdentity(hostId)` and the ordinary path is unchanged; the flag\n * is what decides the behavior when that resolution is missing or refuses.\n *\n * Omitted, a send is platform mail and keeps the configured sender, because\n * that is what the console's own senders are. `email-audience-coverage.spec`\n * sweeps the tenant-owned trees so the omission cannot be an accident there.\n */\n audience?: SendingIdentityAudience\n /**\n * Short label for logs, e.g. `'invite'` or `'usage-summary'`. Makes a\n * failure in the runtime logs traceable to the feature that caused it.\n *\n * Since AGL-2407 it is also stamped as a Resend `context` TAG on every\n * send — see `contextTag` below.\n */\n context?: string\n /**\n * What the platform send-rate governor is allowed to do to this message\n * (AGL-2409). Omitted, it is derived from `context`: `'campaign'` is a\n * campaign and everything else is transactional, so no existing caller\n * changes and the default is the one that can never be refused.\n *\n * Set it to `'bulk'` ONLY from a resumable sweep — a cron that leaves its\n * subject unstamped and picks it up on the next run. A refusal for a bulk\n * send means \"not this hour\", and a caller that cannot come back would turn\n * that into a message nobody ever gets.\n */\n priority?: EmailSendPriority\n /**\n * Declares this message as MARKETING mail for one site's audience — see\n * `marketing-send.ts` for what that means and why it is one seam.\n *\n * Set it and the message gains, in one place, the three things marketing\n * mail owes: the RFC 8058 unsubscribe header pair plus a visible opt-out\n * link, a check against both suppression lists, and a ceiling on how much\n * one person receives from one site.\n *\n * ONE RECIPIENT. An unsubscribe link is an HMAC over the address it belongs\n * to, and a suppression verdict is about one person — so a marketing send\n * addressed to a list would carry the wrong link for everybody after the\n * first, and would ask the gate about one of them. Callers fan out.\n */\n marketing?: MarketingSendContext\n}\n\n/**\n * The `context` tag, attached to every send (AGL-2407).\n *\n * ## Why this is here and not at 37 call sites\n *\n * Until now `tags` were set by exactly one sender, `campaign-send.ts`, which\n * stamps `hostId` and `campaignId` for the opens/clicks webhook. Everything\n * else went out with NO tags at all, so a bounce on an invite, a password\n * reset, a receipt or a usage summary reached the webhook carrying nothing to\n * identify it, and was dropped.\n *\n * The obvious fix — thread an identifier through every call site — asks 37\n * places to remember, which is the shape that produces the 38th that does\n * not. But `context` is ALREADY threaded through 35 of the 37 for logging,\n * and it is exactly the right value: it names the sender. So the tag is\n * derived here, once, and no caller changes.\n *\n * Resend tag values are restricted to ASCII letters, digits, `_` and `-`;\n * anything else is rejected and would fail the whole send. Every `context` in\n * the tree is already a plain slug, but this is mail delivery — a value that\n * makes the send fail is far worse than a value that is sanitised — so the\n * label is normalised rather than trusted, and a context that sanitises to\n * nothing yields no tag rather than an invalid one.\n */\nexport function contextTag(context: string | undefined): EmailTag[] {\n const value = String(context ?? '')\n .trim()\n .replace(/[^A-Za-z0-9_-]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 64)\n return value ? [{ name: 'context', value }] : []\n}\n\n/**\n * Why a send did not happen. `unconfigured` and `no-recipient` mean nothing\n * was attempted; `rejected` and `network` mean Resend was called and failed;\n * `rate-limited` is either, and says so in `status`.\n *\n * `rate-limited` (AGL-2409) and `frequency-capped` are the two a caller may\n * reasonably retry unchanged — see {@link isDeferrableSendResult}, which is\n * where that distinction is made once rather than at each sweep.\n */\nexport type SendEmailFailureReason =\n | 'unconfigured'\n | 'no-recipient'\n /**\n * Resend answered and would not take this message.\n *\n * Per-message and terminal: a malformed payload, an address the provider\n * will not accept, a tag it rejected. A caller that retries it unchanged\n * gets the same answer, so a batch settles the recipient rather than\n * holding a slot open for them.\n *\n * A 429 is deliberately NOT this. See `rate-limited`.\n */\n | 'rejected'\n | 'network'\n /**\n * A refusal that is about the RATE, not about this message — so the message\n * is intact and a later attempt sends it.\n *\n * Two sources, which is why it is one value. The platform hourly governor\n * refuses before the network and nothing is attempted (AGL-2409). Resend\n * answers `429` on the wire when requests arrive faster than it accepts\n * them, and `status` is 429 in that case.\n *\n * The provider arm reports here rather than as `rejected` because of what\n * the two mean to a batch. A `rejected` recipient is settled and never\n * addressed again, which is right for an address the provider will not take\n * and wrong for every recipient a 429 touches: nothing about them was\n * refused, the request was simply too soon. Classified as `rejected` a\n * single rate-limited burst silently deletes the rest of a campaign's\n * audience while the campaign reports itself complete — and the `sent`\n * figure stays honest throughout, so no rate on the report can show it.\n *\n * The provider's quota errors — `daily_quota_exceeded`,\n * `monthly_quota_exceeded` — are also 429 and also land here. That is the\n * right answer for the same reason: none of them is a statement about the\n * recipient, and all of them clear with time.\n */\n | 'rate-limited'\n /**\n * The org selected a custom sending domain and that domain is not verified.\n *\n * Distinct from `unconfigured` because the two need opposite responses: an\n * unconfigured deployment is the operator's to fix, while this is a\n * customer's DNS that is not finished, and the customer is the only person\n * who can finish it. `detail` carries the sentence naming the domain.\n *\n * This is the LAST line of defence, not the visible one. A caller that\n * reaches it has already skipped the check its route should have made, and\n * a refusal seen only here is a log line — which is the shape of the\n * `USAGE_EMAIL_FROM` outage. `performCampaignSend` refuses first, with a\n * `409`, so a person finds out.\n */\n | 'unverified-domain'\n /**\n * A MARKETING send whose recipient is on a suppression list — they\n * unsubscribed from this site, hard-bounced, or pressed \"report spam\".\n *\n * Terminal, and the only outcome here a caller must not retry: retrying is\n * the behavior the suppression exists to stop. Distinct from `rejected`\n * because nothing was attempted and nothing failed — this is the control\n * working.\n */\n | 'suppressed'\n /**\n * A MARKETING send refused because this person has already received their\n * ceiling from this site inside the window.\n *\n * Retryable, unlike `suppressed`: the window rolls. A resumable sweep does\n * not need to do anything about it — its next run asks again.\n */\n | 'frequency-capped'\n /**\n * A MARKETING send refused because this site has been mailing this person\n * for longer than the sunset window with nothing to show for it.\n *\n * TERMINAL for a sweep, and it sits with `suppressed` rather than with\n * `frequency-capped` for a reason worth stating: the frequency window\n * clears by the passage of time, so waiting works. A sunset clears when the\n * PERSON engages, which more mail from us cannot cause — so a sweep that\n * treated it as deferrable would re-read the same doomed row on every beat\n * forever. Nothing about the recipient has been reduced; the next message\n * after they open anything goes.\n */\n | 'unengaged'\n\nexport type SendEmailResult =\n | { sent: true; id: string | null }\n | {\n sent: false\n reason: SendEmailFailureReason\n /** HTTP status, when Resend answered. */\n status?: number\n /** Resend's error body or the thrown message, trimmed for logs. */\n detail?: string\n /**\n * `rate-limited` only: the earliest instant a caller may try again —\n * when the platform hourly window rolls, or what the provider's own\n * `retry-after` asked for. A resumable sweep does not need to wait on\n * it; its next scheduled run is the retry.\n */\n retryAtMs?: number\n }\n\n/**\n * The retry instant when the platform send-rate governor deferred this\n * message, or `null` for every other outcome (AGL-2409).\n *\n * A FUNCTION rather than `result.reason === 'rate-limited'` at each call site,\n * because `strictNullChecks` is OFF repo-wide and TypeScript will not narrow a\n * boolean-literal discriminant without it: `if (result.sent) … else\n * result.reason` does not compile, in every consumer, for a reason that has\n * nothing to do with this union. One helper is also one place to change if the\n * shape of a deferral ever moves.\n */\nexport function rateLimitedRetryAtMs(\n result: SendEmailResult | null | undefined,\n): number | null {\n const failure = result as { reason?: string; retryAtMs?: number } | null\n if (!failure || failure.reason !== 'rate-limited') return null\n const retryAtMs = Number(failure.retryAtMs)\n return Number.isFinite(retryAtMs) ? retryAtMs : 0\n}\n\n/**\n * How long to wait when the provider names no interval of its own.\n *\n * One second, because Resend's rate limit is counted per second — so a wait\n * of a whole window is the shortest one that is certain to have cleared it.\n */\nconst PROVIDER_RETRY_FALLBACK_MS = 1_000\n\n/**\n * The longest wait a provider header may ask for.\n *\n * A `retry-after` is read off the network and reaches a scheduler, so it is\n * clamped rather than trusted: a header of `86400` would park a campaign for\n * a day on one response nobody saw. An hour is past every documented window\n * and short enough that a wrong one costs a run rather than a day.\n */\nconst PROVIDER_RETRY_MAX_MS = 3_600_000\n\n/** One header as whole seconds, or null when it is absent or unreadable. */\nfunction headerSeconds(\n headers: { get?: (name: string) => string | null } | null | undefined,\n name: string,\n): number | null {\n const raw = headers?.get?.(name)\n // `Number(null)` and `Number('')` are both 0, which would read as \"retry\n // immediately\" for a header that is not there at all.\n if (raw === null || raw === undefined || String(raw).trim() === '') return null\n const seconds = Number(raw)\n return Number.isFinite(seconds) && seconds >= 0 ? seconds : null\n}\n\n/**\n * When the provider says a refused request may be repeated.\n *\n * Read from the two headers Resend documents beside a 429, both in whole\n * seconds: `retry-after` first because it is the direct answer to this\n * question, then `ratelimit-reset`, which names when the window rolls. A\n * response carrying neither falls back to one window.\n */\nexport function providerRetryAtMs(\n headers: { get?: (name: string) => string | null } | null | undefined,\n nowMs: number = Date.now(),\n): number {\n const seconds =\n headerSeconds(headers, 'retry-after') ??\n headerSeconds(headers, 'ratelimit-reset')\n const waitMs =\n seconds === null\n ? PROVIDER_RETRY_FALLBACK_MS\n : Math.min(seconds * 1_000, PROVIDER_RETRY_MAX_MS)\n return nowMs + waitMs\n}\n\n/**\n * Why a send did not happen, or `null` when it did.\n *\n * The same accessor `rateLimitedRetryAtMs` is, generalized: `strictNullChecks`\n * is OFF repo-wide, so TypeScript will not narrow the union on `result.sent`\n * and reading `result.reason` at a call site does not compile.\n */\nexport function sendFailureReason(\n result: SendEmailResult | null | undefined,\n): SendEmailFailureReason | null {\n const failure = result as {\n sent?: boolean\n reason?: SendEmailFailureReason\n } | null\n if (!failure || failure.sent) return null\n return failure.reason ?? null\n}\n\n/**\n * Whether this outcome is worth coming back for.\n *\n * TRUE only for the two refusals a later attempt can pass: the platform hour\n * rolls, and so does the marketing frequency window. Everything else is\n * either a delivery that happened or a failure a retry repeats — a\n * suppression most of all, since retrying is the exact behavior a suppression\n * exists to stop, and a sunset for the same reason at one remove: it clears\n * when the recipient engages, which no amount of further mail from us brings\n * about.\n *\n * A resumable sweep uses this to decide whether to leave its subject\n * unstamped. Stamping on a deferrable refusal discards a message; NOT\n * stamping on a terminal one re-reads the same doomed row on every beat until\n * it crowds out the work that could succeed. Both are silent, so the\n * distinction lives here instead of at each sweep.\n *\n * A FUNCTION rather than `result.reason === …` at each call site, because\n * `strictNullChecks` is OFF repo-wide and TypeScript will not narrow a\n * boolean-literal discriminant without it — the same reason\n * {@link rateLimitedRetryAtMs} beside it is one.\n */\nexport function isDeferrableSendResult(\n result: SendEmailResult | null | undefined,\n): boolean {\n const reason = sendFailureReason(result)\n return reason === 'rate-limited' || reason === 'frequency-capped'\n}\n\nexport interface EmailConfig {\n apiKey: string | undefined\n from: string | undefined\n}\n\n/**\n * Reads the email environment.\n *\n * Deliberately read per call rather than captured at module load: these run\n * in serverless handlers where the module may be evaluated during a build,\n * long before the runtime env exists.\n */\nexport function getEmailConfig(): EmailConfig {\n return {\n apiKey: process.env.RESEND_API_KEY || undefined,\n from: process.env.USAGE_EMAIL_FROM || undefined,\n }\n}\n\n/**\n * True when both `RESEND_API_KEY` and `USAGE_EMAIL_FROM` are present.\n *\n * Callers that answer an HTTP request (rather than firing best-effort mail)\n * use this to return a 501 with an actionable message instead of pretending\n * to have sent something.\n */\nexport function isEmailConfigured(): boolean {\n const { apiKey, from } = getEmailConfig()\n return Boolean(apiKey && from)\n}\n\n/**\n * Applies a white-label display name to a configured sender while keeping\n * its verified address (White-Label Phase 1). Accepts either a bare address\n * (`noreply@aglyn.com`) or an RFC-5322 `Name <addr>` header and returns\n * `\"<fromName>\" <addr>`. A blank name, or a value with no extractable\n * address, yields the original `from` untouched — the sender identity is\n * never dropped on the floor.\n */\nexport function applyFromName(\n from: string | undefined,\n fromName: string | undefined,\n): string | undefined {\n const name = (fromName ?? '').trim()\n if (!from || !name) return from\n const angle = from.match(/<([^>]+)>/)\n const address = (angle ? angle[1] : from).trim()\n if (!address.includes('@')) return from\n // Quote the display name so commas/specials stay inside one mailbox.\n return `\"${name.replace(/\"/g, '')}\" <${address}>`\n}\n\nfunction normalizeRecipients(to: string | string[]): string[] {\n const list = Array.isArray(to) ? to : [to]\n return list\n .map((address) => String(address ?? '').trim())\n .filter((address) => address.includes('@'))\n}\n\n/** A Resend send payload in the provider's own wire shape. */\nexport interface ResendSendPayload {\n to?: unknown\n from?: unknown\n subject?: unknown\n [field: string]: unknown\n}\n\n/**\n * The one place that POSTs to Resend's send endpoint, and the last thing\n * standing between a payload and the network.\n *\n * A payload carrying no recipient cannot become a message. Resend answers it\n * `422 missing_required_field`, which costs an API call and then shows up in\n * the vendor dashboard as a red line indistinguishable from mail that\n * genuinely failed to deliver — carrying no subject, no recipient and nothing\n * naming the code that produced it. Diagnosing that means reading a log\n * outside the deployment and guessing. So the refusal happens here, before\n * the fetch, and names the caller's `context`.\n *\n * It throws rather than returning a `SendEmailResult`: this is a programming\n * error, not a delivery outcome. `sendEmail` filters recipients well before\n * it reaches this call, so nothing on the ordinary path can trip it. The\n * guard exists because `RESEND_SEND_ENDPOINT` is exported and any module can\n * therefore reach the send endpoint on its own, bypassing every check\n * `sendEmail` owns.\n */\nexport async function postResendEmail(\n apiKey: string,\n payload: ResendSendPayload,\n context?: string,\n): Promise<Response> {\n const raw = payload?.to\n const recipients = (Array.isArray(raw) ? raw : raw == null ? [] : [raw])\n .map((address) => String(address ?? '').trim())\n .filter(Boolean)\n if (!recipients.length) {\n throw new Error(\n `${context ? `${context} ` : ''}send refused before the network — a ` +\n 'Resend payload with no `to` field cannot become a message, and the ' +\n 'attempt would surface only as a 422 in the Resend dashboard',\n )\n }\n\n return fetch(RESEND_SEND_ENDPOINT, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(payload),\n })\n}\n\n/**\n * Sends one email through Resend.\n *\n * **Never throws and never rejects.** Outbound mail is best-effort across\n * every caller in this codebase — a checkout must not fail because a receipt\n * bounced — so every outcome comes back as a `SendEmailResult` instead. The\n * one thing callers must not do is ignore the result: `sent` is what tells\n * the user whether a message actually went out (AGL-708).\n *\n * When the env vars are missing this warns once per call and returns\n * `{ sent: false, reason: 'unconfigured' }` rather than failing, so local and\n * preview environments keep working without a Resend account.\n */\nexport async function sendEmail(\n options: SendEmailOptions,\n): Promise<SendEmailResult> {\n const { apiKey, from: configuredFrom } = getEmailConfig()\n const label = options.context ? `${options.context} email` : 'email'\n\n /*\n * THE SENDING-IDENTITY REFUSAL.\n *\n * Enforced here AND at the campaign route, independently, for the reason\n * the send-rate governor is enforced twice: the route's check is the one a\n * person sees, and this one is what holds when a caller does not make it.\n * A governor is injectable and a route is skippable, so neither may be the\n * only thing standing between an unverified domain and a send.\n *\n * Placed above the `apiKey`/`from` gate so a refusal cannot be reported as\n * `unconfigured` — the two have different owners and different fixes.\n */\n const identityRefusal = sendingIdentityRefusal(options.sendingIdentity)\n if (identityRefusal) {\n console.warn(`${label} refused — ${identityRefusal.message}`)\n return {\n sent: false,\n reason: 'unverified-domain',\n detail: identityRefusal.message,\n }\n }\n\n // A resolved identity outranks the configured sender: it is the server's\n // answer to which verified address this message leaves on. Without one, the\n // white-label display name is applied to the configured verified sender\n // (White-Label Phase 1).\n //\n // Two sources, and `options` is neither of them. Nothing the caller passes\n // reaches the address — only the display name in front of it.\n const resolvedFrom = options.sendingIdentity?.from ?? null\n\n /*\n * THE PLATFORM DOMAIN IS NOT A FALLBACK FOR TENANT MAIL.\n *\n * `configuredFrom` is `USAGE_EMAIL_FROM` — an address on `aglyn.com`, where\n * Aglyn's own billing, account and console mail leaves from. A site's mail\n * reaching it means that site's list quality is charged against the domain\n * every other customer's password reset depends on.\n *\n * `resolveHostSendingIdentity` already refuses above, so a tenant caller\n * that resolved an identity never arrives here with `resolvedFrom` null.\n * This is the arm for a tenant caller that resolved NOTHING — the shape a\n * new send site takes when its author does not know an identity is owed —\n * and it is checked here rather than left to the call sites because ninety\n * of them cannot each be relied on to remember.\n */\n if (options.audience === 'tenant' && !resolvedFrom) {\n console.warn(\n `${label} refused — a site's mail cannot leave on the shared platform ` +\n 'domain, and no sending identity was resolved for it',\n )\n return {\n sent: false,\n reason: 'unverified-domain',\n detail:\n 'This message belongs to a site and no sending identity was ' +\n 'resolved for it, so it was refused rather than sent from the ' +\n 'shared Aglyn address.',\n }\n }\n\n const from = applyFromName(\n resolvedFrom ?? configuredFrom,\n options.fromName,\n )\n\n if (!apiKey || !from) {\n console.warn(\n `${label} skipped — set RESEND_API_KEY and USAGE_EMAIL_FROM to ` +\n 'deliver mail',\n )\n return { sent: false, reason: 'unconfigured' }\n }\n\n const to = normalizeRecipients(options.to)\n if (!to.length) {\n console.warn(`${label} skipped — no valid recipient address`)\n return { sent: false, reason: 'no-recipient' }\n }\n\n /*\n * THE MARKETING GATE.\n *\n * Everything a marketing message owes, asked once, here — because the four\n * merchant-triggered bulk paths that owed it discharged none of it, and\n * asking four call sites to remember is the shape that produces the fifth\n * that does not.\n *\n * Ahead of the send-rate governor deliberately. A refusal here is a message\n * that must never leave, so spending platform hourly budget deciding that\n * would be budget the rest of the hour's mail no longer has.\n *\n * Nothing installed is UNGATED. Same posture as the governor: the durable\n * half lives in another library, and a deployment that never installs it\n * must still send.\n */\n /*\n * TWO URLS, and which one goes where is the whole RFC 8058 story.\n *\n * `unsubscribeUrl` is what a PERSON clicks in the footer below, and the\n * gate points it at the preference page so the stream this message belongs\n * to is one of the things they can stop instead of all of it.\n * `oneClickUrl` is what `List-Unsubscribe` names, and it must stay on the\n * route whose POST writes immediately — a mailbox provider POSTs it with\n * nobody present and reads the 200 as \"done\", which a page of checkboxes\n * cannot honor.\n *\n * It falls back to `unsubscribeUrl` for a caller that minted one link and\n * named no page, which is the only shape that was ever possible before the\n * gate minted the pair.\n */\n let unsubscribeUrl = options.marketing?.unsubscribeUrl ?? ''\n let oneClickUrl = options.marketing?.oneClickUrl ?? ''\n if (options.marketing) {\n if (to.length !== 1) {\n // Not a delivery outcome — a caller error, and one that would put the\n // first recipient's signed unsubscribe link in everybody else's copy.\n console.error(\n `${label} refused — a marketing send addresses exactly one ` +\n `recipient, and this one names ${to.length}`,\n )\n return {\n sent: false,\n reason: 'no-recipient',\n detail: 'A marketing send addresses exactly one recipient.',\n }\n }\n const gate = getMarketingSendGate()\n if (gate) {\n let verdict: Awaited<ReturnType<typeof gate>> | null\n try {\n verdict = await gate({\n hostId: options.marketing.hostId,\n siteBase: options.marketing.siteBase,\n email: to[0],\n context: options.context,\n capped: options.marketing.capped !== false,\n // Passed through verbatim, INCLUDING absent. A default topic\n // applied here would invent a stream for every caller that named\n // none and let a topic opt-out refuse messages that belong to no\n // stream — see `MarketingSendContext.topicId`.\n ...(options.marketing.topicId\n ? { topicId: options.marketing.topicId }\n : {}),\n consentHostIds: options.marketing.consentHostIds,\n })\n } catch (error) {\n /*\n * FAILS OPEN, and the asymmetry with `filterSendableForHost` is\n * deliberate rather than an oversight. That helper fails CLOSED\n * because a suppression list it could not read is not a list that\n * said an address is safe to mail — and it keeps doing so, inside\n * the gate. What is being caught here is the gate itself being\n * unreachable or throwing, which is an outage on the control; an\n * outage on a control that becomes an outage on the product is the\n * worse of the two bugs, and it is the posture `sendEmail` takes\n * everywhere else.\n */\n console.error(`${label} marketing gate failed — allowing`, error)\n verdict = null\n }\n if (verdict && !verdict.allowed) {\n /*\n * A cadence refusal reports as `frequency-capped` rather than earning\n * a value of its own in {@link SendEmailFailureReason}.\n *\n * That union is what {@link isDeferrableSendResult} switches on, and\n * the two are deferrable for exactly the same reason: a later attempt\n * passes because time went by. A third value would have to be added\n * to that predicate as well, and a sweep built against the older\n * vocabulary would silently treat the recipient's own request as\n * terminal and stamp the subject — discarding a message the recipient\n * asked to receive later rather than never. Which of the two it was\n * is in `detail`, where a person reading a log needs it.\n */\n const reason: SendEmailFailureReason =\n verdict.refusal === 'frequency-capped' ||\n verdict.refusal === 'cadence-limited'\n ? 'frequency-capped'\n : verdict.refusal === 'unengaged'\n ? 'unengaged'\n : 'suppressed'\n console.warn(`${label} not sent — ${verdict.detail ?? reason}`)\n return { sent: false, reason, detail: verdict.detail }\n }\n unsubscribeUrl = unsubscribeUrl || verdict?.unsubscribeUrl || ''\n oneClickUrl = oneClickUrl || verdict?.oneClickUrl || ''\n }\n if (!unsubscribeUrl) {\n // A marketing message with no way out is the defect this gate exists to\n // close, so it is said out loud rather than shipped quietly. Not a\n // refusal: the cause is a missing `EMAIL_UNSUBSCRIBE_SECRET` or a host\n // with no public origin — an operator's configuration, not the\n // recipient's problem — and refusing here would turn it into silence.\n console.warn(\n `${label} carries no unsubscribe link — set ` +\n 'EMAIL_UNSUBSCRIBE_SECRET and publish the site on a domain',\n )\n }\n }\n\n /*\n * BULK MAIL WITH NO WAY OUT DOES NOT LEAVE ON THE POOLED IDENTITY.\n *\n * The pool carries marketing for every site that has no domain of its own,\n * and what keeps that survivable is that a recipient can always stop it: the\n * complaint that would otherwise be charged to every other site on the member\n * has a cheaper alternative one click away. A message that lost its\n * unsubscribe link removes that alternative, so it is refused here rather\n * than sent at other people's expense.\n *\n * Asked AFTER the gate, because only here is the answer known. The URL may\n * arrive from the caller, from the gate that mints one per recipient, or —\n * for a campaign, which composes its own one-click pair upstream — as a\n * `List-Unsubscribe` header and no marketing context at all. Reading only one\n * of the three would refuse the senders that are behaving.\n *\n * The classification is DERIVED, never declared. See `isMarketingMessage`.\n */\n const pooledRefusal = isMarketingMessage(options)\n ? pooledMarketingRefusal(\n options.sendingIdentity,\n Boolean(unsubscribeUrl) ||\n Boolean(options.headers?.['List-Unsubscribe']),\n )\n : null\n if (pooledRefusal) {\n console.warn(`${label} refused — ${pooledRefusal.message}`)\n return {\n sent: false,\n reason: 'unverified-domain',\n detail: pooledRefusal.message,\n }\n }\n\n /*\n * THE PLATFORM SEND-RATE GOVERNOR (AGL-2409).\n *\n * Asked on EVERY send, including transactional ones, because the ceiling is\n * about total volume on one sending domain — a governor that only saw\n * campaigns would report a quiet hour while ten thousand receipts went out.\n * The governor counts what it grants.\n *\n * Two properties this block must have, in order:\n *\n * 1. **A refusal is honoured only for a refusable priority.** This is the\n * second of the two enforcement points described in `send-rate.ts`.\n * `emailSendRateVerdict` already cannot refuse a transactional send;\n * the governor is INJECTABLE, so a wrong one is reachable, and the send\n * path must still be unable to drop a password reset. Anything that is\n * not explicitly a campaign or a bulk sweep sends regardless of the\n * answer.\n * 2. **It fails open.** A governor that throws — Firestore unreachable, no\n * Admin app, a bug — must not stop mail. The counter being unavailable\n * is an outage on the control, and an outage on a control that turns\n * into an outage on the product is a worse bug than the burst it was\n * guarding. The same posture `sendEmail` takes everywhere else: it\n * never throws, and neither does this.\n */\n const priority = resolveSendPriority(options.context, options.priority)\n const governor = getEmailSendGovernor()\n if (governor) {\n let verdict: Awaited<ReturnType<typeof governor>> | null\n try {\n verdict = await governor({\n priority,\n count: to.length,\n context: options.context,\n })\n } catch (error) {\n console.error(`${label} send-rate governor failed — allowing`, error)\n verdict = null\n }\n if (verdict && !verdict.allowed && isRefusablePriority(priority)) {\n const retryAtMs =\n verdict.retryAtMs ?? emailSendRateWindowStartMs(Date.now())\n console.warn(\n `${label} deferred — platform send rate reached ` +\n `(${verdict.used ?? '?'}/${verdict.ceiling ?? '?'} this hour)`,\n )\n return {\n sent: false,\n reason: 'rate-limited',\n retryAtMs,\n detail:\n `Platform hourly send rate reached (${verdict.ceiling ?? '?'}/hour). ` +\n 'Transactional mail is unaffected.',\n }\n }\n }\n\n /*\n * THE VISIBLE OPT-OUT, on both parts.\n *\n * The header pair below is for the mailbox provider; this is for the person\n * — CAN-SPAM asks for a mechanism the recipient can see and use, and most\n * clients render no control for the header at all. Both helpers are\n * idempotent by URL, so a sender that placed its own link (a designed\n * template rendering `{{unsubscribeUrl}}`, the campaign body's footer)\n * keeps its own placement and does not get a second one.\n *\n * `text` first and `html` from the result, so the synthesized HTML part\n * that stands in for a text-only message carries the link as an anchor\n * rather than as characters.\n */\n const text = unsubscribeUrl\n ? appendUnsubscribeText(options.text ?? '', unsubscribeUrl)\n : options.text\n const html = unsubscribeUrl\n ? options.html\n ? appendUnsubscribeHtml(options.html, unsubscribeUrl)\n : renderTextEmailHtml(text ?? '', options.subject)\n : options.html\n\n try {\n const response = await postResendEmail(\n apiKey,\n {\n from,\n to,\n subject: options.subject,\n ...(text ? { text } : {}),\n // The HTML part, from the caller when it has one and otherwise\n // synthesized from `text`. A message with no HTML part carries no\n // anchors, so its links are not links in the inbox AND Resend has\n // nothing to rewrite for click tracking — see `text-email-html.ts`.\n // The caller always wins: this can only fill a gap, never override a\n // designed template.\n ...(() => {\n const body = html || renderTextEmailHtml(text ?? '', options.subject)\n return body ? { html: body } : {}\n })(),\n // The caller's headers plus the RFC 8058 pair for a marketing send.\n // Caller-first, so the campaign sender's own pair is the one that\n // ships and a merchant-authored header is never silently replaced.\n ...(() => {\n const headers = {\n ...unsubscribeHeaders(oneClickUrl || unsubscribeUrl),\n ...(options.headers ?? {}),\n }\n return Object.keys(headers).length ? { headers } : {}\n })(),\n // The caller's tags plus the `context` tag (AGL-2407). Caller-first\n // so a sender that stamps its own `context` keeps it: a tag list with\n // two entries of one name is not a shape worth discovering in\n // production, and the explicit one is the more specific.\n ...(() => {\n const caller = options.tags ?? []\n const derived = caller.some((tag) => tag?.name === 'context')\n ? []\n : contextTag(options.context)\n const tags = [...caller, ...derived]\n return tags.length ? { tags } : {}\n })(),\n ...(options.replyTo ? { reply_to: options.replyTo } : {}),\n },\n options.context,\n )\n\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n /*\n * A 429 IS NOT A STATEMENT ABOUT THIS RECIPIENT, so it does not report\n * as one. See the `rate-limited` member of\n * {@link SendEmailFailureReason} for why the distinction is load-bearing\n * rather than cosmetic — every caller in the tree already has a branch\n * for a deferral, and none of them has one for \"rejected, but try this\n * exact address again later\".\n *\n * A warning rather than an error: the provider asking for a slower pace\n * is a normal thing to be told, and logging it at the level a failed\n * delivery uses is what teaches an operator to skim past both.\n */\n if (response.status === 429) {\n const retryAtMs = providerRetryAtMs(response.headers)\n console.warn(\n `${label} deferred — the provider is rate limiting; retry in ` +\n `${Math.max(0, Math.round((retryAtMs - Date.now()) / 1000))}s`,\n )\n return {\n sent: false,\n reason: 'rate-limited',\n status: response.status,\n retryAtMs,\n detail: detail.slice(0, 500),\n }\n }\n console.error(`${label} failed`, response.status, detail)\n return {\n sent: false,\n reason: 'rejected',\n status: response.status,\n detail: detail.slice(0, 500),\n }\n }\n\n const body = (await response.json().catch(() => null)) as {\n id?: string\n } | null\n return { sent: true, id: body?.id ?? null }\n } catch (error) {\n console.error(`${label} failed`, error)\n return {\n sent: false,\n reason: 'network',\n detail: String((error as Error)?.message ?? error).slice(0, 500),\n }\n }\n}\n\nexport default sendEmail\n"],"names":["emailSendRateWindowStartMs","getEmailSendGovernor","isRefusablePriority","resolveSendPriority","renderTextEmailHtml","sendingIdentityRefusal","pooledMarketingRefusal","appendUnsubscribeHtml","appendUnsubscribeText","getMarketingSendGate","isMarketingMessage","unsubscribeHeaders","RESEND_SEND_ENDPOINT","contextTag","context","value","String","trim","replace","slice","name","rateLimitedRetryAtMs","result","failure","reason","retryAtMs","Number","isFinite","PROVIDER_RETRY_FALLBACK_MS","PROVIDER_RETRY_MAX_MS","headerSeconds","headers","raw","get","undefined","seconds","providerRetryAtMs","nowMs","Date","now","waitMs","Math","min","sendFailureReason","sent","isDeferrableSendResult","getEmailConfig","apiKey","process","env","RESEND_API_KEY","from","USAGE_EMAIL_FROM","isEmailConfigured","Boolean","applyFromName","fromName","angle","match","address","includes","normalizeRecipients","to","list","Array","isArray","map","filter","postResendEmail","payload","recipients","length","Error","fetch","method","Authorization","body","JSON","stringify","sendEmail","options","configuredFrom","label","identityRefusal","sendingIdentity","console","warn","message","detail","resolvedFrom","audience","unsubscribeUrl","marketing","oneClickUrl","error","gate","verdict","hostId","siteBase","email","capped","topicId","consentHostIds","allowed","refusal","pooledRefusal","priority","governor","count","used","ceiling","text","html","subject","response","Object","keys","caller","tags","derived","some","tag","replyTo","reply_to","ok","catch","status","max","round","json","id"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/send-email.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 Resend send endpoint. Every outbound application email in Aglyn goes\n * through here — invites, receipts, usage summaries, campaigns, staff alerts.\n *\n * Auth email (verification, password reset) is Firebase's job and does NOT\n * come through this module.\n */\nimport {\n type EmailSendPriority,\n emailSendRateWindowStartMs,\n getEmailSendGovernor,\n isRefusablePriority,\n resolveSendPriority,\n} from './send-rate'\nimport { renderTextEmailHtml } from './text-email-html'\nimport {\n sendingIdentityRefusal,\n pooledMarketingRefusal,\n type SendingIdentityAudience,\n type SendingIdentityVerdict,\n} from './sending-domain'\nimport {\n appendUnsubscribeHtml,\n appendUnsubscribeText,\n getMarketingSendGate,\n isMarketingMessage,\n unsubscribeHeaders,\n type MarketingSendContext,\n} from './marketing-send'\n\nexport const RESEND_SEND_ENDPOINT = 'https://api.resend.com/emails'\n\n/** A Resend delivery tag, used for webhook attribution (AGL-268). */\nexport interface EmailTag {\n name: string\n value: string\n}\n\nexport interface SendEmailOptions {\n /** One or more recipient addresses. */\n to: string | string[]\n subject: string\n /** Plain-text body. Supply at least one of `text` or `html`. */\n text?: string\n /**\n * HTML body. Supply at least one of `text` or `html`.\n *\n * Omitted, one is synthesized from `text` so the message always carries an\n * HTML part — a text-only message has no anchors, so its links are inert in\n * the inbox and Resend's click tracking has nothing to rewrite. See\n * `text-email-html.ts`.\n */\n html?: string\n /** Extra MIME headers, e.g. `List-Unsubscribe`. */\n headers?: Record<string, string>\n /** Delivery tags for the opens/clicks webhook. */\n tags?: EmailTag[]\n replyTo?: string | string[]\n /*\n * THERE IS NO `from`.\n *\n * There was: a raw override of the configured sender, subordinate to a\n * resolved identity but winning over everything else, verified against\n * nothing. Every address this function can send from now comes from one of\n * exactly two places — the deployment's own `USAGE_EMAIL_FROM`, or a\n * {@link SendingIdentityVerdict} the server resolved from a document — and\n * neither is reachable from a request body.\n *\n * Deleting it rather than guarding it is what makes that a property instead\n * of a habit. A guard would have to be written correctly at each of the\n * ninety-odd call sites, or once here and then trusted; an option that does\n * not exist cannot be passed by the next sender, and `resolveSendingIdentity`\n * becomes the whole of the answer to \"as whom does this leave\".\n *\n * The resolution below reads no `from` from `options` either, so this is not\n * only a compile-time close. Marketplace plugin bundles reach `sendEmail` as\n * JavaScript and are typechecked against nothing.\n */\n /**\n * White-label display name for the sender (White-Label Phase 1). Replaces\n * only the display name in front of the verified address — the address\n * itself is never taken from the caller, so this cannot forge a different\n * sender. Callers pass `resolveBrandingProfile(org).fromName` here so an\n * agency's mail reads as their brand instead of \"Aglyn\".\n */\n fromName?: string\n /**\n * The server-resolved sending identity for this message, from\n * `resolveSendingIdentity`.\n *\n * Supplied, it decides the address and it may refuse the send outright —\n * `fromName` is subordinate to it, because a verdict is the answer to \"may\n * this leave, and as whom\" and a display name is not. Omitted, every\n * existing caller keeps the behavior it had: the configured platform\n * identity with an optional display name.\n *\n * Callers resolve it from the ORG DOCUMENT, never from request input. An\n * address assembled from a request body is a `From:` override wearing a new\n * name, and the invariant `applyFromName` exists to hold is that the\n * address cannot move off a verified identity.\n */\n sendingIdentity?: SendingIdentityVerdict | null\n /**\n * Whose mail this is — see {@link SendingIdentityAudience}.\n *\n * `tenant` says the message belongs to a SITE, and it makes the platform\n * sender unreachable: a tenant message with no resolved identity is refused\n * rather than sent from `aglyn.com`. Pair it with `sendingIdentity` from\n * `hostSendingIdentity(hostId)` and the ordinary path is unchanged; the flag\n * is what decides the behavior when that resolution is missing or refuses.\n *\n * Omitted, a send is platform mail and keeps the configured sender, because\n * that is what the console's own senders are. `email-audience-coverage.spec`\n * sweeps the tenant-owned trees so the omission cannot be an accident there.\n */\n audience?: SendingIdentityAudience\n /**\n * Short label for logs, e.g. `'invite'` or `'usage-summary'`. Makes a\n * failure in the runtime logs traceable to the feature that caused it.\n *\n * Since AGL-2407 it is also stamped as a Resend `context` TAG on every\n * send — see `contextTag` below.\n */\n context?: string\n /**\n * What the platform send-rate governor is allowed to do to this message\n * (AGL-2409). Omitted, it is derived from `context`: `'campaign'` is a\n * campaign and everything else is transactional, so no existing caller\n * changes and the default is the one that can never be refused.\n *\n * Set it to `'bulk'` ONLY from a resumable sweep — a cron that leaves its\n * subject unstamped and picks it up on the next run. A refusal for a bulk\n * send means \"not this hour\", and a caller that cannot come back would turn\n * that into a message nobody ever gets.\n */\n priority?: EmailSendPriority\n /**\n * Declares this message as MARKETING mail for one site's audience — see\n * `marketing-send.ts` for what that means and why it is one seam.\n *\n * Set it and the message gains, in one place, the three things marketing\n * mail owes: the RFC 8058 unsubscribe header pair plus a visible opt-out\n * link, a check against both suppression lists, and a ceiling on how much\n * one person receives from one site.\n *\n * ONE RECIPIENT. An unsubscribe link is an HMAC over the address it belongs\n * to, and a suppression verdict is about one person — so a marketing send\n * addressed to a list would carry the wrong link for everybody after the\n * first, and would ask the gate about one of them. Callers fan out.\n */\n marketing?: MarketingSendContext\n}\n\n/**\n * The `context` tag, attached to every send (AGL-2407).\n *\n * ## Why this is here and not at 37 call sites\n *\n * Until now `tags` were set by exactly one sender, `campaign-send.ts`, which\n * stamps `hostId` and `campaignId` for the opens/clicks webhook. Everything\n * else went out with NO tags at all, so a bounce on an invite, a password\n * reset, a receipt or a usage summary reached the webhook carrying nothing to\n * identify it, and was dropped.\n *\n * The obvious fix — thread an identifier through every call site — asks 37\n * places to remember, which is the shape that produces the 38th that does\n * not. But `context` is ALREADY threaded through 35 of the 37 for logging,\n * and it is exactly the right value: it names the sender. So the tag is\n * derived here, once, and no caller changes.\n *\n * Resend tag values are restricted to ASCII letters, digits, `_` and `-`;\n * anything else is rejected and would fail the whole send. Every `context` in\n * the tree is already a plain slug, but this is mail delivery — a value that\n * makes the send fail is far worse than a value that is sanitised — so the\n * label is normalised rather than trusted, and a context that sanitises to\n * nothing yields no tag rather than an invalid one.\n */\nexport function contextTag(context: string | undefined): EmailTag[] {\n const value = String(context ?? '')\n .trim()\n .replace(/[^A-Za-z0-9_-]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 64)\n return value ? [{ name: 'context', value }] : []\n}\n\n/**\n * Why a send did not happen. `unconfigured` and `no-recipient` mean nothing\n * was attempted; `rejected` and `network` mean Resend was called and failed;\n * `rate-limited` is either, and says so in `status`.\n *\n * `rate-limited` (AGL-2409) and `frequency-capped` are the two a caller may\n * reasonably retry unchanged — see {@link isDeferrableSendResult}, which is\n * where that distinction is made once rather than at each sweep.\n */\nexport type SendEmailFailureReason =\n | 'unconfigured'\n | 'no-recipient'\n /**\n * Resend answered and would not take this message.\n *\n * Per-message and terminal: a malformed payload, an address the provider\n * will not accept, a tag it rejected. A caller that retries it unchanged\n * gets the same answer, so a batch settles the recipient rather than\n * holding a slot open for them.\n *\n * A 429 is deliberately NOT this. See `rate-limited`.\n */\n | 'rejected'\n | 'network'\n /**\n * A refusal that is about the RATE, not about this message — so the message\n * is intact and a later attempt sends it.\n *\n * Two sources, which is why it is one value. The platform hourly governor\n * refuses before the network and nothing is attempted (AGL-2409). Resend\n * answers `429` on the wire when requests arrive faster than it accepts\n * them, and `status` is 429 in that case.\n *\n * The provider arm reports here rather than as `rejected` because of what\n * the two mean to a batch. A `rejected` recipient is settled and never\n * addressed again, which is right for an address the provider will not take\n * and wrong for every recipient a 429 touches: nothing about them was\n * refused, the request was simply too soon. Classified as `rejected` a\n * single rate-limited burst silently deletes the rest of a campaign's\n * audience while the campaign reports itself complete — and the `sent`\n * figure stays honest throughout, so no rate on the report can show it.\n *\n * The provider's quota errors — `daily_quota_exceeded`,\n * `monthly_quota_exceeded` — are also 429 and also land here. That is the\n * right answer for the same reason: none of them is a statement about the\n * recipient, and all of them clear with time.\n */\n | 'rate-limited'\n /**\n * The org selected a custom sending domain and that domain is not verified.\n *\n * Distinct from `unconfigured` because the two need opposite responses: an\n * unconfigured deployment is the operator's to fix, while this is a\n * customer's DNS that is not finished, and the customer is the only person\n * who can finish it. `detail` carries the sentence naming the domain.\n *\n * This is the LAST line of defence, not the visible one. A caller that\n * reaches it has already skipped the check its route should have made, and\n * a refusal seen only here is a log line — which is the shape of the\n * `USAGE_EMAIL_FROM` outage. `performCampaignSend` refuses first, with a\n * `409`, so a person finds out.\n */\n | 'unverified-domain'\n /**\n * A MARKETING send whose recipient is on a suppression list — they\n * unsubscribed from this site, hard-bounced, or pressed \"report spam\".\n *\n * Terminal, and the only outcome here a caller must not retry: retrying is\n * the behavior the suppression exists to stop. Distinct from `rejected`\n * because nothing was attempted and nothing failed — this is the control\n * working.\n */\n | 'suppressed'\n /**\n * A MARKETING send refused because this person has already received their\n * ceiling from this site inside the window.\n *\n * Retryable, unlike `suppressed`: the window rolls. A resumable sweep does\n * not need to do anything about it — its next run asks again.\n */\n | 'frequency-capped'\n /**\n * A MARKETING send refused because this site has been mailing this person\n * for longer than the sunset window with nothing to show for it.\n *\n * TERMINAL for a sweep, and it sits with `suppressed` rather than with\n * `frequency-capped` for a reason worth stating: the frequency window\n * clears by the passage of time, so waiting works. A sunset clears when the\n * PERSON engages, which more mail from us cannot cause — so a sweep that\n * treated it as deferrable would re-read the same doomed row on every beat\n * forever. Nothing about the recipient has been reduced; the next message\n * after they open anything goes.\n */\n | 'unengaged'\n\nexport type SendEmailResult =\n | { sent: true; id: string | null }\n | {\n sent: false\n reason: SendEmailFailureReason\n /** HTTP status, when Resend answered. */\n status?: number\n /** Resend's error body or the thrown message, trimmed for logs. */\n detail?: string\n /**\n * `rate-limited` only: the earliest instant a caller may try again —\n * when the platform hourly window rolls, or what the provider's own\n * `retry-after` asked for. A resumable sweep does not need to wait on\n * it; its next scheduled run is the retry.\n */\n retryAtMs?: number\n }\n\n/**\n * The retry instant when the platform send-rate governor deferred this\n * message, or `null` for every other outcome (AGL-2409).\n *\n * A FUNCTION rather than `result.reason === 'rate-limited'` at each call site,\n * because `strictNullChecks` is OFF repo-wide and TypeScript will not narrow a\n * boolean-literal discriminant without it: `if (result.sent) … else\n * result.reason` does not compile, in every consumer, for a reason that has\n * nothing to do with this union. One helper is also one place to change if the\n * shape of a deferral ever moves.\n */\nexport function rateLimitedRetryAtMs(\n result: SendEmailResult | null | undefined,\n): number | null {\n const failure = result as { reason?: string; retryAtMs?: number } | null\n if (!failure || failure.reason !== 'rate-limited') return null\n const retryAtMs = Number(failure.retryAtMs)\n return Number.isFinite(retryAtMs) ? retryAtMs : 0\n}\n\n/**\n * How long to wait when the provider names no interval of its own.\n *\n * One second, because Resend's rate limit is counted per second — so a wait\n * of a whole window is the shortest one that is certain to have cleared it.\n */\nconst PROVIDER_RETRY_FALLBACK_MS = 1_000\n\n/**\n * The longest wait a provider header may ask for.\n *\n * A `retry-after` is read off the network and reaches a scheduler, so it is\n * clamped rather than trusted: a header of `86400` would park a campaign for\n * a day on one response nobody saw. An hour is past every documented window\n * and short enough that a wrong one costs a run rather than a day.\n */\nconst PROVIDER_RETRY_MAX_MS = 3_600_000\n\n/** One header as whole seconds, or null when it is absent or unreadable. */\nfunction headerSeconds(\n headers: { get?: (name: string) => string | null } | null | undefined,\n name: string,\n): number | null {\n const raw = headers?.get?.(name)\n // `Number(null)` and `Number('')` are both 0, which would read as \"retry\n // immediately\" for a header that is not there at all.\n if (raw === null || raw === undefined || String(raw).trim() === '') return null\n const seconds = Number(raw)\n return Number.isFinite(seconds) && seconds >= 0 ? seconds : null\n}\n\n/**\n * When the provider says a refused request may be repeated.\n *\n * Read from the two headers Resend documents beside a 429, both in whole\n * seconds: `retry-after` first because it is the direct answer to this\n * question, then `ratelimit-reset`, which names when the window rolls. A\n * response carrying neither falls back to one window.\n */\nexport function providerRetryAtMs(\n headers: { get?: (name: string) => string | null } | null | undefined,\n nowMs: number = Date.now(),\n): number {\n const seconds =\n headerSeconds(headers, 'retry-after') ??\n headerSeconds(headers, 'ratelimit-reset')\n const waitMs =\n seconds === null\n ? PROVIDER_RETRY_FALLBACK_MS\n : Math.min(seconds * 1_000, PROVIDER_RETRY_MAX_MS)\n return nowMs + waitMs\n}\n\n/**\n * Why a send did not happen, or `null` when it did.\n *\n * The same accessor `rateLimitedRetryAtMs` is, generalized: `strictNullChecks`\n * is OFF repo-wide, so TypeScript will not narrow the union on `result.sent`\n * and reading `result.reason` at a call site does not compile.\n */\nexport function sendFailureReason(\n result: SendEmailResult | null | undefined,\n): SendEmailFailureReason | null {\n const failure = result as {\n sent?: boolean\n reason?: SendEmailFailureReason\n } | null\n if (!failure || failure.sent) return null\n return failure.reason ?? null\n}\n\n/**\n * Whether this outcome is worth coming back for.\n *\n * TRUE only for the two refusals a later attempt can pass: the platform hour\n * rolls, and so does the marketing frequency window. Everything else is\n * either a delivery that happened or a failure a retry repeats — a\n * suppression most of all, since retrying is the exact behavior a suppression\n * exists to stop, and a sunset for the same reason at one remove: it clears\n * when the recipient engages, which no amount of further mail from us brings\n * about.\n *\n * A resumable sweep uses this to decide whether to leave its subject\n * unstamped. Stamping on a deferrable refusal discards a message; NOT\n * stamping on a terminal one re-reads the same doomed row on every beat until\n * it crowds out the work that could succeed. Both are silent, so the\n * distinction lives here instead of at each sweep.\n *\n * A FUNCTION rather than `result.reason === …` at each call site, because\n * `strictNullChecks` is OFF repo-wide and TypeScript will not narrow a\n * boolean-literal discriminant without it — the same reason\n * {@link rateLimitedRetryAtMs} beside it is one.\n */\nexport function isDeferrableSendResult(\n result: SendEmailResult | null | undefined,\n): boolean {\n const reason = sendFailureReason(result)\n return reason === 'rate-limited' || reason === 'frequency-capped'\n}\n\nexport interface EmailConfig {\n apiKey: string | undefined\n from: string | undefined\n}\n\n/**\n * Reads the email environment.\n *\n * Deliberately read per call rather than captured at module load: these run\n * in serverless handlers where the module may be evaluated during a build,\n * long before the runtime env exists.\n */\nexport function getEmailConfig(): EmailConfig {\n return {\n apiKey: process.env.RESEND_API_KEY || undefined,\n from: process.env.USAGE_EMAIL_FROM || undefined,\n }\n}\n\n/**\n * True when both `RESEND_API_KEY` and `USAGE_EMAIL_FROM` are present.\n *\n * Callers that answer an HTTP request (rather than firing best-effort mail)\n * use this to return a 501 with an actionable message instead of pretending\n * to have sent something.\n */\nexport function isEmailConfigured(): boolean {\n const { apiKey, from } = getEmailConfig()\n return Boolean(apiKey && from)\n}\n\n/**\n * Applies a white-label display name to a configured sender while keeping\n * its verified address (White-Label Phase 1). Accepts either a bare address\n * (`noreply@aglyn.com`) or an RFC-5322 `Name <addr>` header and returns\n * `\"<fromName>\" <addr>`. A blank name, or a value with no extractable\n * address, yields the original `from` untouched — the sender identity is\n * never dropped on the floor.\n */\nexport function applyFromName(\n from: string | undefined,\n fromName: string | undefined,\n): string | undefined {\n const name = (fromName ?? '').trim()\n if (!from || !name) return from\n const angle = from.match(/<([^>]+)>/)\n const address = (angle ? angle[1] : from).trim()\n if (!address.includes('@')) return from\n // Quote the display name so commas/specials stay inside one mailbox.\n return `\"${name.replace(/\"/g, '')}\" <${address}>`\n}\n\nfunction normalizeRecipients(to: string | string[]): string[] {\n const list = Array.isArray(to) ? to : [to]\n return list\n .map((address) => String(address ?? '').trim())\n .filter((address) => address.includes('@'))\n}\n\n/** A Resend send payload in the provider's own wire shape. */\nexport interface ResendSendPayload {\n to?: unknown\n from?: unknown\n subject?: unknown\n [field: string]: unknown\n}\n\n/**\n * The one place that POSTs to Resend's send endpoint, and the last thing\n * standing between a payload and the network.\n *\n * A payload carrying no recipient cannot become a message. Resend answers it\n * `422 missing_required_field`, which costs an API call and then shows up in\n * the vendor dashboard as a red line indistinguishable from mail that\n * genuinely failed to deliver — carrying no subject, no recipient and nothing\n * naming the code that produced it. Diagnosing that means reading a log\n * outside the deployment and guessing. So the refusal happens here, before\n * the fetch, and names the caller's `context`.\n *\n * It throws rather than returning a `SendEmailResult`: this is a programming\n * error, not a delivery outcome. `sendEmail` filters recipients well before\n * it reaches this call, so nothing on the ordinary path can trip it. The\n * guard exists because `RESEND_SEND_ENDPOINT` is exported and any module can\n * therefore reach the send endpoint on its own, bypassing every check\n * `sendEmail` owns.\n */\nexport async function postResendEmail(\n apiKey: string,\n payload: ResendSendPayload,\n context?: string,\n): Promise<Response> {\n const raw = payload?.to\n const recipients = (Array.isArray(raw) ? raw : raw == null ? [] : [raw])\n .map((address) => String(address ?? '').trim())\n .filter(Boolean)\n if (!recipients.length) {\n throw new Error(\n `${context ? `${context} ` : ''}send refused before the network — a ` +\n 'Resend payload with no `to` field cannot become a message, and the ' +\n 'attempt would surface only as a 422 in the Resend dashboard',\n )\n }\n\n return fetch(RESEND_SEND_ENDPOINT, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(payload),\n })\n}\n\n/**\n * Sends one email through Resend.\n *\n * **Never throws and never rejects.** Outbound mail is best-effort across\n * every caller in this codebase — a checkout must not fail because a receipt\n * bounced — so every outcome comes back as a `SendEmailResult` instead. The\n * one thing callers must not do is ignore the result: `sent` is what tells\n * the user whether a message actually went out (AGL-708).\n *\n * When the env vars are missing this warns once per call and returns\n * `{ sent: false, reason: 'unconfigured' }` rather than failing, so local and\n * preview environments keep working without a Resend account.\n */\nexport async function sendEmail(\n options: SendEmailOptions,\n): Promise<SendEmailResult> {\n const { apiKey, from: configuredFrom } = getEmailConfig()\n const label = options.context ? `${options.context} email` : 'email'\n\n /*\n * THE SENDING-IDENTITY REFUSAL.\n *\n * Enforced here AND at the campaign route, independently, for the reason\n * the send-rate governor is enforced twice: the route's check is the one a\n * person sees, and this one is what holds when a caller does not make it.\n * A governor is injectable and a route is skippable, so neither may be the\n * only thing standing between an unverified domain and a send.\n *\n * Placed above the `apiKey`/`from` gate so a refusal cannot be reported as\n * `unconfigured` — the two have different owners and different fixes.\n */\n const identityRefusal = sendingIdentityRefusal(options.sendingIdentity)\n if (identityRefusal) {\n console.warn(`${label} refused — ${identityRefusal.message}`)\n return {\n sent: false,\n reason: 'unverified-domain',\n detail: identityRefusal.message,\n }\n }\n\n // A resolved identity outranks the configured sender: it is the server's\n // answer to which verified address this message leaves on. Without one, the\n // white-label display name is applied to the configured verified sender\n // (White-Label Phase 1).\n //\n // Two sources, and `options` is neither of them. Nothing the caller passes\n // reaches the address — only the display name in front of it.\n const resolvedFrom = options.sendingIdentity?.from ?? null\n\n /*\n * THE PLATFORM DOMAIN IS NOT A FALLBACK FOR TENANT MAIL.\n *\n * `configuredFrom` is `USAGE_EMAIL_FROM` — an address on `aglyn.com`, where\n * Aglyn's own billing, account and console mail leaves from. A site's mail\n * reaching it means that site's list quality is charged against the domain\n * every other customer's password reset depends on.\n *\n * `resolveHostSendingIdentity` already refuses above, so a tenant caller\n * that resolved an identity never arrives here with `resolvedFrom` null.\n * This is the arm for a tenant caller that resolved NOTHING — the shape a\n * new send site takes when its author does not know an identity is owed —\n * and it is checked here rather than left to the call sites because ninety\n * of them cannot each be relied on to remember.\n */\n if (options.audience === 'tenant' && !resolvedFrom) {\n console.warn(\n `${label} refused — a site's mail cannot leave on the shared platform ` +\n 'domain, and no sending identity was resolved for it',\n )\n return {\n sent: false,\n reason: 'unverified-domain',\n detail:\n 'This message belongs to a site and no sending identity was ' +\n 'resolved for it, so it was refused rather than sent from the ' +\n 'shared Aglyn address.',\n }\n }\n\n const from = applyFromName(\n resolvedFrom ?? configuredFrom,\n options.fromName,\n )\n\n if (!apiKey || !from) {\n console.warn(\n `${label} skipped — set RESEND_API_KEY and USAGE_EMAIL_FROM to ` +\n 'deliver mail',\n )\n return { sent: false, reason: 'unconfigured' }\n }\n\n const to = normalizeRecipients(options.to)\n if (!to.length) {\n console.warn(`${label} skipped — no valid recipient address`)\n return { sent: false, reason: 'no-recipient' }\n }\n\n /*\n * THE MARKETING GATE.\n *\n * Everything a marketing message owes, asked once, here — because the four\n * merchant-triggered bulk paths that owed it discharged none of it, and\n * asking four call sites to remember is the shape that produces the fifth\n * that does not.\n *\n * Ahead of the send-rate governor deliberately. A refusal here is a message\n * that must never leave, so spending platform hourly budget deciding that\n * would be budget the rest of the hour's mail no longer has.\n *\n * Nothing installed is UNGATED. Same posture as the governor: the durable\n * half lives in another library, and a deployment that never installs it\n * must still send.\n */\n /*\n * TWO URLS, and which one goes where is the whole RFC 8058 story.\n *\n * `unsubscribeUrl` is what a PERSON clicks in the footer below, and the\n * gate points it at the preference page so the stream this message belongs\n * to is one of the things they can stop instead of all of it.\n * `oneClickUrl` is what `List-Unsubscribe` names, and it must stay on the\n * route whose POST writes immediately — a mailbox provider POSTs it with\n * nobody present and reads the 200 as \"done\", which a page of checkboxes\n * cannot honor.\n *\n * It falls back to `unsubscribeUrl` for a caller that minted one link and\n * named no page, which is the only shape that was ever possible before the\n * gate minted the pair.\n */\n let unsubscribeUrl = options.marketing?.unsubscribeUrl ?? ''\n let oneClickUrl = options.marketing?.oneClickUrl ?? ''\n if (options.marketing) {\n if (to.length !== 1) {\n // Not a delivery outcome — a caller error, and one that would put the\n // first recipient's signed unsubscribe link in everybody else's copy.\n console.error(\n `${label} refused — a marketing send addresses exactly one ` +\n `recipient, and this one names ${to.length}`,\n )\n return {\n sent: false,\n reason: 'no-recipient',\n detail: 'A marketing send addresses exactly one recipient.',\n }\n }\n const gate = getMarketingSendGate()\n if (gate) {\n let verdict: Awaited<ReturnType<typeof gate>> | null\n try {\n verdict = await gate({\n hostId: options.marketing.hostId,\n siteBase: options.marketing.siteBase,\n email: to[0],\n context: options.context,\n capped: options.marketing.capped !== false,\n // Passed through verbatim, INCLUDING absent. A default topic\n // applied here would invent a stream for every caller that named\n // none and let a topic opt-out refuse messages that belong to no\n // stream — see `MarketingSendContext.topicId`.\n ...(options.marketing.topicId\n ? { topicId: options.marketing.topicId }\n : {}),\n consentHostIds: options.marketing.consentHostIds,\n consentAwaitsConfirmation: options.marketing.consentAwaitsConfirmation,\n })\n } catch (error) {\n /*\n * FAILS OPEN, and the asymmetry with `filterSendableForHost` is\n * deliberate rather than an oversight. That helper fails CLOSED\n * because a suppression list it could not read is not a list that\n * said an address is safe to mail — and it keeps doing so, inside\n * the gate. What is being caught here is the gate itself being\n * unreachable or throwing, which is an outage on the control; an\n * outage on a control that becomes an outage on the product is the\n * worse of the two bugs, and it is the posture `sendEmail` takes\n * everywhere else.\n */\n console.error(`${label} marketing gate failed — allowing`, error)\n verdict = null\n }\n if (verdict && !verdict.allowed) {\n /*\n * A cadence refusal reports as `frequency-capped` rather than earning\n * a value of its own in {@link SendEmailFailureReason}.\n *\n * That union is what {@link isDeferrableSendResult} switches on, and\n * the two are deferrable for exactly the same reason: a later attempt\n * passes because time went by. A third value would have to be added\n * to that predicate as well, and a sweep built against the older\n * vocabulary would silently treat the recipient's own request as\n * terminal and stamp the subject — discarding a message the recipient\n * asked to receive later rather than never. Which of the two it was\n * is in `detail`, where a person reading a log needs it.\n */\n const reason: SendEmailFailureReason =\n verdict.refusal === 'frequency-capped' ||\n verdict.refusal === 'cadence-limited'\n ? 'frequency-capped'\n : verdict.refusal === 'unengaged'\n ? 'unengaged'\n : 'suppressed'\n console.warn(`${label} not sent — ${verdict.detail ?? reason}`)\n return { sent: false, reason, detail: verdict.detail }\n }\n unsubscribeUrl = unsubscribeUrl || verdict?.unsubscribeUrl || ''\n oneClickUrl = oneClickUrl || verdict?.oneClickUrl || ''\n }\n if (!unsubscribeUrl) {\n // A marketing message with no way out is the defect this gate exists to\n // close, so it is said out loud rather than shipped quietly. Not a\n // refusal: the cause is a missing `EMAIL_UNSUBSCRIBE_SECRET` or a host\n // with no public origin — an operator's configuration, not the\n // recipient's problem — and refusing here would turn it into silence.\n console.warn(\n `${label} carries no unsubscribe link — set ` +\n 'EMAIL_UNSUBSCRIBE_SECRET and publish the site on a domain',\n )\n }\n }\n\n /*\n * BULK MAIL WITH NO WAY OUT DOES NOT LEAVE ON THE POOLED IDENTITY.\n *\n * The pool carries marketing for every site that has no domain of its own,\n * and what keeps that survivable is that a recipient can always stop it: the\n * complaint that would otherwise be charged to every other site on the member\n * has a cheaper alternative one click away. A message that lost its\n * unsubscribe link removes that alternative, so it is refused here rather\n * than sent at other people's expense.\n *\n * Asked AFTER the gate, because only here is the answer known. The URL may\n * arrive from the caller, from the gate that mints one per recipient, or —\n * for a campaign, which composes its own one-click pair upstream — as a\n * `List-Unsubscribe` header and no marketing context at all. Reading only one\n * of the three would refuse the senders that are behaving.\n *\n * The classification is DERIVED, never declared. See `isMarketingMessage`.\n */\n const pooledRefusal = isMarketingMessage(options)\n ? pooledMarketingRefusal(\n options.sendingIdentity,\n Boolean(unsubscribeUrl) ||\n Boolean(options.headers?.['List-Unsubscribe']),\n )\n : null\n if (pooledRefusal) {\n console.warn(`${label} refused — ${pooledRefusal.message}`)\n return {\n sent: false,\n reason: 'unverified-domain',\n detail: pooledRefusal.message,\n }\n }\n\n /*\n * THE PLATFORM SEND-RATE GOVERNOR (AGL-2409).\n *\n * Asked on EVERY send, including transactional ones, because the ceiling is\n * about total volume on one sending domain — a governor that only saw\n * campaigns would report a quiet hour while ten thousand receipts went out.\n * The governor counts what it grants.\n *\n * Two properties this block must have, in order:\n *\n * 1. **A refusal is honoured only for a refusable priority.** This is the\n * second of the two enforcement points described in `send-rate.ts`.\n * `emailSendRateVerdict` already cannot refuse a transactional send;\n * the governor is INJECTABLE, so a wrong one is reachable, and the send\n * path must still be unable to drop a password reset. Anything that is\n * not explicitly a campaign or a bulk sweep sends regardless of the\n * answer.\n * 2. **It fails open.** A governor that throws — Firestore unreachable, no\n * Admin app, a bug — must not stop mail. The counter being unavailable\n * is an outage on the control, and an outage on a control that turns\n * into an outage on the product is a worse bug than the burst it was\n * guarding. The same posture `sendEmail` takes everywhere else: it\n * never throws, and neither does this.\n */\n const priority = resolveSendPriority(options.context, options.priority)\n const governor = getEmailSendGovernor()\n if (governor) {\n let verdict: Awaited<ReturnType<typeof governor>> | null\n try {\n verdict = await governor({\n priority,\n count: to.length,\n context: options.context,\n })\n } catch (error) {\n console.error(`${label} send-rate governor failed — allowing`, error)\n verdict = null\n }\n if (verdict && !verdict.allowed && isRefusablePriority(priority)) {\n const retryAtMs =\n verdict.retryAtMs ?? emailSendRateWindowStartMs(Date.now())\n console.warn(\n `${label} deferred — platform send rate reached ` +\n `(${verdict.used ?? '?'}/${verdict.ceiling ?? '?'} this hour)`,\n )\n return {\n sent: false,\n reason: 'rate-limited',\n retryAtMs,\n detail:\n `Platform hourly send rate reached (${verdict.ceiling ?? '?'}/hour). ` +\n 'Transactional mail is unaffected.',\n }\n }\n }\n\n /*\n * THE VISIBLE OPT-OUT, on both parts.\n *\n * The header pair below is for the mailbox provider; this is for the person\n * — CAN-SPAM asks for a mechanism the recipient can see and use, and most\n * clients render no control for the header at all. Both helpers are\n * idempotent by URL, so a sender that placed its own link (a designed\n * template rendering `{{unsubscribeUrl}}`, the campaign body's footer)\n * keeps its own placement and does not get a second one.\n *\n * `text` first and `html` from the result, so the synthesized HTML part\n * that stands in for a text-only message carries the link as an anchor\n * rather than as characters.\n */\n const text = unsubscribeUrl\n ? appendUnsubscribeText(options.text ?? '', unsubscribeUrl)\n : options.text\n const html = unsubscribeUrl\n ? options.html\n ? appendUnsubscribeHtml(options.html, unsubscribeUrl)\n : renderTextEmailHtml(text ?? '', options.subject)\n : options.html\n\n try {\n const response = await postResendEmail(\n apiKey,\n {\n from,\n to,\n subject: options.subject,\n ...(text ? { text } : {}),\n // The HTML part, from the caller when it has one and otherwise\n // synthesized from `text`. A message with no HTML part carries no\n // anchors, so its links are not links in the inbox AND Resend has\n // nothing to rewrite for click tracking — see `text-email-html.ts`.\n // The caller always wins: this can only fill a gap, never override a\n // designed template.\n ...(() => {\n const body = html || renderTextEmailHtml(text ?? '', options.subject)\n return body ? { html: body } : {}\n })(),\n // The caller's headers plus the RFC 8058 pair for a marketing send.\n // Caller-first, so the campaign sender's own pair is the one that\n // ships and a merchant-authored header is never silently replaced.\n ...(() => {\n const headers = {\n ...unsubscribeHeaders(oneClickUrl || unsubscribeUrl),\n ...(options.headers ?? {}),\n }\n return Object.keys(headers).length ? { headers } : {}\n })(),\n // The caller's tags plus the `context` tag (AGL-2407). Caller-first\n // so a sender that stamps its own `context` keeps it: a tag list with\n // two entries of one name is not a shape worth discovering in\n // production, and the explicit one is the more specific.\n ...(() => {\n const caller = options.tags ?? []\n const derived = caller.some((tag) => tag?.name === 'context')\n ? []\n : contextTag(options.context)\n const tags = [...caller, ...derived]\n return tags.length ? { tags } : {}\n })(),\n ...(options.replyTo ? { reply_to: options.replyTo } : {}),\n },\n options.context,\n )\n\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n /*\n * A 429 IS NOT A STATEMENT ABOUT THIS RECIPIENT, so it does not report\n * as one. See the `rate-limited` member of\n * {@link SendEmailFailureReason} for why the distinction is load-bearing\n * rather than cosmetic — every caller in the tree already has a branch\n * for a deferral, and none of them has one for \"rejected, but try this\n * exact address again later\".\n *\n * A warning rather than an error: the provider asking for a slower pace\n * is a normal thing to be told, and logging it at the level a failed\n * delivery uses is what teaches an operator to skim past both.\n */\n if (response.status === 429) {\n const retryAtMs = providerRetryAtMs(response.headers)\n console.warn(\n `${label} deferred — the provider is rate limiting; retry in ` +\n `${Math.max(0, Math.round((retryAtMs - Date.now()) / 1000))}s`,\n )\n return {\n sent: false,\n reason: 'rate-limited',\n status: response.status,\n retryAtMs,\n detail: detail.slice(0, 500),\n }\n }\n console.error(`${label} failed`, response.status, detail)\n return {\n sent: false,\n reason: 'rejected',\n status: response.status,\n detail: detail.slice(0, 500),\n }\n }\n\n const body = (await response.json().catch(() => null)) as {\n id?: string\n } | null\n return { sent: true, id: body?.id ?? null }\n } catch (error) {\n console.error(`${label} failed`, error)\n return {\n sent: false,\n reason: 'network',\n detail: String((error as Error)?.message ?? error).slice(0, 500),\n }\n }\n}\n\nexport default sendEmail\n"],"names":["emailSendRateWindowStartMs","getEmailSendGovernor","isRefusablePriority","resolveSendPriority","renderTextEmailHtml","sendingIdentityRefusal","pooledMarketingRefusal","appendUnsubscribeHtml","appendUnsubscribeText","getMarketingSendGate","isMarketingMessage","unsubscribeHeaders","RESEND_SEND_ENDPOINT","contextTag","context","value","String","trim","replace","slice","name","rateLimitedRetryAtMs","result","failure","reason","retryAtMs","Number","isFinite","PROVIDER_RETRY_FALLBACK_MS","PROVIDER_RETRY_MAX_MS","headerSeconds","headers","raw","get","undefined","seconds","providerRetryAtMs","nowMs","Date","now","waitMs","Math","min","sendFailureReason","sent","isDeferrableSendResult","getEmailConfig","apiKey","process","env","RESEND_API_KEY","from","USAGE_EMAIL_FROM","isEmailConfigured","Boolean","applyFromName","fromName","angle","match","address","includes","normalizeRecipients","to","list","Array","isArray","map","filter","postResendEmail","payload","recipients","length","Error","fetch","method","Authorization","body","JSON","stringify","sendEmail","options","configuredFrom","label","identityRefusal","sendingIdentity","console","warn","message","detail","resolvedFrom","audience","unsubscribeUrl","marketing","oneClickUrl","error","gate","verdict","hostId","siteBase","email","capped","topicId","consentHostIds","consentAwaitsConfirmation","allowed","refusal","pooledRefusal","priority","governor","count","used","ceiling","text","html","subject","response","Object","keys","caller","tags","derived","some","tag","replyTo","reply_to","ok","catch","status","max","round","json","id"],"mappings":"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"}
|