@aglyn/shared-util-email 1.0.0-beta.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +104 -0
- package/package.json +37 -0
- package/src/index.d.ts +37 -0
- package/src/index.js +46 -0
- package/src/index.js.map +1 -0
- package/src/lib/email-delivery-events.d.ts +198 -0
- package/src/lib/email-delivery-events.js +310 -0
- package/src/lib/email-delivery-events.js.map +1 -0
- package/src/lib/email-health.d.ts +154 -0
- package/src/lib/email-health.js +264 -0
- package/src/lib/email-health.js.map +1 -0
- package/src/lib/email-media-src.d.ts +33 -0
- package/src/lib/email-media-src.js +113 -0
- package/src/lib/email-media-src.js.map +1 -0
- package/src/lib/email-merge.d.ts +31 -0
- package/src/lib/email-merge.js +36 -0
- package/src/lib/email-merge.js.map +1 -0
- package/src/lib/email-render.d.ts +114 -0
- package/src/lib/email-render.js +293 -0
- package/src/lib/email-render.js.map +1 -0
- package/src/lib/email-revenue-window.d.ts +55 -0
- package/src/lib/email-revenue-window.js +58 -0
- package/src/lib/email-revenue-window.js.map +1 -0
- package/src/lib/host-email-render.d.ts +99 -0
- package/src/lib/host-email-render.js +126 -0
- package/src/lib/host-email-render.js.map +1 -0
- package/src/lib/host-sender.d.ts +86 -0
- package/src/lib/host-sender.js +131 -0
- package/src/lib/host-sender.js.map +1 -0
- package/src/lib/marketing-send.d.ts +466 -0
- package/src/lib/marketing-send.js +459 -0
- package/src/lib/marketing-send.js.map +1 -0
- package/src/lib/platform-sending-domain.d.ts +362 -0
- package/src/lib/platform-sending-domain.js +697 -0
- package/src/lib/platform-sending-domain.js.map +1 -0
- package/src/lib/received-email.d.ts +86 -0
- package/src/lib/received-email.js +124 -0
- package/src/lib/received-email.js.map +1 -0
- package/src/lib/send-ceilings.d.ts +394 -0
- package/src/lib/send-ceilings.js +341 -0
- package/src/lib/send-ceilings.js.map +1 -0
- package/src/lib/send-email.d.ts +385 -0
- package/src/lib/send-email.js +586 -0
- package/src/lib/send-email.js.map +1 -0
- package/src/lib/send-rate.d.ts +298 -0
- package/src/lib/send-rate.js +310 -0
- package/src/lib/send-rate.js.map +1 -0
- package/src/lib/sender-reputation.d.ts +357 -0
- package/src/lib/sender-reputation.js +392 -0
- package/src/lib/sender-reputation.js.map +1 -0
- package/src/lib/sending-domain.d.ts +744 -0
- package/src/lib/sending-domain.js +798 -0
- package/src/lib/sending-domain.js.map +1 -0
- package/src/lib/sending-mailbox.d.ts +132 -0
- package/src/lib/sending-mailbox.js +192 -0
- package/src/lib/sending-mailbox.js.map +1 -0
- package/src/lib/stored-email-nodes.d.ts +38 -0
- package/src/lib/stored-email-nodes.js +78 -0
- package/src/lib/stored-email-nodes.js.map +1 -0
- package/src/lib/svix-signature.d.ts +38 -0
- package/src/lib/svix-signature.js +55 -0
- package/src/lib/svix-signature.js.map +1 -0
- package/src/lib/system-email-catalog.d.ts +170 -0
- package/src/lib/system-email-catalog.js +828 -0
- package/src/lib/system-email-catalog.js.map +1 -0
- package/src/lib/tenant-email-catalog.d.ts +78 -0
- package/src/lib/tenant-email-catalog.js +452 -0
- package/src/lib/tenant-email-catalog.js.map +1 -0
- package/src/lib/text-email-html.d.ts +32 -0
- package/src/lib/text-email-html.js +119 -0
- package/src/lib/text-email-html.js.map +1 -0
|
@@ -0,0 +1,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
|