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

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 (53) 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/hosted-page-screen.d.ts +27 -0
  13. package/src/lib/hosted-page-screen.js +228 -23
  14. package/src/lib/hosted-page-screen.js.map +1 -1
  15. package/src/lib/mail-provider-resend.d.ts +194 -0
  16. package/src/lib/mail-provider-resend.js +649 -0
  17. package/src/lib/mail-provider-resend.js.map +1 -0
  18. package/src/lib/mail-provider-webhook.d.ts +80 -0
  19. package/src/lib/mail-provider-webhook.js +156 -0
  20. package/src/lib/mail-provider-webhook.js.map +1 -0
  21. package/src/lib/mail-provider.d.ts +148 -0
  22. package/src/lib/mail-provider.js +84 -0
  23. package/src/lib/mail-provider.js.map +1 -0
  24. package/src/lib/mail-providers.d.ts +99 -0
  25. package/src/lib/mail-providers.js +182 -0
  26. package/src/lib/mail-providers.js.map +1 -0
  27. package/src/lib/outbound-phishing-screen.d.ts +80 -3
  28. package/src/lib/outbound-phishing-screen.js +457 -29
  29. package/src/lib/outbound-phishing-screen.js.map +1 -1
  30. package/src/lib/received-email.d.ts +7 -44
  31. package/src/lib/received-email.js +8 -104
  32. package/src/lib/received-email.js.map +1 -1
  33. package/src/lib/risk-notice-catalog.d.ts +32 -2
  34. package/src/lib/risk-notice-catalog.js +86 -55
  35. package/src/lib/risk-notice-catalog.js.map +1 -1
  36. package/src/lib/send-ceilings.d.ts +0 -57
  37. package/src/lib/send-ceilings.js +0 -47
  38. package/src/lib/send-ceilings.js.map +1 -1
  39. package/src/lib/send-email.d.ts +52 -58
  40. package/src/lib/send-email.js +128 -134
  41. package/src/lib/send-email.js.map +1 -1
  42. package/src/lib/send-rate.js +4 -3
  43. package/src/lib/send-rate.js.map +1 -1
  44. package/src/lib/sending-domain.d.ts +15 -0
  45. package/src/lib/sending-domain.js.map +1 -1
  46. package/src/lib/system-email-catalog.js +37 -11
  47. package/src/lib/system-email-catalog.js.map +1 -1
  48. package/src/lib/tenant-email-catalog.d.ts +7 -0
  49. package/src/lib/tenant-email-catalog.js +10 -591
  50. package/src/lib/tenant-email-catalog.js.map +1 -1
  51. package/src/lib/tenant-emails.generated.d.ts +11 -0
  52. package/src/lib/tenant-emails.generated.js +649 -0
  53. package/src/lib/tenant-emails.generated.js.map +1 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/send-ceilings.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 EMAIL CEILINGS, DIMENSIONED AGAINST EACH OTHER.\n *\n * Three independent limits govern how much campaign mail leaves the platform,\n * and until this module none of them was expressed in terms of any other:\n *\n * - a per-send recipient cap, in `campaign-send.ts`;\n * - a platform hourly ceiling, in `send-rate.ts`;\n * - a per-plan monthly allowance, `emailSendsPerMonth` in `PLAN_ENTITLEMENTS`.\n *\n * Three numbers picked separately describe a machine that cannot exist. The\n * arithmetic below is the whole point of the file: every derived ceiling is a\n * function of the others, so the relations can be checked rather than\n * believed, and a future change to any one of them fails a test instead of\n * quietly overselling the sending domain.\n *\n * ## The units\n *\n * Every figure here is a COUNT OF MESSAGES — one per recipient address handed\n * to the sender — matching `email-metering.ts` exactly. The window each one\n * counts over is the only thing that differs: one invocation, one hour, one\n * calendar month.\n *\n * ## The derivation\n *\n * A month is projected from an hour at {@link EMAIL_CEILING_MONTH_DAYS} days\n * of {@link EMAIL_CEILING_HOURS_PER_DAY} hours. That projection is\n * deliberately generous — it assumes an org sends flat out around the clock\n * for a whole month, which nobody does — because its job is to prove an upper\n * bound. A plan that oversells even against continuous sending oversells\n * against every real pattern too.\n *\n * orgPerHour = floor(platformPerHour x orgShare)\n * deliverableMonthly = orgPerHour x 24 x 30\n *\n * With the shipped defaults (2,000/hour platform, 25% share):\n *\n * orgPerHour = floor(2,000 x 0.25) = 500 / hour\n * deliverableMonthly = 500 x 24 x 30 = 360,000 / month\n *\n * ## The three relations\n *\n * **R1 — a send must fit in the org's hour.** `perSend <= orgPerHour`. At the\n * defaults these are both 500, so one maximal send is exactly one hour of the\n * org's share. A per-send cap ABOVE the hourly cap would be a cap that can\n * never be reached, which is the shape that teaches an operator to ignore the\n * number the composer shows them.\n *\n * **R2 — an org may not be the whole platform.** `orgPerHour <=\n * platformPerHour`. At 25% four orgs can occupy the hour together; at 100%\n * one org can shut every other tenant out of campaign sending, which is the\n * tenant-versus-tenant denial of service the share exists to close.\n * Transactional mail is exempt from both ceilings and is unaffected by this.\n *\n * **R3 — a plan may not sell more than the platform can deliver.**\n * `planMonthly <= deliverableMonthly`. Every shipped plan holds it today, and\n * {@link describeEmailCeilings} reports a breach rather than repairing it:\n * the repair would be to lower an entitlement, and an entitlement is what a\n * price bought. See `email-ceiling-dimensioning.spec.ts`, which pins exactly\n * which plans fail — an empty set — so that it cannot grow unnoticed.\n *\n * ## What a violated relation does NOT do\n *\n * It does not clamp anything. `PLAN_ENTITLEMENTS` is the authority for what a\n * customer bought, and silently resolving an entitlement down to what the\n * current platform ceiling can deliver would be a price change made by\n * arithmetic. The model reports; the operator decides; the hourly ceiling is\n * what actually paces the mail, and it defers rather than refuses.\n */\n\n/**\n * Recipients one campaign invocation may address.\n *\n * The cap is a bound on a single function call, not a plan feature: beyond it\n * a send stops being a request and becomes a batch job that has to survive\n * timeouts and resume without double-sending. It is stated here rather than\n * privately in `campaign-send.ts` so that R1 can be checked against it.\n *\n * A merchant whose audience is larger is NOT told this number is their\n * audience. The composer reports the true audience and how much of it this\n * send reaches — a truncation reported as a total is the silent cap this\n * product keeps rediscovering.\n */\nexport const EMAIL_MAX_RECIPIENTS_PER_SEND = 500\n\n/**\n * People one email may resolve, and therefore ever reach.\n *\n * The read budget for resolving one audience, and the ceiling on how many\n * addresses one email's reach record may hold — the two have to be the same\n * number, because an email cannot reach more people than its audience can\n * resolve. Stated here with the other ceilings for the reason the per-send\n * cap is: held privately in the sender it was a number nothing could be\n * checked against.\n *\n * Reaching it truncates nothing silently. The resolution reports it, the\n * recorded `audienceSize` becomes a floor rather than a total, and every\n * surface derived from it says so.\n */\nexport const EMAIL_MAX_AUDIENCE_PER_SEND = 5000\n\n/**\n * Batches one email may run before it stops on its own.\n *\n * An audience larger than {@link EMAIL_MAX_RECIPIENTS_PER_SEND} is delivered\n * across several invocations, each one picking up where the last stopped. The\n * frontier advances by the per-send cap every time, so\n * `ceil(audience / perSend)` batches finish any audience the sender can\n * resolve — and the slack above it absorbs the batches a transient provider\n * failure or an hourly deferral costs.\n *\n * It is a TERMINATION guard rather than a capacity limit. Nothing about a\n * healthy campaign should approach it; what it stops is a send that makes no\n * progress rescheduling itself forever, which is the one failure a\n * self-resuming job can have that nobody notices.\n */\nexport const EMAIL_MAX_SEND_BATCHES =\n Math.ceil(EMAIL_MAX_AUDIENCE_PER_SEND / EMAIL_MAX_RECIPIENTS_PER_SEND) * 2\n\n/**\n * The fraction of the platform hour one org's campaigns may occupy.\n *\n * 25% is the starting value: it leaves room for four concurrent large senders\n * plus all transactional traffic (which the governor counts but may never\n * refuse), and at the default platform ceiling it lands the org's hour on\n * exactly {@link EMAIL_MAX_RECIPIENTS_PER_SEND}, so R1 holds with no slack\n * and the two caps explain each other.\n *\n * Compiled in rather than stored, unlike the platform ceiling beside it. The\n * platform ceiling is a ramp — an operator moves it during an incident or a\n * warm-up, and `send-rate.ts` makes that a value change on\n * `rateLimits/sendRateConfig` for exactly that reason. This share is a\n * fairness policy between tenants, not a ramp: changing it redistributes\n * headroom between paying customers, which is a decision that should carry a\n * deploy and a review. If that stops being true it becomes a second field on\n * the same config document and inherits the same staff card.\n */\nexport const EMAIL_ORG_HOURLY_SHARE = 0.25\n\n/** Lower bound on a share. Zero would refuse every campaign on the platform. */\nexport const EMAIL_ORG_HOURLY_SHARE_MIN = 0.01\n\n/** Upper bound. Above 1 an org would be entitled to more than the whole hour. */\nexport const EMAIL_ORG_HOURLY_SHARE_MAX = 1\n\n/**\n * Days used to project an hourly ceiling onto a month.\n *\n * 30 rather than the true length of the calendar month, and that choice is\n * conservative in the direction that matters: a 31-day month would raise the\n * projected ceiling and make an overselling plan look like it fits. The\n * monthly ALLOWANCE is a real calendar month — `email-metering.ts` keys it\n * `YYYY-MM` — and nothing here changes that. This constant only bounds the\n * projection used to compare the two ceilings.\n */\nexport const EMAIL_CEILING_MONTH_DAYS = 30\n\n/** Hours in the projection day. Named so the arithmetic reads as arithmetic. */\nexport const EMAIL_CEILING_HOURS_PER_DAY = 24\n\n/** Hours in the projected month: 720 at the shipped constants. */\nexport const EMAIL_CEILING_MONTH_HOURS =\n EMAIL_CEILING_MONTH_DAYS * EMAIL_CEILING_HOURS_PER_DAY\n\n/** A relation between two ceilings that must hold, named for reporting. */\nexport type EmailCeilingRelation =\n /** R1: a single send must fit inside the org's hourly share. */\n | 'send-exceeds-org-hour'\n /** R2: an org's hourly share must fit inside the platform hour. */\n | 'org-hour-exceeds-platform-hour'\n /** R3: a plan may not sell more than the platform can deliver in a month. */\n | 'plan-exceeds-deliverable-month'\n\n/** One failed relation, with the two numbers that failed it. */\nexport interface EmailCeilingViolation {\n relation: EmailCeilingRelation\n /** The ceiling that is too large. */\n claimed: number\n /** The ceiling it must not exceed. */\n available: number\n /** Human-readable, and the text a surface may show verbatim. */\n detail: string\n}\n\n/** The reconciled model: every ceiling, and whether they agree. */\nexport interface EmailCeilingModel {\n /** Recipients one invocation may address. */\n perSend: number\n /** Messages one org's campaigns may send in an hour. */\n orgPerHour: number\n /** Messages the whole platform may send in an hour. */\n platformPerHour: number\n /** The share used to derive `orgPerHour`. */\n orgShare: number\n /** Upper bound on what one org can actually get out in a month. */\n deliverableMonthly: number\n /**\n * The plan's monthly campaign allowance as a FINITE number.\n *\n * An unlimited plan reports `deliverableMonthly` here and sets\n * {@link planUnlimited}. `UNLIMITED` is `Number.POSITIVE_INFINITY`, and\n * `JSON.stringify(Infinity)` is `null` — `Number(null)` is `0` and\n * `Number.isFinite(0)` is `true`, so the sentinel crossing the wire arrives\n * as a cap of ZERO on the most expensive plan and sails through every\n * guard. A finite number plus an explicit flag is the only shape that\n * survives serialization.\n */\n planMonthly: number\n /** True when the plan's allowance is unbounded. Never infer this from the number. */\n planUnlimited: boolean\n /**\n * Hours of the org's own hourly share a full plan allowance would need.\n *\n * The figure that makes an overselling plan legible: at the defaults an\n * agency allowance of 1,000,000 needs 2,000 hours of a 720-hour month.\n */\n hoursToSpendPlan: number\n /** Every relation that does not hold. Empty when the model is coherent. */\n violations: EmailCeilingViolation[]\n /** True when all three relations hold. */\n coherent: boolean\n}\n\n/** Clamps a raw ceiling to a positive integer, or returns `fallback`. */\nfunction positiveInt(raw: unknown, fallback: number): number {\n const value = Number(raw)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback\n}\n\n/**\n * Clamps a share into `[MIN, MAX]`.\n *\n * An unreadable share falls back to the default and never to zero: a share of\n * zero would derive an org hourly ceiling of zero and refuse every campaign\n * on the platform, which is the stubbed-resolver failure — a clamp that goes\n * green having refused everything.\n */\nexport function normalizeOrgHourlyShare(raw: unknown): number {\n const value = Number(raw)\n if (!Number.isFinite(value) || value <= 0) return EMAIL_ORG_HOURLY_SHARE\n return Math.min(\n EMAIL_ORG_HOURLY_SHARE_MAX,\n Math.max(EMAIL_ORG_HOURLY_SHARE_MIN, value),\n )\n}\n\n/**\n * Messages one org's campaigns may send in one hour.\n *\n * Floored at 1, never 0: a platform ceiling small enough to round the share\n * away must throttle campaigns to a trickle, not stop them dead. Stopping\n * them dead is indistinguishable from an outage and is what an operator would\n * accidentally configure while ramping DOWN during an incident.\n */\nexport function orgHourlyCampaignCeiling(\n platformPerHour: number,\n share: number = EMAIL_ORG_HOURLY_SHARE,\n): number {\n const platform = positiveInt(platformPerHour, 0)\n if (platform <= 0) return 1\n return Math.max(1, Math.floor(platform * normalizeOrgHourlyShare(share)))\n}\n\n/**\n * The most one org could get out in a projected month, at its hourly share.\n *\n * This is the ceiling a plan's monthly allowance has to fit inside for the\n * plan to be deliverable, and it is an upper bound rather than a forecast —\n * see the projection note on {@link EMAIL_CEILING_MONTH_DAYS}.\n */\nexport function deliverableMonthlyCeiling(\n platformPerHour: number,\n share: number = EMAIL_ORG_HOURLY_SHARE,\n monthHours: number = EMAIL_CEILING_MONTH_HOURS,\n): number {\n return (\n orgHourlyCampaignCeiling(platformPerHour, share) *\n positiveInt(monthHours, EMAIL_CEILING_MONTH_HOURS)\n )\n}\n\n/**\n * The most one org's campaigns could get out in a day, at its hourly share.\n *\n * The ceiling a new-sender ramp step has to fit inside: a step promising more\n * than this would promise a new tenant more than an established one may send.\n */\nexport function orgDailyCampaignCeiling(\n platformPerHour: number,\n share: number = EMAIL_ORG_HOURLY_SHARE,\n): number {\n return orgHourlyCampaignCeiling(platformPerHour, share) * EMAIL_CEILING_HOURS_PER_DAY\n}\n\n/** Why a partly-delivered email will not run another batch. */\nexport type CampaignBatchStop =\n /** Nothing is left to address. */\n | 'complete'\n /** {@link EMAIL_MAX_SEND_BATCHES} reached. */\n | 'batch-limit'\n /** The batch settled nobody, so another one would settle nobody either. */\n | 'no-progress'\n\n/** What happens to an email after one batch of it has been delivered. */\nexport interface CampaignBatchPlan {\n /** People this email has resolved and not yet addressed. */\n remaining: number\n /** Batches this email has now run, including the one just finished. */\n batch: number\n /** True when another batch will run. */\n resuming: boolean\n /**\n * Why no further batch will run. Null while {@link resuming} is true —\n * a reason for a state that has not happened is a reason a surface would\n * show.\n */\n stop: CampaignBatchStop | null\n}\n\nexport interface CampaignBatchPlanInput {\n /** People this batch could have addressed, before the per-send cap. */\n mailable: number\n /** People it did address — `min(mailable, perSend)`. */\n addressed: number\n /**\n * Of those it addressed, how many are left for a later batch to try again:\n * the tail an hourly deferral cut off, and nothing else. A recipient the\n * send SETTLED — delivered, suppressed or off-topic — is not retryable and\n * is not counted here.\n */\n retryable: number\n /**\n * People this batch settled: delivered plus permanently excluded. Zero is\n * what {@link CampaignBatchStop} `no-progress` is, and it is the reason\n * this is an input rather than a derivation — a batch that addressed 500\n * people and settled none of them will address the same 500 next time.\n */\n settled: number\n /** Batches run before this one. */\n batchesSoFar: number\n /** Injectable for tests; defaults to the shipped guard. */\n maxBatches?: number\n}\n\n/**\n * Whether a partly-delivered email runs again, and how much is left.\n *\n * Pure, so the termination rule can be read and tested without a send. The\n * one property it exists to hold is that a campaign always terminates:\n * `resuming` is false whenever there is nothing left, whenever the batch\n * guard is reached, and whenever a batch settled nobody — the last of which\n * is the only way a self-rescheduling job can loop.\n */\nexport function campaignBatchPlan(\n input: CampaignBatchPlanInput,\n): CampaignBatchPlan {\n const mailable = positiveInt(input.mailable, 0)\n const addressed = Math.min(mailable, positiveInt(input.addressed, 0))\n const retryable = Math.min(addressed, positiveInt(input.retryable, 0))\n const settled = positiveInt(input.settled, 0)\n const batch = positiveInt(input.batchesSoFar, 0) + 1\n const maxBatches = positiveInt(input.maxBatches, EMAIL_MAX_SEND_BATCHES)\n const remaining = Math.max(0, mailable - addressed) + retryable\n\n if (remaining <= 0) {\n return { remaining: 0, batch, resuming: false, stop: 'complete' }\n }\n if (batch >= maxBatches) {\n return { remaining, batch, resuming: false, stop: 'batch-limit' }\n }\n if (settled <= 0) {\n return { remaining, batch, resuming: false, stop: 'no-progress' }\n }\n return { remaining, batch, resuming: true, stop: null }\n}\n\nexport interface DescribeEmailCeilingsInput {\n /** The live platform hourly ceiling, from `rateLimits/sendRateConfig`. */\n platformPerHour: number\n /**\n * The plan's `emailSendsPerMonth`. May be `Infinity` (`UNLIMITED`); the\n * model converts it to a finite number plus a flag.\n */\n planMonthlyLimit: number\n /** Recipients per invocation. Defaults to the shipped cap. */\n perSend?: number\n /** Fraction of the platform hour for one org. Defaults to the shipped share. */\n orgShare?: number\n /** Hours in the projected month. Injectable for tests. */\n monthHours?: number\n}\n\n/**\n * Builds the reconciled model and names every relation that fails.\n *\n * Pure, and total: no input produces a throw, because this runs on the path\n * that decides whether a campaign goes out and a thrown model would be an\n * outage caused by bookkeeping. Nonsense inputs clamp to the shipped\n * defaults, which is the same posture `normalizeEmailSendRateConfig` takes\n * for the same reason.\n */\nexport function describeEmailCeilings(\n input: DescribeEmailCeilingsInput,\n): EmailCeilingModel {\n const orgShare = normalizeOrgHourlyShare(input.orgShare ?? EMAIL_ORG_HOURLY_SHARE)\n const platformPerHour = positiveInt(input.platformPerHour, 0)\n const perSend = positiveInt(input.perSend, EMAIL_MAX_RECIPIENTS_PER_SEND)\n const monthHours = positiveInt(input.monthHours, EMAIL_CEILING_MONTH_HOURS)\n const orgPerHour = orgHourlyCampaignCeiling(platformPerHour, orgShare)\n const deliverableMonthly = orgPerHour * monthHours\n\n const rawPlan = Number(input.planMonthlyLimit)\n const planUnlimited = rawPlan === Number.POSITIVE_INFINITY\n // A negative or unreadable allowance reads as 0 — no included band — rather\n // than as unlimited. `emailSendsOverage` makes the same choice: the\n // direction to be wrong in is the one that cannot let unbounded mail out.\n const planMonthly = planUnlimited\n ? deliverableMonthly\n : Number.isFinite(rawPlan) && rawPlan > 0\n ? Math.floor(rawPlan)\n : 0\n\n const violations: EmailCeilingViolation[] = []\n if (perSend > orgPerHour) {\n violations.push({\n relation: 'send-exceeds-org-hour',\n claimed: perSend,\n available: orgPerHour,\n detail:\n `A single send may address ${perSend} recipients but a workspace may ` +\n `only send ${orgPerHour} an hour, so a full send can never complete ` +\n 'inside one window.',\n })\n }\n if (orgPerHour > platformPerHour) {\n violations.push({\n relation: 'org-hour-exceeds-platform-hour',\n claimed: orgPerHour,\n available: platformPerHour,\n detail:\n `One workspace may send ${orgPerHour} an hour against a platform ` +\n `ceiling of ${platformPerHour}, so a single tenant can occupy the ` +\n 'whole hour.',\n })\n }\n // An unlimited plan violates R3 by construction: no finite platform can\n // deliver an unbounded allowance. Reported, not repaired — the hourly\n // ceiling is what paces it, and it defers rather than refuses.\n if (planUnlimited || planMonthly > deliverableMonthly) {\n violations.push({\n relation: 'plan-exceeds-deliverable-month',\n claimed: planUnlimited ? Number.POSITIVE_INFINITY : planMonthly,\n available: deliverableMonthly,\n detail: planUnlimited\n ? 'The plan sells an unlimited monthly allowance, which no finite ' +\n `platform ceiling can deliver; ${deliverableMonthly.toLocaleString()} ` +\n 'a month is the most this workspace can actually send.'\n : `The plan includes ${planMonthly.toLocaleString()} campaign emails a ` +\n `month but the platform can deliver at most ` +\n `${deliverableMonthly.toLocaleString()} to one workspace.`,\n })\n }\n\n return {\n perSend,\n orgPerHour,\n platformPerHour,\n orgShare,\n deliverableMonthly,\n planMonthly,\n planUnlimited,\n hoursToSpendPlan: planUnlimited\n ? Number.POSITIVE_INFINITY\n : orgPerHour > 0\n ? Math.ceil(planMonthly / orgPerHour)\n : 0,\n violations,\n coherent: violations.length === 0,\n }\n}\n\n/**\n * The wire shape for the monitoring surface: what a workspace has sent\n * against what it may send.\n *\n * Every field is a finite number. `planUnlimited` and `hoursToSpendPlanKnown`\n * carry what the numbers cannot — see the note on\n * {@link EmailCeilingModel.planMonthly} for why a sentinel is never sent.\n */\nexport interface EmailSendHeadroom {\n /** Campaign messages this workspace has sent this calendar month. */\n monthUsed: number\n /** The plan's monthly allowance, finite. */\n monthLimit: number\n /** True when the plan's allowance is unbounded. */\n planUnlimited: boolean\n /** Allowance left this month, floored at 0. Zero when unlimited is false and spent. */\n monthRemaining: number\n /** Campaign messages this workspace has sent in the current hour. */\n hourUsed: number\n /** What it may send in an hour. */\n hourLimit: number\n /** Headroom left in the current hour, floored at 0. */\n hourRemaining: number\n /** When the hour rolls, ms since epoch. */\n hourResetMs: number\n /** Recipients one send may address. */\n perSend: number\n /** Upper bound on what the platform can deliver to one workspace in a month. */\n deliverableMonthly: number\n /**\n * True when the plan sells more than {@link deliverableMonthly}.\n *\n * Surfaced rather than hidden: a customer whose plan promises a million\n * emails is entitled to know that the pacing controls will not let them\n * spend it, and an operator is entitled to see it before a support ticket\n * arrives asking why.\n */\n planExceedsDeliverable: boolean\n}\n\n/**\n * Assembles the monitoring figure from the two counters and the model.\n *\n * Pure. The counters are read by the caller — this library is `scope:shared`\n * and may not reach Firestore — so the same function serves the console, the\n * composer and a test with no harness at all.\n */\nexport function emailSendHeadroom(input: {\n model: EmailCeilingModel\n /** Campaign sends recorded for the calendar month. */\n monthUsed: number\n /** Campaign sends recorded in the current hourly window. */\n hourUsed: number\n /** When the current window rolls. */\n hourResetMs: number\n}): EmailSendHeadroom {\n const { model } = input\n // A corrupt or negative counter must not read as headroom a cap honours —\n // the clamp `campaignEmailSendsForMonth` and `emailSendRateVerdict` both\n // apply, for the same reason.\n const monthUsed = Math.max(0, Math.floor(Number(input.monthUsed) || 0))\n const hourUsed = Math.max(0, Math.floor(Number(input.hourUsed) || 0))\n return {\n monthUsed,\n monthLimit: model.planMonthly,\n planUnlimited: model.planUnlimited,\n monthRemaining: model.planUnlimited\n ? model.deliverableMonthly\n : Math.max(0, model.planMonthly - monthUsed),\n hourUsed,\n hourLimit: model.orgPerHour,\n hourRemaining: Math.max(0, model.orgPerHour - hourUsed),\n hourResetMs: Math.max(0, Math.floor(Number(input.hourResetMs) || 0)),\n perSend: model.perSend,\n deliverableMonthly: model.deliverableMonthly,\n planExceedsDeliverable: model.violations.some(\n (violation) => violation.relation === 'plan-exceeds-deliverable-month',\n ),\n }\n}\n"],"names":["EMAIL_MAX_RECIPIENTS_PER_SEND","EMAIL_MAX_AUDIENCE_PER_SEND","EMAIL_MAX_SEND_BATCHES","Math","ceil","EMAIL_ORG_HOURLY_SHARE","EMAIL_ORG_HOURLY_SHARE_MIN","EMAIL_ORG_HOURLY_SHARE_MAX","EMAIL_CEILING_MONTH_DAYS","EMAIL_CEILING_HOURS_PER_DAY","EMAIL_CEILING_MONTH_HOURS","positiveInt","raw","fallback","value","Number","isFinite","floor","normalizeOrgHourlyShare","min","max","orgHourlyCampaignCeiling","platformPerHour","share","platform","deliverableMonthlyCeiling","monthHours","orgDailyCampaignCeiling","campaignBatchPlan","input","mailable","addressed","retryable","settled","batch","batchesSoFar","maxBatches","remaining","resuming","stop","describeEmailCeilings","orgShare","perSend","orgPerHour","deliverableMonthly","rawPlan","planMonthlyLimit","planUnlimited","POSITIVE_INFINITY","planMonthly","violations","push","relation","claimed","available","detail","toLocaleString","hoursToSpendPlan","coherent","length","emailSendHeadroom","model","monthUsed","hourUsed","monthLimit","monthRemaining","hourLimit","hourRemaining","hourResetMs","planExceedsDeliverable","some","violation"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/send-ceilings.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 EMAIL CEILINGS, DIMENSIONED AGAINST EACH OTHER.\n *\n * Three independent limits govern how much campaign mail leaves the platform,\n * and until this module none of them was expressed in terms of any other:\n *\n * - a per-send recipient cap, in `campaign-send.ts`;\n * - a platform hourly ceiling, in `send-rate.ts`;\n * - a per-plan monthly allowance, `emailSendsPerMonth` in `PLAN_ENTITLEMENTS`.\n *\n * Three numbers picked separately describe a machine that cannot exist. The\n * arithmetic below is the whole point of the file: every derived ceiling is a\n * function of the others, so the relations can be checked rather than\n * believed, and a future change to any one of them fails a test instead of\n * quietly overselling the sending domain.\n *\n * ## The units\n *\n * Every figure here is a COUNT OF MESSAGES — one per recipient address handed\n * to the sender — matching `email-metering.ts` exactly. The window each one\n * counts over is the only thing that differs: one invocation, one hour, one\n * calendar month.\n *\n * ## The derivation\n *\n * A month is projected from an hour at {@link EMAIL_CEILING_MONTH_DAYS} days\n * of {@link EMAIL_CEILING_HOURS_PER_DAY} hours. That projection is\n * deliberately generous — it assumes an org sends flat out around the clock\n * for a whole month, which nobody does — because its job is to prove an upper\n * bound. A plan that oversells even against continuous sending oversells\n * against every real pattern too.\n *\n * orgPerHour = floor(platformPerHour x orgShare)\n * deliverableMonthly = orgPerHour x 24 x 30\n *\n * With the shipped defaults (2,000/hour platform, 25% share):\n *\n * orgPerHour = floor(2,000 x 0.25) = 500 / hour\n * deliverableMonthly = 500 x 24 x 30 = 360,000 / month\n *\n * ## The three relations\n *\n * **R1 — a send must fit in the org's hour.** `perSend <= orgPerHour`. At the\n * defaults these are both 500, so one maximal send is exactly one hour of the\n * org's share. A per-send cap ABOVE the hourly cap would be a cap that can\n * never be reached, which is the shape that teaches an operator to ignore the\n * number the composer shows them.\n *\n * **R2 — an org may not be the whole platform.** `orgPerHour <=\n * platformPerHour`. At 25% four orgs can occupy the hour together; at 100%\n * one org can shut every other tenant out of campaign sending, which is the\n * tenant-versus-tenant denial of service the share exists to close.\n * Transactional mail is exempt from both ceilings and is unaffected by this.\n *\n * **R3 — a plan may not sell more than the platform can deliver.**\n * `planMonthly <= deliverableMonthly`. Every shipped plan holds it today, and\n * {@link describeEmailCeilings} reports a breach rather than repairing it:\n * the repair would be to lower an entitlement, and an entitlement is what a\n * price bought. See `email-ceiling-dimensioning.spec.ts`, which pins exactly\n * which plans fail — an empty set — so that it cannot grow unnoticed.\n *\n * ## What a violated relation does NOT do\n *\n * It does not clamp anything. `PLAN_ENTITLEMENTS` is the authority for what a\n * customer bought, and silently resolving an entitlement down to what the\n * current platform ceiling can deliver would be a price change made by\n * arithmetic. The model reports; the operator decides; the hourly ceiling is\n * what actually paces the mail, and it defers rather than refuses.\n */\n\n/**\n * Recipients one campaign invocation may address.\n *\n * The cap is a bound on a single function call, not a plan feature: beyond it\n * a send stops being a request and becomes a batch job that has to survive\n * timeouts and resume without double-sending. It is stated here rather than\n * privately in `campaign-send.ts` so that R1 can be checked against it.\n *\n * A merchant whose audience is larger is NOT told this number is their\n * audience. The composer reports the true audience and how much of it this\n * send reaches — a truncation reported as a total is the silent cap this\n * product keeps rediscovering.\n */\nexport const EMAIL_MAX_RECIPIENTS_PER_SEND = 500\n\n/**\n * People one email may resolve, and therefore ever reach.\n *\n * The read budget for resolving one audience, and the ceiling on how many\n * addresses one email's reach record may hold — the two have to be the same\n * number, because an email cannot reach more people than its audience can\n * resolve. Stated here with the other ceilings for the reason the per-send\n * cap is: held privately in the sender it was a number nothing could be\n * checked against.\n *\n * Reaching it truncates nothing silently. The resolution reports it, the\n * recorded `audienceSize` becomes a floor rather than a total, and every\n * surface derived from it says so.\n */\nexport const EMAIL_MAX_AUDIENCE_PER_SEND = 5000\n\n/**\n * Batches one email may run before it stops on its own.\n *\n * An audience larger than {@link EMAIL_MAX_RECIPIENTS_PER_SEND} is delivered\n * across several invocations, each one picking up where the last stopped. The\n * frontier advances by the per-send cap every time, so\n * `ceil(audience / perSend)` batches finish any audience the sender can\n * resolve — and the slack above it absorbs the batches a transient provider\n * failure or an hourly deferral costs.\n *\n * It is a TERMINATION guard rather than a capacity limit. Nothing about a\n * healthy campaign should approach it; what it stops is a send that makes no\n * progress rescheduling itself forever, which is the one failure a\n * self-resuming job can have that nobody notices.\n */\nexport const EMAIL_MAX_SEND_BATCHES =\n Math.ceil(EMAIL_MAX_AUDIENCE_PER_SEND / EMAIL_MAX_RECIPIENTS_PER_SEND) * 2\n\n/**\n * The fraction of the platform hour one org's campaigns may occupy.\n *\n * 25% is the starting value: it leaves room for four concurrent large senders\n * plus all transactional traffic (which the governor counts but may never\n * refuse), and at the default platform ceiling it lands the org's hour on\n * exactly {@link EMAIL_MAX_RECIPIENTS_PER_SEND}, so R1 holds with no slack\n * and the two caps explain each other.\n *\n * Compiled in rather than stored, unlike the platform ceiling beside it. The\n * platform ceiling is a ramp — an operator moves it during an incident or a\n * warm-up, and `send-rate.ts` makes that a value change on\n * `rateLimits/sendRateConfig` for exactly that reason. This share is a\n * fairness policy between tenants, not a ramp: changing it redistributes\n * headroom between paying customers, which is a decision that should carry a\n * deploy and a review. If that stops being true it becomes a second field on\n * the same config document and inherits the same staff card.\n */\nexport const EMAIL_ORG_HOURLY_SHARE = 0.25\n\n/** Lower bound on a share. Zero would refuse every campaign on the platform. */\nexport const EMAIL_ORG_HOURLY_SHARE_MIN = 0.01\n\n/** Upper bound. Above 1 an org would be entitled to more than the whole hour. */\nexport const EMAIL_ORG_HOURLY_SHARE_MAX = 1\n\n/**\n * Days used to project an hourly ceiling onto a month.\n *\n * 30 rather than the true length of the calendar month, and that choice is\n * conservative in the direction that matters: a 31-day month would raise the\n * projected ceiling and make an overselling plan look like it fits. The\n * monthly ALLOWANCE is a real calendar month — `email-metering.ts` keys it\n * `YYYY-MM` — and nothing here changes that. This constant only bounds the\n * projection used to compare the two ceilings.\n */\nexport const EMAIL_CEILING_MONTH_DAYS = 30\n\n/** Hours in the projection day. Named so the arithmetic reads as arithmetic. */\nexport const EMAIL_CEILING_HOURS_PER_DAY = 24\n\n/** Hours in the projected month: 720 at the shipped constants. */\nexport const EMAIL_CEILING_MONTH_HOURS =\n EMAIL_CEILING_MONTH_DAYS * EMAIL_CEILING_HOURS_PER_DAY\n\n/** A relation between two ceilings that must hold, named for reporting. */\nexport type EmailCeilingRelation =\n /** R1: a single send must fit inside the org's hourly share. */\n | 'send-exceeds-org-hour'\n /** R2: an org's hourly share must fit inside the platform hour. */\n | 'org-hour-exceeds-platform-hour'\n /** R3: a plan may not sell more than the platform can deliver in a month. */\n | 'plan-exceeds-deliverable-month'\n\n/** One failed relation, with the two numbers that failed it. */\nexport interface EmailCeilingViolation {\n relation: EmailCeilingRelation\n /** The ceiling that is too large. */\n claimed: number\n /** The ceiling it must not exceed. */\n available: number\n /** Human-readable, and the text a surface may show verbatim. */\n detail: string\n}\n\n/** The reconciled model: every ceiling, and whether they agree. */\nexport interface EmailCeilingModel {\n /** Recipients one invocation may address. */\n perSend: number\n /** Messages one org's campaigns may send in an hour. */\n orgPerHour: number\n /** Messages the whole platform may send in an hour. */\n platformPerHour: number\n /** The share used to derive `orgPerHour`. */\n orgShare: number\n /** Upper bound on what one org can actually get out in a month. */\n deliverableMonthly: number\n /**\n * The plan's monthly campaign allowance as a FINITE number.\n *\n * An unlimited plan reports `deliverableMonthly` here and sets\n * {@link planUnlimited}. `UNLIMITED` is `Number.POSITIVE_INFINITY`, and\n * `JSON.stringify(Infinity)` is `null` — `Number(null)` is `0` and\n * `Number.isFinite(0)` is `true`, so the sentinel crossing the wire arrives\n * as a cap of ZERO on the most expensive plan and sails through every\n * guard. A finite number plus an explicit flag is the only shape that\n * survives serialization.\n */\n planMonthly: number\n /** True when the plan's allowance is unbounded. Never infer this from the number. */\n planUnlimited: boolean\n /**\n * Hours of the org's own hourly share a full plan allowance would need.\n *\n * The figure that makes an overselling plan legible: at the defaults an\n * agency allowance of 1,000,000 needs 2,000 hours of a 720-hour month.\n */\n hoursToSpendPlan: number\n /** Every relation that does not hold. Empty when the model is coherent. */\n violations: EmailCeilingViolation[]\n /** True when all three relations hold. */\n coherent: boolean\n}\n\n/** Clamps a raw ceiling to a positive integer, or returns `fallback`. */\nfunction positiveInt(raw: unknown, fallback: number): number {\n const value = Number(raw)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback\n}\n\n/**\n * Clamps a share into `[MIN, MAX]`.\n *\n * An unreadable share falls back to the default and never to zero: a share of\n * zero would derive an org hourly ceiling of zero and refuse every campaign\n * on the platform, which is the stubbed-resolver failure — a clamp that goes\n * green having refused everything.\n */\nexport function normalizeOrgHourlyShare(raw: unknown): number {\n const value = Number(raw)\n if (!Number.isFinite(value) || value <= 0) return EMAIL_ORG_HOURLY_SHARE\n return Math.min(\n EMAIL_ORG_HOURLY_SHARE_MAX,\n Math.max(EMAIL_ORG_HOURLY_SHARE_MIN, value),\n )\n}\n\n/**\n * Messages one org's campaigns may send in one hour.\n *\n * Floored at 1, never 0: a platform ceiling small enough to round the share\n * away must throttle campaigns to a trickle, not stop them dead. Stopping\n * them dead is indistinguishable from an outage and is what an operator would\n * accidentally configure while ramping DOWN during an incident.\n */\nexport function orgHourlyCampaignCeiling(\n platformPerHour: number,\n share: number = EMAIL_ORG_HOURLY_SHARE,\n): number {\n const platform = positiveInt(platformPerHour, 0)\n if (platform <= 0) return 1\n return Math.max(1, Math.floor(platform * normalizeOrgHourlyShare(share)))\n}\n\n/**\n * The most one org could get out in a projected month, at its hourly share.\n *\n * This is the ceiling a plan's monthly allowance has to fit inside for the\n * plan to be deliverable, and it is an upper bound rather than a forecast —\n * see the projection note on {@link EMAIL_CEILING_MONTH_DAYS}.\n */\nexport function deliverableMonthlyCeiling(\n platformPerHour: number,\n share: number = EMAIL_ORG_HOURLY_SHARE,\n monthHours: number = EMAIL_CEILING_MONTH_HOURS,\n): number {\n return (\n orgHourlyCampaignCeiling(platformPerHour, share) *\n positiveInt(monthHours, EMAIL_CEILING_MONTH_HOURS)\n )\n}\n\n/**\n * The most one org's campaigns could get out in a day, at its hourly share.\n *\n * The ceiling a new-sender ramp step has to fit inside: a step promising more\n * than this would promise a new tenant more than an established one may send.\n */\nexport function orgDailyCampaignCeiling(\n platformPerHour: number,\n share: number = EMAIL_ORG_HOURLY_SHARE,\n): number {\n return orgHourlyCampaignCeiling(platformPerHour, share) * EMAIL_CEILING_HOURS_PER_DAY\n}\n\nexport interface DescribeEmailCeilingsInput {\n /** The live platform hourly ceiling, from `rateLimits/sendRateConfig`. */\n platformPerHour: number\n /**\n * The plan's `emailSendsPerMonth`. May be `Infinity` (`UNLIMITED`); the\n * model converts it to a finite number plus a flag.\n */\n planMonthlyLimit: number\n /** Recipients per invocation. Defaults to the shipped cap. */\n perSend?: number\n /** Fraction of the platform hour for one org. Defaults to the shipped share. */\n orgShare?: number\n /** Hours in the projected month. Injectable for tests. */\n monthHours?: number\n}\n\n/**\n * Builds the reconciled model and names every relation that fails.\n *\n * Pure, and total: no input produces a throw, because this runs on the path\n * that decides whether a campaign goes out and a thrown model would be an\n * outage caused by bookkeeping. Nonsense inputs clamp to the shipped\n * defaults, which is the same posture `normalizeEmailSendRateConfig` takes\n * for the same reason.\n */\nexport function describeEmailCeilings(\n input: DescribeEmailCeilingsInput,\n): EmailCeilingModel {\n const orgShare = normalizeOrgHourlyShare(input.orgShare ?? EMAIL_ORG_HOURLY_SHARE)\n const platformPerHour = positiveInt(input.platformPerHour, 0)\n const perSend = positiveInt(input.perSend, EMAIL_MAX_RECIPIENTS_PER_SEND)\n const monthHours = positiveInt(input.monthHours, EMAIL_CEILING_MONTH_HOURS)\n const orgPerHour = orgHourlyCampaignCeiling(platformPerHour, orgShare)\n const deliverableMonthly = orgPerHour * monthHours\n\n const rawPlan = Number(input.planMonthlyLimit)\n const planUnlimited = rawPlan === Number.POSITIVE_INFINITY\n // A negative or unreadable allowance reads as 0 — no included band — rather\n // than as unlimited. `emailSendsOverage` makes the same choice: the\n // direction to be wrong in is the one that cannot let unbounded mail out.\n const planMonthly = planUnlimited\n ? deliverableMonthly\n : Number.isFinite(rawPlan) && rawPlan > 0\n ? Math.floor(rawPlan)\n : 0\n\n const violations: EmailCeilingViolation[] = []\n if (perSend > orgPerHour) {\n violations.push({\n relation: 'send-exceeds-org-hour',\n claimed: perSend,\n available: orgPerHour,\n detail:\n `A single send may address ${perSend} recipients but a workspace may ` +\n `only send ${orgPerHour} an hour, so a full send can never complete ` +\n 'inside one window.',\n })\n }\n if (orgPerHour > platformPerHour) {\n violations.push({\n relation: 'org-hour-exceeds-platform-hour',\n claimed: orgPerHour,\n available: platformPerHour,\n detail:\n `One workspace may send ${orgPerHour} an hour against a platform ` +\n `ceiling of ${platformPerHour}, so a single tenant can occupy the ` +\n 'whole hour.',\n })\n }\n // An unlimited plan violates R3 by construction: no finite platform can\n // deliver an unbounded allowance. Reported, not repaired — the hourly\n // ceiling is what paces it, and it defers rather than refuses.\n if (planUnlimited || planMonthly > deliverableMonthly) {\n violations.push({\n relation: 'plan-exceeds-deliverable-month',\n claimed: planUnlimited ? Number.POSITIVE_INFINITY : planMonthly,\n available: deliverableMonthly,\n detail: planUnlimited\n ? 'The plan sells an unlimited monthly allowance, which no finite ' +\n `platform ceiling can deliver; ${deliverableMonthly.toLocaleString()} ` +\n 'a month is the most this workspace can actually send.'\n : `The plan includes ${planMonthly.toLocaleString()} campaign emails a ` +\n `month but the platform can deliver at most ` +\n `${deliverableMonthly.toLocaleString()} to one workspace.`,\n })\n }\n\n return {\n perSend,\n orgPerHour,\n platformPerHour,\n orgShare,\n deliverableMonthly,\n planMonthly,\n planUnlimited,\n hoursToSpendPlan: planUnlimited\n ? Number.POSITIVE_INFINITY\n : orgPerHour > 0\n ? Math.ceil(planMonthly / orgPerHour)\n : 0,\n violations,\n coherent: violations.length === 0,\n }\n}\n\n/**\n * The wire shape for the monitoring surface: what a workspace has sent\n * against what it may send.\n *\n * Every field is a finite number. `planUnlimited` and `hoursToSpendPlanKnown`\n * carry what the numbers cannot — see the note on\n * {@link EmailCeilingModel.planMonthly} for why a sentinel is never sent.\n */\nexport interface EmailSendHeadroom {\n /** Campaign messages this workspace has sent this calendar month. */\n monthUsed: number\n /** The plan's monthly allowance, finite. */\n monthLimit: number\n /** True when the plan's allowance is unbounded. */\n planUnlimited: boolean\n /** Allowance left this month, floored at 0. Zero when unlimited is false and spent. */\n monthRemaining: number\n /** Campaign messages this workspace has sent in the current hour. */\n hourUsed: number\n /** What it may send in an hour. */\n hourLimit: number\n /** Headroom left in the current hour, floored at 0. */\n hourRemaining: number\n /** When the hour rolls, ms since epoch. */\n hourResetMs: number\n /** Recipients one send may address. */\n perSend: number\n /** Upper bound on what the platform can deliver to one workspace in a month. */\n deliverableMonthly: number\n /**\n * True when the plan sells more than {@link deliverableMonthly}.\n *\n * Surfaced rather than hidden: a customer whose plan promises a million\n * emails is entitled to know that the pacing controls will not let them\n * spend it, and an operator is entitled to see it before a support ticket\n * arrives asking why.\n */\n planExceedsDeliverable: boolean\n}\n\n/**\n * Assembles the monitoring figure from the two counters and the model.\n *\n * Pure. The counters are read by the caller — this library is `scope:shared`\n * and may not reach Firestore — so the same function serves the console, the\n * composer and a test with no harness at all.\n */\nexport function emailSendHeadroom(input: {\n model: EmailCeilingModel\n /** Campaign sends recorded for the calendar month. */\n monthUsed: number\n /** Campaign sends recorded in the current hourly window. */\n hourUsed: number\n /** When the current window rolls. */\n hourResetMs: number\n}): EmailSendHeadroom {\n const { model } = input\n // A corrupt or negative counter must not read as headroom a cap honours —\n // the clamp `campaignEmailSendsForMonth` and `emailSendRateVerdict` both\n // apply, for the same reason.\n const monthUsed = Math.max(0, Math.floor(Number(input.monthUsed) || 0))\n const hourUsed = Math.max(0, Math.floor(Number(input.hourUsed) || 0))\n return {\n monthUsed,\n monthLimit: model.planMonthly,\n planUnlimited: model.planUnlimited,\n monthRemaining: model.planUnlimited\n ? model.deliverableMonthly\n : Math.max(0, model.planMonthly - monthUsed),\n hourUsed,\n hourLimit: model.orgPerHour,\n hourRemaining: Math.max(0, model.orgPerHour - hourUsed),\n hourResetMs: Math.max(0, Math.floor(Number(input.hourResetMs) || 0)),\n perSend: model.perSend,\n deliverableMonthly: model.deliverableMonthly,\n planExceedsDeliverable: model.violations.some(\n (violation) => violation.relation === 'plan-exceeds-deliverable-month',\n ),\n }\n}\n"],"names":["EMAIL_MAX_RECIPIENTS_PER_SEND","EMAIL_MAX_AUDIENCE_PER_SEND","EMAIL_MAX_SEND_BATCHES","Math","ceil","EMAIL_ORG_HOURLY_SHARE","EMAIL_ORG_HOURLY_SHARE_MIN","EMAIL_ORG_HOURLY_SHARE_MAX","EMAIL_CEILING_MONTH_DAYS","EMAIL_CEILING_HOURS_PER_DAY","EMAIL_CEILING_MONTH_HOURS","positiveInt","raw","fallback","value","Number","isFinite","floor","normalizeOrgHourlyShare","min","max","orgHourlyCampaignCeiling","platformPerHour","share","platform","deliverableMonthlyCeiling","monthHours","orgDailyCampaignCeiling","describeEmailCeilings","input","orgShare","perSend","orgPerHour","deliverableMonthly","rawPlan","planMonthlyLimit","planUnlimited","POSITIVE_INFINITY","planMonthly","violations","push","relation","claimed","available","detail","toLocaleString","hoursToSpendPlan","coherent","length","emailSendHeadroom","model","monthUsed","hourUsed","monthLimit","monthRemaining","hourLimit","hourRemaining","hourResetMs","planExceedsDeliverable","some","violation"],"mappings":"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"}
@@ -15,8 +15,11 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  /**
18
- * The Resend send endpoint. Every outbound application email in Aglyn goes
19
- * through here — invites, receipts, usage summaries, campaigns, staff alerts.
18
+ * The send seam. Every outbound application email in Aglyn goes through
19
+ * here — invites, receipts, usage summaries, campaigns, staff alerts — and
20
+ * everything that decides whether, as whom and to whom a message leaves is
21
+ * decided here. The provider that then carries it is chosen by
22
+ * `mail-providers.ts` and only carries it.
20
23
  *
21
24
  * Auth email (verification, password reset) is Firebase's job and does NOT
22
25
  * come through this module.
@@ -25,8 +28,10 @@ import { type EmailSendPriority } from './send-rate';
25
28
  import { type SendingIdentityAudience, type SendingIdentityVerdict } from './sending-domain';
26
29
  import { type MarketingSendContext } from './marketing-send';
27
30
  import { type EmailSendPurpose } from './email-deliverability';
28
- export declare const RESEND_SEND_ENDPOINT = "https://api.resend.com/emails";
29
- /** A Resend delivery tag, used for webhook attribution (AGL-268). */
31
+ /**
32
+ * A delivery tag: attribution the provider hands back on every delivery
33
+ * event for the message, which is how a bounce finds its sender (AGL-268).
34
+ */
30
35
  export interface EmailTag {
31
36
  name: string;
32
37
  value: string;
@@ -42,7 +47,7 @@ export interface SendEmailOptions {
42
47
  *
43
48
  * Omitted, one is synthesized from `text` so the message always carries an
44
49
  * HTML part — a text-only message has no anchors, so its links are inert in
45
- * the inbox and Resend's click tracking has nothing to rewrite. See
50
+ * the inbox and the provider's click tracking has nothing to rewrite. See
46
51
  * `text-email-html.ts`.
47
52
  */
48
53
  html?: string;
@@ -57,6 +62,11 @@ export interface SendEmailOptions {
57
62
  * itself is never taken from the caller, so this cannot forge a different
58
63
  * sender. Callers pass `resolveBrandingProfile(org).fromName` here so an
59
64
  * agency's mail reads as their brand instead of "Aglyn".
65
+ *
66
+ * A SITE's mail leaves under the site's own name instead (AGL-3442): an
67
+ * identity from `hostSendingIdentity` carries it, and it takes the place of
68
+ * the org's branding default. A name of the caller's own, such as the
69
+ * person writing a CRM email, still wins. See {@link messageFromName}.
60
70
  */
61
71
  fromName?: string;
62
72
  /**
@@ -100,8 +110,8 @@ export interface SendEmailOptions {
100
110
  * Short label for logs, e.g. `'invite'` or `'usage-summary'`. Makes a
101
111
  * failure in the runtime logs traceable to the feature that caused it.
102
112
  *
103
- * Since AGL-2407 it is also stamped as a Resend `context` TAG on every
104
- * send — see `contextTag` below.
113
+ * Since AGL-2407 it is also stamped as a `context` TAG on every send —
114
+ * see `contextTag` below.
105
115
  */
106
116
  context?: string;
107
117
  /**
@@ -170,8 +180,8 @@ export interface SendEmailOptions {
170
180
  * and it is exactly the right value: it names the sender. So the tag is
171
181
  * derived here, once, and no caller changes.
172
182
  *
173
- * Resend tag values are restricted to ASCII letters, digits, `_` and `-`;
174
- * anything else is rejected and would fail the whole send. Every `context` in
183
+ * Providers restrict tag values — to ASCII letters, digits, `_` and `-` at
184
+ * the strictest — and one that rejects a value fails the whole send. Every `context` in
175
185
  * the tree is already a plain slug, but this is mail delivery — a value that
176
186
  * makes the send fail is far worse than a value that is sanitised — so the
177
187
  * label is normalised rather than trusted, and a context that sanitises to
@@ -180,7 +190,7 @@ export interface SendEmailOptions {
180
190
  export declare function contextTag(context: string | undefined): EmailTag[];
181
191
  /**
182
192
  * Why a send did not happen. `unconfigured` and `no-recipient` mean nothing
183
- * was attempted; `rejected` and `network` mean Resend was called and failed;
193
+ * was attempted; `rejected` and `network` mean the provider was asked and failed;
184
194
  * `rate-limited` is either, and says so in `status`.
185
195
  *
186
196
  * `rate-limited` (AGL-2409) and `frequency-capped` are the two a caller may
@@ -189,7 +199,7 @@ export declare function contextTag(context: string | undefined): EmailTag[];
189
199
  */
190
200
  export type SendEmailFailureReason = 'unconfigured' | 'no-recipient'
191
201
  /**
192
- * Resend answered and would not take this message.
202
+ * The provider answered and would not take this message.
193
203
  *
194
204
  * Per-message and terminal: a malformed payload, an address the provider
195
205
  * will not accept, a tag it rejected. A caller that retries it unchanged
@@ -204,9 +214,9 @@ export type SendEmailFailureReason = 'unconfigured' | 'no-recipient'
204
214
  * is intact and a later attempt sends it.
205
215
  *
206
216
  * Two sources, which is why it is one value. The platform hourly governor
207
- * refuses before the network and nothing is attempted (AGL-2409). Resend
208
- * answers `429` on the wire when requests arrive faster than it accepts
209
- * them, and `status` is 429 in that case.
217
+ * refuses before the network and nothing is attempted (AGL-2409). The
218
+ * provider asks for a slower pace when requests arrive faster than it
219
+ * accepts them, and `status` is its answer (429 over HTTP) in that case.
210
220
  *
211
221
  * The provider arm reports here rather than as `rejected` because of what
212
222
  * the two mean to a batch. A `rejected` recipient is settled and never
@@ -309,9 +319,9 @@ export type SendEmailResult = {
309
319
  } | {
310
320
  sent: false;
311
321
  reason: SendEmailFailureReason;
312
- /** HTTP status, when Resend answered. */
322
+ /** HTTP status, when the provider answered over HTTP. */
313
323
  status?: number;
314
- /** Resend's error body or the thrown message, trimmed for logs. */
324
+ /** The provider's error text or the thrown message, trimmed for logs. */
315
325
  detail?: string;
316
326
  /**
317
327
  * `rate-limited` only: the earliest instant a caller may try again —
@@ -333,17 +343,6 @@ export type SendEmailResult = {
333
343
  * shape of a deferral ever moves.
334
344
  */
335
345
  export declare function rateLimitedRetryAtMs(result: SendEmailResult | null | undefined): number | null;
336
- /**
337
- * When the provider says a refused request may be repeated.
338
- *
339
- * Read from the two headers Resend documents beside a 429, both in whole
340
- * seconds: `retry-after` first because it is the direct answer to this
341
- * question, then `ratelimit-reset`, which names when the window rolls. A
342
- * response carrying neither falls back to one window.
343
- */
344
- export declare function providerRetryAtMs(headers: {
345
- get?: (name: string) => string | null;
346
- } | null | undefined, nowMs?: number): number;
347
346
  /**
348
347
  * Why a send did not happen, or `null` when it did.
349
348
  *
@@ -389,6 +388,24 @@ export declare function isDeferrableSendResult(result: SendEmailResult | null |
389
388
  export declare function emailSendPurpose(options: Pick<SendEmailOptions, 'priority' | 'context' | 'headers'> & {
390
389
  marketing?: unknown;
391
390
  }): EmailSendPurpose;
391
+ /**
392
+ * The display name a message leaves under (AGL-3442).
393
+ *
394
+ * A site's mail goes out under the site's own name — the business name its
395
+ * emails render as `{{host.businessName}}`, else its display name, else the
396
+ * org's branding default — which `hostSendingIdentity` resolves with the
397
+ * address from the host and org documents it already reads. That is decided
398
+ * here rather than at each sender because every site sender passes the org's
399
+ * branding default as `fromName`, and to a visitor that is the platform's or
400
+ * the agency's name rather than the business they dealt with. So the default
401
+ * is replaced, and only the default: a name of the caller's own, such as the
402
+ * person writing a CRM email, is still the name the message leaves under.
403
+ *
404
+ * Mail on any other identity — the platform's own, or a campaign's, which
405
+ * resolves its identity without a host stamp and records the name its
406
+ * composer chose — keeps the caller's `fromName` unchanged.
407
+ */
408
+ export declare function messageFromName(options: Pick<SendEmailOptions, 'fromName' | 'sendingIdentity'>): string | undefined;
392
409
  /**
393
410
  * The send seam's half of the outbound phishing screen (AGL-3356): every
394
411
  * message a SITE sends whose identity carries the workspace
@@ -398,7 +415,11 @@ export declare function emailSendPurpose(options: Pick<SendEmailOptions, 'priori
398
415
  */
399
416
  export declare function askOutboundScreen(options: SendEmailOptions, label?: string): Promise<SendEmailResult | null>;
400
417
  export interface EmailConfig {
401
- apiKey: string | undefined;
418
+ /** The id of the provider mail is handed to — see `mail-providers.ts`. */
419
+ provider: string;
420
+ /** Settings that provider still needs before it can send; empty when it can. */
421
+ missingSettings: string[];
422
+ /** The platform's own sender, `USAGE_EMAIL_FROM`. */
402
423
  from: string | undefined;
403
424
  }
404
425
  /**
@@ -410,7 +431,7 @@ export interface EmailConfig {
410
431
  */
411
432
  export declare function getEmailConfig(): EmailConfig;
412
433
  /**
413
- * True when both `RESEND_API_KEY` and `USAGE_EMAIL_FROM` are present.
434
+ * True when the provider has its settings and `USAGE_EMAIL_FROM` is present.
414
435
  *
415
436
  * Callers that answer an HTTP request (rather than firing best-effort mail)
416
437
  * use this to return a 501 with an actionable message instead of pretending
@@ -426,35 +447,8 @@ export declare function isEmailConfigured(): boolean;
426
447
  * never dropped on the floor.
427
448
  */
428
449
  export declare function applyFromName(from: string | undefined, fromName: string | undefined): string | undefined;
429
- /** A Resend send payload in the provider's own wire shape. */
430
- export interface ResendSendPayload {
431
- to?: unknown;
432
- from?: unknown;
433
- subject?: unknown;
434
- [field: string]: unknown;
435
- }
436
- /**
437
- * The one place that POSTs to Resend's send endpoint, and the last thing
438
- * standing between a payload and the network.
439
- *
440
- * A payload carrying no recipient cannot become a message. Resend answers it
441
- * `422 missing_required_field`, which costs an API call and then shows up in
442
- * the vendor dashboard as a red line indistinguishable from mail that
443
- * genuinely failed to deliver — carrying no subject, no recipient and nothing
444
- * naming the code that produced it. Diagnosing that means reading a log
445
- * outside the deployment and guessing. So the refusal happens here, before
446
- * the fetch, and names the caller's `context`.
447
- *
448
- * It throws rather than returning a `SendEmailResult`: this is a programming
449
- * error, not a delivery outcome. `sendEmail` filters recipients well before
450
- * it reaches this call, so nothing on the ordinary path can trip it. The
451
- * guard exists because `RESEND_SEND_ENDPOINT` is exported and any module can
452
- * therefore reach the send endpoint on its own, bypassing every check
453
- * `sendEmail` owns.
454
- */
455
- export declare function postResendEmail(apiKey: string, payload: ResendSendPayload, context?: string): Promise<Response>;
456
450
  /**
457
- * Sends one email through Resend.
451
+ * Sends one email through the deployment's mail provider.
458
452
  *
459
453
  * **Never throws and never rejects.** Outbound mail is best-effort across
460
454
  * every caller in this codebase — a checkout must not fail because a receipt
@@ -464,7 +458,7 @@ export declare function postResendEmail(apiKey: string, payload: ResendSendPaylo
464
458
  *
465
459
  * When the env vars are missing this warns once per call and returns
466
460
  * `{ sent: false, reason: 'unconfigured' }` rather than failing, so local and
467
- * preview environments keep working without a Resend account.
461
+ * preview environments keep working without a mail provider.
468
462
  */
469
463
  export declare function sendEmail(options: SendEmailOptions): Promise<SendEmailResult>;
470
464
  export default sendEmail;