@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,357 @@
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
+ /**
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
+ */
81
+ /** Days of history a rate is computed over. */
82
+ export declare const EMAIL_REPUTATION_WINDOW_DAYS = 7;
83
+ /**
84
+ * Messages a window must carry before any rate in it is actionable.
85
+ *
86
+ * 200 is the first ramp step (see {@link EMAIL_RAMP_STEPS}), so a brand-new
87
+ * tenant cannot be tripped by its very first day of sending — it has to send
88
+ * a second day, by which time there is a real denominator.
89
+ */
90
+ export declare const EMAIL_REPUTATION_MIN_VOLUME = 200;
91
+ /** Bad events a finding must carry before it is actionable. */
92
+ export declare const EMAIL_REPUTATION_MIN_EVENTS = 3;
93
+ /** Google's "keep it under this" spam rate. */
94
+ export declare const EMAIL_COMPLAINT_RATE_WATCH = 0.001;
95
+ /** Google's "never reach this" spam rate. */
96
+ export declare const EMAIL_COMPLAINT_RATE_TRIP = 0.003;
97
+ /** Bounce rate that earns a warning. */
98
+ export declare const EMAIL_BOUNCE_RATE_WATCH = 0.05;
99
+ /** Bounce rate that stops the sender. */
100
+ export declare const EMAIL_BOUNCE_RATE_TRIP = 0.1;
101
+ /**
102
+ * What a tenant's findings are allowed to do to it.
103
+ *
104
+ * The three Amazon SES publishes for its own tenant isolation, and the names
105
+ * are theirs because the behaviors are theirs. **These are persisted values**
106
+ * — they are stored on the org — so they are added to and never renamed.
107
+ */
108
+ export type EmailReputationPolicy =
109
+ /** Stop the sender on a high-severity finding. The default. */
110
+ 'standard'
111
+ /** Stop the sender on any finding at all. */
112
+ | 'strict'
113
+ /** Record only. Never stops anything. */
114
+ | 'none';
115
+ export declare const EMAIL_REPUTATION_POLICIES: readonly EmailReputationPolicy[];
116
+ /** The default when an org has configured nothing. */
117
+ export declare const EMAIL_REPUTATION_DEFAULT_POLICY: EmailReputationPolicy;
118
+ /**
119
+ * Reads a stored policy, falling back to the default.
120
+ *
121
+ * Never falls back to `'none'`. An unreadable policy that parked the breaker
122
+ * would be a control switched off by a typo, which is the shape where a
123
+ * ceiling silently stops existing.
124
+ */
125
+ export declare function normalizeEmailReputationPolicy(raw: unknown): EmailReputationPolicy;
126
+ /**
127
+ * WHICH POLICY THIS SENDER IS ACTUALLY GRADED ON, given whose domain it is.
128
+ *
129
+ * A pooled sender shares one domain with every other site that has no domain
130
+ * of its own, so a complaint it earns is charged to their receipts as much as
131
+ * to its own. That asymmetry used to be handled by keeping marketing off the
132
+ * pool altogether. It is handled here instead: on the pool a campaign is
133
+ * graded `strict`, which stops it on the WATCH thresholds — Google's "keep
134
+ * under" 0.10% complaint rate and a 5% bounce rate — rather than waiting for
135
+ * the trip levels three and two times higher.
136
+ *
137
+ * On a domain the merchant owns, the org's own setting stands. The reputation
138
+ * being spent there is theirs alone, and how fast they spend it is theirs to
139
+ * decide.
140
+ *
141
+ * ## The pool overrides `none`, and that is the point
142
+ *
143
+ * A workspace that has switched its own breaker off must not thereby switch
144
+ * off the one protecting the other sites on its pool member. Same posture as
145
+ * the platform marketing frequency ceiling, which is the same number on every
146
+ * plan for the same reason: a control that exists to protect tenants from each
147
+ * other cannot be something one tenant sets aside.
148
+ *
149
+ * @param source the resolved {@link SendingIdentitySource}. Anything that is
150
+ * not `'shared'` — a custom domain, the platform's own identity, or an
151
+ * unresolved send — takes the configured policy, because only the pool
152
+ * spends somebody else's reputation.
153
+ */
154
+ export declare function effectiveReputationPolicy(source: string | null | undefined, configured: unknown): EmailReputationPolicy;
155
+ /** Which measurement produced a finding. */
156
+ export type EmailReputationFindingCode = 'complaint-rate' | 'bounce-rate';
157
+ /** How badly. `high` is what a `standard` policy stops on. */
158
+ export type EmailReputationSeverity = 'low' | 'high';
159
+ /** One measurement that crossed a threshold, with the numbers behind it. */
160
+ export interface EmailReputationFinding {
161
+ code: EmailReputationFindingCode;
162
+ severity: EmailReputationSeverity;
163
+ /** The measured rate, as a fraction. */
164
+ rate: number;
165
+ /** The threshold it crossed, as a fraction. */
166
+ threshold: number;
167
+ /** Events in the window — the numerator. */
168
+ events: number;
169
+ /** Messages in the window — the denominator. */
170
+ volume: number;
171
+ /**
172
+ * False when a guard was unmet, so this finding is reported and cannot
173
+ * stop anything. See the header for why both guards exist.
174
+ */
175
+ actionable: boolean;
176
+ /** Human-readable, and the text a surface may show verbatim. */
177
+ detail: string;
178
+ }
179
+ /** The counts a window holds. Every field is a message count. */
180
+ export interface EmailReputationCounts {
181
+ /** Campaign messages handed to the sender in the window. The denominator. */
182
+ accepted: number;
183
+ /** Permanent bounces reported against them. */
184
+ bounced: number;
185
+ /** Spam complaints reported against them. */
186
+ complained: number;
187
+ }
188
+ /** A tenant's reputation state. */
189
+ export type EmailReputationState =
190
+ /** No finding, or none actionable. */
191
+ 'ok'
192
+ /** A finding this policy does not stop on. */
193
+ | 'watch'
194
+ /** The sender is stopped. */
195
+ | 'tripped'
196
+ /**
197
+ * Findings stand but a grace period is ignoring them, so a tenant that has
198
+ * fixed its list can send again without an operator having to time the
199
+ * moment the window rolls off. SES's own `Reinstated`.
200
+ */
201
+ | 'reinstated';
202
+ /** The reconciled reputation of one tenant. */
203
+ export interface EmailReputationVerdict {
204
+ state: EmailReputationState;
205
+ /** True only when a campaign may not go out. */
206
+ blocked: boolean;
207
+ policy: EmailReputationPolicy;
208
+ complaintRate: number;
209
+ bounceRate: number;
210
+ /** Messages the window measured. */
211
+ volume: number;
212
+ /** Days the window covers. */
213
+ windowDays: number;
214
+ /** Every threshold crossed, actionable or not. */
215
+ findings: EmailReputationFinding[];
216
+ /**
217
+ * Why a merchant cannot send, in words they can act on. Empty when
218
+ * {@link blocked} is false — a surface must never show a reason for a
219
+ * state that is not blocking.
220
+ */
221
+ reason: string;
222
+ /** When a grace period ends, ms. Null unless {@link state} is `reinstated`. */
223
+ reinstatedUntilMs: number | null;
224
+ }
225
+ /** `events / volume`, or 0 when there is no denominator. */
226
+ export declare function emailReputationRate(events: number, volume: number): number;
227
+ /** A fraction as the percentage a person reads, to two places. */
228
+ export declare function formatReputationRate(rate: number): string;
229
+ export interface EmailReputationVerdictInput {
230
+ counts: EmailReputationCounts;
231
+ policy?: EmailReputationPolicy;
232
+ /** Days the counts cover, for the message. */
233
+ windowDays?: number;
234
+ /** A grace period that ignores active findings, ms since epoch. */
235
+ reinstatedUntilMs?: number | null;
236
+ /** Injectable for tests. */
237
+ now?: number;
238
+ }
239
+ /**
240
+ * Grades one tenant's window, and says whether a campaign may go out.
241
+ *
242
+ * Pure and total: no input throws, because this runs on the path that decides
243
+ * whether a campaign sends and a thrown grade would be an outage caused by
244
+ * bookkeeping. That is the posture `describeEmailCeilings` takes beside it.
245
+ *
246
+ * A tenant with no history at all grades `ok` — an absent counter is a tenant
247
+ * that has not sent, not a tenant with a perfect record and not one to
248
+ * refuse. The direction matters: the opposite reading would refuse every
249
+ * first campaign on the platform, which is the stubbed-resolver failure where
250
+ * a clamp goes green having stopped everything.
251
+ */
252
+ export declare function emailReputationVerdict(input: EmailReputationVerdictInput): EmailReputationVerdict;
253
+ /** One step of the ramp. */
254
+ export interface EmailRampStep {
255
+ /** Days since the workspace was created before this step is reachable. */
256
+ minAgeDays: number;
257
+ /** Campaign messages it must have delivered before this step is reachable. */
258
+ minDelivered: number;
259
+ /** Campaign messages a day at this step. */
260
+ perDay: number;
261
+ }
262
+ /**
263
+ * The ramp, lowest step first.
264
+ *
265
+ * The first step is SES's published sandbox figure. Each step after it is
266
+ * roughly a five-fold increase gated on having actually delivered most of the
267
+ * step below it, which is the warm-up curve every deliverability guide
268
+ * describes and the one a mailbox provider's own reputation model is built to
269
+ * see.
270
+ */
271
+ export declare const EMAIL_RAMP_STEPS: readonly EmailRampStep[];
272
+ /**
273
+ * Age at which the ramp stops binding entirely.
274
+ *
275
+ * Past this the hourly share and the plan are the only ceilings, which is
276
+ * what they were before the ramp existed. Seven days matches the last step's
277
+ * reach and is short enough that it is a warm-up rather than a tier.
278
+ */
279
+ export declare const EMAIL_RAMP_GRADUATION_DAYS = 7;
280
+ /** What the ramp allows a tenant today. */
281
+ export interface EmailRampVerdict {
282
+ /** True when the ramp no longer binds and the hourly share is the ceiling. */
283
+ graduated: boolean;
284
+ /** Campaign messages this tenant may send today. */
285
+ perDay: number;
286
+ /**
287
+ * The step as a FRACTION of a graduated day, in `(0, 1]`.
288
+ *
289
+ * The same restriction {@link perDay} states, in the form a surface with a
290
+ * different daily allowance can apply — see the surfaces note in the header
291
+ * above. 1 when graduated, which is the identity every consumer wants for
292
+ * an established workspace.
293
+ */
294
+ share: number;
295
+ /** Which step it is on, 0-based. Equals the step count when graduated. */
296
+ step: number;
297
+ /** Days until the next step is reachable on age alone. Null when graduated. */
298
+ daysToNextStep: number | null;
299
+ /** Human-readable, and the text a surface may show verbatim. */
300
+ detail: string;
301
+ }
302
+ export interface EmailRampInput {
303
+ /**
304
+ * Days since the workspace was created. A negative, unreadable or absent
305
+ * value GRADUATES — see the header for why that direction is the only safe
306
+ * one.
307
+ */
308
+ ageDays: number | null | undefined;
309
+ /** Campaign messages this workspace has delivered, all time. */
310
+ deliveredLifetime: number;
311
+ /**
312
+ * The hourly share expressed as a day, which is the ceiling a graduated
313
+ * tenant has. A ramp step is never allowed to exceed it — a step above the
314
+ * ceiling underneath it would be a number that can never be reached.
315
+ */
316
+ graduatedPerDay: number;
317
+ }
318
+ /**
319
+ * The daily campaign ceiling for one tenant.
320
+ *
321
+ * Pure and total, like every other ceiling in this library.
322
+ */
323
+ export declare function emailRampVerdict(input: EmailRampInput): EmailRampVerdict;
324
+ /**
325
+ * A daily allowance, held to the share of it a young workspace has earned.
326
+ *
327
+ * The ramp applied to a surface whose day is a plan figure rather than the
328
+ * graduated campaign ceiling — see the share note in the header. A graduated
329
+ * workspace, and an absent verdict, get the allowance untouched, so a caller
330
+ * that cannot resolve a ramp is not silently throttling anybody.
331
+ *
332
+ * ## The two clamps, and why each is the direction it is
333
+ *
334
+ * FLOORED AT 1 while the allowance is positive, the same reasoning
335
+ * `orgHourlyCampaignCeiling` floors its own share at 1: the first step is a
336
+ * small fraction of a graduated day, and a plan band low enough to round that
337
+ * fraction away must throttle a workspace to a trickle rather than stop it
338
+ * dead. A ceiling of zero on day one is indistinguishable from a plan that
339
+ * carries no one-to-one email at all, and the two have to stay tellable
340
+ * apart.
341
+ *
342
+ * An allowance of ZERO stays zero, because that is not a small band — it is
343
+ * a plan with no allowance, and a ramp that raised it to 1 would sell a
344
+ * feature the tier does not carry. Pacing never grants.
345
+ *
346
+ * An INFINITE allowance stays infinite. It is the sentinel a contract with no
347
+ * ceiling is written as, a share of it is still unbounded, and the clamp
348
+ * below would read the infinity as a corrupt counter and answer zero — a
349
+ * ramp that refused the agreements that buy the most.
350
+ */
351
+ export declare function rampedDailyAllowance(allowance: number, ramp: EmailRampVerdict | null | undefined): number;
352
+ /** Days between two instants, floored. Negative inputs read as 0. */
353
+ export declare function daysBetween(fromMs: number, toMs: number): number;
354
+ /** The UTC day a timestamp falls in, as `YYYY-MM-DD`. */
355
+ export declare function reputationDayKey(atMs?: number): string;
356
+ /** The `YYYY-MM-DD` keys of the window ending on `atMs`, oldest first. */
357
+ export declare function reputationWindowDayKeys(atMs?: number, windowDays?: number): string[];