@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.
Files changed (72) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +104 -0
  3. package/package.json +37 -0
  4. package/src/index.d.ts +37 -0
  5. package/src/index.js +46 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/email-delivery-events.d.ts +198 -0
  8. package/src/lib/email-delivery-events.js +310 -0
  9. package/src/lib/email-delivery-events.js.map +1 -0
  10. package/src/lib/email-health.d.ts +154 -0
  11. package/src/lib/email-health.js +264 -0
  12. package/src/lib/email-health.js.map +1 -0
  13. package/src/lib/email-media-src.d.ts +33 -0
  14. package/src/lib/email-media-src.js +113 -0
  15. package/src/lib/email-media-src.js.map +1 -0
  16. package/src/lib/email-merge.d.ts +31 -0
  17. package/src/lib/email-merge.js +36 -0
  18. package/src/lib/email-merge.js.map +1 -0
  19. package/src/lib/email-render.d.ts +114 -0
  20. package/src/lib/email-render.js +293 -0
  21. package/src/lib/email-render.js.map +1 -0
  22. package/src/lib/email-revenue-window.d.ts +55 -0
  23. package/src/lib/email-revenue-window.js +58 -0
  24. package/src/lib/email-revenue-window.js.map +1 -0
  25. package/src/lib/host-email-render.d.ts +99 -0
  26. package/src/lib/host-email-render.js +126 -0
  27. package/src/lib/host-email-render.js.map +1 -0
  28. package/src/lib/host-sender.d.ts +86 -0
  29. package/src/lib/host-sender.js +131 -0
  30. package/src/lib/host-sender.js.map +1 -0
  31. package/src/lib/marketing-send.d.ts +466 -0
  32. package/src/lib/marketing-send.js +459 -0
  33. package/src/lib/marketing-send.js.map +1 -0
  34. package/src/lib/platform-sending-domain.d.ts +362 -0
  35. package/src/lib/platform-sending-domain.js +697 -0
  36. package/src/lib/platform-sending-domain.js.map +1 -0
  37. package/src/lib/received-email.d.ts +86 -0
  38. package/src/lib/received-email.js +124 -0
  39. package/src/lib/received-email.js.map +1 -0
  40. package/src/lib/send-ceilings.d.ts +394 -0
  41. package/src/lib/send-ceilings.js +341 -0
  42. package/src/lib/send-ceilings.js.map +1 -0
  43. package/src/lib/send-email.d.ts +385 -0
  44. package/src/lib/send-email.js +586 -0
  45. package/src/lib/send-email.js.map +1 -0
  46. package/src/lib/send-rate.d.ts +298 -0
  47. package/src/lib/send-rate.js +310 -0
  48. package/src/lib/send-rate.js.map +1 -0
  49. package/src/lib/sender-reputation.d.ts +357 -0
  50. package/src/lib/sender-reputation.js +392 -0
  51. package/src/lib/sender-reputation.js.map +1 -0
  52. package/src/lib/sending-domain.d.ts +744 -0
  53. package/src/lib/sending-domain.js +798 -0
  54. package/src/lib/sending-domain.js.map +1 -0
  55. package/src/lib/sending-mailbox.d.ts +132 -0
  56. package/src/lib/sending-mailbox.js +192 -0
  57. package/src/lib/sending-mailbox.js.map +1 -0
  58. package/src/lib/stored-email-nodes.d.ts +38 -0
  59. package/src/lib/stored-email-nodes.js +78 -0
  60. package/src/lib/stored-email-nodes.js.map +1 -0
  61. package/src/lib/svix-signature.d.ts +38 -0
  62. package/src/lib/svix-signature.js +55 -0
  63. package/src/lib/svix-signature.js.map +1 -0
  64. package/src/lib/system-email-catalog.d.ts +170 -0
  65. package/src/lib/system-email-catalog.js +828 -0
  66. package/src/lib/system-email-catalog.js.map +1 -0
  67. package/src/lib/tenant-email-catalog.d.ts +78 -0
  68. package/src/lib/tenant-email-catalog.js +452 -0
  69. package/src/lib/tenant-email-catalog.js.map +1 -0
  70. package/src/lib/text-email-html.d.ts +32 -0
  71. package/src/lib/text-email-html.js +119 -0
  72. package/src/lib/text-email-html.js.map +1 -0
@@ -0,0 +1,392 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * PER-TENANT SENDING REPUTATION — the policy half.
19
+ *
20
+ * Every tenant's marketing mail leaves on one shared sending domain under
21
+ * `p=reject`. A mailbox provider grades that domain, not the tenant, so one
22
+ * merchant's bad list is felt by every other merchant's campaigns AND by the
23
+ * transactional mail — password resets, receipts, booking reminders — that
24
+ * shares the domain with them. Nothing in the product computed a rate at any
25
+ * scope, so there was no number to act on and no point at which anything
26
+ * stopped.
27
+ *
28
+ * This module is the decision, as a pure function of counts. The durable
29
+ * counters live in `@aglyn/tenant-data-admin` (`email-sender-reputation.ts`),
30
+ * which is the only layer that may hold the Admin SDK — the same split
31
+ * `send-rate.ts` draws beside it, for the same reason.
32
+ *
33
+ * ## What it may do, and what it may never do
34
+ *
35
+ * **It refuses a SEND. It never touches a person or their data.** No contact
36
+ * is deleted, no audience is trimmed, nobody is unsubscribed and no list
37
+ * membership changes. That is the enforce-at-the-reduction rule
38
+ * (`over-limit.ts`): a limit that refuses a person or their data leaks, so a
39
+ * capacity control gates the DROP and never the holding. A send is a flow —
40
+ * refusing one strands nobody's data, and the merchant can send it after
41
+ * cleaning the list.
42
+ *
43
+ * **It may only ever refuse a CAMPAIGN.** A transactional message can never
44
+ * reach this decision: {@link emailReputationVerdict} is consulted from the campaign
45
+ * sender alone, and both layers underneath it — `emailSendRateVerdict` and
46
+ * `sendEmail` itself — independently refuse to drop a transactional priority
47
+ * whatever a control above them says. A password reset refused by a
48
+ * reputation breaker converts a deliverability risk into an outage on
49
+ * somebody else's business, and the mail explaining why is itself mail that
50
+ * would not send.
51
+ *
52
+ * ## The three numbers, and the two that are not rates
53
+ *
54
+ * A rate on its own is not evidence. Two guards stand in front of every
55
+ * threshold and both have to clear:
56
+ *
57
+ * - {@link EMAIL_REPUTATION_MIN_VOLUME} — a denominator below which a rate
58
+ * is noise. One complaint out of four is 25% and means nothing.
59
+ * - {@link EMAIL_REPUTATION_MIN_EVENTS} — a numerator below which a rate is
60
+ * one person having a bad day. M3AAWG's own sizing caveat is that test
61
+ * sends below ten thousand recipients do not yield statistically
62
+ * significant results; at our volumes the honest response is not to pretend
63
+ * otherwise but to require that the signal be repeated before it bites.
64
+ *
65
+ * A threshold crossed with either guard unmet is reported as a FINDING and
66
+ * changes nothing. That is deliberate: the operator surface has to show a
67
+ * rate climbing before it trips, or the first anybody hears of the control is
68
+ * a refused campaign.
69
+ *
70
+ * ## The thresholds
71
+ *
72
+ * `complaintRate` is graded against Google's published bulk-sender rule —
73
+ * keep spam complaints under 0.10% and never at or above 0.30% — so the watch
74
+ * level and the trip level are the two numbers Google itself names rather
75
+ * than two we picked.
76
+ *
77
+ * `bounceRate` has no published cross-provider number; 5% watch and 10% trip
78
+ * is the shape Amazon SES enforces on its own senders, and it is the one
79
+ * decision here taken from a vendor rather than from a standard.
80
+ */ /** Days of history a rate is computed over. */ export const EMAIL_REPUTATION_WINDOW_DAYS = 7;
81
+ /**
82
+ * Messages a window must carry before any rate in it is actionable.
83
+ *
84
+ * 200 is the first ramp step (see {@link EMAIL_RAMP_STEPS}), so a brand-new
85
+ * tenant cannot be tripped by its very first day of sending — it has to send
86
+ * a second day, by which time there is a real denominator.
87
+ */ export const EMAIL_REPUTATION_MIN_VOLUME = 200;
88
+ /** Bad events a finding must carry before it is actionable. */ export const EMAIL_REPUTATION_MIN_EVENTS = 3;
89
+ /** Google's "keep it under this" spam rate. */ export const EMAIL_COMPLAINT_RATE_WATCH = 0.001;
90
+ /** Google's "never reach this" spam rate. */ export const EMAIL_COMPLAINT_RATE_TRIP = 0.003;
91
+ /** Bounce rate that earns a warning. */ export const EMAIL_BOUNCE_RATE_WATCH = 0.05;
92
+ /** Bounce rate that stops the sender. */ export const EMAIL_BOUNCE_RATE_TRIP = 0.1;
93
+ export const EMAIL_REPUTATION_POLICIES = [
94
+ 'standard',
95
+ 'strict',
96
+ 'none'
97
+ ];
98
+ /** The default when an org has configured nothing. */ export const EMAIL_REPUTATION_DEFAULT_POLICY = 'standard';
99
+ /**
100
+ * Reads a stored policy, falling back to the default.
101
+ *
102
+ * Never falls back to `'none'`. An unreadable policy that parked the breaker
103
+ * would be a control switched off by a typo, which is the shape where a
104
+ * ceiling silently stops existing.
105
+ */ export function normalizeEmailReputationPolicy(raw) {
106
+ const value = String(raw != null ? raw : '');
107
+ return EMAIL_REPUTATION_POLICIES.includes(value) ? value : EMAIL_REPUTATION_DEFAULT_POLICY;
108
+ }
109
+ /**
110
+ * WHICH POLICY THIS SENDER IS ACTUALLY GRADED ON, given whose domain it is.
111
+ *
112
+ * A pooled sender shares one domain with every other site that has no domain
113
+ * of its own, so a complaint it earns is charged to their receipts as much as
114
+ * to its own. That asymmetry used to be handled by keeping marketing off the
115
+ * pool altogether. It is handled here instead: on the pool a campaign is
116
+ * graded `strict`, which stops it on the WATCH thresholds — Google's "keep
117
+ * under" 0.10% complaint rate and a 5% bounce rate — rather than waiting for
118
+ * the trip levels three and two times higher.
119
+ *
120
+ * On a domain the merchant owns, the org's own setting stands. The reputation
121
+ * being spent there is theirs alone, and how fast they spend it is theirs to
122
+ * decide.
123
+ *
124
+ * ## The pool overrides `none`, and that is the point
125
+ *
126
+ * A workspace that has switched its own breaker off must not thereby switch
127
+ * off the one protecting the other sites on its pool member. Same posture as
128
+ * the platform marketing frequency ceiling, which is the same number on every
129
+ * plan for the same reason: a control that exists to protect tenants from each
130
+ * other cannot be something one tenant sets aside.
131
+ *
132
+ * @param source the resolved {@link SendingIdentitySource}. Anything that is
133
+ * not `'shared'` — a custom domain, the platform's own identity, or an
134
+ * unresolved send — takes the configured policy, because only the pool
135
+ * spends somebody else's reputation.
136
+ */ export function effectiveReputationPolicy(source, configured) {
137
+ if (source === 'shared') return 'strict';
138
+ return normalizeEmailReputationPolicy(configured);
139
+ }
140
+ /** A count clamped to a non-negative integer. A corrupt counter reads as 0. */ function count(raw) {
141
+ const value = Math.floor(Number(raw));
142
+ return Number.isFinite(value) && value > 0 ? value : 0;
143
+ }
144
+ /** `events / volume`, or 0 when there is no denominator. */ export function emailReputationRate(events, volume) {
145
+ const numerator = count(events);
146
+ const denominator = count(volume);
147
+ return denominator > 0 ? numerator / denominator : 0;
148
+ }
149
+ /** A fraction as the percentage a person reads, to two places. */ export function formatReputationRate(rate) {
150
+ const value = Number(rate);
151
+ if (!Number.isFinite(value) || value <= 0) return '0%';
152
+ return `${(value * 100).toFixed(2)}%`;
153
+ }
154
+ function buildFinding(input) {
155
+ const { rate, events, volume, watch, trip } = input;
156
+ if (rate < watch) return null;
157
+ const severity = rate >= trip ? 'high' : 'low';
158
+ const threshold = severity === 'high' ? trip : watch;
159
+ const actionable = volume >= EMAIL_REPUTATION_MIN_VOLUME && events >= EMAIL_REPUTATION_MIN_EVENTS;
160
+ return {
161
+ code: input.code,
162
+ severity,
163
+ rate,
164
+ threshold,
165
+ events,
166
+ volume,
167
+ actionable,
168
+ detail: `${events.toLocaleString()} ${input.noun} in ` + `${volume.toLocaleString()} messages is ${formatReputationRate(rate)}, ` + `against a limit of ${formatReputationRate(threshold)}` + (actionable ? '.' : ' — too little volume to act on yet, so this is recorded and ' + 'nothing is stopped.')
169
+ };
170
+ }
171
+ /**
172
+ * Grades one tenant's window, and says whether a campaign may go out.
173
+ *
174
+ * Pure and total: no input throws, because this runs on the path that decides
175
+ * whether a campaign sends and a thrown grade would be an outage caused by
176
+ * bookkeeping. That is the posture `describeEmailCeilings` takes beside it.
177
+ *
178
+ * A tenant with no history at all grades `ok` — an absent counter is a tenant
179
+ * that has not sent, not a tenant with a perfect record and not one to
180
+ * refuse. The direction matters: the opposite reading would refuse every
181
+ * first campaign on the platform, which is the stubbed-resolver failure where
182
+ * a clamp goes green having stopped everything.
183
+ */ export function emailReputationVerdict(input) {
184
+ var _input_now, _input_reinstatedUntilMs;
185
+ var _input_counts, _input_counts1, _input_counts2;
186
+ const policy = normalizeEmailReputationPolicy(input.policy);
187
+ const accepted = count((_input_counts = input.counts) == null ? void 0 : _input_counts.accepted);
188
+ const bounced = count((_input_counts1 = input.counts) == null ? void 0 : _input_counts1.bounced);
189
+ const complained = count((_input_counts2 = input.counts) == null ? void 0 : _input_counts2.complained);
190
+ const windowDays = count(input.windowDays) || EMAIL_REPUTATION_WINDOW_DAYS;
191
+ const complaintRate = emailReputationRate(complained, accepted);
192
+ const bounceRate = emailReputationRate(bounced, accepted);
193
+ const findings = [
194
+ buildFinding({
195
+ code: 'complaint-rate',
196
+ rate: complaintRate,
197
+ events: complained,
198
+ volume: accepted,
199
+ watch: EMAIL_COMPLAINT_RATE_WATCH,
200
+ trip: EMAIL_COMPLAINT_RATE_TRIP,
201
+ noun: 'spam complaints'
202
+ }),
203
+ buildFinding({
204
+ code: 'bounce-rate',
205
+ rate: bounceRate,
206
+ events: bounced,
207
+ volume: accepted,
208
+ watch: EMAIL_BOUNCE_RATE_WATCH,
209
+ trip: EMAIL_BOUNCE_RATE_TRIP,
210
+ noun: 'permanent bounces'
211
+ })
212
+ ].filter((finding)=>finding !== null);
213
+ const actionable = findings.filter((finding)=>finding.actionable);
214
+ const stopping = policy === 'none' ? [] : policy === 'strict' ? actionable : actionable.filter((finding)=>finding.severity === 'high');
215
+ const now = Number((_input_now = input.now) != null ? _input_now : Date.now());
216
+ const reinstatedUntilMs = Number((_input_reinstatedUntilMs = input.reinstatedUntilMs) != null ? _input_reinstatedUntilMs : 0);
217
+ const reinstated = Number.isFinite(reinstatedUntilMs) && reinstatedUntilMs > now ? Math.floor(reinstatedUntilMs) : null;
218
+ const base = {
219
+ policy,
220
+ complaintRate,
221
+ bounceRate,
222
+ volume: accepted,
223
+ windowDays,
224
+ findings
225
+ };
226
+ if (stopping.length && reinstated !== null) {
227
+ return _extends({}, base, {
228
+ state: 'reinstated',
229
+ blocked: false,
230
+ reason: '',
231
+ reinstatedUntilMs: reinstated
232
+ });
233
+ }
234
+ if (stopping.length) {
235
+ return _extends({}, base, {
236
+ state: 'tripped',
237
+ blocked: true,
238
+ reason: 'Campaign sending is paused for this workspace because the mail it ' + `has sent in the last ${windowDays} days is being rejected or ` + `reported as spam too often. ${stopping.map((finding)=>finding.detail).join(' ')} Nobody has been removed from your audience and no ` + 'contact has been changed. Remove the addresses that bounced, send ' + 'only to people who asked for this mail, and campaign sending ' + 'resumes as the last ' + `${windowDays} days roll off. Transactional mail — receipts, ` + 'booking reminders, password resets — keeps sending.',
239
+ reinstatedUntilMs: null
240
+ });
241
+ }
242
+ return _extends({}, base, {
243
+ state: actionable.length ? 'watch' : 'ok',
244
+ blocked: false,
245
+ reason: '',
246
+ reinstatedUntilMs: reinstated
247
+ });
248
+ }
249
+ /**
250
+ * The ramp, lowest step first.
251
+ *
252
+ * The first step is SES's published sandbox figure. Each step after it is
253
+ * roughly a five-fold increase gated on having actually delivered most of the
254
+ * step below it, which is the warm-up curve every deliverability guide
255
+ * describes and the one a mailbox provider's own reputation model is built to
256
+ * see.
257
+ */ export const EMAIL_RAMP_STEPS = [
258
+ {
259
+ minAgeDays: 0,
260
+ minDelivered: 0,
261
+ perDay: 200
262
+ },
263
+ {
264
+ minAgeDays: 1,
265
+ minDelivered: 100,
266
+ perDay: 1000
267
+ },
268
+ {
269
+ minAgeDays: 3,
270
+ minDelivered: 800,
271
+ perDay: 5000
272
+ }
273
+ ];
274
+ /**
275
+ * Age at which the ramp stops binding entirely.
276
+ *
277
+ * Past this the hourly share and the plan are the only ceilings, which is
278
+ * what they were before the ramp existed. Seven days matches the last step's
279
+ * reach and is short enough that it is a warm-up rather than a tier.
280
+ */ export const EMAIL_RAMP_GRADUATION_DAYS = 7;
281
+ /**
282
+ * The daily campaign ceiling for one tenant.
283
+ *
284
+ * Pure and total, like every other ceiling in this library.
285
+ */ export function emailRampVerdict(input) {
286
+ const graduatedPerDay = Math.max(1, count(input.graduatedPerDay));
287
+ const rawAge = Number(input.ageDays);
288
+ const graduate = ()=>({
289
+ graduated: true,
290
+ perDay: graduatedPerDay,
291
+ share: 1,
292
+ step: EMAIL_RAMP_STEPS.length,
293
+ daysToNextStep: null,
294
+ detail: 'This workspace is past its first week of sending, so the new-sender ' + 'ramp no longer applies.'
295
+ });
296
+ /*
297
+ * `null` and `undefined` are checked BEFORE the coercion, because
298
+ * `Number(null)` is 0 — a finite, non-negative number that reads as "created
299
+ * today" and would put every org whose record predates the creation
300
+ * timestamp onto the first ramp step. The absent case has to be caught as
301
+ * itself; a numeric guard cannot see it.
302
+ */ if (input.ageDays === null || input.ageDays === undefined) return graduate();
303
+ if (!Number.isFinite(rawAge) || rawAge < 0) return graduate();
304
+ const ageDays = Math.floor(rawAge);
305
+ if (ageDays >= EMAIL_RAMP_GRADUATION_DAYS) return graduate();
306
+ const delivered = count(input.deliveredLifetime);
307
+ let step = 0;
308
+ for(let index = 1; index < EMAIL_RAMP_STEPS.length; index += 1){
309
+ const candidate = EMAIL_RAMP_STEPS[index];
310
+ if (ageDays >= candidate.minAgeDays && delivered >= candidate.minDelivered) {
311
+ step = index;
312
+ } else {
313
+ break;
314
+ }
315
+ }
316
+ const next = EMAIL_RAMP_STEPS[step + 1];
317
+ // Never above the ceiling underneath it: a graduated tenant's day is the
318
+ // hardest number in this arithmetic, and a ramp step over it would promise
319
+ // a new tenant more than an established one may send.
320
+ const perDay = Math.min(graduatedPerDay, EMAIL_RAMP_STEPS[step].perDay);
321
+ return {
322
+ graduated: false,
323
+ perDay,
324
+ // `graduatedPerDay` is floored at 1 above, so this divides by a positive
325
+ // number, and `perDay` is the smaller of the two, so it lands in `(0, 1]`.
326
+ share: perDay / graduatedPerDay,
327
+ step,
328
+ daysToNextStep: next ? Math.max(0, next.minAgeDays - ageDays) : null,
329
+ detail: `This workspace was created ${ageDays === 0 ? 'today' : `${ageDays} ` + `day${ageDays === 1 ? '' : 's'} ago`}, ` + `so it may send ${perDay.toLocaleString()} campaign emails a day while ` + 'it establishes a sending history. The rest of a campaign is not lost — ' + 'it goes out automatically on the following days.'
330
+ };
331
+ }
332
+ /**
333
+ * A daily allowance, held to the share of it a young workspace has earned.
334
+ *
335
+ * The ramp applied to a surface whose day is a plan figure rather than the
336
+ * graduated campaign ceiling — see the share note in the header. A graduated
337
+ * workspace, and an absent verdict, get the allowance untouched, so a caller
338
+ * that cannot resolve a ramp is not silently throttling anybody.
339
+ *
340
+ * ## The two clamps, and why each is the direction it is
341
+ *
342
+ * FLOORED AT 1 while the allowance is positive, the same reasoning
343
+ * `orgHourlyCampaignCeiling` floors its own share at 1: the first step is a
344
+ * small fraction of a graduated day, and a plan band low enough to round that
345
+ * fraction away must throttle a workspace to a trickle rather than stop it
346
+ * dead. A ceiling of zero on day one is indistinguishable from a plan that
347
+ * carries no one-to-one email at all, and the two have to stay tellable
348
+ * apart.
349
+ *
350
+ * An allowance of ZERO stays zero, because that is not a small band — it is
351
+ * a plan with no allowance, and a ramp that raised it to 1 would sell a
352
+ * feature the tier does not carry. Pacing never grants.
353
+ *
354
+ * An INFINITE allowance stays infinite. It is the sentinel a contract with no
355
+ * ceiling is written as, a share of it is still unbounded, and the clamp
356
+ * below would read the infinity as a corrupt counter and answer zero — a
357
+ * ramp that refused the agreements that buy the most.
358
+ */ export function rampedDailyAllowance(allowance, ramp) {
359
+ const uncapped = Number(allowance);
360
+ if (uncapped === Number.POSITIVE_INFINITY) return uncapped;
361
+ const included = count(uncapped);
362
+ if (included <= 0 || !ramp || ramp.graduated) return included;
363
+ // An unreadable share collapses to the floor rather than to the whole
364
+ // allowance: a ramp that binds and cannot say by how much owes the
365
+ // narrowest answer it has, not the widest.
366
+ const share = Number(ramp.share);
367
+ const earned = Number.isFinite(share) && share > 0 ? included * share : 0;
368
+ return Math.min(included, Math.max(1, Math.floor(earned)));
369
+ }
370
+ /** Days between two instants, floored. Negative inputs read as 0. */ export function daysBetween(fromMs, toMs) {
371
+ const from = Number(fromMs);
372
+ const to = Number(toMs);
373
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from <= 0) return 0;
374
+ return Math.max(0, Math.floor((to - from) / 86400000));
375
+ }
376
+ /** The UTC day a timestamp falls in, as `YYYY-MM-DD`. */ export function reputationDayKey(atMs = Date.now()) {
377
+ const value = Number(atMs);
378
+ const date = new Date(Number.isFinite(value) && value > 0 ? value : Date.now());
379
+ return date.toISOString().slice(0, 10);
380
+ }
381
+ /** The `YYYY-MM-DD` keys of the window ending on `atMs`, oldest first. */ export function reputationWindowDayKeys(atMs = Date.now(), windowDays = EMAIL_REPUTATION_WINDOW_DAYS) {
382
+ const days = Math.max(1, count(windowDays) || EMAIL_REPUTATION_WINDOW_DAYS);
383
+ const end = Number(atMs);
384
+ const at = Number.isFinite(end) && end > 0 ? end : Date.now();
385
+ const keys = [];
386
+ for(let offset = days - 1; offset >= 0; offset -= 1){
387
+ keys.push(reputationDayKey(at - offset * 86400000));
388
+ }
389
+ return keys;
390
+ }
391
+
392
+ //# sourceMappingURL=sender-reputation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/sender-reputation.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * PER-TENANT SENDING REPUTATION — the policy half.\n *\n * Every tenant's marketing mail leaves on one shared sending domain under\n * `p=reject`. A mailbox provider grades that domain, not the tenant, so one\n * merchant's bad list is felt by every other merchant's campaigns AND by the\n * transactional mail — password resets, receipts, booking reminders — that\n * shares the domain with them. Nothing in the product computed a rate at any\n * scope, so there was no number to act on and no point at which anything\n * stopped.\n *\n * This module is the decision, as a pure function of counts. The durable\n * counters live in `@aglyn/tenant-data-admin` (`email-sender-reputation.ts`),\n * which is the only layer that may hold the Admin SDK — the same split\n * `send-rate.ts` draws beside it, for the same reason.\n *\n * ## What it may do, and what it may never do\n *\n * **It refuses a SEND. It never touches a person or their data.** No contact\n * is deleted, no audience is trimmed, nobody is unsubscribed and no list\n * membership changes. That is the enforce-at-the-reduction rule\n * (`over-limit.ts`): a limit that refuses a person or their data leaks, so a\n * capacity control gates the DROP and never the holding. A send is a flow —\n * refusing one strands nobody's data, and the merchant can send it after\n * cleaning the list.\n *\n * **It may only ever refuse a CAMPAIGN.** A transactional message can never\n * reach this decision: {@link emailReputationVerdict} is consulted from the campaign\n * sender alone, and both layers underneath it — `emailSendRateVerdict` and\n * `sendEmail` itself — independently refuse to drop a transactional priority\n * whatever a control above them says. A password reset refused by a\n * reputation breaker converts a deliverability risk into an outage on\n * somebody else's business, and the mail explaining why is itself mail that\n * would not send.\n *\n * ## The three numbers, and the two that are not rates\n *\n * A rate on its own is not evidence. Two guards stand in front of every\n * threshold and both have to clear:\n *\n * - {@link EMAIL_REPUTATION_MIN_VOLUME} — a denominator below which a rate\n * is noise. One complaint out of four is 25% and means nothing.\n * - {@link EMAIL_REPUTATION_MIN_EVENTS} — a numerator below which a rate is\n * one person having a bad day. M3AAWG's own sizing caveat is that test\n * sends below ten thousand recipients do not yield statistically\n * significant results; at our volumes the honest response is not to pretend\n * otherwise but to require that the signal be repeated before it bites.\n *\n * A threshold crossed with either guard unmet is reported as a FINDING and\n * changes nothing. That is deliberate: the operator surface has to show a\n * rate climbing before it trips, or the first anybody hears of the control is\n * a refused campaign.\n *\n * ## The thresholds\n *\n * `complaintRate` is graded against Google's published bulk-sender rule —\n * keep spam complaints under 0.10% and never at or above 0.30% — so the watch\n * level and the trip level are the two numbers Google itself names rather\n * than two we picked.\n *\n * `bounceRate` has no published cross-provider number; 5% watch and 10% trip\n * is the shape Amazon SES enforces on its own senders, and it is the one\n * decision here taken from a vendor rather than from a standard.\n */\n\n/** Days of history a rate is computed over. */\nexport const EMAIL_REPUTATION_WINDOW_DAYS = 7\n\n/**\n * Messages a window must carry before any rate in it is actionable.\n *\n * 200 is the first ramp step (see {@link EMAIL_RAMP_STEPS}), so a brand-new\n * tenant cannot be tripped by its very first day of sending — it has to send\n * a second day, by which time there is a real denominator.\n */\nexport const EMAIL_REPUTATION_MIN_VOLUME = 200\n\n/** Bad events a finding must carry before it is actionable. */\nexport const EMAIL_REPUTATION_MIN_EVENTS = 3\n\n/** Google's \"keep it under this\" spam rate. */\nexport const EMAIL_COMPLAINT_RATE_WATCH = 0.001\n\n/** Google's \"never reach this\" spam rate. */\nexport const EMAIL_COMPLAINT_RATE_TRIP = 0.003\n\n/** Bounce rate that earns a warning. */\nexport const EMAIL_BOUNCE_RATE_WATCH = 0.05\n\n/** Bounce rate that stops the sender. */\nexport const EMAIL_BOUNCE_RATE_TRIP = 0.1\n\n/**\n * What a tenant's findings are allowed to do to it.\n *\n * The three Amazon SES publishes for its own tenant isolation, and the names\n * are theirs because the behaviors are theirs. **These are persisted values**\n * — they are stored on the org — so they are added to and never renamed.\n */\nexport type EmailReputationPolicy =\n /** Stop the sender on a high-severity finding. The default. */\n | 'standard'\n /** Stop the sender on any finding at all. */\n | 'strict'\n /** Record only. Never stops anything. */\n | 'none'\n\nexport const EMAIL_REPUTATION_POLICIES: readonly EmailReputationPolicy[] = [\n 'standard',\n 'strict',\n 'none',\n]\n\n/** The default when an org has configured nothing. */\nexport const EMAIL_REPUTATION_DEFAULT_POLICY: EmailReputationPolicy = 'standard'\n\n/**\n * Reads a stored policy, falling back to the default.\n *\n * Never falls back to `'none'`. An unreadable policy that parked the breaker\n * would be a control switched off by a typo, which is the shape where a\n * ceiling silently stops existing.\n */\nexport function normalizeEmailReputationPolicy(\n raw: unknown,\n): EmailReputationPolicy {\n const value = String(raw ?? '')\n return (EMAIL_REPUTATION_POLICIES as readonly string[]).includes(value)\n ? (value as EmailReputationPolicy)\n : EMAIL_REPUTATION_DEFAULT_POLICY\n}\n\n/**\n * WHICH POLICY THIS SENDER IS ACTUALLY GRADED ON, given whose domain it is.\n *\n * A pooled sender shares one domain with every other site that has no domain\n * of its own, so a complaint it earns is charged to their receipts as much as\n * to its own. That asymmetry used to be handled by keeping marketing off the\n * pool altogether. It is handled here instead: on the pool a campaign is\n * graded `strict`, which stops it on the WATCH thresholds — Google's \"keep\n * under\" 0.10% complaint rate and a 5% bounce rate — rather than waiting for\n * the trip levels three and two times higher.\n *\n * On a domain the merchant owns, the org's own setting stands. The reputation\n * being spent there is theirs alone, and how fast they spend it is theirs to\n * decide.\n *\n * ## The pool overrides `none`, and that is the point\n *\n * A workspace that has switched its own breaker off must not thereby switch\n * off the one protecting the other sites on its pool member. Same posture as\n * the platform marketing frequency ceiling, which is the same number on every\n * plan for the same reason: a control that exists to protect tenants from each\n * other cannot be something one tenant sets aside.\n *\n * @param source the resolved {@link SendingIdentitySource}. Anything that is\n * not `'shared'` — a custom domain, the platform's own identity, or an\n * unresolved send — takes the configured policy, because only the pool\n * spends somebody else's reputation.\n */\nexport function effectiveReputationPolicy(\n source: string | null | undefined,\n configured: unknown,\n): EmailReputationPolicy {\n if (source === 'shared') return 'strict'\n return normalizeEmailReputationPolicy(configured)\n}\n\n/** Which measurement produced a finding. */\nexport type EmailReputationFindingCode = 'complaint-rate' | 'bounce-rate'\n\n/** How badly. `high` is what a `standard` policy stops on. */\nexport type EmailReputationSeverity = 'low' | 'high'\n\n/** One measurement that crossed a threshold, with the numbers behind it. */\nexport interface EmailReputationFinding {\n code: EmailReputationFindingCode\n severity: EmailReputationSeverity\n /** The measured rate, as a fraction. */\n rate: number\n /** The threshold it crossed, as a fraction. */\n threshold: number\n /** Events in the window — the numerator. */\n events: number\n /** Messages in the window — the denominator. */\n volume: number\n /**\n * False when a guard was unmet, so this finding is reported and cannot\n * stop anything. See the header for why both guards exist.\n */\n actionable: boolean\n /** Human-readable, and the text a surface may show verbatim. */\n detail: string\n}\n\n/** The counts a window holds. Every field is a message count. */\nexport interface EmailReputationCounts {\n /** Campaign messages handed to the sender in the window. The denominator. */\n accepted: number\n /** Permanent bounces reported against them. */\n bounced: number\n /** Spam complaints reported against them. */\n complained: number\n}\n\n/** A tenant's reputation state. */\nexport type EmailReputationState =\n /** No finding, or none actionable. */\n | 'ok'\n /** A finding this policy does not stop on. */\n | 'watch'\n /** The sender is stopped. */\n | 'tripped'\n /**\n * Findings stand but a grace period is ignoring them, so a tenant that has\n * fixed its list can send again without an operator having to time the\n * moment the window rolls off. SES's own `Reinstated`.\n */\n | 'reinstated'\n\n/** The reconciled reputation of one tenant. */\nexport interface EmailReputationVerdict {\n state: EmailReputationState\n /** True only when a campaign may not go out. */\n blocked: boolean\n policy: EmailReputationPolicy\n complaintRate: number\n bounceRate: number\n /** Messages the window measured. */\n volume: number\n /** Days the window covers. */\n windowDays: number\n /** Every threshold crossed, actionable or not. */\n findings: EmailReputationFinding[]\n /**\n * Why a merchant cannot send, in words they can act on. Empty when\n * {@link blocked} is false — a surface must never show a reason for a\n * state that is not blocking.\n */\n reason: string\n /** When a grace period ends, ms. Null unless {@link state} is `reinstated`. */\n reinstatedUntilMs: number | null\n}\n\n/** A count clamped to a non-negative integer. A corrupt counter reads as 0. */\nfunction count(raw: unknown): number {\n const value = Math.floor(Number(raw))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\n/** `events / volume`, or 0 when there is no denominator. */\nexport function emailReputationRate(events: number, volume: number): number {\n const numerator = count(events)\n const denominator = count(volume)\n return denominator > 0 ? numerator / denominator : 0\n}\n\n/** A fraction as the percentage a person reads, to two places. */\nexport function formatReputationRate(rate: number): string {\n const value = Number(rate)\n if (!Number.isFinite(value) || value <= 0) return '0%'\n return `${(value * 100).toFixed(2)}%`\n}\n\nfunction buildFinding(input: {\n code: EmailReputationFindingCode\n rate: number\n events: number\n volume: number\n watch: number\n trip: number\n noun: string\n}): EmailReputationFinding | null {\n const { rate, events, volume, watch, trip } = input\n if (rate < watch) return null\n const severity: EmailReputationSeverity = rate >= trip ? 'high' : 'low'\n const threshold = severity === 'high' ? trip : watch\n const actionable =\n volume >= EMAIL_REPUTATION_MIN_VOLUME && events >= EMAIL_REPUTATION_MIN_EVENTS\n return {\n code: input.code,\n severity,\n rate,\n threshold,\n events,\n volume,\n actionable,\n detail:\n `${events.toLocaleString()} ${input.noun} in ` +\n `${volume.toLocaleString()} messages is ${formatReputationRate(rate)}, ` +\n `against a limit of ${formatReputationRate(threshold)}` +\n (actionable\n ? '.'\n : ' — too little volume to act on yet, so this is recorded and ' +\n 'nothing is stopped.'),\n }\n}\n\nexport interface EmailReputationVerdictInput {\n counts: EmailReputationCounts\n policy?: EmailReputationPolicy\n /** Days the counts cover, for the message. */\n windowDays?: number\n /** A grace period that ignores active findings, ms since epoch. */\n reinstatedUntilMs?: number | null\n /** Injectable for tests. */\n now?: number\n}\n\n/**\n * Grades one tenant's window, and says whether a campaign may go out.\n *\n * Pure and total: no input throws, because this runs on the path that decides\n * whether a campaign sends and a thrown grade would be an outage caused by\n * bookkeeping. That is the posture `describeEmailCeilings` takes beside it.\n *\n * A tenant with no history at all grades `ok` — an absent counter is a tenant\n * that has not sent, not a tenant with a perfect record and not one to\n * refuse. The direction matters: the opposite reading would refuse every\n * first campaign on the platform, which is the stubbed-resolver failure where\n * a clamp goes green having stopped everything.\n */\nexport function emailReputationVerdict(\n input: EmailReputationVerdictInput,\n): EmailReputationVerdict {\n const policy = normalizeEmailReputationPolicy(input.policy)\n const accepted = count(input.counts?.accepted)\n const bounced = count(input.counts?.bounced)\n const complained = count(input.counts?.complained)\n const windowDays = count(input.windowDays) || EMAIL_REPUTATION_WINDOW_DAYS\n const complaintRate = emailReputationRate(complained, accepted)\n const bounceRate = emailReputationRate(bounced, accepted)\n\n const findings = [\n buildFinding({\n code: 'complaint-rate',\n rate: complaintRate,\n events: complained,\n volume: accepted,\n watch: EMAIL_COMPLAINT_RATE_WATCH,\n trip: EMAIL_COMPLAINT_RATE_TRIP,\n noun: 'spam complaints',\n }),\n buildFinding({\n code: 'bounce-rate',\n rate: bounceRate,\n events: bounced,\n volume: accepted,\n watch: EMAIL_BOUNCE_RATE_WATCH,\n trip: EMAIL_BOUNCE_RATE_TRIP,\n noun: 'permanent bounces',\n }),\n ].filter((finding): finding is EmailReputationFinding => finding !== null)\n\n const actionable = findings.filter((finding) => finding.actionable)\n const stopping =\n policy === 'none'\n ? []\n : policy === 'strict'\n ? actionable\n : actionable.filter((finding) => finding.severity === 'high')\n\n const now = Number(input.now ?? Date.now())\n const reinstatedUntilMs = Number(input.reinstatedUntilMs ?? 0)\n const reinstated =\n Number.isFinite(reinstatedUntilMs) && reinstatedUntilMs > now\n ? Math.floor(reinstatedUntilMs)\n : null\n\n const base = {\n policy,\n complaintRate,\n bounceRate,\n volume: accepted,\n windowDays,\n findings,\n }\n\n if (stopping.length && reinstated !== null) {\n return {\n ...base,\n state: 'reinstated',\n blocked: false,\n reason: '',\n reinstatedUntilMs: reinstated,\n }\n }\n if (stopping.length) {\n return {\n ...base,\n state: 'tripped',\n blocked: true,\n reason:\n 'Campaign sending is paused for this workspace because the mail it ' +\n `has sent in the last ${windowDays} days is being rejected or ` +\n `reported as spam too often. ${stopping\n .map((finding) => finding.detail)\n .join(' ')} Nobody has been removed from your audience and no ` +\n 'contact has been changed. Remove the addresses that bounced, send ' +\n 'only to people who asked for this mail, and campaign sending ' +\n 'resumes as the last ' +\n `${windowDays} days roll off. Transactional mail — receipts, ` +\n 'booking reminders, password resets — keeps sending.',\n reinstatedUntilMs: null,\n }\n }\n return {\n ...base,\n state: actionable.length ? 'watch' : 'ok',\n blocked: false,\n reason: '',\n reinstatedUntilMs: reinstated,\n }\n}\n\n/*==========================================\n * THE NEW-SENDER RAMP.\n *\n * A brand-new, unvetted signup and a customer with a year of clean sending\n * got the same share of the platform hour. Public self-serve signup makes\n * that the shape by which a shared domain gets blocked: nothing in the\n * product stopped a tenant created five minutes ago from putting its whole\n * first import onto the domain every other tenant's receipts leave on.\n *\n * The pattern the industry ships for this is a sandbox, and two documented\n * versions bracket it. Postmark reviews each new account by hand, typically\n * inside a day, and until then the account may only mail domains it has\n * verified. Amazon SES makes it mechanical and publishes the numbers: 200\n * messages per 24 hours until a request lifts it.\n *\n * The SES shape is the one taken here, for three reasons: it needs no human\n * in the loop on a Sept-1 public signup, it is a number rather than a\n * judgement, and it is legible to the merchant — \"your workspace is new, so\n * it sends 200 a day this week\" is a sentence, where \"an operator has not\n * reviewed you yet\" is a wait with no end.\n *\n * ## It is PACING, not an entitlement\n *\n * The ramp never reduces what a plan includes; it spreads the first week of\n * it. A campaign over the day's step is DEFERRED, not refused — it resumes\n * on the next day exactly as it resumes on the next hour, through the same\n * batching the send path already has. Nothing is lost and nothing is\n * removed.\n *\n * ## Why a step needs BOTH a day count and a volume\n *\n * A ramp gated on age alone is a ramp you skip by waiting: sign up, do\n * nothing for a week, then send the whole import at full speed on day eight\n * with no delivery history at all. A step therefore has to be EARNED by\n * clean volume as well as reached in time — which is what a warm-up is.\n *\n * ## What an org with no creation date gets\n *\n * GRADUATED, not throttled. An unreadable age must resolve to the permissive\n * answer here, and that is not a preference: an org record whose `createdAt`\n * is missing is an EXISTING customer, and reading a missing field as \"brand\n * new\" would ramp every paying tenant on the platform down to 200 a day on\n * the deploy. The direction to be wrong in is the one that does not throttle\n * a customer who has been sending for a year.\n *\n * ## The step is a SHARE, and that is what lets a second surface use it\n *\n * A step is stated two ways: `perDay`, the campaign figure, and `share`, the\n * same restriction as a fraction of a graduated day. They are one number —\n * `perDay / graduatedPerDay` — because a ramp is a proportion of what an\n * established workspace may do, and only the campaign path happens to have\n * the graduated day as its own allowance.\n *\n * Every other daily allowance on the platform is a plan figure sized for one\n * workspace rather than for the whole platform's hour, so a step compared\n * against one directly would never bind: `min(band, step)` is the band, and\n * the ramp would be a guard that always passes.\n * {@link rampedDailyAllowance} takes the share to the allowance instead,\n * which is the arithmetic the campaign path already does — its allowance IS\n * the graduated day, so share times allowance is `perDay` exactly.\n *=========================================*/\n\n/** One step of the ramp. */\nexport interface EmailRampStep {\n /** Days since the workspace was created before this step is reachable. */\n minAgeDays: number\n /** Campaign messages it must have delivered before this step is reachable. */\n minDelivered: number\n /** Campaign messages a day at this step. */\n perDay: number\n}\n\n/**\n * The ramp, lowest step first.\n *\n * The first step is SES's published sandbox figure. Each step after it is\n * roughly a five-fold increase gated on having actually delivered most of the\n * step below it, which is the warm-up curve every deliverability guide\n * describes and the one a mailbox provider's own reputation model is built to\n * see.\n */\nexport const EMAIL_RAMP_STEPS: readonly EmailRampStep[] = [\n { minAgeDays: 0, minDelivered: 0, perDay: 200 },\n { minAgeDays: 1, minDelivered: 100, perDay: 1_000 },\n { minAgeDays: 3, minDelivered: 800, perDay: 5_000 },\n]\n\n/**\n * Age at which the ramp stops binding entirely.\n *\n * Past this the hourly share and the plan are the only ceilings, which is\n * what they were before the ramp existed. Seven days matches the last step's\n * reach and is short enough that it is a warm-up rather than a tier.\n */\nexport const EMAIL_RAMP_GRADUATION_DAYS = 7\n\n/** What the ramp allows a tenant today. */\nexport interface EmailRampVerdict {\n /** True when the ramp no longer binds and the hourly share is the ceiling. */\n graduated: boolean\n /** Campaign messages this tenant may send today. */\n perDay: number\n /**\n * The step as a FRACTION of a graduated day, in `(0, 1]`.\n *\n * The same restriction {@link perDay} states, in the form a surface with a\n * different daily allowance can apply — see the surfaces note in the header\n * above. 1 when graduated, which is the identity every consumer wants for\n * an established workspace.\n */\n share: number\n /** Which step it is on, 0-based. Equals the step count when graduated. */\n step: number\n /** Days until the next step is reachable on age alone. Null when graduated. */\n daysToNextStep: number | null\n /** Human-readable, and the text a surface may show verbatim. */\n detail: string\n}\n\nexport interface EmailRampInput {\n /**\n * Days since the workspace was created. A negative, unreadable or absent\n * value GRADUATES — see the header for why that direction is the only safe\n * one.\n */\n ageDays: number | null | undefined\n /** Campaign messages this workspace has delivered, all time. */\n deliveredLifetime: number\n /**\n * The hourly share expressed as a day, which is the ceiling a graduated\n * tenant has. A ramp step is never allowed to exceed it — a step above the\n * ceiling underneath it would be a number that can never be reached.\n */\n graduatedPerDay: number\n}\n\n/**\n * The daily campaign ceiling for one tenant.\n *\n * Pure and total, like every other ceiling in this library.\n */\nexport function emailRampVerdict(input: EmailRampInput): EmailRampVerdict {\n const graduatedPerDay = Math.max(1, count(input.graduatedPerDay))\n const rawAge = Number(input.ageDays)\n const graduate = (): EmailRampVerdict => ({\n graduated: true,\n perDay: graduatedPerDay,\n share: 1,\n step: EMAIL_RAMP_STEPS.length,\n daysToNextStep: null,\n detail:\n 'This workspace is past its first week of sending, so the new-sender ' +\n 'ramp no longer applies.',\n })\n /*\n * `null` and `undefined` are checked BEFORE the coercion, because\n * `Number(null)` is 0 — a finite, non-negative number that reads as \"created\n * today\" and would put every org whose record predates the creation\n * timestamp onto the first ramp step. The absent case has to be caught as\n * itself; a numeric guard cannot see it.\n */\n if (input.ageDays === null || input.ageDays === undefined) return graduate()\n if (!Number.isFinite(rawAge) || rawAge < 0) return graduate()\n const ageDays = Math.floor(rawAge)\n if (ageDays >= EMAIL_RAMP_GRADUATION_DAYS) return graduate()\n\n const delivered = count(input.deliveredLifetime)\n let step = 0\n for (let index = 1; index < EMAIL_RAMP_STEPS.length; index += 1) {\n const candidate = EMAIL_RAMP_STEPS[index]\n if (ageDays >= candidate.minAgeDays && delivered >= candidate.minDelivered) {\n step = index\n } else {\n break\n }\n }\n const next = EMAIL_RAMP_STEPS[step + 1]\n // Never above the ceiling underneath it: a graduated tenant's day is the\n // hardest number in this arithmetic, and a ramp step over it would promise\n // a new tenant more than an established one may send.\n const perDay = Math.min(graduatedPerDay, EMAIL_RAMP_STEPS[step].perDay)\n return {\n graduated: false,\n perDay,\n // `graduatedPerDay` is floored at 1 above, so this divides by a positive\n // number, and `perDay` is the smaller of the two, so it lands in `(0, 1]`.\n share: perDay / graduatedPerDay,\n step,\n daysToNextStep: next ? Math.max(0, next.minAgeDays - ageDays) : null,\n detail:\n `This workspace was created ${ageDays === 0 ? 'today' : `${ageDays} ` + `day${ageDays === 1 ? '' : 's'} ago`}, ` +\n `so it may send ${perDay.toLocaleString()} campaign emails a day while ` +\n 'it establishes a sending history. The rest of a campaign is not lost — ' +\n 'it goes out automatically on the following days.',\n }\n}\n\n/**\n * A daily allowance, held to the share of it a young workspace has earned.\n *\n * The ramp applied to a surface whose day is a plan figure rather than the\n * graduated campaign ceiling — see the share note in the header. A graduated\n * workspace, and an absent verdict, get the allowance untouched, so a caller\n * that cannot resolve a ramp is not silently throttling anybody.\n *\n * ## The two clamps, and why each is the direction it is\n *\n * FLOORED AT 1 while the allowance is positive, the same reasoning\n * `orgHourlyCampaignCeiling` floors its own share at 1: the first step is a\n * small fraction of a graduated day, and a plan band low enough to round that\n * fraction away must throttle a workspace to a trickle rather than stop it\n * dead. A ceiling of zero on day one is indistinguishable from a plan that\n * carries no one-to-one email at all, and the two have to stay tellable\n * apart.\n *\n * An allowance of ZERO stays zero, because that is not a small band — it is\n * a plan with no allowance, and a ramp that raised it to 1 would sell a\n * feature the tier does not carry. Pacing never grants.\n *\n * An INFINITE allowance stays infinite. It is the sentinel a contract with no\n * ceiling is written as, a share of it is still unbounded, and the clamp\n * below would read the infinity as a corrupt counter and answer zero — a\n * ramp that refused the agreements that buy the most.\n */\nexport function rampedDailyAllowance(\n allowance: number,\n ramp: EmailRampVerdict | null | undefined,\n): number {\n const uncapped = Number(allowance)\n if (uncapped === Number.POSITIVE_INFINITY) return uncapped\n const included = count(uncapped)\n if (included <= 0 || !ramp || ramp.graduated) return included\n // An unreadable share collapses to the floor rather than to the whole\n // allowance: a ramp that binds and cannot say by how much owes the\n // narrowest answer it has, not the widest.\n const share = Number(ramp.share)\n const earned = Number.isFinite(share) && share > 0 ? included * share : 0\n return Math.min(included, Math.max(1, Math.floor(earned)))\n}\n\n/** Days between two instants, floored. Negative inputs read as 0. */\nexport function daysBetween(fromMs: number, toMs: number): number {\n const from = Number(fromMs)\n const to = Number(toMs)\n if (!Number.isFinite(from) || !Number.isFinite(to) || from <= 0) return 0\n return Math.max(0, Math.floor((to - from) / 86_400_000))\n}\n\n/** The UTC day a timestamp falls in, as `YYYY-MM-DD`. */\nexport function reputationDayKey(atMs: number = Date.now()): string {\n const value = Number(atMs)\n const date = new Date(Number.isFinite(value) && value > 0 ? value : Date.now())\n return date.toISOString().slice(0, 10)\n}\n\n/** The `YYYY-MM-DD` keys of the window ending on `atMs`, oldest first. */\nexport function reputationWindowDayKeys(\n atMs: number = Date.now(),\n windowDays: number = EMAIL_REPUTATION_WINDOW_DAYS,\n): string[] {\n const days = Math.max(1, count(windowDays) || EMAIL_REPUTATION_WINDOW_DAYS)\n const end = Number(atMs)\n const at = Number.isFinite(end) && end > 0 ? end : Date.now()\n const keys: string[] = []\n for (let offset = days - 1; offset >= 0; offset -= 1) {\n keys.push(reputationDayKey(at - offset * 86_400_000))\n }\n return keys\n}\n"],"names":["EMAIL_REPUTATION_WINDOW_DAYS","EMAIL_REPUTATION_MIN_VOLUME","EMAIL_REPUTATION_MIN_EVENTS","EMAIL_COMPLAINT_RATE_WATCH","EMAIL_COMPLAINT_RATE_TRIP","EMAIL_BOUNCE_RATE_WATCH","EMAIL_BOUNCE_RATE_TRIP","EMAIL_REPUTATION_POLICIES","EMAIL_REPUTATION_DEFAULT_POLICY","normalizeEmailReputationPolicy","raw","value","String","includes","effectiveReputationPolicy","source","configured","count","Math","floor","Number","isFinite","emailReputationRate","events","volume","numerator","denominator","formatReputationRate","rate","toFixed","buildFinding","input","watch","trip","severity","threshold","actionable","code","detail","toLocaleString","noun","emailReputationVerdict","policy","accepted","counts","bounced","complained","windowDays","complaintRate","bounceRate","findings","filter","finding","stopping","now","Date","reinstatedUntilMs","reinstated","base","length","state","blocked","reason","map","join","EMAIL_RAMP_STEPS","minAgeDays","minDelivered","perDay","EMAIL_RAMP_GRADUATION_DAYS","emailRampVerdict","graduatedPerDay","max","rawAge","ageDays","graduate","graduated","share","step","daysToNextStep","undefined","delivered","deliveredLifetime","index","candidate","next","min","rampedDailyAllowance","allowance","ramp","uncapped","POSITIVE_INFINITY","included","earned","daysBetween","fromMs","toMs","from","to","reputationDayKey","atMs","date","toISOString","slice","reputationWindowDayKeys","days","end","at","keys","offset","push"],"mappings":"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"}