@aglyn/shared-util-email 1.0.0-beta.218 → 1.0.0-beta.219

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.
Files changed (42) hide show
  1. package/README.md +58 -19
  2. package/package.json +3 -3
  3. package/src/index.d.ts +2 -0
  4. package/src/index.js +9 -1
  5. package/src/index.js.map +1 -1
  6. package/src/lib/email-delivery-events.d.ts +41 -48
  7. package/src/lib/email-delivery-events.js +18 -247
  8. package/src/lib/email-delivery-events.js.map +1 -1
  9. package/src/lib/email-health.d.ts +30 -43
  10. package/src/lib/email-health.js +45 -141
  11. package/src/lib/email-health.js.map +1 -1
  12. package/src/lib/mail-provider-resend.d.ts +194 -0
  13. package/src/lib/mail-provider-resend.js +649 -0
  14. package/src/lib/mail-provider-resend.js.map +1 -0
  15. package/src/lib/mail-provider-webhook.d.ts +80 -0
  16. package/src/lib/mail-provider-webhook.js +156 -0
  17. package/src/lib/mail-provider-webhook.js.map +1 -0
  18. package/src/lib/mail-provider.d.ts +148 -0
  19. package/src/lib/mail-provider.js +84 -0
  20. package/src/lib/mail-provider.js.map +1 -0
  21. package/src/lib/mail-providers.d.ts +99 -0
  22. package/src/lib/mail-providers.js +182 -0
  23. package/src/lib/mail-providers.js.map +1 -0
  24. package/src/lib/received-email.d.ts +7 -44
  25. package/src/lib/received-email.js +8 -104
  26. package/src/lib/received-email.js.map +1 -1
  27. package/src/lib/send-ceilings.d.ts +0 -57
  28. package/src/lib/send-ceilings.js +0 -47
  29. package/src/lib/send-ceilings.js.map +1 -1
  30. package/src/lib/send-email.d.ts +29 -58
  31. package/src/lib/send-email.js +98 -131
  32. package/src/lib/send-email.js.map +1 -1
  33. package/src/lib/send-rate.js +4 -3
  34. package/src/lib/send-rate.js.map +1 -1
  35. package/src/lib/system-email-catalog.js +3 -3
  36. package/src/lib/system-email-catalog.js.map +1 -1
  37. package/src/lib/tenant-email-catalog.d.ts +7 -0
  38. package/src/lib/tenant-email-catalog.js +10 -591
  39. package/src/lib/tenant-email-catalog.js.map +1 -1
  40. package/src/lib/tenant-emails.generated.d.ts +11 -0
  41. package/src/lib/tenant-emails.generated.js +594 -0
  42. package/src/lib/tenant-emails.generated.js.map +1 -0
@@ -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'\nimport {\n EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS,\n getEmailDeliverabilityPreflight,\n type EmailDeliverabilityPreflightVerdict,\n type EmailSendPurpose,\n} from './email-deliverability'\nimport { bareSenderAddress } from './email-delivery-events'\nimport { screenTenantMessage } from './outbound-screen-gate'\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 * Delivers to an address whose account is BANNED (AGL-3420). A banned\n * account is sent nothing after its ban notice, by any sender; the lock\n * and lift notices themselves are the only mail that states this, and no\n * other sender may.\n */\n accountBanExempt?: boolean\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 * What the RECIPIENT did that makes this message owed to them (AGL-3356):\n * `order` — a receipt, a gift card, a shipping notice for something they\n * bought; `booking` — a confirmation, reminder or change to something they\n * booked; `account` — a password reset or sign-in link they asked for.\n *\n * The outbound phishing screen's SOFT rules never hold an owed message; a\n * lookalike link still does, because a receipt that links to a brand's\n * disguise is the disguise. Omitted — the default, and the one a forgotten\n * caller gets — the message is treated as merchant-authored and every\n * tier applies. A MARKETING message is never owed, whatever it declares.\n */\n owedFor?: 'order' | 'booking' | 'account'\n /**\n * A review row that the caller's own screen held and staff RELEASED for\n * exactly this content — a campaign put back on the clock, an automation\n * step whose hold was dismissed. The send seam's screen honors it only\n * after confirming, through the installed gate, that it is a release for\n * this site; naming a row is not a way past the screen.\n */\n releasedReviewId?: string | null\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 /**\n * The recipient's domain takes no mail — no MX and no address record, or\n * an RFC 7505 null MX — so the message would only have bounced\n * (AGL-3328). Refused for every purpose, transactional included, before\n * the provider was called; the address is suppressed with a reason of its\n * own. Terminal: the preflight answers the same until the domain's DNS\n * changes, which it re-reads daily.\n */\n | 'undeliverable'\n /**\n * A BULK or cold message whose recipient sits behind a mail gateway that\n * refused this sending domain twice in thirty days and delivered nothing\n * (AGL-3328). Terminal for this message rather than deferrable: the hold\n * lifts when the gateway delivers again or the refusals age out, which is\n * not something a sweep's next beat can wait out. Mail the recipient asked\n * for is never held.\n */\n | 'gateway-held'\n /**\n * The site or workspace this message belongs to is under a full lockdown\n * (AGL-3356), carried in on its sending identity. Terminal for this\n * message: a sweep that holds work across a suspension skips the host\n * before it gets here, so a caller that reaches this refusal is one with\n * no way to come back, and retrying would only ask the same question.\n */\n | 'suspended'\n /**\n * The outbound phishing screen held this message for staff review, or\n * staff already rejected a message carrying the same signals from this\n * site (AGL-3356). Nothing was attempted. Terminal for this message:\n * `detail` names the `HS-…` reference, and once staff release the review\n * the site's next message carrying the same signals sends.\n */\n | 'held-for-review'\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\n/**\n * Who a message is for, as the deliverability preflight reads it\n * (AGL-3328): `bulk` for anything a control may already refuse — marketing\n * by either of its two marks, a campaign, a resumable sweep — and\n * `transactional` for the rest, which a gateway hold never touches.\n *\n * Derived from what a sender already declares, for the reason\n * `isMarketingMessage` is: a label callers had to remember would be\n * forgotten by the one that matters, and the permissive answer is the one a\n * forgotten caller gets.\n */\nexport function emailSendPurpose(\n options: Pick<SendEmailOptions, 'priority' | 'context' | 'headers'> & { marketing?: unknown },\n): EmailSendPurpose {\n if (isMarketingMessage(options)) return 'bulk'\n if (options.headers?.['List-Unsubscribe']) return 'bulk'\n return isRefusablePriority(resolveSendPriority(options.context, options.priority))\n ? 'bulk'\n : 'transactional'\n}\n\n/** The preflight's answer, or `null` when none is installed, it failed or it ran out of time. */\nasync function askDeliverabilityPreflight(\n request: Parameters<NonNullable<ReturnType<typeof getEmailDeliverabilityPreflight>>>[0],\n label: string,\n): Promise<EmailDeliverabilityPreflightVerdict | null> {\n const preflight = getEmailDeliverabilityPreflight()\n if (!preflight) return null\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n return await Promise.race([\n preflight(request),\n new Promise<null>((resolve) => {\n timer = setTimeout(() => {\n console.warn(`${label} deliverability preflight timed out — allowing`)\n resolve(null)\n }, EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS)\n ;(timer as { unref?: () => void })?.unref?.()\n }),\n ])\n } catch (error) {\n // Fails OPEN, the governor's posture: an outage on the control must not\n // become an outage on the mail.\n console.error(`${label} deliverability preflight failed — allowing`, error)\n return null\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\n/**\n * The send seam's half of the outbound phishing screen (AGL-3356): every\n * message a SITE sends whose identity carries the workspace\n * `hostSendingIdentity` stamped, through {@link screenTenantMessage} — the\n * pure screen, its tiers, and the installed review store. `null` when the\n * message may go; fails open, as that function documents.\n */\nexport async function askOutboundScreen(\n options: SendEmailOptions,\n label = 'email',\n): Promise<SendEmailResult | null> {\n const workspace = options.sendingIdentity?.workspace\n if (options.audience !== 'tenant' || !workspace?.hostId) return null\n const refusal = await screenTenantMessage(\n {\n workspace,\n subject: options.subject,\n fromName: options.fromName ?? null,\n // The resolved address, never anything the caller passed: the only\n // `From:` this message can leave on (AGL-3362).\n fromAddress: options.sendingIdentity?.from ?? null,\n replyTo: options.replyTo ?? null,\n bodies: [options.text, options.html],\n // A marketing message is never owed, whatever it declares.\n owed: Boolean(options.owedFor) && !isMarketingMessage(options),\n context: options.context ?? null,\n releasedReviewId: options.releasedReviewId ?? null,\n },\n label,\n )\n if (!refusal) return null\n console.warn(`${label} not sent — ${refusal.detail}`)\n return { sent: false, reason: 'held-for-review', detail: refusal.detail }\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 // A suspension is not a DNS state, and the merchant reading the\n // failure has nothing to finish — so it is named for what it is.\n reason:\n identityRefusal.code === 'workspace-suspended'\n ? 'suspended'\n : '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 let 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 OUTBOUND PHISHING SCREEN (AGL-3356).\n *\n * Asked for every message a SITE sends whose identity came through\n * `hostSendingIdentity` — which stamps the workspace on it — so one check\n * covers every tenant sender, first-party or marketplace, without any of\n * them calling it. See `outbound-screen-gate.ts`.\n *\n * Ahead of the preflight and every other gate: a message that must not\n * leave is decided before anything is spent on who receives it.\n */\n const screenRefusal = await askOutboundScreen(options, label)\n if (screenRefusal) return screenRefusal\n\n /*\n * THE DELIVERABILITY PREFLIGHT (AGL-3328).\n *\n * Asked before every other gate, because its refusal is the most final: a\n * recipient whose domain takes no mail is not a recipient this message or\n * any other can reach, and spending the marketing gate's reads or the\n * governor's hourly budget deciding more about them would be spending it\n * on a bounce. A recipient behind a gateway that refused this sending\n * domain twice is held — for bulk mail only; the purpose decides that,\n * and the preflight is told it.\n *\n * A multi-recipient message drops the refused and held recipients and goes\n * to the rest: one colleague's dead address must not stop the others'\n * notification.\n */\n const sendingAddress = bareSenderAddress(from)\n const preflight = await askDeliverabilityPreflight(\n {\n recipients: to.map((address) => address.toLowerCase()),\n purpose: emailSendPurpose(options),\n sendingDomain: sendingAddress ? sendingAddress.slice(sendingAddress.lastIndexOf('@') + 1) : null,\n sendingSource: options.sendingIdentity?.source ?? 'platform',\n context: options.context,\n hostId: options.marketing?.hostId ?? null,\n ...(options.accountBanExempt ? { accountBanExempt: true } : {}),\n },\n label,\n )\n if (preflight && (preflight.refused?.length || preflight.held?.length)) {\n const stopped = new Set(\n [...(preflight.refused ?? []), ...(preflight.held ?? [])].map((entry) =>\n String(entry?.email ?? '').toLowerCase(),\n ),\n )\n const remaining = to.filter((address) => !stopped.has(address.toLowerCase()))\n if (!remaining.length) {\n const refused = preflight.refused?.[0]\n const held = preflight.held?.[0]\n const detail = refused?.reason ?? held?.reason ?? 'The recipient cannot receive this message.'\n console.warn(`${label} not sent — ${detail}`)\n return { sent: false, reason: refused ? 'undeliverable' : 'gateway-held', detail }\n }\n console.warn(\n `${label} — ${to.length - remaining.length} of ${to.length} recipients ` +\n 'dropped by the deliverability preflight',\n )\n to = remaining\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","EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS","getEmailDeliverabilityPreflight","bareSenderAddress","screenTenantMessage","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","emailSendPurpose","options","priority","askDeliverabilityPreflight","request","label","preflight","timer","Promise","race","resolve","setTimeout","console","warn","unref","error","clearTimeout","askOutboundScreen","workspace","sendingIdentity","audience","hostId","refusal","subject","fromName","fromAddress","from","replyTo","bodies","text","html","owed","Boolean","owedFor","releasedReviewId","detail","getEmailConfig","apiKey","process","env","RESEND_API_KEY","USAGE_EMAIL_FROM","isEmailConfigured","applyFromName","angle","match","address","includes","normalizeRecipients","to","list","Array","isArray","map","filter","postResendEmail","payload","recipients","length","Error","fetch","method","Authorization","body","JSON","stringify","sendEmail","configuredFrom","identityRefusal","message","code","resolvedFrom","screenRefusal","sendingAddress","toLowerCase","purpose","sendingDomain","lastIndexOf","sendingSource","source","marketing","accountBanExempt","refused","held","stopped","Set","entry","email","remaining","has","unsubscribeUrl","oneClickUrl","gate","verdict","siteBase","capped","topicId","consentHostIds","consentAwaitsConfirmation","allowed","pooledRefusal","governor","count","used","ceiling","response","Object","keys","caller","tags","derived","some","tag","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 send seam. Every outbound application email in Aglyn goes through\n * here — invites, receipts, usage summaries, campaigns, staff alerts — and\n * everything that decides whether, as whom and to whom a message leaves is\n * decided here. The provider that then carries it is chosen by\n * `mail-providers.ts` and only carries it.\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'\nimport {\n EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS,\n getEmailDeliverabilityPreflight,\n type EmailDeliverabilityPreflightVerdict,\n type EmailSendPurpose,\n} from './email-deliverability'\nimport { bareSenderAddress } from './email-delivery-events'\nimport { screenTenantMessage } from './outbound-screen-gate'\nimport { providerRetryAtMs, type MailProviderSendResult } from './mail-provider'\nimport { mailProvider, mailProviderProblem } from './mail-providers'\n\n/**\n * A delivery tag: attribution the provider hands back on every delivery\n * event for the message, which is how a bounce finds its sender (AGL-268).\n */\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 the provider'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 * Delivers to an address whose account is BANNED (AGL-3420). A banned\n * account is sent nothing after its ban notice, by any sender; the lock\n * and lift notices themselves are the only mail that states this, and no\n * other sender may.\n */\n accountBanExempt?: boolean\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 `context` TAG on every send —\n * 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 * What the RECIPIENT did that makes this message owed to them (AGL-3356):\n * `order` — a receipt, a gift card, a shipping notice for something they\n * bought; `booking` — a confirmation, reminder or change to something they\n * booked; `account` — a password reset or sign-in link they asked for.\n *\n * The outbound phishing screen's SOFT rules never hold an owed message; a\n * lookalike link still does, because a receipt that links to a brand's\n * disguise is the disguise. Omitted — the default, and the one a forgotten\n * caller gets — the message is treated as merchant-authored and every\n * tier applies. A MARKETING message is never owed, whatever it declares.\n */\n owedFor?: 'order' | 'booking' | 'account'\n /**\n * A review row that the caller's own screen held and staff RELEASED for\n * exactly this content — a campaign put back on the clock, an automation\n * step whose hold was dismissed. The send seam's screen honors it only\n * after confirming, through the installed gate, that it is a release for\n * this site; naming a row is not a way past the screen.\n */\n releasedReviewId?: string | null\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 * Providers restrict tag values — to ASCII letters, digits, `_` and `-` at\n * the strictest — and one that rejects a value fails 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 the provider was asked 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 * The provider 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). The\n * provider asks for a slower pace when requests arrive faster than it\n * accepts them, and `status` is its answer (429 over HTTP) 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 /**\n * The recipient's domain takes no mail — no MX and no address record, or\n * an RFC 7505 null MX — so the message would only have bounced\n * (AGL-3328). Refused for every purpose, transactional included, before\n * the provider was called; the address is suppressed with a reason of its\n * own. Terminal: the preflight answers the same until the domain's DNS\n * changes, which it re-reads daily.\n */\n | 'undeliverable'\n /**\n * A BULK or cold message whose recipient sits behind a mail gateway that\n * refused this sending domain twice in thirty days and delivered nothing\n * (AGL-3328). Terminal for this message rather than deferrable: the hold\n * lifts when the gateway delivers again or the refusals age out, which is\n * not something a sweep's next beat can wait out. Mail the recipient asked\n * for is never held.\n */\n | 'gateway-held'\n /**\n * The site or workspace this message belongs to is under a full lockdown\n * (AGL-3356), carried in on its sending identity. Terminal for this\n * message: a sweep that holds work across a suspension skips the host\n * before it gets here, so a caller that reaches this refusal is one with\n * no way to come back, and retrying would only ask the same question.\n */\n | 'suspended'\n /**\n * The outbound phishing screen held this message for staff review, or\n * staff already rejected a message carrying the same signals from this\n * site (AGL-3356). Nothing was attempted. Terminal for this message:\n * `detail` names the `HS-…` reference, and once staff release the review\n * the site's next message carrying the same signals sends.\n */\n | 'held-for-review'\n\nexport type SendEmailResult =\n | { sent: true; id: string | null }\n | {\n sent: false\n reason: SendEmailFailureReason\n /** HTTP status, when the provider answered over HTTP. */\n status?: number\n /** The provider's error text 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 * 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\n/**\n * Who a message is for, as the deliverability preflight reads it\n * (AGL-3328): `bulk` for anything a control may already refuse — marketing\n * by either of its two marks, a campaign, a resumable sweep — and\n * `transactional` for the rest, which a gateway hold never touches.\n *\n * Derived from what a sender already declares, for the reason\n * `isMarketingMessage` is: a label callers had to remember would be\n * forgotten by the one that matters, and the permissive answer is the one a\n * forgotten caller gets.\n */\nexport function emailSendPurpose(\n options: Pick<SendEmailOptions, 'priority' | 'context' | 'headers'> & { marketing?: unknown },\n): EmailSendPurpose {\n if (isMarketingMessage(options)) return 'bulk'\n if (options.headers?.['List-Unsubscribe']) return 'bulk'\n return isRefusablePriority(resolveSendPriority(options.context, options.priority))\n ? 'bulk'\n : 'transactional'\n}\n\n/** The preflight's answer, or `null` when none is installed, it failed or it ran out of time. */\nasync function askDeliverabilityPreflight(\n request: Parameters<NonNullable<ReturnType<typeof getEmailDeliverabilityPreflight>>>[0],\n label: string,\n): Promise<EmailDeliverabilityPreflightVerdict | null> {\n const preflight = getEmailDeliverabilityPreflight()\n if (!preflight) return null\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n return await Promise.race([\n preflight(request),\n new Promise<null>((resolve) => {\n timer = setTimeout(() => {\n console.warn(`${label} deliverability preflight timed out — allowing`)\n resolve(null)\n }, EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS)\n ;(timer as { unref?: () => void })?.unref?.()\n }),\n ])\n } catch (error) {\n // Fails OPEN, the governor's posture: an outage on the control must not\n // become an outage on the mail.\n console.error(`${label} deliverability preflight failed — allowing`, error)\n return null\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\n/**\n * The send seam's half of the outbound phishing screen (AGL-3356): every\n * message a SITE sends whose identity carries the workspace\n * `hostSendingIdentity` stamped, through {@link screenTenantMessage} — the\n * pure screen, its tiers, and the installed review store. `null` when the\n * message may go; fails open, as that function documents.\n */\nexport async function askOutboundScreen(\n options: SendEmailOptions,\n label = 'email',\n): Promise<SendEmailResult | null> {\n const workspace = options.sendingIdentity?.workspace\n if (options.audience !== 'tenant' || !workspace?.hostId) return null\n const refusal = await screenTenantMessage(\n {\n workspace,\n subject: options.subject,\n fromName: options.fromName ?? null,\n // The resolved address, never anything the caller passed: the only\n // `From:` this message can leave on (AGL-3362).\n fromAddress: options.sendingIdentity?.from ?? null,\n replyTo: options.replyTo ?? null,\n bodies: [options.text, options.html],\n // A marketing message is never owed, whatever it declares.\n owed: Boolean(options.owedFor) && !isMarketingMessage(options),\n context: options.context ?? null,\n releasedReviewId: options.releasedReviewId ?? null,\n },\n label,\n )\n if (!refusal) return null\n console.warn(`${label} not sent — ${refusal.detail}`)\n return { sent: false, reason: 'held-for-review', detail: refusal.detail }\n}\n\nexport interface EmailConfig {\n /** The id of the provider mail is handed to — see `mail-providers.ts`. */\n provider: string\n /** Settings that provider still needs before it can send; empty when it can. */\n missingSettings: string[]\n /** The platform's own sender, `USAGE_EMAIL_FROM`. */\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 const provider = mailProvider()\n return {\n provider: provider.id,\n missingSettings: provider.missingSettings(),\n from: process.env.USAGE_EMAIL_FROM || undefined,\n }\n}\n\n/**\n * True when the provider has its settings and `USAGE_EMAIL_FROM` is 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 { missingSettings, from } = getEmailConfig()\n return !missingSettings.length && Boolean(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/**\n * Sends one email through the deployment's mail provider.\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 mail provider.\n */\nexport async function sendEmail(\n options: SendEmailOptions,\n): Promise<SendEmailResult> {\n const provider = mailProvider()\n const configuredFrom = process.env.USAGE_EMAIL_FROM || undefined\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 provider/`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 // A suspension is not a DNS state, and the merchant reading the\n // failure has nothing to finish — so it is named for what it is.\n reason:\n identityRefusal.code === 'workspace-suspended'\n ? 'suspended'\n : '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 const missing = [\n ...provider.missingSettings(),\n ...(from ? [] : ['USAGE_EMAIL_FROM']),\n ]\n if (missing.length) {\n /*\n * An operator who chose a provider this process does not have is told\n * so as an ERROR: that is not a deployment without mail, it is mail\n * somebody set up going nowhere. See `mail-providers.ts`.\n */\n const problem = mailProviderProblem(provider)\n if (problem) {\n console.error(`${label} not sent — ${problem}`)\n return { sent: false, reason: 'unconfigured', detail: problem }\n }\n console.warn(`${label} skipped — set ${missing.join(' and ')} to deliver mail`)\n return { sent: false, reason: 'unconfigured' }\n }\n\n let 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 OUTBOUND PHISHING SCREEN (AGL-3356).\n *\n * Asked for every message a SITE sends whose identity came through\n * `hostSendingIdentity` — which stamps the workspace on it — so one check\n * covers every tenant sender, first-party or marketplace, without any of\n * them calling it. See `outbound-screen-gate.ts`.\n *\n * Ahead of the preflight and every other gate: a message that must not\n * leave is decided before anything is spent on who receives it.\n */\n const screenRefusal = await askOutboundScreen(options, label)\n if (screenRefusal) return screenRefusal\n\n /*\n * THE DELIVERABILITY PREFLIGHT (AGL-3328).\n *\n * Asked before every other gate, because its refusal is the most final: a\n * recipient whose domain takes no mail is not a recipient this message or\n * any other can reach, and spending the marketing gate's reads or the\n * governor's hourly budget deciding more about them would be spending it\n * on a bounce. A recipient behind a gateway that refused this sending\n * domain twice is held — for bulk mail only; the purpose decides that,\n * and the preflight is told it.\n *\n * A multi-recipient message drops the refused and held recipients and goes\n * to the rest: one colleague's dead address must not stop the others'\n * notification.\n */\n const sendingAddress = bareSenderAddress(from)\n const preflight = await askDeliverabilityPreflight(\n {\n recipients: to.map((address) => address.toLowerCase()),\n purpose: emailSendPurpose(options),\n sendingDomain: sendingAddress ? sendingAddress.slice(sendingAddress.lastIndexOf('@') + 1) : null,\n sendingSource: options.sendingIdentity?.source ?? 'platform',\n context: options.context,\n hostId: options.marketing?.hostId ?? null,\n ...(options.accountBanExempt ? { accountBanExempt: true } : {}),\n },\n label,\n )\n if (preflight && (preflight.refused?.length || preflight.held?.length)) {\n const stopped = new Set(\n [...(preflight.refused ?? []), ...(preflight.held ?? [])].map((entry) =>\n String(entry?.email ?? '').toLowerCase(),\n ),\n )\n const remaining = to.filter((address) => !stopped.has(address.toLowerCase()))\n if (!remaining.length) {\n const refused = preflight.refused?.[0]\n const held = preflight.held?.[0]\n const detail = refused?.reason ?? held?.reason ?? 'The recipient cannot receive this message.'\n console.warn(`${label} not sent — ${detail}`)\n return { sent: false, reason: refused ? 'undeliverable' : 'gateway-held', detail }\n }\n console.warn(\n `${label} — ${to.length - remaining.length} of ${to.length} recipients ` +\n 'dropped by the deliverability preflight',\n )\n to = remaining\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 // 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 the provider has\n // nothing to rewrite for click tracking — see `text-email-html.ts`. The\n // caller always wins: this can only fill a gap, never override a\n // designed template.\n const body = html || renderTextEmailHtml(text ?? '', options.subject)\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 ships\n // and a merchant-authored header is never silently replaced.\n const headers = {\n ...unsubscribeHeaders(oneClickUrl || unsubscribeUrl),\n ...(options.headers ?? {}),\n }\n // The caller's tags plus the `context` tag (AGL-2407). Caller-first so a\n // sender that stamps its own `context` keeps it: a tag list with two\n // entries of one name is not a shape worth discovering in production,\n // and the explicit one is the more specific.\n const callerTags = options.tags ?? []\n const tags = [\n ...callerTags,\n ...(callerTags.some((tag) => tag?.name === 'context')\n ? []\n : contextTag(options.context)),\n ]\n\n const outcome = await provider.send(\n {\n from,\n to,\n subject: options.subject,\n ...(text ? { text } : {}),\n ...(body ? { html: body } : {}),\n ...(Object.keys(headers).length ? { headers } : {}),\n ...(tags.length ? { tags } : {}),\n ...(options.replyTo ? { replyTo: options.replyTo } : {}),\n },\n options.context,\n )\n\n if (!outcome.accepted) {\n // `strictNullChecks` is off repo-wide, so the union does not narrow on\n // its boolean discriminant; the cast says what the branch already knows.\n const refusal = outcome as Extract<MailProviderSendResult, { accepted: false }>\n const detail = String(refusal.detail ?? '')\n /*\n * A REQUEST TO SLOW DOWN IS NOT A STATEMENT ABOUT THIS RECIPIENT, so it\n * does not report 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 (refusal.rateLimited) {\n const retryAtMs = refusal.retryAtMs ?? providerRetryAtMs(null)\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 ...(refusal.status !== undefined ? { status: refusal.status } : {}),\n retryAtMs,\n detail: detail.slice(0, 500),\n }\n }\n console.error(`${label} failed`, refusal.status, detail)\n return {\n sent: false,\n reason: 'rejected',\n ...(refusal.status !== undefined ? { status: refusal.status } : {}),\n detail: detail.slice(0, 500),\n }\n }\n\n return {\n sent: true,\n id: (outcome as Extract<MailProviderSendResult, { accepted: true }>).id ?? null,\n }\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","EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS","getEmailDeliverabilityPreflight","bareSenderAddress","screenTenantMessage","providerRetryAtMs","mailProvider","mailProviderProblem","contextTag","context","value","String","trim","replace","slice","name","rateLimitedRetryAtMs","result","failure","reason","retryAtMs","Number","isFinite","sendFailureReason","sent","isDeferrableSendResult","emailSendPurpose","options","headers","priority","askDeliverabilityPreflight","request","label","preflight","timer","Promise","race","resolve","setTimeout","console","warn","unref","error","clearTimeout","askOutboundScreen","workspace","sendingIdentity","audience","hostId","refusal","subject","fromName","fromAddress","from","replyTo","bodies","text","html","owed","Boolean","owedFor","releasedReviewId","detail","getEmailConfig","provider","id","missingSettings","process","env","USAGE_EMAIL_FROM","undefined","isEmailConfigured","length","applyFromName","angle","match","address","includes","normalizeRecipients","to","list","Array","isArray","map","filter","sendEmail","configuredFrom","identityRefusal","message","code","resolvedFrom","missing","problem","join","screenRefusal","sendingAddress","recipients","toLowerCase","purpose","sendingDomain","lastIndexOf","sendingSource","source","marketing","accountBanExempt","refused","held","stopped","Set","entry","email","remaining","has","unsubscribeUrl","oneClickUrl","gate","verdict","siteBase","capped","topicId","consentHostIds","consentAwaitsConfirmation","allowed","pooledRefusal","governor","count","Date","now","used","ceiling","body","callerTags","tags","some","tag","outcome","send","Object","keys","accepted","rateLimited","Math","max","round","status"],"mappings":"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"}
@@ -221,7 +221,8 @@
221
221
  };
222
222
  }
223
223
  /**
224
- * Contexts that are campaign sends, derived rather than threaded.
224
+ * Contexts whose sends take the refusable `campaign` priority, derived rather
225
+ * than threaded.
225
226
  *
226
227
  * The same move `contextTag` makes one file over, and for the same reason: a
227
228
  * `priority` argument added to 37 call sites is 37 places to remember and one
@@ -238,7 +239,7 @@
238
239
  * change, not a regression. The opposite polarity (enumerate what is
239
240
  * protected) would make a forgotten entry a dropped password reset, which is
240
241
  * the failure you find from a support ticket.
241
- */ const CAMPAIGN_CONTEXTS = new Set([
242
+ */ const REFUSABLE_SEND_CONTEXTS = new Set([
242
243
  'campaign'
243
244
  ]);
244
245
  /**
@@ -247,7 +248,7 @@
247
248
  */ export function resolveSendPriority(context, explicit) {
248
249
  if (explicit === 'campaign' || explicit === 'bulk') return explicit;
249
250
  if (explicit === 'transactional') return 'transactional';
250
- return CAMPAIGN_CONTEXTS.has(String(context != null ? context : '').trim()) ? 'campaign' : 'transactional';
251
+ return REFUSABLE_SEND_CONTEXTS.has(String(context != null ? context : '').trim()) ? 'campaign' : 'transactional';
251
252
  }
252
253
  /** True when a control is permitted to refuse this priority at all. */ export function isRefusablePriority(priority) {
253
254
  return priority === 'campaign' || priority === 'bulk';
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/send-rate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE PLATFORM SEND-RATE GOVERNOR — policy half (AGL-2409).\n *\n * Everything Aglyn sends leaves on ONE Resend key, from ONE verified sending\n * domain, under `p=reject`. A throttle or a reputation hit there is not a spam\n * folder: it is a rejection, and it lands on every customer's password resets\n * and receipts at the same time. Before this there was no per-hour or per-day\n * throttle, no batching and no concurrency limiter anywhere in the email path\n * — so the two bursts the product can already produce (the monthly usage\n * summary fanning out to 1,000 orgs in one invocation, and scheduled campaigns\n * at a ceiling of 10 × 500 × 4 = 20,000 messages an hour) had nothing to ramp\n * against, and the day a ramp was needed there would have been nothing to\n * turn.\n *\n * ## Why the policy is pure and dependency-free\n *\n * Same split as `plugin-api-rate-limit.ts` and `api-http.ts`: the decision is\n * a pure function of (priority, ceiling, used, count) and is unit-testable\n * without a Firestore harness or a route. The durable counter lives in\n * `@aglyn/tenant-data-admin` (`email-send-rate.ts`), which is the only layer\n * that may hold the Admin SDK — `@aglyn/shared-util-email` is `scope:shared`\n * and may not import it, and the existing edge already runs the other way\n * (`tenant-data-admin` imports THIS library). So the governor reaches\n * `sendEmail` by injection, {@link setEmailSendGovernor}, rather than by an\n * import that would be a dependency cycle.\n *\n * ## THE BOUNDARY THAT MATTERS MOST\n *\n * **A rate control may only ever refuse a CAMPAIGN or a BULK sweep. It may\n * never refuse a transactional message.** A password reset or an order\n * receipt refused by a throttle is a strictly worse outcome than the burst\n * being throttled: it converts a reputation risk into an outage on somebody\n * else's business, and the mail explaining why is itself mail that will not\n * send. That is the same rule `email-metering.ts` states for the monthly\n * quota, applied to time distribution instead of monthly totals.\n *\n * The rule is enforced in two independent places on purpose — here, in\n * {@link emailSendRateVerdict}, which cannot return `allowed: false` for a\n * transactional send at all; and again in `sendEmail`, which ignores a\n * refusal for a transactional priority whatever the installed governor says.\n * A governor is injectable, so a wrong one is reachable; the send path must\n * still be unable to drop a password reset.\n */\n\n/** The three send priorities, ordered by what a control may do to them. */\nexport type EmailSendPriority =\n /**\n * Answers something a human just did, or a machine event a customer's\n * business depends on: password reset, invite, order confirmation, booking\n * reminder, workflow notification, membership recovery. **Never refused,\n * never delayed.** Counted, because the ceiling is about total domain\n * volume and this volume is real.\n */\n | 'transactional'\n /**\n * A scheduled fan-out that answers no immediate human action: the monthly\n * usage summary, abandoned-cart and restock sweeps, booking reminder\n * batches. Refusable, because the caller is a resumable cron — a refusal\n * means \"not this hour\", and the next run picks the subject up. A caller\n * that is NOT resumable must not use this priority.\n */\n | 'bulk'\n /**\n * Merchant marketing. Refusable outright, with a message the merchant\n * sees. The one discretionary class, exactly as in `email-metering.ts`.\n */\n | 'campaign'\n\n/** Fixed one-hour window, matching the unit the issue and the ramp speak in. */\nexport const EMAIL_SEND_RATE_WINDOW_MS = 3_600_000\n\n/**\n * The default ceiling, in messages per hour, when nothing has been\n * configured.\n *\n * **This number is a default, not the policy.** The whole point of AGL-2409\n * is that a ramp must be a value change and not a deploy, so the live ceiling\n * is read from `rateLimits/sendRateConfig` and set from the staff console.\n * This is only what an unconfigured deployment (a fork, a self-host, a\n * preview) gets.\n *\n * 2,000/hour is chosen against measured shape rather than taste. Steady-state\n * volume on the domain is a few hundred a day, so this is roughly two orders\n * of magnitude of headroom over normal traffic — it cannot trip on real use,\n * which is the property that keeps a limit from being raised until it means\n * nothing. What it does remove is the two bursts named in the issue: 20,000\n * campaign messages an hour becomes 2,000, and a 1,000-org usage fan-out in\n * one invocation becomes a fan-out that has to ask.\n */\nexport const EMAIL_SEND_RATE_DEFAULT_PER_HOUR = 2_000\n\n/**\n * Hard bound on a configured ceiling. Not a business rule — a typo guard, so\n * a slipped digit in the console cannot set a ceiling that is effectively no\n * ceiling. Raising it past this is a deploy, which is the correct friction\n * for a change of that size.\n */\nexport const EMAIL_SEND_RATE_MAX_PER_HOUR = 100_000\n\n/** A configured ceiling of 0 would refuse every campaign; 1 is the floor. */\nexport const EMAIL_SEND_RATE_MIN_PER_HOUR = 1\n\n/*==========================================\n * THE PROVIDER'S OWN RATE, WHICH IS A DIFFERENT UNIT.\n *\n * Everything above this point counts MESSAGES OVER AN HOUR. Resend counts\n * REQUESTS OVER A SECOND, and the two are independent: an hour's worth of\n * granted budget spent in four seconds satisfies the governor completely and\n * trips the provider on the fifth request.\n *\n * That gap is what a loop with a sequential `await` and nothing else does not\n * close. Sequential is a pace, but its rate is whatever the round trip\n * happens to be — so the same code that is comfortably inside the limit\n * against a slow network is over it against a fast one, and the only symptom\n * is a 429 the loop then has to be right about.\n *=========================================*/\n\n/**\n * Requests per second the provider accepts, from its published default.\n *\n * Resend documents 10 requests per second PER TEAM — across every API key on\n * the account, not per key and not per sending domain. Past it the API\n * answers HTTP 429 `rate_limit_exceeded`, carrying `retry-after` and\n * `ratelimit-reset` in whole seconds\n * (https://resend.com/changelog/api-rate-limit).\n *\n * A vendor's number, so it is stated once here rather than divided into an\n * interval at the one loop that has to respect it. It can be raised by\n * arrangement with the provider; raising it here without that arrangement\n * buys 429s.\n */\nexport const EMAIL_PROVIDER_REQUESTS_PER_SECOND = 10\n\n/**\n * Requests per second a BATCH may take of that rate.\n *\n * One less than the provider allows, and the missing request is not a safety\n * margin — it is the transactional mail. The limit is counted per TEAM, so a\n * campaign loop running flat out at the full rate is a campaign that answers\n * a password reset arriving in the same second with a 429. That is the\n * outcome this file's opening rule forbids a rate control from producing, and\n * a batch is the only sender in the tree fast enough to produce it.\n *\n * One request per second is not much headroom, and it is not meant to be a\n * budget for concurrent campaigns: it is enough for the one-off sends that\n * make up all of this deployment's other traffic. It also keeps a batch off\n * the exact boundary, where clock jitter alone would earn a 429 on a send\n * that is nominally inside the limit.\n */\nexport const EMAIL_BATCH_REQUESTS_PER_SECOND =\n EMAIL_PROVIDER_REQUESTS_PER_SECOND - 1\n\n/** The shortest gap between two batch requests that stays inside that rate. */\nexport const EMAIL_BATCH_MIN_REQUEST_INTERVAL_MS = Math.ceil(\n 1_000 / EMAIL_BATCH_REQUESTS_PER_SECOND,\n)\n\n/**\n * Environment name for a provider rate that is not the published default.\n *\n * Deployment-level rather than stored config, because it is a property of the\n * Resend ACCOUNT the deployment holds a key for — the same layer\n * `RESEND_API_KEY` lives at, and a different question from the hourly ceiling\n * an operator ramps. Resend raises the limit for trusted senders on request,\n * and a self-host runs its own account entirely, so the number cannot be a\n * constant in a shipped build.\n */\nexport const EMAIL_PROVIDER_RATE_ENV = 'EMAIL_PROVIDER_REQUESTS_PER_SECOND'\n\n/**\n * The interval a batch paces itself by on this deployment.\n *\n * Unset or unreadable resolves to the published default, never to zero: a\n * typo in an env var must not silently remove a rate control. Zero is\n * accepted only when it is written explicitly, and it disables pacing — which\n * is what a harness with no provider behind it passes, and what an operator\n * who has moved rate control somewhere else in front of this process sets.\n */\nexport function batchRequestIntervalMs(\n raw: unknown = process.env[EMAIL_PROVIDER_RATE_ENV],\n): number {\n if (raw === null || raw === undefined || String(raw).trim() === '') {\n return EMAIL_BATCH_MIN_REQUEST_INTERVAL_MS\n }\n const perSecond = Number(raw)\n if (!Number.isFinite(perSecond) || perSecond < 0) {\n return EMAIL_BATCH_MIN_REQUEST_INTERVAL_MS\n }\n if (perSecond === 0) return 0\n // The same one-request reservation the default makes, and floored at one so\n // a configured rate of 1 paces at a second rather than dividing by zero.\n return Math.ceil(1_000 / Math.max(1, Math.floor(perSecond) - 1))\n}\n\n/**\n * A pace for a loop that issues one provider request per iteration.\n *\n * Returns a function to await once per iteration. It waits only for whatever\n * is LEFT of the interval since the previous request rather than sleeping a\n * fixed amount, which is the property that makes it free where it is not\n * needed: a loop whose own work already takes longer than the interval —\n * every send in production, which pays a network round trip and a Firestore\n * read per recipient — never waits at all, and a loop that would otherwise\n * fire five hundred requests as fast as the socket allows is spread out to\n * the documented rate.\n *\n * Per-caller state rather than a module-level clock, so two concurrent sends\n * do not share one pace. That is deliberately NOT a claim that the process as\n * a whole stays under the rate: the provider counts the whole team, and this\n * bounds one loop. It removes the burst a single batch produces, which is the\n * only place in this codebase that issues requests in a tight loop.\n *\n * `intervalMs` of 0 (or anything unreadable) disables the wait entirely,\n * which is what a caller with nothing to pace passes. Read per call rather\n * than captured at module load, for the reason `getEmailConfig` is: 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 createProviderRequestPacer(\n intervalMs: number = batchRequestIntervalMs(),\n): () => Promise<void> {\n const gap = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 0\n /** The earliest instant the next request may go. Zero lets the first fly. */\n let nextAtMs = 0\n return async () => {\n if (!gap) return\n const waitMs = nextAtMs - Date.now()\n if (waitMs > 0) {\n await new Promise((resolve) => setTimeout(resolve, waitMs))\n }\n // Measured from the moment this call RELEASES rather than from the slot\n // it was owed. A schedule of fixed instants lets a loop that stalled for\n // a minute bank a minute's worth of slots and spend them at once, which\n // is the burst this exists to remove; measuring forward from here means\n // the gap can only ever be longer than the interval, never shorter.\n nextAtMs = Date.now() + gap\n }\n}\n\n/** The live ceiling, as stored and as the console edits it. */\nexport interface EmailSendRateConfig {\n /** Messages per hour across the whole platform. */\n perHour: number\n /**\n * False parks the governor: every priority is granted and still counted,\n * so the console keeps showing real volume. The off switch exists because\n * an operator who suspects the governor is refusing legitimate mail must be\n * able to stop it in one click rather than by raising a number they then\n * have to remember to lower.\n */\n enabled: boolean\n /** When staff last changed it, ms. Null when never configured. */\n updatedAtMs: number | null\n /** Who changed it; shown beside the value so a ramp has an author. */\n updatedByEmail: string | null\n /** Why — the ramp step, the incident. Free text, bounded. */\n note: string\n}\n\n/** Bound on the stored note, so a config read stays a small document. */\nexport const EMAIL_SEND_RATE_NOTE_MAX = 500\n\n/**\n * Reads a stored config document into a usable shape.\n *\n * Every field is clamped rather than trusted. This document decides whether a\n * campaign goes out, and a `perHour` of `NaN` (or `-1`, or a string) reaching\n * the comparison would silently refuse everything refusable on the platform —\n * an outage produced by a bad write. An unreadable value falls back to the\n * default ceiling, never to zero and never to unlimited.\n */\nexport function normalizeEmailSendRateConfig(\n raw: Partial<EmailSendRateConfig> | null | undefined,\n): EmailSendRateConfig {\n const rawPerHour = Number(raw?.perHour)\n const perHour = Number.isFinite(rawPerHour)\n ? Math.min(\n EMAIL_SEND_RATE_MAX_PER_HOUR,\n Math.max(EMAIL_SEND_RATE_MIN_PER_HOUR, Math.floor(rawPerHour)),\n )\n : EMAIL_SEND_RATE_DEFAULT_PER_HOUR\n const updatedAtMs = Number(raw?.updatedAtMs)\n return {\n perHour,\n // Absent means ON. A governor that a missing field turns off is not a\n // governor; the operator must have written `false` for it to be off.\n enabled: raw?.enabled !== false,\n updatedAtMs: Number.isFinite(updatedAtMs) && updatedAtMs > 0 ? updatedAtMs : null,\n updatedByEmail: raw?.updatedByEmail ? String(raw.updatedByEmail) : null,\n note: String(raw?.note ?? '').slice(0, EMAIL_SEND_RATE_NOTE_MAX),\n }\n}\n\n/**\n * Contexts that are campaign sends, derived rather than threaded.\n *\n * The same move `contextTag` makes one file over, and for the same reason: a\n * `priority` argument added to 37 call sites is 37 places to remember and one\n * place that does not. `campaign-send.ts` already passes `context:\n * 'campaign'`, and that is already the value `email-metering.ts` keys its\n * enforceable meter on, so nothing new has to be remembered for the one class\n * that must be refusable.\n *\n * ### Polarity\n *\n * Note which way an omission fails. This set enumerates what is REFUSABLE, so\n * a sender that is not listed and does not pass `priority` explicitly is\n * treated as transactional and is never refused — the status quo before this\n * change, not a regression. The opposite polarity (enumerate what is\n * protected) would make a forgotten entry a dropped password reset, which is\n * the failure you find from a support ticket.\n */\nconst CAMPAIGN_CONTEXTS: ReadonlySet<string> = new Set(['campaign'])\n\n/**\n * The priority for a send. An explicit `priority` always wins; otherwise a\n * campaign context is recognised, and everything else is transactional.\n */\nexport function resolveSendPriority(\n context: string | undefined,\n explicit?: EmailSendPriority,\n): EmailSendPriority {\n if (explicit === 'campaign' || explicit === 'bulk') return explicit\n if (explicit === 'transactional') return 'transactional'\n return CAMPAIGN_CONTEXTS.has(String(context ?? '').trim())\n ? 'campaign'\n : 'transactional'\n}\n\n/** True when a control is permitted to refuse this priority at all. */\nexport function isRefusablePriority(priority: EmailSendPriority): boolean {\n return priority === 'campaign' || priority === 'bulk'\n}\n\n/** Start of the fixed window containing `nowMs`. */\nexport function emailSendRateWindowStartMs(\n nowMs: number,\n windowMs: number = EMAIL_SEND_RATE_WINDOW_MS,\n): number {\n return Math.floor(nowMs / windowMs) * windowMs\n}\n\nexport interface EmailSendRateInput {\n priority: EmailSendPriority\n /** Messages already counted in this window. */\n used: number\n /** Messages this send would add. */\n count: number\n /** The live ceiling for the window. */\n ceiling: number\n /** False parks the governor — grant everything, still count it. */\n enabled?: boolean\n /** Start of the window, for `retryAtMs`. */\n windowStartMs: number\n windowMs?: number\n}\n\nexport interface EmailSendRateVerdict {\n allowed: boolean\n priority: EmailSendPriority\n ceiling: number\n /** Count in the window BEFORE this send. */\n used: number\n /** Headroom left after this send, floored at 0. */\n remaining: number\n /** When the window rolls and a refused caller may try again. */\n retryAtMs: number\n /**\n * True when the send was granted despite there being no headroom, because\n * refusing it was not permitted. This is how the console shows that a\n * window went over its ceiling on transactional volume — which is not an\n * error, it is the ceiling declining to enforce, exactly like the monthly\n * overage `emailSendsOverage` records.\n */\n overCeiling: boolean\n}\n\n/**\n * The decision. Pure.\n *\n * A transactional send is ALWAYS `allowed`, whatever the numbers say — there\n * is no branch in this function that can refuse one. That is deliberate and\n * is the first of the two enforcement points described at the top of this\n * file.\n */\nexport function emailSendRateVerdict(\n input: EmailSendRateInput,\n): EmailSendRateVerdict {\n const windowMs = input.windowMs ?? EMAIL_SEND_RATE_WINDOW_MS\n const retryAtMs = input.windowStartMs + windowMs\n // A corrupt or negative counter must not read as headroom a cap honours,\n // the same clamp `campaignEmailSendsForMonth` applies.\n const usedRaw = Number(input.used)\n const used = Number.isFinite(usedRaw) && usedRaw > 0 ? Math.floor(usedRaw) : 0\n const countRaw = Number(input.count)\n const count = Number.isFinite(countRaw) && countRaw > 0 ? Math.floor(countRaw) : 0\n const ceilingRaw = Number(input.ceiling)\n const ceiling = Number.isFinite(ceilingRaw) && ceilingRaw > 0\n ? Math.floor(ceilingRaw)\n : EMAIL_SEND_RATE_DEFAULT_PER_HOUR\n const enabled = input.enabled !== false\n\n const wouldExceed = used + count > ceiling\n // Transactional mail is never refused, and neither is anything at all while\n // the governor is parked. Both still count.\n const allowed = !wouldExceed || !enabled || !isRefusablePriority(input.priority)\n return {\n allowed,\n priority: input.priority,\n ceiling,\n used,\n remaining: Math.max(0, ceiling - (used + (allowed ? count : 0))),\n retryAtMs,\n overCeiling: allowed && wouldExceed,\n }\n}\n\n/** What `sendEmail` asks the installed governor. */\nexport interface EmailSendGovernorRequest {\n priority: EmailSendPriority\n /** Recipient addresses on this one send. */\n count: number\n context?: string\n}\n\n/** What the governor answers. A subset of the verdict `sendEmail` needs. */\nexport interface EmailSendGovernorVerdict {\n allowed: boolean\n ceiling?: number\n used?: number\n remaining?: number\n retryAtMs?: number\n /** True when the durable counter was unreachable and this failed open. */\n degraded?: boolean\n}\n\nexport type EmailSendGovernor = (\n request: EmailSendGovernorRequest,\n) => Promise<EmailSendGovernorVerdict>\n\n/**\n * The installed governor, or null.\n *\n * Module-scoped and null by default, so `sendEmail` in a unit test, a preview\n * build or a self-host deployment that never installs one behaves exactly as\n * it did before this change. Null is UNGOVERNED, not refused: a send path\n * that cannot reach the counter must still send.\n */\nlet installedGovernor: EmailSendGovernor | null = null\n\n/** Installs the durable governor. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setEmailSendGovernor(governor: EmailSendGovernor | null): void {\n installedGovernor = governor\n}\n\n/** The installed governor, or null when nothing has been installed. */\nexport function getEmailSendGovernor(): EmailSendGovernor | null {\n return installedGovernor\n}\n\n/** Test seam: forget any installed governor. */\nexport function resetEmailSendGovernorForTests(): void {\n installedGovernor = null\n}\n"],"names":["EMAIL_SEND_RATE_WINDOW_MS","EMAIL_SEND_RATE_DEFAULT_PER_HOUR","EMAIL_SEND_RATE_MAX_PER_HOUR","EMAIL_SEND_RATE_MIN_PER_HOUR","EMAIL_PROVIDER_REQUESTS_PER_SECOND","EMAIL_BATCH_REQUESTS_PER_SECOND","EMAIL_BATCH_MIN_REQUEST_INTERVAL_MS","Math","ceil","EMAIL_PROVIDER_RATE_ENV","batchRequestIntervalMs","raw","process","env","undefined","String","trim","perSecond","Number","isFinite","max","floor","createProviderRequestPacer","intervalMs","gap","nextAtMs","waitMs","Date","now","Promise","resolve","setTimeout","EMAIL_SEND_RATE_NOTE_MAX","normalizeEmailSendRateConfig","rawPerHour","perHour","min","updatedAtMs","enabled","updatedByEmail","note","slice","CAMPAIGN_CONTEXTS","Set","resolveSendPriority","context","explicit","has","isRefusablePriority","priority","emailSendRateWindowStartMs","nowMs","windowMs","emailSendRateVerdict","input","retryAtMs","windowStartMs","usedRaw","used","countRaw","count","ceilingRaw","ceiling","wouldExceed","allowed","remaining","overCeiling","installedGovernor","setEmailSendGovernor","governor","getEmailSendGovernor","resetEmailSendGovernorForTests"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/send-rate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE PLATFORM SEND-RATE GOVERNOR — policy half (AGL-2409).\n *\n * Everything Aglyn sends leaves on ONE Resend key, from ONE verified sending\n * domain, under `p=reject`. A throttle or a reputation hit there is not a spam\n * folder: it is a rejection, and it lands on every customer's password resets\n * and receipts at the same time. Before this there was no per-hour or per-day\n * throttle, no batching and no concurrency limiter anywhere in the email path\n * — so the two bursts the product can already produce (the monthly usage\n * summary fanning out to 1,000 orgs in one invocation, and scheduled campaigns\n * at a ceiling of 10 × 500 × 4 = 20,000 messages an hour) had nothing to ramp\n * against, and the day a ramp was needed there would have been nothing to\n * turn.\n *\n * ## Why the policy is pure and dependency-free\n *\n * Same split as `plugin-api-rate-limit.ts` and `api-http.ts`: the decision is\n * a pure function of (priority, ceiling, used, count) and is unit-testable\n * without a Firestore harness or a route. The durable counter lives in\n * `@aglyn/tenant-data-admin` (`email-send-rate.ts`), which is the only layer\n * that may hold the Admin SDK — `@aglyn/shared-util-email` is `scope:shared`\n * and may not import it, and the existing edge already runs the other way\n * (`tenant-data-admin` imports THIS library). So the governor reaches\n * `sendEmail` by injection, {@link setEmailSendGovernor}, rather than by an\n * import that would be a dependency cycle.\n *\n * ## THE BOUNDARY THAT MATTERS MOST\n *\n * **A rate control may only ever refuse a CAMPAIGN or a BULK sweep. It may\n * never refuse a transactional message.** A password reset or an order\n * receipt refused by a throttle is a strictly worse outcome than the burst\n * being throttled: it converts a reputation risk into an outage on somebody\n * else's business, and the mail explaining why is itself mail that will not\n * send. That is the same rule `email-metering.ts` states for the monthly\n * quota, applied to time distribution instead of monthly totals.\n *\n * The rule is enforced in two independent places on purpose — here, in\n * {@link emailSendRateVerdict}, which cannot return `allowed: false` for a\n * transactional send at all; and again in `sendEmail`, which ignores a\n * refusal for a transactional priority whatever the installed governor says.\n * A governor is injectable, so a wrong one is reachable; the send path must\n * still be unable to drop a password reset.\n */\n\n/** The three send priorities, ordered by what a control may do to them. */\nexport type EmailSendPriority =\n /**\n * Answers something a human just did, or a machine event a customer's\n * business depends on: password reset, invite, order confirmation, booking\n * reminder, workflow notification, membership recovery. **Never refused,\n * never delayed.** Counted, because the ceiling is about total domain\n * volume and this volume is real.\n */\n | 'transactional'\n /**\n * A scheduled fan-out that answers no immediate human action: the monthly\n * usage summary, abandoned-cart and restock sweeps, booking reminder\n * batches. Refusable, because the caller is a resumable cron — a refusal\n * means \"not this hour\", and the next run picks the subject up. A caller\n * that is NOT resumable must not use this priority.\n */\n | 'bulk'\n /**\n * Merchant marketing. Refusable outright, with a message the merchant\n * sees. The one discretionary class, exactly as in `email-metering.ts`.\n */\n | 'campaign'\n\n/** Fixed one-hour window, matching the unit the issue and the ramp speak in. */\nexport const EMAIL_SEND_RATE_WINDOW_MS = 3_600_000\n\n/**\n * The default ceiling, in messages per hour, when nothing has been\n * configured.\n *\n * **This number is a default, not the policy.** The whole point of AGL-2409\n * is that a ramp must be a value change and not a deploy, so the live ceiling\n * is read from `rateLimits/sendRateConfig` and set from the staff console.\n * This is only what an unconfigured deployment (a fork, a self-host, a\n * preview) gets.\n *\n * 2,000/hour is chosen against measured shape rather than taste. Steady-state\n * volume on the domain is a few hundred a day, so this is roughly two orders\n * of magnitude of headroom over normal traffic — it cannot trip on real use,\n * which is the property that keeps a limit from being raised until it means\n * nothing. What it does remove is the two bursts named in the issue: 20,000\n * campaign messages an hour becomes 2,000, and a 1,000-org usage fan-out in\n * one invocation becomes a fan-out that has to ask.\n */\nexport const EMAIL_SEND_RATE_DEFAULT_PER_HOUR = 2_000\n\n/**\n * Hard bound on a configured ceiling. Not a business rule — a typo guard, so\n * a slipped digit in the console cannot set a ceiling that is effectively no\n * ceiling. Raising it past this is a deploy, which is the correct friction\n * for a change of that size.\n */\nexport const EMAIL_SEND_RATE_MAX_PER_HOUR = 100_000\n\n/** A configured ceiling of 0 would refuse every campaign; 1 is the floor. */\nexport const EMAIL_SEND_RATE_MIN_PER_HOUR = 1\n\n/*==========================================\n * THE PROVIDER'S OWN RATE, WHICH IS A DIFFERENT UNIT.\n *\n * Everything above this point counts MESSAGES OVER AN HOUR. Resend counts\n * REQUESTS OVER A SECOND, and the two are independent: an hour's worth of\n * granted budget spent in four seconds satisfies the governor completely and\n * trips the provider on the fifth request.\n *\n * That gap is what a loop with a sequential `await` and nothing else does not\n * close. Sequential is a pace, but its rate is whatever the round trip\n * happens to be — so the same code that is comfortably inside the limit\n * against a slow network is over it against a fast one, and the only symptom\n * is a 429 the loop then has to be right about.\n *=========================================*/\n\n/**\n * Requests per second the provider accepts, from its published default.\n *\n * Resend documents 10 requests per second PER TEAM — across every API key on\n * the account, not per key and not per sending domain. Past it the API\n * answers HTTP 429 `rate_limit_exceeded`, carrying `retry-after` and\n * `ratelimit-reset` in whole seconds\n * (https://resend.com/changelog/api-rate-limit).\n *\n * A vendor's number, so it is stated once here rather than divided into an\n * interval at the one loop that has to respect it. It can be raised by\n * arrangement with the provider; raising it here without that arrangement\n * buys 429s.\n */\nexport const EMAIL_PROVIDER_REQUESTS_PER_SECOND = 10\n\n/**\n * Requests per second a BATCH may take of that rate.\n *\n * One less than the provider allows, and the missing request is not a safety\n * margin — it is the transactional mail. The limit is counted per TEAM, so a\n * campaign loop running flat out at the full rate is a campaign that answers\n * a password reset arriving in the same second with a 429. That is the\n * outcome this file's opening rule forbids a rate control from producing, and\n * a batch is the only sender in the tree fast enough to produce it.\n *\n * One request per second is not much headroom, and it is not meant to be a\n * budget for concurrent campaigns: it is enough for the one-off sends that\n * make up all of this deployment's other traffic. It also keeps a batch off\n * the exact boundary, where clock jitter alone would earn a 429 on a send\n * that is nominally inside the limit.\n */\nexport const EMAIL_BATCH_REQUESTS_PER_SECOND =\n EMAIL_PROVIDER_REQUESTS_PER_SECOND - 1\n\n/** The shortest gap between two batch requests that stays inside that rate. */\nexport const EMAIL_BATCH_MIN_REQUEST_INTERVAL_MS = Math.ceil(\n 1_000 / EMAIL_BATCH_REQUESTS_PER_SECOND,\n)\n\n/**\n * Environment name for a provider rate that is not the published default.\n *\n * Deployment-level rather than stored config, because it is a property of the\n * Resend ACCOUNT the deployment holds a key for — the same layer\n * `RESEND_API_KEY` lives at, and a different question from the hourly ceiling\n * an operator ramps. Resend raises the limit for trusted senders on request,\n * and a self-host runs its own account entirely, so the number cannot be a\n * constant in a shipped build.\n */\nexport const EMAIL_PROVIDER_RATE_ENV = 'EMAIL_PROVIDER_REQUESTS_PER_SECOND'\n\n/**\n * The interval a batch paces itself by on this deployment.\n *\n * Unset or unreadable resolves to the published default, never to zero: a\n * typo in an env var must not silently remove a rate control. Zero is\n * accepted only when it is written explicitly, and it disables pacing — which\n * is what a harness with no provider behind it passes, and what an operator\n * who has moved rate control somewhere else in front of this process sets.\n */\nexport function batchRequestIntervalMs(\n raw: unknown = process.env[EMAIL_PROVIDER_RATE_ENV],\n): number {\n if (raw === null || raw === undefined || String(raw).trim() === '') {\n return EMAIL_BATCH_MIN_REQUEST_INTERVAL_MS\n }\n const perSecond = Number(raw)\n if (!Number.isFinite(perSecond) || perSecond < 0) {\n return EMAIL_BATCH_MIN_REQUEST_INTERVAL_MS\n }\n if (perSecond === 0) return 0\n // The same one-request reservation the default makes, and floored at one so\n // a configured rate of 1 paces at a second rather than dividing by zero.\n return Math.ceil(1_000 / Math.max(1, Math.floor(perSecond) - 1))\n}\n\n/**\n * A pace for a loop that issues one provider request per iteration.\n *\n * Returns a function to await once per iteration. It waits only for whatever\n * is LEFT of the interval since the previous request rather than sleeping a\n * fixed amount, which is the property that makes it free where it is not\n * needed: a loop whose own work already takes longer than the interval —\n * every send in production, which pays a network round trip and a Firestore\n * read per recipient — never waits at all, and a loop that would otherwise\n * fire five hundred requests as fast as the socket allows is spread out to\n * the documented rate.\n *\n * Per-caller state rather than a module-level clock, so two concurrent sends\n * do not share one pace. That is deliberately NOT a claim that the process as\n * a whole stays under the rate: the provider counts the whole team, and this\n * bounds one loop. It removes the burst a single batch produces, which is the\n * only place in this codebase that issues requests in a tight loop.\n *\n * `intervalMs` of 0 (or anything unreadable) disables the wait entirely,\n * which is what a caller with nothing to pace passes. Read per call rather\n * than captured at module load, for the reason `getEmailConfig` is: 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 createProviderRequestPacer(\n intervalMs: number = batchRequestIntervalMs(),\n): () => Promise<void> {\n const gap = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 0\n /** The earliest instant the next request may go. Zero lets the first fly. */\n let nextAtMs = 0\n return async () => {\n if (!gap) return\n const waitMs = nextAtMs - Date.now()\n if (waitMs > 0) {\n await new Promise((resolve) => setTimeout(resolve, waitMs))\n }\n // Measured from the moment this call RELEASES rather than from the slot\n // it was owed. A schedule of fixed instants lets a loop that stalled for\n // a minute bank a minute's worth of slots and spend them at once, which\n // is the burst this exists to remove; measuring forward from here means\n // the gap can only ever be longer than the interval, never shorter.\n nextAtMs = Date.now() + gap\n }\n}\n\n/** The live ceiling, as stored and as the console edits it. */\nexport interface EmailSendRateConfig {\n /** Messages per hour across the whole platform. */\n perHour: number\n /**\n * False parks the governor: every priority is granted and still counted,\n * so the console keeps showing real volume. The off switch exists because\n * an operator who suspects the governor is refusing legitimate mail must be\n * able to stop it in one click rather than by raising a number they then\n * have to remember to lower.\n */\n enabled: boolean\n /** When staff last changed it, ms. Null when never configured. */\n updatedAtMs: number | null\n /** Who changed it; shown beside the value so a ramp has an author. */\n updatedByEmail: string | null\n /** Why — the ramp step, the incident. Free text, bounded. */\n note: string\n}\n\n/** Bound on the stored note, so a config read stays a small document. */\nexport const EMAIL_SEND_RATE_NOTE_MAX = 500\n\n/**\n * Reads a stored config document into a usable shape.\n *\n * Every field is clamped rather than trusted. This document decides whether a\n * campaign goes out, and a `perHour` of `NaN` (or `-1`, or a string) reaching\n * the comparison would silently refuse everything refusable on the platform —\n * an outage produced by a bad write. An unreadable value falls back to the\n * default ceiling, never to zero and never to unlimited.\n */\nexport function normalizeEmailSendRateConfig(\n raw: Partial<EmailSendRateConfig> | null | undefined,\n): EmailSendRateConfig {\n const rawPerHour = Number(raw?.perHour)\n const perHour = Number.isFinite(rawPerHour)\n ? Math.min(\n EMAIL_SEND_RATE_MAX_PER_HOUR,\n Math.max(EMAIL_SEND_RATE_MIN_PER_HOUR, Math.floor(rawPerHour)),\n )\n : EMAIL_SEND_RATE_DEFAULT_PER_HOUR\n const updatedAtMs = Number(raw?.updatedAtMs)\n return {\n perHour,\n // Absent means ON. A governor that a missing field turns off is not a\n // governor; the operator must have written `false` for it to be off.\n enabled: raw?.enabled !== false,\n updatedAtMs: Number.isFinite(updatedAtMs) && updatedAtMs > 0 ? updatedAtMs : null,\n updatedByEmail: raw?.updatedByEmail ? String(raw.updatedByEmail) : null,\n note: String(raw?.note ?? '').slice(0, EMAIL_SEND_RATE_NOTE_MAX),\n }\n}\n\n/**\n * Contexts whose sends take the refusable `campaign` priority, derived rather\n * than threaded.\n *\n * The same move `contextTag` makes one file over, and for the same reason: a\n * `priority` argument added to 37 call sites is 37 places to remember and one\n * place that does not. `campaign-send.ts` already passes `context:\n * 'campaign'`, and that is already the value `email-metering.ts` keys its\n * enforceable meter on, so nothing new has to be remembered for the one class\n * that must be refusable.\n *\n * ### Polarity\n *\n * Note which way an omission fails. This set enumerates what is REFUSABLE, so\n * a sender that is not listed and does not pass `priority` explicitly is\n * treated as transactional and is never refused — the status quo before this\n * change, not a regression. The opposite polarity (enumerate what is\n * protected) would make a forgotten entry a dropped password reset, which is\n * the failure you find from a support ticket.\n */\nconst REFUSABLE_SEND_CONTEXTS: ReadonlySet<string> = new Set(['campaign'])\n\n/**\n * The priority for a send. An explicit `priority` always wins; otherwise a\n * campaign context is recognised, and everything else is transactional.\n */\nexport function resolveSendPriority(\n context: string | undefined,\n explicit?: EmailSendPriority,\n): EmailSendPriority {\n if (explicit === 'campaign' || explicit === 'bulk') return explicit\n if (explicit === 'transactional') return 'transactional'\n return REFUSABLE_SEND_CONTEXTS.has(String(context ?? '').trim())\n ? 'campaign'\n : 'transactional'\n}\n\n/** True when a control is permitted to refuse this priority at all. */\nexport function isRefusablePriority(priority: EmailSendPriority): boolean {\n return priority === 'campaign' || priority === 'bulk'\n}\n\n/** Start of the fixed window containing `nowMs`. */\nexport function emailSendRateWindowStartMs(\n nowMs: number,\n windowMs: number = EMAIL_SEND_RATE_WINDOW_MS,\n): number {\n return Math.floor(nowMs / windowMs) * windowMs\n}\n\nexport interface EmailSendRateInput {\n priority: EmailSendPriority\n /** Messages already counted in this window. */\n used: number\n /** Messages this send would add. */\n count: number\n /** The live ceiling for the window. */\n ceiling: number\n /** False parks the governor — grant everything, still count it. */\n enabled?: boolean\n /** Start of the window, for `retryAtMs`. */\n windowStartMs: number\n windowMs?: number\n}\n\nexport interface EmailSendRateVerdict {\n allowed: boolean\n priority: EmailSendPriority\n ceiling: number\n /** Count in the window BEFORE this send. */\n used: number\n /** Headroom left after this send, floored at 0. */\n remaining: number\n /** When the window rolls and a refused caller may try again. */\n retryAtMs: number\n /**\n * True when the send was granted despite there being no headroom, because\n * refusing it was not permitted. This is how the console shows that a\n * window went over its ceiling on transactional volume — which is not an\n * error, it is the ceiling declining to enforce, exactly like the monthly\n * overage `emailSendsOverage` records.\n */\n overCeiling: boolean\n}\n\n/**\n * The decision. Pure.\n *\n * A transactional send is ALWAYS `allowed`, whatever the numbers say — there\n * is no branch in this function that can refuse one. That is deliberate and\n * is the first of the two enforcement points described at the top of this\n * file.\n */\nexport function emailSendRateVerdict(\n input: EmailSendRateInput,\n): EmailSendRateVerdict {\n const windowMs = input.windowMs ?? EMAIL_SEND_RATE_WINDOW_MS\n const retryAtMs = input.windowStartMs + windowMs\n // A corrupt or negative counter must not read as headroom a cap honours,\n // the same clamp `campaignEmailSendsForMonth` applies.\n const usedRaw = Number(input.used)\n const used = Number.isFinite(usedRaw) && usedRaw > 0 ? Math.floor(usedRaw) : 0\n const countRaw = Number(input.count)\n const count = Number.isFinite(countRaw) && countRaw > 0 ? Math.floor(countRaw) : 0\n const ceilingRaw = Number(input.ceiling)\n const ceiling = Number.isFinite(ceilingRaw) && ceilingRaw > 0\n ? Math.floor(ceilingRaw)\n : EMAIL_SEND_RATE_DEFAULT_PER_HOUR\n const enabled = input.enabled !== false\n\n const wouldExceed = used + count > ceiling\n // Transactional mail is never refused, and neither is anything at all while\n // the governor is parked. Both still count.\n const allowed = !wouldExceed || !enabled || !isRefusablePriority(input.priority)\n return {\n allowed,\n priority: input.priority,\n ceiling,\n used,\n remaining: Math.max(0, ceiling - (used + (allowed ? count : 0))),\n retryAtMs,\n overCeiling: allowed && wouldExceed,\n }\n}\n\n/** What `sendEmail` asks the installed governor. */\nexport interface EmailSendGovernorRequest {\n priority: EmailSendPriority\n /** Recipient addresses on this one send. */\n count: number\n context?: string\n}\n\n/** What the governor answers. A subset of the verdict `sendEmail` needs. */\nexport interface EmailSendGovernorVerdict {\n allowed: boolean\n ceiling?: number\n used?: number\n remaining?: number\n retryAtMs?: number\n /** True when the durable counter was unreachable and this failed open. */\n degraded?: boolean\n}\n\nexport type EmailSendGovernor = (\n request: EmailSendGovernorRequest,\n) => Promise<EmailSendGovernorVerdict>\n\n/**\n * The installed governor, or null.\n *\n * Module-scoped and null by default, so `sendEmail` in a unit test, a preview\n * build or a self-host deployment that never installs one behaves exactly as\n * it did before this change. Null is UNGOVERNED, not refused: a send path\n * that cannot reach the counter must still send.\n */\nlet installedGovernor: EmailSendGovernor | null = null\n\n/** Installs the durable governor. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setEmailSendGovernor(governor: EmailSendGovernor | null): void {\n installedGovernor = governor\n}\n\n/** The installed governor, or null when nothing has been installed. */\nexport function getEmailSendGovernor(): EmailSendGovernor | null {\n return installedGovernor\n}\n\n/** Test seam: forget any installed governor. */\nexport function resetEmailSendGovernorForTests(): void {\n installedGovernor = null\n}\n"],"names":["EMAIL_SEND_RATE_WINDOW_MS","EMAIL_SEND_RATE_DEFAULT_PER_HOUR","EMAIL_SEND_RATE_MAX_PER_HOUR","EMAIL_SEND_RATE_MIN_PER_HOUR","EMAIL_PROVIDER_REQUESTS_PER_SECOND","EMAIL_BATCH_REQUESTS_PER_SECOND","EMAIL_BATCH_MIN_REQUEST_INTERVAL_MS","Math","ceil","EMAIL_PROVIDER_RATE_ENV","batchRequestIntervalMs","raw","process","env","undefined","String","trim","perSecond","Number","isFinite","max","floor","createProviderRequestPacer","intervalMs","gap","nextAtMs","waitMs","Date","now","Promise","resolve","setTimeout","EMAIL_SEND_RATE_NOTE_MAX","normalizeEmailSendRateConfig","rawPerHour","perHour","min","updatedAtMs","enabled","updatedByEmail","note","slice","REFUSABLE_SEND_CONTEXTS","Set","resolveSendPriority","context","explicit","has","isRefusablePriority","priority","emailSendRateWindowStartMs","nowMs","windowMs","emailSendRateVerdict","input","retryAtMs","windowStartMs","usedRaw","used","countRaw","count","ceilingRaw","ceiling","wouldExceed","allowed","remaining","overCeiling","installedGovernor","setEmailSendGovernor","governor","getEmailSendGovernor","resetEmailSendGovernorForTests"],"mappings":"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"}
@@ -677,7 +677,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
677
677
  {
678
678
  key: 'email-verification',
679
679
  name: 'Confirm email',
680
- description: 'Address verification link, sent after sign-up and whenever someone ' + 'asks for another from the verify screen.',
680
+ description: 'Address verification link, sent after sign-up and whenever someone ' + 'asks for another from the verify page.',
681
681
  deliveredBy: 'resend',
682
682
  defaultSubject: 'Confirm your email address',
683
683
  mergeTokens: [
@@ -1119,7 +1119,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
1119
1119
  }
1120
1120
  ],
1121
1121
  footerReason: 'You’re receiving this each morning because the Daily CRM digest is ' + 'on in your {{brand.productName}} notification settings.',
1122
- source: 'apps/console/app/api/crm/daily-digest/route.ts'
1122
+ source: 'libs/plugins/crm/src/lib/server/daily-digest-route.ts'
1123
1123
  },
1124
1124
  {
1125
1125
  key: 'crm-task-reminder',
@@ -1157,7 +1157,7 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
1157
1157
  }
1158
1158
  ],
1159
1159
  footerReason: 'You’re receiving this because task reminders are on in your ' + '{{brand.productName}} notification settings.',
1160
- source: 'apps/console/app/api/crm/task-reminders/route.ts'
1160
+ source: 'libs/plugins/crm/src/lib/server/task-reminders-route.ts'
1161
1161
  },
1162
1162
  {
1163
1163
  key: 'ai-insights-digest',