@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,697 @@
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
+ * THE PER-SITE SENDING DOMAIN — naming, and the reasons the name is shaped
19
+ * the way it is.
20
+ *
21
+ * A site sends on a name inside `mail.aglyn.app`, never on `aglyn.com`. The
22
+ * two apexes carry different reputations on purpose:
23
+ *
24
+ * `aglyn.com` Aglyn talking to its own customers — billing,
25
+ * account notices, console password resets.
26
+ * `{label}.mail.aglyn.app` One site talking to its visitors, on a domain
27
+ * of its own. Marketing AND transactional: a
28
+ * receipt is still the tenant speaking.
29
+ * `shared{n}.mail.aglyn.app` A FIXED, SMALL POOL. Every site that has no
30
+ * domain of its own is assigned one member and
31
+ * stays there. Marketing AND transactional, under
32
+ * a stricter reputation grade.
33
+ *
34
+ * The FIRST line is WHO IS SPEAKING, and it is absolute. A booking reminder
35
+ * that bounces is the tenant's list problem, and charging it against the
36
+ * domain the platform's own invoices leave on means one merchant's bad import
37
+ * degrades every other merchant's password reset. No tenant message reaches
38
+ * `aglyn.com` under any configuration.
39
+ *
40
+ * Neither tenant line is split by whether a message is promotional. What
41
+ * differs is how tightly the sender is graded: on a site's own domain the
42
+ * merchant is the only person whose reputation is at stake, so they may spend
43
+ * it how they like, and on a pool member a campaign is held to the stricter
44
+ * complaint and bounce thresholds because the cost of a bad list is felt by
45
+ * the other sites on that member.
46
+ *
47
+ * ## Why the mail name is its own namespace and not the site's own subdomain
48
+ *
49
+ * The obvious design is to send from `{subdomain}.aglyn.app` — the address the
50
+ * site already has. It is rejected for one reason, and the reason is renaming.
51
+ *
52
+ * A site's subdomain is MUTABLE. `hostId` is the immutable identifier; the
53
+ * slug is a field the rename route rewrites. So a sending domain derived from
54
+ * the live slug moves when the slug moves, and moving a sending domain throws
55
+ * away every day of sending reputation attached to it — which is the entire
56
+ * asset this feature exists to build. Worse, mail already in flight bounces to
57
+ * a return path that no longer resolves.
58
+ *
59
+ * So the label is PINNED once, at provisioning, from the then-current
60
+ * subdomain, and a rename does not touch it. That immediately creates the
61
+ * problem this namespace exists to solve: the old slug becomes free, another
62
+ * site claims it, and now that site's WEB address is a name the first site's
63
+ * MAIL is signed for.
64
+ *
65
+ * Two ways out, and the choice between them is not about the happy path:
66
+ *
67
+ * 1. Keep one flat namespace and RESERVE the pinned slug so nobody may take
68
+ * it. Prettier — `hello@northwind-coffee.aglyn.app`.
69
+ * 2. Put mail in its own namespace, so a web slug is never a mail name.
70
+ *
71
+ * **(2), because of what each does when its guard fails.** Both need a
72
+ * uniqueness claim: under (2) two sites can still want the same mail label.
73
+ * The difference is the failure mode when that claim is buggy, lost, or raced.
74
+ *
75
+ * Under (1) the failure is a site SERVING at a name another site's mail is
76
+ * signed for. Nothing catches it — Vercel sees a project domain, Resend sees
77
+ * a mail domain, and neither knows about the other. It is live, silent, and
78
+ * it is a spoofing surface.
79
+ *
80
+ * Under (2) the failure is two sites wanting one mail label. Resend holds at
81
+ * most one domain object per name per account and `recordIssuedSendingDomain`
82
+ * refuses to overwrite an issued key, so the second claimant is REFUSED. A
83
+ * refusal is visible and recoverable; a confusion is neither.
84
+ *
85
+ * A guard whose breakage fails closed beats a guard whose breakage fails open,
86
+ * and that is the whole argument. Option (2) also costs one DNS level and buys
87
+ * a namespace that contains only mail names — so `*.mail.aglyn.app` is a
88
+ * coherent thing to reason about, block, or move to another provider as a
89
+ * unit, which a flat namespace of mixed websites and mail domains is not.
90
+ *
91
+ * `mail` is already in `RESERVED_SUBDOMAINS`, so no site can hold the label
92
+ * this namespace hangs off. The namespace is free by construction rather than
93
+ * by a rule added to protect it.
94
+ *
95
+ * ## A whole domain per site, for the sites that have one
96
+ *
97
+ * DMARC alignment is evaluated against the `From:` domain and DKIM is what
98
+ * carries it. `aglyn.app` publishes `adkim=s` — strict — so a signature with
99
+ * `d=aglyn.app` cannot authenticate mail `From:` a tenant name, and one
100
+ * tenant's key can never sign as another. That is the isolation, and it is
101
+ * what a dedicated per-site domain buys.
102
+ *
103
+ * ## …and a FIXED POOL for the sites that do not, which is what makes this
104
+ * design survive
105
+ *
106
+ * A per-host domain is `O(hosts)` in three separate resources, and none of them
107
+ * is the one people reach for first:
108
+ *
109
+ * - a provider domain object per site, which is a bundled quota with a hard
110
+ * per-account ceiling;
111
+ * - **three records in OUR OWN ZONE per site** — SPF, MX and DKIM under
112
+ * `send.{label}.…` — which a customer's own domain costs us NOTHING of,
113
+ * because the customer publishes those in their zone;
114
+ * - a place in the verification and re-check sweeps, forever, at the
115
+ * provider's account-wide rate limit that the SENDS also share.
116
+ *
117
+ * The zone wall arrives first and it arrives hard. That asymmetry is the whole
118
+ * argument: a customer-owned `acme.com` scales, a platform subdomain does not,
119
+ * and treating the two as one feature hides it.
120
+ *
121
+ * So the default for a site with no domain of its own is a member of a fixed,
122
+ * small pool — {@link sharedSendingPool} — and the pool does not grow with
123
+ * hosts. Its entire cost is {@link DEFAULT_SHARED_POOL_SIZE} domain objects and
124
+ * three records each, at twelve sites or at a hundred thousand. Sites are
125
+ * assigned by {@link sharedSendingDomainFor}, deterministically and with
126
+ * nothing stored, so the assignment costs no write and no read either.
127
+ *
128
+ * Not ONE pool member, because then one compromised site sending "receipts"
129
+ * would take every other site's password resets with it. Several, so the blast
130
+ * radius is a fraction.
131
+ *
132
+ * ## The address is ON a pool member, never under one
133
+ *
134
+ * This is a DMARC constraint rather than a naming preference. The tempting
135
+ * shape is to keep a per-site `From:` (`hello@acme.mail.aglyn.app`) over one
136
+ * shared key at `d=mail.aglyn.app` — one domain object for the whole platform,
137
+ * and the site's name still in the header. Under `adkim=s` those are not the
138
+ * same name, so the signature does not align.
139
+ *
140
+ * What makes that shape actively dangerous rather than merely wrong is
141
+ * `aspf=r`, which the published record also carries: such a message still
142
+ * scrapes a DMARC pass off SPF alone. It works when you test it to one mailbox
143
+ * and fails the moment a recipient forwards it, because forwarding breaks SPF
144
+ * and the DKIM signature that would have survived is the one that does not
145
+ * align. A shape that passes when you check it and fails in the field is worse
146
+ * than one that refuses.
147
+ *
148
+ * Each pool member is therefore its own domain object with its own key signing
149
+ * for itself, and the `From:` sits on the member exactly. Alignment holds on
150
+ * DKIM without depending on SPF.
151
+ *
152
+ * ## What the pool costs the product, and what bounds it
153
+ *
154
+ * Reputation on a member is pooled across the sites assigned to it, which is
155
+ * the trade the console discloses in as many words. It is bounded by GRADING
156
+ * rather than by exclusion. Marketing mail sends here, and the site that earns
157
+ * complaints is the one that stops: a pooled campaign is held to the WATCH
158
+ * thresholds in `sender-reputation.ts` rather than the trip levels, the
159
+ * new-sender ramp keeps a first import off the member, and `sendEmail` refuses
160
+ * outright the one thing a seven-day window cannot catch in time — bulk mail
161
+ * carrying no unsubscribe link.
162
+ *
163
+ * ## The web apex sends nothing, and says so
164
+ *
165
+ * `aglyn.app` publishes `v=spf1 -all`. SPF is not inherited by subdomains, so
166
+ * it constrains only the apex — correct, because no message ever leaves
167
+ * `From: something@aglyn.app`. Every sending name here is strictly deeper, the
168
+ * pool members included.
169
+ */ import { normalizeLocalPart, normalizeSendingDomain, SENDING_SUBDOMAIN } from "./sending-domain.js";
170
+ /*==========================================
171
+ The apexes
172
+ ==========================================*/ /**
173
+ * The apex assigned site subdomains hang off — the WEB namespace.
174
+ *
175
+ * The same variable and the same default as `TENANT_APEX` in `@aglyn/aglyn`'s
176
+ * `host-naming.ts`, read again here rather than imported because
177
+ * `@aglyn/shared-util-email` is `scope:shared` and may not depend on the
178
+ * application library. Reading the environment in this module is the idiom
179
+ * `sendingSpfInclude` and `sendingReturnPathHost` already establish one file
180
+ * over: the mail policy layer owns its own configuration reads.
181
+ *
182
+ * `||` not `??`, matching every other env read in this repo — an empty string
183
+ * is a variable somebody set to nothing, not a configured value.
184
+ */ export function tenantWebApex() {
185
+ return normalizeSendingDomain(process.env.NEXT_PUBLIC_TENANT_DOMAIN || '') || 'aglyn.app';
186
+ }
187
+ /**
188
+ * The apex tenant sending domains hang off — the MAIL namespace.
189
+ *
190
+ * `mail.{web apex}` by default, so an operator who sets only
191
+ * `NEXT_PUBLIC_TENANT_DOMAIN` gets a mail namespace inside their own zone
192
+ * rather than inside ours. Overridable on its own for the operator who wants
193
+ * mail in a different zone entirely — a separate registrable domain is the
194
+ * strongest form of the reputation split this feature is about, and there is
195
+ * no reason for the software to be the thing preventing it.
196
+ *
197
+ * Never equal to the web apex. If an operator sets them to the same value the
198
+ * override is ignored: a mail namespace that IS the web namespace re-creates
199
+ * exactly the collision this module chose its shape to avoid, and honoring
200
+ * that setting would be honoring a request to be unsafe.
201
+ */ export function platformSendingApex() {
202
+ const web = tenantWebApex();
203
+ const configured = normalizeSendingDomain(process.env.AGLYN_TENANT_MAIL_APEX || '');
204
+ if (configured && configured !== web) return configured;
205
+ return `mail.${web}`;
206
+ }
207
+ /*==========================================
208
+ The shared identity every site can reach
209
+ ==========================================*/ /**
210
+ * The mailbox the shared tenant identity sends as.
211
+ *
212
+ * `notifications` rather than `hello`, which is what a site's own domain
213
+ * defaults to. The two are different promises: `hello@northwind.mail.aglyn.app`
214
+ * is a site inviting a reply, and this one is a platform address carrying mail
215
+ * for whichever site the display name in front of it names. Calling it `hello`
216
+ * would invite replies to a mailbox belonging to no site.
217
+ */ export const SHARED_TENANT_LOCAL_PART = 'notifications';
218
+ /** The label prefix every pool member carries. Reserved by rule, not by list. */ const SHARED_POOL_PREFIX = 'shared';
219
+ /**
220
+ * How many shared sending domains exist, absent configuration.
221
+ *
222
+ * FOUR, and the number is a blast-radius decision rather than a capacity one.
223
+ * The pool is FIXED-SIZE — it does not grow with hosts — so its whole cost is
224
+ * four provider domain objects and twelve records in our own zone, whether the
225
+ * deployment has twelve sites or a hundred thousand. That is the property that
226
+ * makes this design survive a scale a per-host domain does not.
227
+ *
228
+ * One would be simpler and is wrong: every site without a domain of its own
229
+ * would share a single reputation, so one compromised site blasting "receipts"
230
+ * takes every other site's password resets down with it. Four caps that at a
231
+ * quarter, for three more domain objects.
232
+ *
233
+ * Not larger, because each member is a reputation that has to be EARNED — one
234
+ * warm domain sending steadily is worth more than eight cold ones — and
235
+ * because each is a provider slot on an account whose allowance is ten on the
236
+ * plan this deployment pays for. Raising it is a real decision with a real
237
+ * cost, so it is a configuration value rather than a constant somebody bumps.
238
+ */ export const DEFAULT_SHARED_POOL_SIZE = 4;
239
+ /** Hard ceiling on the configured pool, so a typo cannot ask for thousands. */ const MAX_SHARED_POOL_SIZE = 64;
240
+ /** How many members this deployment's pool has. */ export function sharedPoolSize() {
241
+ const raw = Number(String(process.env.AGLYN_TENANT_SHARED_POOL_SIZE || '').trim());
242
+ if (!Number.isFinite(raw)) return DEFAULT_SHARED_POOL_SIZE;
243
+ const size = Math.floor(raw);
244
+ if (size < 1) return DEFAULT_SHARED_POOL_SIZE;
245
+ return Math.min(size, MAX_SHARED_POOL_SIZE);
246
+ }
247
+ /**
248
+ * Whether a label names a pool member.
249
+ *
250
+ * By PATTERN rather than by list, so raising the pool size can never hand a
251
+ * tenant a label the pool is about to want. `PLATFORM_MAIL_RESERVED_LABELS`
252
+ * covers the fixed names; this covers a family whose size is configurable, and
253
+ * a blocklist that had to be edited in step with an environment variable is a
254
+ * blocklist that will be forgotten.
255
+ */ export function isSharedPoolLabel(label) {
256
+ return new RegExp(`^${SHARED_POOL_PREFIX}[1-9][0-9]*$`).test(String(label != null ? label : '').trim().toLowerCase());
257
+ }
258
+ /**
259
+ * The pool, in order: `shared1.mail.aglyn.app`, `shared2.…`, and so on.
260
+ *
261
+ * The names say what they are on purpose. A recipient who looks sees an address
262
+ * that is plainly platform infrastructure rather than one pretending to be the
263
+ * merchant's own — which is what the console tells the merchant is happening,
264
+ * and a `From:` that oversold the arrangement would be the one surface
265
+ * contradicting the disclosure.
266
+ */ export function sharedSendingPool(apex = platformSendingApex(), size = sharedPoolSize()) {
267
+ const pool = [];
268
+ for(let index = 1; index <= size; index += 1){
269
+ // The grammar, not `platformSendingDomainFor`: these labels are RESERVED
270
+ // against tenants, so the tenant gate would refuse every one of them and
271
+ // hand back an empty pool.
272
+ const domain = mailDomainWithinApex(`${SHARED_POOL_PREFIX}${index}`, apex);
273
+ if (domain) pool.push(domain);
274
+ }
275
+ return pool;
276
+ }
277
+ /**
278
+ * FNV-1a, 32-bit. A stable, dependency-free string hash.
279
+ *
280
+ * Stability across processes and deployments is the entire requirement — the
281
+ * assignment below has to be the same answer in the tenant runtime, in a cron
282
+ * and in the console, today and in a year — so it cannot be anything that
283
+ * varies by runtime. `>>> 0` after the multiply keeps the value in unsigned
284
+ * 32-bit range, which is what makes it reproducible rather than dependent on
285
+ * float rounding.
286
+ *
287
+ * Deliberately not a cryptographic hash. Nothing here is a secret, and nobody
288
+ * gains anything by predicting which pool member a site lands on.
289
+ */ function hash32(input) {
290
+ let hash = 0x811c9dc5;
291
+ for(let index = 0; index < input.length; index += 1){
292
+ hash ^= input.charCodeAt(index);
293
+ hash = Math.imul(hash, 0x01000193) >>> 0;
294
+ }
295
+ return hash >>> 0;
296
+ }
297
+ /**
298
+ * Which pool member one host sends on. Deterministic, and stored nowhere.
299
+ *
300
+ * ## Rendezvous hashing, and why not `hash % size`
301
+ *
302
+ * The requirement is that a host's sending identity does not move underneath
303
+ * it. Reputation is built by sending steadily from one name, so a reassignment
304
+ * is not a rebalance — it is a reset, and it throws away every day of standing
305
+ * the old name had earned.
306
+ *
307
+ * `hash(hostId) % size` satisfies that only while `size` never changes. Growing
308
+ * a pool from four to eight remaps roughly HALF of all hosts, silently moving
309
+ * half the platform's transactional mail onto cold domains. The operator's
310
+ * reason for growing the pool is usually that one member is in trouble, which
311
+ * makes the moment they act the worst possible moment to shuffle everybody.
312
+ *
313
+ * Rendezvous (highest-random-weight) hashing scores the host against EVERY
314
+ * member and takes the winner. Adding a member moves only the hosts that now
315
+ * score highest on the new one — about 1/size of them — and removing a member
316
+ * moves only that member's own hosts. Nothing else observes the change.
317
+ *
318
+ * That property is why this is worth ten lines instead of a modulo, and it is
319
+ * also why the assignment needs no stored field: there is nothing to pin,
320
+ * because the function does not change its mind. A pinned column would be one
321
+ * more write per host on a path whose whole point is costing nothing per host.
322
+ *
323
+ * Ties break on the domain name so the answer is total rather than dependent on
324
+ * iteration order. A 32-bit collision across a four-member pool is vanishingly
325
+ * unlikely and would still have to resolve to something.
326
+ */ export function sharedSendingDomainFor(hostId, apex = platformSendingApex(), size = sharedPoolSize()) {
327
+ const id = String(hostId != null ? hostId : '').trim();
328
+ const pool = sharedSendingPool(apex, size);
329
+ if (!id || !pool.length) return '';
330
+ let best = '';
331
+ let bestScore = -1;
332
+ for (const domain of pool){
333
+ const score = hash32(`${id}:${domain}`);
334
+ if (score > bestScore || score === bestScore && domain > best) {
335
+ best = domain;
336
+ bestScore = score;
337
+ }
338
+ }
339
+ return best;
340
+ }
341
+ /**
342
+ * The address one host sends TRANSACTIONAL mail on when it has no domain of
343
+ * its own.
344
+ *
345
+ * `''` when the deployment has no usable pool, or when the caller has no host.
346
+ * The caller must treat that as a REFUSAL rather than substituting anything: a
347
+ * caller with no `hostId` does not know which site it is sending for, and the
348
+ * honest answer to that is not "some pool member".
349
+ *
350
+ * ## The address always sits ON a pool member, never under one
351
+ *
352
+ * Under `adkim=s` the `From:` domain and the DKIM `d=` must be the same name,
353
+ * exactly. Each pool member is its own provider domain object with its own key
354
+ * signing for itself, so an address on the member aligns. An address BENEATH a
355
+ * member — a per-host `From:` over a shared key — does not, and the published
356
+ * record's `aspf=r` would let it scrape a DMARC pass off SPF alone until the
357
+ * first recipient forwarded it. There is deliberately no configuration that can
358
+ * produce that shape: the local part is the only part an operator may set.
359
+ */ export function sharedTenantSendingFrom(hostId, apex = platformSendingApex(), size = sharedPoolSize()) {
360
+ const domain = sharedSendingDomainFor(hostId, apex, size);
361
+ if (!domain) return '';
362
+ const localPart = normalizeLocalPart(process.env.AGLYN_TENANT_SHARED_LOCAL_PART || '') || SHARED_TENANT_LOCAL_PART;
363
+ return `${localPart}@${domain}`;
364
+ }
365
+ /**
366
+ * Whether a domain is one of this deployment's pool members.
367
+ *
368
+ * Used where a surface has a domain and needs to know whether its reputation is
369
+ * pooled — so the disclosure is driven by the same function the send path
370
+ * resolves through, rather than by a second opinion assembled at the surface.
371
+ */ export function isSharedSendingDomain(domain, apex = platformSendingApex(), size = sharedPoolSize()) {
372
+ const name = normalizeSendingDomain(String(domain != null ? domain : ''));
373
+ return Boolean(name) && sharedSendingPool(apex, size).includes(name);
374
+ }
375
+ /**
376
+ * ⛔ WHY A DOMAIN MAY NOT BE TORN DOWN, or `null` when it may.
377
+ *
378
+ * Every path that destroys a sending domain — the site delete, the erasure,
379
+ * the orphan reaper — asks this ONE function first, and it lives here because
380
+ * this module is the only place that knows what the pool is. A guard held
381
+ * anywhere else is a guard the next teardown path forgets to consult.
382
+ *
383
+ * `shared-pool` is the answer that matters, and the reason it has to be
384
+ * explicit rather than emergent. A pool member is owned by NO HOST: it is
385
+ * platform infrastructure that every site without a domain of its own sends
386
+ * on, and {@link sharedSendingDomainFor} assigns hosts to it by hash rather
387
+ * than by a stored pointer. So any reaper keyed on "nothing points at this"
388
+ * describes a pool member perfectly, and releasing one takes a quarter of the
389
+ * platform's transactional mail — receipts, password resets, booking
390
+ * confirmations — down with it. Silently: nothing raises an error when a
391
+ * domain merely stops being verified, the sends just start refusing.
392
+ *
393
+ * It is asked of the LABEL as well as of the domain, in three ways, because
394
+ * pool membership answered from the CURRENT pool alone is not enough:
395
+ *
396
+ * - the domain is in the pool this deployment builds today;
397
+ * - the caller's own label is a pool label. {@link platformSendingLabel}
398
+ * refuses to derive a label for a reserved name, so a pool member's domain
399
+ * hands back an empty label, and a caller that then fell back to a label
400
+ * it was handed separately would walk straight past the derivation that
401
+ * was protecting it;
402
+ * - the label UNDER the apex is a pool label, whatever the pool size says.
403
+ * A deployment shrunk from eight members to four still holds `shared5`
404
+ * through `shared8` at the provider, and they are still infrastructure —
405
+ * a membership test against the current pool would hand all four to the
406
+ * reaper on the day somebody lowered the number.
407
+ *
408
+ * `not-our-zone` is the other refusal and a different kind: a customer's own
409
+ * verified domain, which this deployment never provisioned, holds no provider
410
+ * slot for, and must never write DNS into.
411
+ */ export function sendingDomainTeardownRefusal(domain, label = null, apex = platformSendingApex(), size = sharedPoolSize()) {
412
+ if (isSharedSendingDomain(domain, apex, size)) return 'shared-pool';
413
+ if (isSharedPoolLabel(String(label != null ? label : ''))) return 'shared-pool';
414
+ /*
415
+ * The bare suffix strip, not `platformSendingLabel`. That one re-applies the
416
+ * tenant policy and answers `''` for every reserved name — which is every
417
+ * pool label — so asking it here would return nothing for exactly the names
418
+ * this check exists to catch.
419
+ */ const name = normalizeSendingDomain(String(domain != null ? domain : ''));
420
+ const root = normalizeSendingDomain(apex);
421
+ const beneath = name && root && name !== root && name.endsWith(`.${root}`) ? name.slice(0, -(root.length + 1)) : '';
422
+ if (isSharedPoolLabel(beneath)) return 'shared-pool';
423
+ if (!isPlatformSendingDomain(domain, apex)) return 'not-our-zone';
424
+ return null;
425
+ }
426
+ /*==========================================
427
+ Labels a site's mail may not take
428
+ ==========================================*/ /**
429
+ * Labels that would name mail infrastructure inside the mail apex.
430
+ *
431
+ * The question this set answers is narrow: which labels, taken by a tenant,
432
+ * would position that tenant to publish records under a name some other part
433
+ * of the system relies on?
434
+ *
435
+ * `send` is the one that matters and the one a pattern check misses.
436
+ * {@link SENDING_SUBDOMAIN} is `send`, so a tenant holding that label holds
437
+ * `send.mail.aglyn.app` — the return-path name of the mail apex itself, and a
438
+ * bounce-routing surface rather than anything a site needs.
439
+ *
440
+ * Names containing a dot or an underscore are NOT in here and do not need to
441
+ * be: {@link LABEL_PATTERN} admits neither character, so `_dmarc` and
442
+ * `resend._domainkey` are unreachable by construction rather than by
443
+ * blocklist. A blocklist entry for a string the grammar cannot produce reads
444
+ * as though the grammar allows it.
445
+ */ export const PLATFORM_MAIL_RESERVED_LABELS = [
446
+ // The return-path label. `SENDING_SUBDOMAIN`, spelled out so a reader sees
447
+ // the collision, and asserted equal to it in the spec so renaming one cannot
448
+ // silently leave the other behind.
449
+ 'send',
450
+ 'sends',
451
+ 'bounce',
452
+ 'bounces',
453
+ 'feedback',
454
+ 'mx',
455
+ 'mta',
456
+ 'spf',
457
+ 'dkim',
458
+ 'dmarc',
459
+ 'domainkey',
460
+ 'postmaster',
461
+ 'abuse',
462
+ 'noreply',
463
+ 'no-reply',
464
+ 'unsubscribe',
465
+ 'links',
466
+ 'links1',
467
+ 'track',
468
+ 'tracking',
469
+ 'resend',
470
+ // The apex's own label, so no site's mail name can read as the namespace.
471
+ 'mail',
472
+ 'www'
473
+ ];
474
+ const RESERVED = new Set(PLATFORM_MAIL_RESERVED_LABELS);
475
+ /**
476
+ * A single DNS label: lowercase alphanumeric and dashes, no leading or
477
+ * trailing dash, at most 63 octets.
478
+ *
479
+ * Its own pattern rather than a reuse of `SUBDOMAIN_PATTERN`, which lives in
480
+ * `@aglyn/aglyn` and this library may not import. It is also STRICTER in the
481
+ * direction that matters — it refuses a trailing dash, which the host pattern
482
+ * permits — so a slug satisfying both is safe here, and a slug that somehow
483
+ * satisfied only the other one is refused rather than provisioned.
484
+ */ const LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
485
+ /**
486
+ * Whether a label is one a TENANT may not take inside the mail apex.
487
+ *
488
+ * Two sources, and the second is why this is a function rather than a `Set`
489
+ * lookup: the fixed infrastructure names above, plus the pool family, whose
490
+ * size is a configuration value. A pool grown from four to eight must not be
491
+ * able to want a label a site was handed last week, and a blocklist that had to
492
+ * be edited in step with an environment variable is one that gets forgotten.
493
+ *
494
+ * This is the TENANT policy, not the name grammar. The pool's own builder
495
+ * deliberately does not consult it — `shared3` is reserved precisely so that
496
+ * the pool can have it — which is why {@link mailDomainWithinApex} exists as
497
+ * the shape check underneath both.
498
+ */ export function isReservedMailLabel(label) {
499
+ const name = String(label != null ? label : '').trim().toLowerCase();
500
+ return RESERVED.has(name) || isSharedPoolLabel(name);
501
+ }
502
+ /**
503
+ * Whether a label is shaped like one, ignoring whether it is reserved or
504
+ * taken. Exported so the claim path can tell "not a label" (never fixable by
505
+ * suffixing) from "taken" (fixable).
506
+ */ export function isWellFormedMailLabel(label) {
507
+ return LABEL_PATTERN.test(String(label != null ? label : '').trim().toLowerCase());
508
+ }
509
+ /*==========================================
510
+ Label → sending domain
511
+ ==========================================*/ /**
512
+ * The sending domain for one PINNED label, or `''` when the label must not
513
+ * become one.
514
+ *
515
+ * The ONLY place a label is turned into a mail domain. Everything downstream —
516
+ * the Resend domain object, the DNS records written into the zone, the `From:`
517
+ * header — is built from what this returns, so a name this refuses cannot
518
+ * reach any of them.
519
+ *
520
+ * It takes the PINNED label, never a host's live subdomain, and that is the
521
+ * rename guarantee expressed as a type: there is no argument here that changes
522
+ * when a site is renamed. {@link mailLabelCandidate} is the separate, one-time
523
+ * step that proposes a label from a slug.
524
+ *
525
+ * `''` rather than a throw: every caller's correct response to an unusable
526
+ * label is the same one — provision nothing — and a falsy return is what this
527
+ * library's other normalizers already give back for input they will not
528
+ * accept.
529
+ */ export function platformSendingDomainFor(label, apex = platformSendingApex()) {
530
+ const name = String(label != null ? label : '').trim().toLowerCase();
531
+ return isReservedMailLabel(name) ? '' : mailDomainWithinApex(name, apex);
532
+ }
533
+ /**
534
+ * The NAME GRAMMAR, with no policy about who may hold the name.
535
+ *
536
+ * Split out from {@link platformSendingDomainFor} because the pool needs the
537
+ * grammar and must not be subject to the tenant policy: `shared3` is a reserved
538
+ * label exactly so that no site can take it, and a pool builder routed through
539
+ * the tenant gate would find every one of its own names refused and produce an
540
+ * empty pool — a deadlock in which the guard protecting the pool is what stops
541
+ * the pool existing.
542
+ *
543
+ * Still ONE generator for the shape, which is the invariant that matters: every
544
+ * name this module emits, tenant or pool, is a single well-formed label
545
+ * strictly one level below the apex. Two generators for that is how a verifier
546
+ * comes to look at a name nothing writes.
547
+ *
548
+ * Not exported. A caller outside this module that wants a name for a site wants
549
+ * the policy applied.
550
+ */ function mailDomainWithinApex(label, apex) {
551
+ const name = String(label != null ? label : '').trim().toLowerCase();
552
+ const root = normalizeSendingDomain(apex);
553
+ if (!name || !root) return '';
554
+ if (!LABEL_PATTERN.test(name)) return '';
555
+ const domain = `${name}.${root}`;
556
+ // Strictly one label deeper than the apex. A label reproducing the apex, or
557
+ // carrying it as a suffix, would resolve to the apex itself.
558
+ if (domain === root || name === root || name.endsWith(`.${root}`)) return '';
559
+ return domain;
560
+ }
561
+ /**
562
+ * Propose a mail label from a site's subdomain. Called ONCE, at provisioning.
563
+ *
564
+ * Its output is pinned and then never recomputed, so this function's result
565
+ * changing later — because the site was renamed, or because a label joined the
566
+ * reserved set — cannot move an existing site's mail. That is deliberate: the
567
+ * value of a sending domain is its age, and a name that can be recomputed is a
568
+ * name that can be lost.
569
+ *
570
+ * `attempt` de-collides. A label already claimed by another site yields
571
+ * `{label}-2`, `-3`, and so on, which is the same shape `suggestSubdomains`
572
+ * uses for a taken web slug — a merchant meeting `northwind-coffee-2` has met
573
+ * that pattern before. Truncated so the suffix never pushes the label past a
574
+ * legal length, because a name that is silently cut at 63 octets is a name
575
+ * whose DNS records do not match its domain.
576
+ *
577
+ * Returns `''` when nothing usable can be built, and the caller must treat
578
+ * that as "this site cannot be provisioned" rather than substituting anything.
579
+ */ export function mailLabelCandidate(subdomain, attempt = 1) {
580
+ const base = String(subdomain != null ? subdomain : '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');
581
+ if (!base) return '';
582
+ const suffix = attempt > 1 ? `-${attempt}` : '';
583
+ const stem = base.slice(0, 63 - suffix.length).replace(/-+$/, '');
584
+ const label = `${stem}${suffix}`;
585
+ // A reserved base is de-collided by the suffix rather than refused —
586
+ // `send-2` names no infrastructure. The first attempt at a reserved name
587
+ // still fails, so the caller advances and lands somewhere legal.
588
+ return LABEL_PATTERN.test(label) && !isReservedMailLabel(label) ? label : '';
589
+ }
590
+ /**
591
+ * Whether a domain is one this deployment provisions and owns, as against a
592
+ * customer's own name.
593
+ *
594
+ * The distinction decides who does the DNS work — a domain inside our apex is
595
+ * written by API and needs nothing from the tenant — and what a delete may
596
+ * remove. A record set inside our zone is ours to clean up; a customer's zone
597
+ * is one we must never write to.
598
+ *
599
+ * The bare apex is deliberately NOT one of these.
600
+ */ export function isPlatformSendingDomain(domain, apex = platformSendingApex()) {
601
+ const name = normalizeSendingDomain(String(domain != null ? domain : ''));
602
+ const root = normalizeSendingDomain(apex);
603
+ return Boolean(name && root && name !== root && name.endsWith(`.${root}`));
604
+ }
605
+ /**
606
+ * The pinned label a platform sending domain belongs to, or `''`.
607
+ *
608
+ * The inverse of {@link platformSendingDomainFor}, and it re-runs the same
609
+ * refusals rather than merely stripping the suffix. A stored domain is data
610
+ * like any other — it can predate a reserved-label entry or have been written
611
+ * by hand — and a cleanup path that trusted it would derive a label from a
612
+ * name the current rules would never have issued.
613
+ */ export function platformSendingLabel(domain, apex = platformSendingApex()) {
614
+ if (!isPlatformSendingDomain(domain, apex)) return '';
615
+ const name = normalizeSendingDomain(String(domain != null ? domain : ''));
616
+ const root = normalizeSendingDomain(apex);
617
+ const label = name.slice(0, -(root.length + 1));
618
+ return platformSendingDomainFor(label, root) === name ? label : '';
619
+ }
620
+ /**
621
+ * Turn the records a sending domain requires into records addressed within the
622
+ * zone.
623
+ *
624
+ * Derived from {@link sendingDnsRecords}' output rather than rebuilt, for the
625
+ * reason that function exists at all: the records we WRITE must be the records
626
+ * the verifier LOOKS FOR. Two generators is how a wizard comes to print one
627
+ * target while the check reads another — a check that cannot fail, then a
628
+ * check that cannot pass.
629
+ *
630
+ * Records with no value are dropped. A domain with no issued DKIM key yields
631
+ * no DKIM record, and writing an empty TXT would publish a record that says
632
+ * nothing while looking published.
633
+ */ export function platformZoneRecords(records, zone = tenantWebApex()) {
634
+ const root = normalizeSendingDomain(zone);
635
+ const suffix = `.${root}`;
636
+ const zoned = [];
637
+ for (const entry of records != null ? records : []){
638
+ var _ref, _ref1;
639
+ const value = String((_ref = entry == null ? void 0 : entry.value) != null ? _ref : '').trim();
640
+ const name = String((_ref1 = entry == null ? void 0 : entry.name) != null ? _ref1 : '').trim().toLowerCase().replace(/\.$/, '');
641
+ if (!value || !name || !name.endsWith(suffix)) continue;
642
+ zoned.push(_extends({
643
+ type: entry.type,
644
+ name: name.slice(0, -suffix.length),
645
+ value
646
+ }, entry.priority ? {
647
+ priority: entry.priority
648
+ } : {}));
649
+ }
650
+ return zoned;
651
+ }
652
+ /**
653
+ * A fully-qualified name under a sending domain, addressed within the zone.
654
+ *
655
+ * `''` when the label does not build a domain, or when that domain does not
656
+ * sit inside the zone — which is the guard that keeps a cleanup path from
657
+ * emitting a bare name and asking a zone API to delete it.
658
+ */ function zoneName(prefix, label, apex, zone) {
659
+ const domain = platformSendingDomainFor(label, apex);
660
+ const root = normalizeSendingDomain(zone);
661
+ if (!domain || !root || !domain.endsWith(`.${root}`)) return '';
662
+ const within = domain.slice(0, -(root.length + 1));
663
+ return prefix ? `${prefix}.${within}` : within;
664
+ }
665
+ /**
666
+ * Every name one sending domain owns inside the zone, for the cleanup path.
667
+ *
668
+ * Derived rather than written out at the call site, because the mail apex is a
669
+ * configuration value: a teardown that assembled `send.{label}.mail` by hand
670
+ * would delete nothing on a deployment whose mail apex is not `mail.…`, and
671
+ * would leave a live signing key in the zone for every site it "cleaned".
672
+ *
673
+ * The DKIM name needs the SELECTOR, which is the provider's choice and not
674
+ * ours — `sendingDkimSelector` proposes one and the provider overwrites it. A
675
+ * caller with no selector gets the other names and must treat the DKIM record
676
+ * as unhandled rather than guessing, since a guessed selector deletes nothing
677
+ * and reports success.
678
+ */ export function platformZoneNamesFor(label, selector, apex = platformSendingApex(), zone = tenantWebApex()) {
679
+ const names = [
680
+ // The sending domain itself, and its return-path subdomain.
681
+ zoneName('', label, apex, zone),
682
+ zoneName(SENDING_SUBDOMAIN, label, apex, zone)
683
+ ];
684
+ const key = String(selector != null ? selector : '').trim();
685
+ if (key) names.push(zoneName(`${key}._domainkey`, label, apex, zone));
686
+ return names.filter(Boolean);
687
+ }
688
+ /**
689
+ * The return-path name for one pinned label, relative to the zone.
690
+ *
691
+ * Used where a caller needs only the bounce name — the record that has to keep
692
+ * resolving after a rename, because bounces arrive after the send.
693
+ */ export function platformReturnPathName(label, apex = platformSendingApex(), zone = tenantWebApex()) {
694
+ return zoneName(SENDING_SUBDOMAIN, label, apex, zone);
695
+ }
696
+
697
+ //# sourceMappingURL=platform-sending-domain.js.map