@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,362 @@
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
+ * The apex assigned site subdomains hang off — the WEB namespace.
19
+ *
20
+ * The same variable and the same default as `TENANT_APEX` in `@aglyn/aglyn`'s
21
+ * `host-naming.ts`, read again here rather than imported because
22
+ * `@aglyn/shared-util-email` is `scope:shared` and may not depend on the
23
+ * application library. Reading the environment in this module is the idiom
24
+ * `sendingSpfInclude` and `sendingReturnPathHost` already establish one file
25
+ * over: the mail policy layer owns its own configuration reads.
26
+ *
27
+ * `||` not `??`, matching every other env read in this repo — an empty string
28
+ * is a variable somebody set to nothing, not a configured value.
29
+ */
30
+ export declare function tenantWebApex(): string;
31
+ /**
32
+ * The apex tenant sending domains hang off — the MAIL namespace.
33
+ *
34
+ * `mail.{web apex}` by default, so an operator who sets only
35
+ * `NEXT_PUBLIC_TENANT_DOMAIN` gets a mail namespace inside their own zone
36
+ * rather than inside ours. Overridable on its own for the operator who wants
37
+ * mail in a different zone entirely — a separate registrable domain is the
38
+ * strongest form of the reputation split this feature is about, and there is
39
+ * no reason for the software to be the thing preventing it.
40
+ *
41
+ * Never equal to the web apex. If an operator sets them to the same value the
42
+ * override is ignored: a mail namespace that IS the web namespace re-creates
43
+ * exactly the collision this module chose its shape to avoid, and honoring
44
+ * that setting would be honoring a request to be unsafe.
45
+ */
46
+ export declare function platformSendingApex(): string;
47
+ /**
48
+ * The mailbox the shared tenant identity sends as.
49
+ *
50
+ * `notifications` rather than `hello`, which is what a site's own domain
51
+ * defaults to. The two are different promises: `hello@northwind.mail.aglyn.app`
52
+ * is a site inviting a reply, and this one is a platform address carrying mail
53
+ * for whichever site the display name in front of it names. Calling it `hello`
54
+ * would invite replies to a mailbox belonging to no site.
55
+ */
56
+ export declare const SHARED_TENANT_LOCAL_PART = "notifications";
57
+ /**
58
+ * How many shared sending domains exist, absent configuration.
59
+ *
60
+ * FOUR, and the number is a blast-radius decision rather than a capacity one.
61
+ * The pool is FIXED-SIZE — it does not grow with hosts — so its whole cost is
62
+ * four provider domain objects and twelve records in our own zone, whether the
63
+ * deployment has twelve sites or a hundred thousand. That is the property that
64
+ * makes this design survive a scale a per-host domain does not.
65
+ *
66
+ * One would be simpler and is wrong: every site without a domain of its own
67
+ * would share a single reputation, so one compromised site blasting "receipts"
68
+ * takes every other site's password resets down with it. Four caps that at a
69
+ * quarter, for three more domain objects.
70
+ *
71
+ * Not larger, because each member is a reputation that has to be EARNED — one
72
+ * warm domain sending steadily is worth more than eight cold ones — and
73
+ * because each is a provider slot on an account whose allowance is ten on the
74
+ * plan this deployment pays for. Raising it is a real decision with a real
75
+ * cost, so it is a configuration value rather than a constant somebody bumps.
76
+ */
77
+ export declare const DEFAULT_SHARED_POOL_SIZE = 4;
78
+ /** How many members this deployment's pool has. */
79
+ export declare function sharedPoolSize(): number;
80
+ /**
81
+ * Whether a label names a pool member.
82
+ *
83
+ * By PATTERN rather than by list, so raising the pool size can never hand a
84
+ * tenant a label the pool is about to want. `PLATFORM_MAIL_RESERVED_LABELS`
85
+ * covers the fixed names; this covers a family whose size is configurable, and
86
+ * a blocklist that had to be edited in step with an environment variable is a
87
+ * blocklist that will be forgotten.
88
+ */
89
+ export declare function isSharedPoolLabel(label: string): boolean;
90
+ /**
91
+ * The pool, in order: `shared1.mail.aglyn.app`, `shared2.…`, and so on.
92
+ *
93
+ * The names say what they are on purpose. A recipient who looks sees an address
94
+ * that is plainly platform infrastructure rather than one pretending to be the
95
+ * merchant's own — which is what the console tells the merchant is happening,
96
+ * and a `From:` that oversold the arrangement would be the one surface
97
+ * contradicting the disclosure.
98
+ */
99
+ export declare function sharedSendingPool(apex?: string, size?: number): string[];
100
+ /**
101
+ * Which pool member one host sends on. Deterministic, and stored nowhere.
102
+ *
103
+ * ## Rendezvous hashing, and why not `hash % size`
104
+ *
105
+ * The requirement is that a host's sending identity does not move underneath
106
+ * it. Reputation is built by sending steadily from one name, so a reassignment
107
+ * is not a rebalance — it is a reset, and it throws away every day of standing
108
+ * the old name had earned.
109
+ *
110
+ * `hash(hostId) % size` satisfies that only while `size` never changes. Growing
111
+ * a pool from four to eight remaps roughly HALF of all hosts, silently moving
112
+ * half the platform's transactional mail onto cold domains. The operator's
113
+ * reason for growing the pool is usually that one member is in trouble, which
114
+ * makes the moment they act the worst possible moment to shuffle everybody.
115
+ *
116
+ * Rendezvous (highest-random-weight) hashing scores the host against EVERY
117
+ * member and takes the winner. Adding a member moves only the hosts that now
118
+ * score highest on the new one — about 1/size of them — and removing a member
119
+ * moves only that member's own hosts. Nothing else observes the change.
120
+ *
121
+ * That property is why this is worth ten lines instead of a modulo, and it is
122
+ * also why the assignment needs no stored field: there is nothing to pin,
123
+ * because the function does not change its mind. A pinned column would be one
124
+ * more write per host on a path whose whole point is costing nothing per host.
125
+ *
126
+ * Ties break on the domain name so the answer is total rather than dependent on
127
+ * iteration order. A 32-bit collision across a four-member pool is vanishingly
128
+ * unlikely and would still have to resolve to something.
129
+ */
130
+ export declare function sharedSendingDomainFor(hostId: string | null | undefined, apex?: string, size?: number): string;
131
+ /**
132
+ * The address one host sends TRANSACTIONAL mail on when it has no domain of
133
+ * its own.
134
+ *
135
+ * `''` when the deployment has no usable pool, or when the caller has no host.
136
+ * The caller must treat that as a REFUSAL rather than substituting anything: a
137
+ * caller with no `hostId` does not know which site it is sending for, and the
138
+ * honest answer to that is not "some pool member".
139
+ *
140
+ * ## The address always sits ON a pool member, never under one
141
+ *
142
+ * Under `adkim=s` the `From:` domain and the DKIM `d=` must be the same name,
143
+ * exactly. Each pool member is its own provider domain object with its own key
144
+ * signing for itself, so an address on the member aligns. An address BENEATH a
145
+ * member — a per-host `From:` over a shared key — does not, and the published
146
+ * record's `aspf=r` would let it scrape a DMARC pass off SPF alone until the
147
+ * first recipient forwarded it. There is deliberately no configuration that can
148
+ * produce that shape: the local part is the only part an operator may set.
149
+ */
150
+ export declare function sharedTenantSendingFrom(hostId: string | null | undefined, apex?: string, size?: number): string;
151
+ /**
152
+ * Whether a domain is one of this deployment's pool members.
153
+ *
154
+ * Used where a surface has a domain and needs to know whether its reputation is
155
+ * pooled — so the disclosure is driven by the same function the send path
156
+ * resolves through, rather than by a second opinion assembled at the surface.
157
+ */
158
+ export declare function isSharedSendingDomain(domain: string | null | undefined, apex?: string, size?: number): boolean;
159
+ /**
160
+ * ⛔ WHY A DOMAIN MAY NOT BE TORN DOWN, or `null` when it may.
161
+ *
162
+ * Every path that destroys a sending domain — the site delete, the erasure,
163
+ * the orphan reaper — asks this ONE function first, and it lives here because
164
+ * this module is the only place that knows what the pool is. A guard held
165
+ * anywhere else is a guard the next teardown path forgets to consult.
166
+ *
167
+ * `shared-pool` is the answer that matters, and the reason it has to be
168
+ * explicit rather than emergent. A pool member is owned by NO HOST: it is
169
+ * platform infrastructure that every site without a domain of its own sends
170
+ * on, and {@link sharedSendingDomainFor} assigns hosts to it by hash rather
171
+ * than by a stored pointer. So any reaper keyed on "nothing points at this"
172
+ * describes a pool member perfectly, and releasing one takes a quarter of the
173
+ * platform's transactional mail — receipts, password resets, booking
174
+ * confirmations — down with it. Silently: nothing raises an error when a
175
+ * domain merely stops being verified, the sends just start refusing.
176
+ *
177
+ * It is asked of the LABEL as well as of the domain, in three ways, because
178
+ * pool membership answered from the CURRENT pool alone is not enough:
179
+ *
180
+ * - the domain is in the pool this deployment builds today;
181
+ * - the caller's own label is a pool label. {@link platformSendingLabel}
182
+ * refuses to derive a label for a reserved name, so a pool member's domain
183
+ * hands back an empty label, and a caller that then fell back to a label
184
+ * it was handed separately would walk straight past the derivation that
185
+ * was protecting it;
186
+ * - the label UNDER the apex is a pool label, whatever the pool size says.
187
+ * A deployment shrunk from eight members to four still holds `shared5`
188
+ * through `shared8` at the provider, and they are still infrastructure —
189
+ * a membership test against the current pool would hand all four to the
190
+ * reaper on the day somebody lowered the number.
191
+ *
192
+ * `not-our-zone` is the other refusal and a different kind: a customer's own
193
+ * verified domain, which this deployment never provisioned, holds no provider
194
+ * slot for, and must never write DNS into.
195
+ */
196
+ export declare function sendingDomainTeardownRefusal(domain: string | null | undefined, label?: string | null | undefined, apex?: string, size?: number): 'shared-pool' | 'not-our-zone' | null;
197
+ /**
198
+ * Labels that would name mail infrastructure inside the mail apex.
199
+ *
200
+ * The question this set answers is narrow: which labels, taken by a tenant,
201
+ * would position that tenant to publish records under a name some other part
202
+ * of the system relies on?
203
+ *
204
+ * `send` is the one that matters and the one a pattern check misses.
205
+ * {@link SENDING_SUBDOMAIN} is `send`, so a tenant holding that label holds
206
+ * `send.mail.aglyn.app` — the return-path name of the mail apex itself, and a
207
+ * bounce-routing surface rather than anything a site needs.
208
+ *
209
+ * Names containing a dot or an underscore are NOT in here and do not need to
210
+ * be: {@link LABEL_PATTERN} admits neither character, so `_dmarc` and
211
+ * `resend._domainkey` are unreachable by construction rather than by
212
+ * blocklist. A blocklist entry for a string the grammar cannot produce reads
213
+ * as though the grammar allows it.
214
+ */
215
+ export declare const PLATFORM_MAIL_RESERVED_LABELS: readonly string[];
216
+ /**
217
+ * Whether a label is one a TENANT may not take inside the mail apex.
218
+ *
219
+ * Two sources, and the second is why this is a function rather than a `Set`
220
+ * lookup: the fixed infrastructure names above, plus the pool family, whose
221
+ * size is a configuration value. A pool grown from four to eight must not be
222
+ * able to want a label a site was handed last week, and a blocklist that had to
223
+ * be edited in step with an environment variable is one that gets forgotten.
224
+ *
225
+ * This is the TENANT policy, not the name grammar. The pool's own builder
226
+ * deliberately does not consult it — `shared3` is reserved precisely so that
227
+ * the pool can have it — which is why {@link mailDomainWithinApex} exists as
228
+ * the shape check underneath both.
229
+ */
230
+ export declare function isReservedMailLabel(label: string): boolean;
231
+ /**
232
+ * Whether a label is shaped like one, ignoring whether it is reserved or
233
+ * taken. Exported so the claim path can tell "not a label" (never fixable by
234
+ * suffixing) from "taken" (fixable).
235
+ */
236
+ export declare function isWellFormedMailLabel(label: string): boolean;
237
+ /**
238
+ * The sending domain for one PINNED label, or `''` when the label must not
239
+ * become one.
240
+ *
241
+ * The ONLY place a label is turned into a mail domain. Everything downstream —
242
+ * the Resend domain object, the DNS records written into the zone, the `From:`
243
+ * header — is built from what this returns, so a name this refuses cannot
244
+ * reach any of them.
245
+ *
246
+ * It takes the PINNED label, never a host's live subdomain, and that is the
247
+ * rename guarantee expressed as a type: there is no argument here that changes
248
+ * when a site is renamed. {@link mailLabelCandidate} is the separate, one-time
249
+ * step that proposes a label from a slug.
250
+ *
251
+ * `''` rather than a throw: every caller's correct response to an unusable
252
+ * label is the same one — provision nothing — and a falsy return is what this
253
+ * library's other normalizers already give back for input they will not
254
+ * accept.
255
+ */
256
+ export declare function platformSendingDomainFor(label: string | null | undefined, apex?: string): string;
257
+ /**
258
+ * Propose a mail label from a site's subdomain. Called ONCE, at provisioning.
259
+ *
260
+ * Its output is pinned and then never recomputed, so this function's result
261
+ * changing later — because the site was renamed, or because a label joined the
262
+ * reserved set — cannot move an existing site's mail. That is deliberate: the
263
+ * value of a sending domain is its age, and a name that can be recomputed is a
264
+ * name that can be lost.
265
+ *
266
+ * `attempt` de-collides. A label already claimed by another site yields
267
+ * `{label}-2`, `-3`, and so on, which is the same shape `suggestSubdomains`
268
+ * uses for a taken web slug — a merchant meeting `northwind-coffee-2` has met
269
+ * that pattern before. Truncated so the suffix never pushes the label past a
270
+ * legal length, because a name that is silently cut at 63 octets is a name
271
+ * whose DNS records do not match its domain.
272
+ *
273
+ * Returns `''` when nothing usable can be built, and the caller must treat
274
+ * that as "this site cannot be provisioned" rather than substituting anything.
275
+ */
276
+ export declare function mailLabelCandidate(subdomain: string | null | undefined, attempt?: number): string;
277
+ /**
278
+ * Whether a domain is one this deployment provisions and owns, as against a
279
+ * customer's own name.
280
+ *
281
+ * The distinction decides who does the DNS work — a domain inside our apex is
282
+ * written by API and needs nothing from the tenant — and what a delete may
283
+ * remove. A record set inside our zone is ours to clean up; a customer's zone
284
+ * is one we must never write to.
285
+ *
286
+ * The bare apex is deliberately NOT one of these.
287
+ */
288
+ export declare function isPlatformSendingDomain(domain: string | null | undefined, apex?: string): boolean;
289
+ /**
290
+ * The pinned label a platform sending domain belongs to, or `''`.
291
+ *
292
+ * The inverse of {@link platformSendingDomainFor}, and it re-runs the same
293
+ * refusals rather than merely stripping the suffix. A stored domain is data
294
+ * like any other — it can predate a reserved-label entry or have been written
295
+ * by hand — and a cleanup path that trusted it would derive a label from a
296
+ * name the current rules would never have issued.
297
+ */
298
+ export declare function platformSendingLabel(domain: string | null | undefined, apex?: string): string;
299
+ /** One record to write into the zone, as a DNS API addresses it. */
300
+ export interface PlatformZoneRecord {
301
+ type: 'TXT' | 'MX' | 'CNAME' | 'CAA';
302
+ /**
303
+ * Name RELATIVE to the ZONE — `send.acme.mail`, not
304
+ * `send.acme.mail.aglyn.app`.
305
+ *
306
+ * Zone APIs address records by their name within the zone, and a name
307
+ * carrying the zone would be created at `…aglyn.app.aglyn.app`. The
308
+ * fully-qualified form is what `sendingDnsRecords` produces for a customer
309
+ * to paste at their own registrar; this is the same record addressed the way
310
+ * an API to our own zone addresses it.
311
+ *
312
+ * The zone is the REGISTRABLE domain (`aglyn.app`), not the mail apex —
313
+ * `mail.aglyn.app` is a name inside that zone, not a zone of its own, so its
314
+ * label is part of every record name here.
315
+ */
316
+ name: string;
317
+ value: string;
318
+ /** `MX` only. */
319
+ priority?: number;
320
+ }
321
+ /**
322
+ * Turn the records a sending domain requires into records addressed within the
323
+ * zone.
324
+ *
325
+ * Derived from {@link sendingDnsRecords}' output rather than rebuilt, for the
326
+ * reason that function exists at all: the records we WRITE must be the records
327
+ * the verifier LOOKS FOR. Two generators is how a wizard comes to print one
328
+ * target while the check reads another — a check that cannot fail, then a
329
+ * check that cannot pass.
330
+ *
331
+ * Records with no value are dropped. A domain with no issued DKIM key yields
332
+ * no DKIM record, and writing an empty TXT would publish a record that says
333
+ * nothing while looking published.
334
+ */
335
+ export declare function platformZoneRecords(records: readonly {
336
+ type: 'TXT' | 'MX' | 'CNAME' | 'CAA';
337
+ name: string;
338
+ value: string;
339
+ priority?: number;
340
+ }[], zone?: string): PlatformZoneRecord[];
341
+ /**
342
+ * Every name one sending domain owns inside the zone, for the cleanup path.
343
+ *
344
+ * Derived rather than written out at the call site, because the mail apex is a
345
+ * configuration value: a teardown that assembled `send.{label}.mail` by hand
346
+ * would delete nothing on a deployment whose mail apex is not `mail.…`, and
347
+ * would leave a live signing key in the zone for every site it "cleaned".
348
+ *
349
+ * The DKIM name needs the SELECTOR, which is the provider's choice and not
350
+ * ours — `sendingDkimSelector` proposes one and the provider overwrites it. A
351
+ * caller with no selector gets the other names and must treat the DKIM record
352
+ * as unhandled rather than guessing, since a guessed selector deletes nothing
353
+ * and reports success.
354
+ */
355
+ export declare function platformZoneNamesFor(label: string, selector?: string | null, apex?: string, zone?: string): string[];
356
+ /**
357
+ * The return-path name for one pinned label, relative to the zone.
358
+ *
359
+ * Used where a caller needs only the bounce name — the record that has to keep
360
+ * resolving after a rename, because bounces arrive after the send.
361
+ */
362
+ export declare function platformReturnPathName(label: string, apex?: string, zone?: string): string;