@aglyn/shared-util-email 1.0.0-beta.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +104 -0
- package/package.json +37 -0
- package/src/index.d.ts +37 -0
- package/src/index.js +46 -0
- package/src/index.js.map +1 -0
- package/src/lib/email-delivery-events.d.ts +198 -0
- package/src/lib/email-delivery-events.js +310 -0
- package/src/lib/email-delivery-events.js.map +1 -0
- package/src/lib/email-health.d.ts +154 -0
- package/src/lib/email-health.js +264 -0
- package/src/lib/email-health.js.map +1 -0
- package/src/lib/email-media-src.d.ts +33 -0
- package/src/lib/email-media-src.js +113 -0
- package/src/lib/email-media-src.js.map +1 -0
- package/src/lib/email-merge.d.ts +31 -0
- package/src/lib/email-merge.js +36 -0
- package/src/lib/email-merge.js.map +1 -0
- package/src/lib/email-render.d.ts +114 -0
- package/src/lib/email-render.js +293 -0
- package/src/lib/email-render.js.map +1 -0
- package/src/lib/email-revenue-window.d.ts +55 -0
- package/src/lib/email-revenue-window.js +58 -0
- package/src/lib/email-revenue-window.js.map +1 -0
- package/src/lib/host-email-render.d.ts +99 -0
- package/src/lib/host-email-render.js +126 -0
- package/src/lib/host-email-render.js.map +1 -0
- package/src/lib/host-sender.d.ts +86 -0
- package/src/lib/host-sender.js +131 -0
- package/src/lib/host-sender.js.map +1 -0
- package/src/lib/marketing-send.d.ts +466 -0
- package/src/lib/marketing-send.js +459 -0
- package/src/lib/marketing-send.js.map +1 -0
- package/src/lib/platform-sending-domain.d.ts +362 -0
- package/src/lib/platform-sending-domain.js +697 -0
- package/src/lib/platform-sending-domain.js.map +1 -0
- package/src/lib/received-email.d.ts +86 -0
- package/src/lib/received-email.js +124 -0
- package/src/lib/received-email.js.map +1 -0
- package/src/lib/send-ceilings.d.ts +394 -0
- package/src/lib/send-ceilings.js +341 -0
- package/src/lib/send-ceilings.js.map +1 -0
- package/src/lib/send-email.d.ts +385 -0
- package/src/lib/send-email.js +586 -0
- package/src/lib/send-email.js.map +1 -0
- package/src/lib/send-rate.d.ts +298 -0
- package/src/lib/send-rate.js +310 -0
- package/src/lib/send-rate.js.map +1 -0
- package/src/lib/sender-reputation.d.ts +357 -0
- package/src/lib/sender-reputation.js +392 -0
- package/src/lib/sender-reputation.js.map +1 -0
- package/src/lib/sending-domain.d.ts +744 -0
- package/src/lib/sending-domain.js +798 -0
- package/src/lib/sending-domain.js.map +1 -0
- package/src/lib/sending-mailbox.d.ts +132 -0
- package/src/lib/sending-mailbox.js +192 -0
- package/src/lib/sending-mailbox.js.map +1 -0
- package/src/lib/stored-email-nodes.d.ts +38 -0
- package/src/lib/stored-email-nodes.js +78 -0
- package/src/lib/stored-email-nodes.js.map +1 -0
- package/src/lib/svix-signature.d.ts +38 -0
- package/src/lib/svix-signature.js +55 -0
- package/src/lib/svix-signature.js.map +1 -0
- package/src/lib/system-email-catalog.d.ts +170 -0
- package/src/lib/system-email-catalog.js +828 -0
- package/src/lib/system-email-catalog.js.map +1 -0
- package/src/lib/tenant-email-catalog.d.ts +78 -0
- package/src/lib/tenant-email-catalog.js +452 -0
- package/src/lib/tenant-email-catalog.js.map +1 -0
- package/src/lib/text-email-html.d.ts +32 -0
- package/src/lib/text-email-html.js +119 -0
- package/src/lib/text-email-html.js.map +1 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* THE EMAIL CEILINGS, DIMENSIONED AGAINST EACH OTHER.
|
|
18
|
+
*
|
|
19
|
+
* Three independent limits govern how much campaign mail leaves the platform,
|
|
20
|
+
* and until this module none of them was expressed in terms of any other:
|
|
21
|
+
*
|
|
22
|
+
* - a per-send recipient cap, in `campaign-send.ts`;
|
|
23
|
+
* - a platform hourly ceiling, in `send-rate.ts`;
|
|
24
|
+
* - a per-plan monthly allowance, `emailSendsPerMonth` in `PLAN_ENTITLEMENTS`.
|
|
25
|
+
*
|
|
26
|
+
* Three numbers picked separately describe a machine that cannot exist. The
|
|
27
|
+
* arithmetic below is the whole point of the file: every derived ceiling is a
|
|
28
|
+
* function of the others, so the relations can be checked rather than
|
|
29
|
+
* believed, and a future change to any one of them fails a test instead of
|
|
30
|
+
* quietly overselling the sending domain.
|
|
31
|
+
*
|
|
32
|
+
* ## The units
|
|
33
|
+
*
|
|
34
|
+
* Every figure here is a COUNT OF MESSAGES — one per recipient address handed
|
|
35
|
+
* to the sender — matching `email-metering.ts` exactly. The window each one
|
|
36
|
+
* counts over is the only thing that differs: one invocation, one hour, one
|
|
37
|
+
* calendar month.
|
|
38
|
+
*
|
|
39
|
+
* ## The derivation
|
|
40
|
+
*
|
|
41
|
+
* A month is projected from an hour at {@link EMAIL_CEILING_MONTH_DAYS} days
|
|
42
|
+
* of {@link EMAIL_CEILING_HOURS_PER_DAY} hours. That projection is
|
|
43
|
+
* deliberately generous — it assumes an org sends flat out around the clock
|
|
44
|
+
* for a whole month, which nobody does — because its job is to prove an upper
|
|
45
|
+
* bound. A plan that oversells even against continuous sending oversells
|
|
46
|
+
* against every real pattern too.
|
|
47
|
+
*
|
|
48
|
+
* orgPerHour = floor(platformPerHour x orgShare)
|
|
49
|
+
* deliverableMonthly = orgPerHour x 24 x 30
|
|
50
|
+
*
|
|
51
|
+
* With the shipped defaults (2,000/hour platform, 25% share):
|
|
52
|
+
*
|
|
53
|
+
* orgPerHour = floor(2,000 x 0.25) = 500 / hour
|
|
54
|
+
* deliverableMonthly = 500 x 24 x 30 = 360,000 / month
|
|
55
|
+
*
|
|
56
|
+
* ## The three relations
|
|
57
|
+
*
|
|
58
|
+
* **R1 — a send must fit in the org's hour.** `perSend <= orgPerHour`. At the
|
|
59
|
+
* defaults these are both 500, so one maximal send is exactly one hour of the
|
|
60
|
+
* org's share. A per-send cap ABOVE the hourly cap would be a cap that can
|
|
61
|
+
* never be reached, which is the shape that teaches an operator to ignore the
|
|
62
|
+
* number the composer shows them.
|
|
63
|
+
*
|
|
64
|
+
* **R2 — an org may not be the whole platform.** `orgPerHour <=
|
|
65
|
+
* platformPerHour`. At 25% four orgs can occupy the hour together; at 100%
|
|
66
|
+
* one org can shut every other tenant out of campaign sending, which is the
|
|
67
|
+
* tenant-versus-tenant denial of service the share exists to close.
|
|
68
|
+
* Transactional mail is exempt from both ceilings and is unaffected by this.
|
|
69
|
+
*
|
|
70
|
+
* **R3 — a plan may not sell more than the platform can deliver.**
|
|
71
|
+
* `planMonthly <= deliverableMonthly`. Every shipped plan holds it today, and
|
|
72
|
+
* {@link describeEmailCeilings} reports a breach rather than repairing it:
|
|
73
|
+
* the repair would be to lower an entitlement, and an entitlement is what a
|
|
74
|
+
* price bought. See `email-ceiling-dimensioning.spec.ts`, which pins exactly
|
|
75
|
+
* which plans fail — an empty set — so that it cannot grow unnoticed.
|
|
76
|
+
*
|
|
77
|
+
* ## What a violated relation does NOT do
|
|
78
|
+
*
|
|
79
|
+
* It does not clamp anything. `PLAN_ENTITLEMENTS` is the authority for what a
|
|
80
|
+
* customer bought, and silently resolving an entitlement down to what the
|
|
81
|
+
* current platform ceiling can deliver would be a price change made by
|
|
82
|
+
* arithmetic. The model reports; the operator decides; the hourly ceiling is
|
|
83
|
+
* what actually paces the mail, and it defers rather than refuses.
|
|
84
|
+
*/ /**
|
|
85
|
+
* Recipients one campaign invocation may address.
|
|
86
|
+
*
|
|
87
|
+
* The cap is a bound on a single function call, not a plan feature: beyond it
|
|
88
|
+
* a send stops being a request and becomes a batch job that has to survive
|
|
89
|
+
* timeouts and resume without double-sending. It is stated here rather than
|
|
90
|
+
* privately in `campaign-send.ts` so that R1 can be checked against it.
|
|
91
|
+
*
|
|
92
|
+
* A merchant whose audience is larger is NOT told this number is their
|
|
93
|
+
* audience. The composer reports the true audience and how much of it this
|
|
94
|
+
* send reaches — a truncation reported as a total is the silent cap this
|
|
95
|
+
* product keeps rediscovering.
|
|
96
|
+
*/ export const EMAIL_MAX_RECIPIENTS_PER_SEND = 500;
|
|
97
|
+
/**
|
|
98
|
+
* People one email may resolve, and therefore ever reach.
|
|
99
|
+
*
|
|
100
|
+
* The read budget for resolving one audience, and the ceiling on how many
|
|
101
|
+
* addresses one email's reach record may hold — the two have to be the same
|
|
102
|
+
* number, because an email cannot reach more people than its audience can
|
|
103
|
+
* resolve. Stated here with the other ceilings for the reason the per-send
|
|
104
|
+
* cap is: held privately in the sender it was a number nothing could be
|
|
105
|
+
* checked against.
|
|
106
|
+
*
|
|
107
|
+
* Reaching it truncates nothing silently. The resolution reports it, the
|
|
108
|
+
* recorded `audienceSize` becomes a floor rather than a total, and every
|
|
109
|
+
* surface derived from it says so.
|
|
110
|
+
*/ export const EMAIL_MAX_AUDIENCE_PER_SEND = 5000;
|
|
111
|
+
/**
|
|
112
|
+
* Batches one email may run before it stops on its own.
|
|
113
|
+
*
|
|
114
|
+
* An audience larger than {@link EMAIL_MAX_RECIPIENTS_PER_SEND} is delivered
|
|
115
|
+
* across several invocations, each one picking up where the last stopped. The
|
|
116
|
+
* frontier advances by the per-send cap every time, so
|
|
117
|
+
* `ceil(audience / perSend)` batches finish any audience the sender can
|
|
118
|
+
* resolve — and the slack above it absorbs the batches a transient provider
|
|
119
|
+
* failure or an hourly deferral costs.
|
|
120
|
+
*
|
|
121
|
+
* It is a TERMINATION guard rather than a capacity limit. Nothing about a
|
|
122
|
+
* healthy campaign should approach it; what it stops is a send that makes no
|
|
123
|
+
* progress rescheduling itself forever, which is the one failure a
|
|
124
|
+
* self-resuming job can have that nobody notices.
|
|
125
|
+
*/ export const EMAIL_MAX_SEND_BATCHES = Math.ceil(EMAIL_MAX_AUDIENCE_PER_SEND / EMAIL_MAX_RECIPIENTS_PER_SEND) * 2;
|
|
126
|
+
/**
|
|
127
|
+
* The fraction of the platform hour one org's campaigns may occupy.
|
|
128
|
+
*
|
|
129
|
+
* 25% is the starting value: it leaves room for four concurrent large senders
|
|
130
|
+
* plus all transactional traffic (which the governor counts but may never
|
|
131
|
+
* refuse), and at the default platform ceiling it lands the org's hour on
|
|
132
|
+
* exactly {@link EMAIL_MAX_RECIPIENTS_PER_SEND}, so R1 holds with no slack
|
|
133
|
+
* and the two caps explain each other.
|
|
134
|
+
*
|
|
135
|
+
* Compiled in rather than stored, unlike the platform ceiling beside it. The
|
|
136
|
+
* platform ceiling is a ramp — an operator moves it during an incident or a
|
|
137
|
+
* warm-up, and `send-rate.ts` makes that a value change on
|
|
138
|
+
* `rateLimits/sendRateConfig` for exactly that reason. This share is a
|
|
139
|
+
* fairness policy between tenants, not a ramp: changing it redistributes
|
|
140
|
+
* headroom between paying customers, which is a decision that should carry a
|
|
141
|
+
* deploy and a review. If that stops being true it becomes a second field on
|
|
142
|
+
* the same config document and inherits the same staff card.
|
|
143
|
+
*/ export const EMAIL_ORG_HOURLY_SHARE = 0.25;
|
|
144
|
+
/** Lower bound on a share. Zero would refuse every campaign on the platform. */ export const EMAIL_ORG_HOURLY_SHARE_MIN = 0.01;
|
|
145
|
+
/** Upper bound. Above 1 an org would be entitled to more than the whole hour. */ export const EMAIL_ORG_HOURLY_SHARE_MAX = 1;
|
|
146
|
+
/**
|
|
147
|
+
* Days used to project an hourly ceiling onto a month.
|
|
148
|
+
*
|
|
149
|
+
* 30 rather than the true length of the calendar month, and that choice is
|
|
150
|
+
* conservative in the direction that matters: a 31-day month would raise the
|
|
151
|
+
* projected ceiling and make an overselling plan look like it fits. The
|
|
152
|
+
* monthly ALLOWANCE is a real calendar month — `email-metering.ts` keys it
|
|
153
|
+
* `YYYY-MM` — and nothing here changes that. This constant only bounds the
|
|
154
|
+
* projection used to compare the two ceilings.
|
|
155
|
+
*/ export const EMAIL_CEILING_MONTH_DAYS = 30;
|
|
156
|
+
/** Hours in the projection day. Named so the arithmetic reads as arithmetic. */ export const EMAIL_CEILING_HOURS_PER_DAY = 24;
|
|
157
|
+
/** Hours in the projected month: 720 at the shipped constants. */ export const EMAIL_CEILING_MONTH_HOURS = EMAIL_CEILING_MONTH_DAYS * EMAIL_CEILING_HOURS_PER_DAY;
|
|
158
|
+
/** Clamps a raw ceiling to a positive integer, or returns `fallback`. */ function positiveInt(raw, fallback) {
|
|
159
|
+
const value = Number(raw);
|
|
160
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Clamps a share into `[MIN, MAX]`.
|
|
164
|
+
*
|
|
165
|
+
* An unreadable share falls back to the default and never to zero: a share of
|
|
166
|
+
* zero would derive an org hourly ceiling of zero and refuse every campaign
|
|
167
|
+
* on the platform, which is the stubbed-resolver failure — a clamp that goes
|
|
168
|
+
* green having refused everything.
|
|
169
|
+
*/ export function normalizeOrgHourlyShare(raw) {
|
|
170
|
+
const value = Number(raw);
|
|
171
|
+
if (!Number.isFinite(value) || value <= 0) return EMAIL_ORG_HOURLY_SHARE;
|
|
172
|
+
return Math.min(EMAIL_ORG_HOURLY_SHARE_MAX, Math.max(EMAIL_ORG_HOURLY_SHARE_MIN, value));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Messages one org's campaigns may send in one hour.
|
|
176
|
+
*
|
|
177
|
+
* Floored at 1, never 0: a platform ceiling small enough to round the share
|
|
178
|
+
* away must throttle campaigns to a trickle, not stop them dead. Stopping
|
|
179
|
+
* them dead is indistinguishable from an outage and is what an operator would
|
|
180
|
+
* accidentally configure while ramping DOWN during an incident.
|
|
181
|
+
*/ export function orgHourlyCampaignCeiling(platformPerHour, share = EMAIL_ORG_HOURLY_SHARE) {
|
|
182
|
+
const platform = positiveInt(platformPerHour, 0);
|
|
183
|
+
if (platform <= 0) return 1;
|
|
184
|
+
return Math.max(1, Math.floor(platform * normalizeOrgHourlyShare(share)));
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The most one org could get out in a projected month, at its hourly share.
|
|
188
|
+
*
|
|
189
|
+
* This is the ceiling a plan's monthly allowance has to fit inside for the
|
|
190
|
+
* plan to be deliverable, and it is an upper bound rather than a forecast —
|
|
191
|
+
* see the projection note on {@link EMAIL_CEILING_MONTH_DAYS}.
|
|
192
|
+
*/ export function deliverableMonthlyCeiling(platformPerHour, share = EMAIL_ORG_HOURLY_SHARE, monthHours = EMAIL_CEILING_MONTH_HOURS) {
|
|
193
|
+
return orgHourlyCampaignCeiling(platformPerHour, share) * positiveInt(monthHours, EMAIL_CEILING_MONTH_HOURS);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The most one org's campaigns could get out in a day, at its hourly share.
|
|
197
|
+
*
|
|
198
|
+
* The ceiling a new-sender ramp step has to fit inside: a step promising more
|
|
199
|
+
* than this would promise a new tenant more than an established one may send.
|
|
200
|
+
*/ export function orgDailyCampaignCeiling(platformPerHour, share = EMAIL_ORG_HOURLY_SHARE) {
|
|
201
|
+
return orgHourlyCampaignCeiling(platformPerHour, share) * EMAIL_CEILING_HOURS_PER_DAY;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Whether a partly-delivered email runs again, and how much is left.
|
|
205
|
+
*
|
|
206
|
+
* Pure, so the termination rule can be read and tested without a send. The
|
|
207
|
+
* one property it exists to hold is that a campaign always terminates:
|
|
208
|
+
* `resuming` is false whenever there is nothing left, whenever the batch
|
|
209
|
+
* guard is reached, and whenever a batch settled nobody — the last of which
|
|
210
|
+
* is the only way a self-rescheduling job can loop.
|
|
211
|
+
*/ export function campaignBatchPlan(input) {
|
|
212
|
+
const mailable = positiveInt(input.mailable, 0);
|
|
213
|
+
const addressed = Math.min(mailable, positiveInt(input.addressed, 0));
|
|
214
|
+
const retryable = Math.min(addressed, positiveInt(input.retryable, 0));
|
|
215
|
+
const settled = positiveInt(input.settled, 0);
|
|
216
|
+
const batch = positiveInt(input.batchesSoFar, 0) + 1;
|
|
217
|
+
const maxBatches = positiveInt(input.maxBatches, EMAIL_MAX_SEND_BATCHES);
|
|
218
|
+
const remaining = Math.max(0, mailable - addressed) + retryable;
|
|
219
|
+
if (remaining <= 0) {
|
|
220
|
+
return {
|
|
221
|
+
remaining: 0,
|
|
222
|
+
batch,
|
|
223
|
+
resuming: false,
|
|
224
|
+
stop: 'complete'
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (batch >= maxBatches) {
|
|
228
|
+
return {
|
|
229
|
+
remaining,
|
|
230
|
+
batch,
|
|
231
|
+
resuming: false,
|
|
232
|
+
stop: 'batch-limit'
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (settled <= 0) {
|
|
236
|
+
return {
|
|
237
|
+
remaining,
|
|
238
|
+
batch,
|
|
239
|
+
resuming: false,
|
|
240
|
+
stop: 'no-progress'
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
remaining,
|
|
245
|
+
batch,
|
|
246
|
+
resuming: true,
|
|
247
|
+
stop: null
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Builds the reconciled model and names every relation that fails.
|
|
252
|
+
*
|
|
253
|
+
* Pure, and total: no input produces a throw, because this runs on the path
|
|
254
|
+
* that decides whether a campaign goes out and a thrown model would be an
|
|
255
|
+
* outage caused by bookkeeping. Nonsense inputs clamp to the shipped
|
|
256
|
+
* defaults, which is the same posture `normalizeEmailSendRateConfig` takes
|
|
257
|
+
* for the same reason.
|
|
258
|
+
*/ export function describeEmailCeilings(input) {
|
|
259
|
+
var _input_orgShare;
|
|
260
|
+
const orgShare = normalizeOrgHourlyShare((_input_orgShare = input.orgShare) != null ? _input_orgShare : EMAIL_ORG_HOURLY_SHARE);
|
|
261
|
+
const platformPerHour = positiveInt(input.platformPerHour, 0);
|
|
262
|
+
const perSend = positiveInt(input.perSend, EMAIL_MAX_RECIPIENTS_PER_SEND);
|
|
263
|
+
const monthHours = positiveInt(input.monthHours, EMAIL_CEILING_MONTH_HOURS);
|
|
264
|
+
const orgPerHour = orgHourlyCampaignCeiling(platformPerHour, orgShare);
|
|
265
|
+
const deliverableMonthly = orgPerHour * monthHours;
|
|
266
|
+
const rawPlan = Number(input.planMonthlyLimit);
|
|
267
|
+
const planUnlimited = rawPlan === Number.POSITIVE_INFINITY;
|
|
268
|
+
// A negative or unreadable allowance reads as 0 — no included band — rather
|
|
269
|
+
// than as unlimited. `emailSendsOverage` makes the same choice: the
|
|
270
|
+
// direction to be wrong in is the one that cannot let unbounded mail out.
|
|
271
|
+
const planMonthly = planUnlimited ? deliverableMonthly : Number.isFinite(rawPlan) && rawPlan > 0 ? Math.floor(rawPlan) : 0;
|
|
272
|
+
const violations = [];
|
|
273
|
+
if (perSend > orgPerHour) {
|
|
274
|
+
violations.push({
|
|
275
|
+
relation: 'send-exceeds-org-hour',
|
|
276
|
+
claimed: perSend,
|
|
277
|
+
available: orgPerHour,
|
|
278
|
+
detail: `A single send may address ${perSend} recipients but a workspace may ` + `only send ${orgPerHour} an hour, so a full send can never complete ` + 'inside one window.'
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (orgPerHour > platformPerHour) {
|
|
282
|
+
violations.push({
|
|
283
|
+
relation: 'org-hour-exceeds-platform-hour',
|
|
284
|
+
claimed: orgPerHour,
|
|
285
|
+
available: platformPerHour,
|
|
286
|
+
detail: `One workspace may send ${orgPerHour} an hour against a platform ` + `ceiling of ${platformPerHour}, so a single tenant can occupy the ` + 'whole hour.'
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
// An unlimited plan violates R3 by construction: no finite platform can
|
|
290
|
+
// deliver an unbounded allowance. Reported, not repaired — the hourly
|
|
291
|
+
// ceiling is what paces it, and it defers rather than refuses.
|
|
292
|
+
if (planUnlimited || planMonthly > deliverableMonthly) {
|
|
293
|
+
violations.push({
|
|
294
|
+
relation: 'plan-exceeds-deliverable-month',
|
|
295
|
+
claimed: planUnlimited ? Number.POSITIVE_INFINITY : planMonthly,
|
|
296
|
+
available: deliverableMonthly,
|
|
297
|
+
detail: planUnlimited ? 'The plan sells an unlimited monthly allowance, which no finite ' + `platform ceiling can deliver; ${deliverableMonthly.toLocaleString()} ` + 'a month is the most this workspace can actually send.' : `The plan includes ${planMonthly.toLocaleString()} campaign emails a ` + `month but the platform can deliver at most ` + `${deliverableMonthly.toLocaleString()} to one workspace.`
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
perSend,
|
|
302
|
+
orgPerHour,
|
|
303
|
+
platformPerHour,
|
|
304
|
+
orgShare,
|
|
305
|
+
deliverableMonthly,
|
|
306
|
+
planMonthly,
|
|
307
|
+
planUnlimited,
|
|
308
|
+
hoursToSpendPlan: planUnlimited ? Number.POSITIVE_INFINITY : orgPerHour > 0 ? Math.ceil(planMonthly / orgPerHour) : 0,
|
|
309
|
+
violations,
|
|
310
|
+
coherent: violations.length === 0
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Assembles the monitoring figure from the two counters and the model.
|
|
315
|
+
*
|
|
316
|
+
* Pure. The counters are read by the caller — this library is `scope:shared`
|
|
317
|
+
* and may not reach Firestore — so the same function serves the console, the
|
|
318
|
+
* composer and a test with no harness at all.
|
|
319
|
+
*/ export function emailSendHeadroom(input) {
|
|
320
|
+
const { model } = input;
|
|
321
|
+
// A corrupt or negative counter must not read as headroom a cap honours —
|
|
322
|
+
// the clamp `campaignEmailSendsForMonth` and `emailSendRateVerdict` both
|
|
323
|
+
// apply, for the same reason.
|
|
324
|
+
const monthUsed = Math.max(0, Math.floor(Number(input.monthUsed) || 0));
|
|
325
|
+
const hourUsed = Math.max(0, Math.floor(Number(input.hourUsed) || 0));
|
|
326
|
+
return {
|
|
327
|
+
monthUsed,
|
|
328
|
+
monthLimit: model.planMonthly,
|
|
329
|
+
planUnlimited: model.planUnlimited,
|
|
330
|
+
monthRemaining: model.planUnlimited ? model.deliverableMonthly : Math.max(0, model.planMonthly - monthUsed),
|
|
331
|
+
hourUsed,
|
|
332
|
+
hourLimit: model.orgPerHour,
|
|
333
|
+
hourRemaining: Math.max(0, model.orgPerHour - hourUsed),
|
|
334
|
+
hourResetMs: Math.max(0, Math.floor(Number(input.hourResetMs) || 0)),
|
|
335
|
+
perSend: model.perSend,
|
|
336
|
+
deliverableMonthly: model.deliverableMonthly,
|
|
337
|
+
planExceedsDeliverable: model.violations.some((violation)=>violation.relation === 'plan-exceeds-deliverable-month')
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
//# sourceMappingURL=send-ceilings.js.map
|
|
@@ -0,0 +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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"}
|