@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,798 @@
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
+ * CUSTOM SENDING DOMAINS — policy half.
18
+ *
19
+ * Everything the platform sends leaves from one verified identity
20
+ * (`USAGE_EMAIL_FROM`) on one domain. A tenant's campaign and another tenant's
21
+ * password reset therefore share a DKIM `d=` and a reputation: one merchant's
22
+ * complaint rate is charged against every merchant's authentication mail. A
23
+ * custom sending domain is the control that moves a tenant's reputation onto a
24
+ * domain that tenant owns.
25
+ *
26
+ * ## Pure, and dependency-free, for the same reason `send-rate.ts` is
27
+ *
28
+ * The decision — which identity does this send leave on, and may it leave at
29
+ * all — is a pure function of a stored record and the environment. It is
30
+ * unit-testable with no Firestore harness, no DNS resolver and no route. The
31
+ * durable half (the record, the DNS probe, the verification sweep) lives in
32
+ * `@aglyn/tenant-data-admin`, which is the only layer that may hold the Admin
33
+ * SDK. `@aglyn/shared-util-email` is `scope:shared` and may not import it.
34
+ *
35
+ * ## THE BOUNDARY THAT MATTERS MOST
36
+ *
37
+ * **A CUSTOMER'S OWN sending domain that is not verified refuses the send. It
38
+ * never falls back to any other identity.** {@link resolveSendingIdentity} has
39
+ * no arm that reaches another address from such a domain, and
40
+ * {@link sendingIdentityRefusal} is what a caller must print.
41
+ *
42
+ * Silent fallback would be wrong three ways, and each is independently
43
+ * disqualifying: the customer believes their DNS is finished when it is not;
44
+ * the recipient sees a `From:` they did not expect from a brand they did;
45
+ * and the tenant's reputation risk lands back on the shared domain the custom
46
+ * domain existed to move it off.
47
+ *
48
+ * **WHOSE DOMAIN IT IS carries the whole rule**, and it is the distinction
49
+ * `SendingDomainSelection.platformIssued` records. There are three cases and
50
+ * only the first refuses:
51
+ *
52
+ * - A domain the CUSTOMER owns, unverified. An instruction of theirs that we
53
+ * would be ignoring. Refused.
54
+ * - A platform subdomain we issued, unverified. The merchant asked for a
55
+ * domain; we chose the name, wrote the records and pointed the site at
56
+ * it, so there is nothing of theirs to contradict and everything that can
57
+ * leave it unfinished is ours to fix. Sends on the pool.
58
+ * - Nothing selected. Likewise no statement to contradict, and the site
59
+ * still has receipts to send. Sends on the pool, which is what the console
60
+ * has always told merchants happens.
61
+ *
62
+ * The last two are why the DEDICATED subdomain can be rationed safely: it is
63
+ * an optimization on top of a floor that always holds, so exhausting the
64
+ * provider's domain allowance degrades delivery reputation rather than
65
+ * stopping the mail.
66
+ *
67
+ * ## The shared identity carries marketing too, and is GRADED for it
68
+ *
69
+ * The shared identity pools reputation across every site using it, so one
70
+ * merchant's imported list is charged against every other site's password
71
+ * resets. The answer to that is measurement, not exclusion: refusing marketing
72
+ * outright made campaigns unreachable for every site that had not bought a
73
+ * domain, while doing nothing about the pooled site whose receipts were already
74
+ * bouncing.
75
+ *
76
+ * So a pooled campaign is graded against the STRICTER reputation thresholds —
77
+ * the watch levels in `sender-reputation.ts` rather than the trip levels — and
78
+ * the site earning the complaints is the one that stops. `sendEmail` refuses
79
+ * outright only the case a seven-day window cannot catch in time: bulk mail
80
+ * carrying no unsubscribe link, which is {@link pooledMarketingRefusal}.
81
+ *
82
+ * That is a REPUTATION rule and not a pricing one. Which plans may run a
83
+ * campaign at all is an entitlement decided elsewhere, and nothing here reads
84
+ * a plan.
85
+ *
86
+ * The failure mode this guards against is the house one. `USAGE_EMAIL_FROM`
87
+ * was empty in production for weeks; because mail is best-effort at every
88
+ * call site, every send returned `{sent: false, reason: 'unconfigured'}`,
89
+ * nothing threw, and no surface said anything was wrong. A refusal that is
90
+ * only a log line is that same defect wearing a new reason string — so the
91
+ * refusal carries the domain, the missing records, and a sentence a person can
92
+ * act on, and the campaign route answers it as a `409` rather than a no-op.
93
+ *
94
+ * ## Scope: this module issues records and decides. It does not verify.
95
+ *
96
+ * Reading live DNS belongs to the durable half, which reuses the pinned-
97
+ * resolver probe that already backs SSO domain verification — the one that
98
+ * distinguishes "the record is absent" from "nobody answered". That third
99
+ * state is why {@link SendingDomainStatus} has no arm meaning "checked and we
100
+ * are not sure": an unreachable resolver leaves the stored status alone.
101
+ */ /*==========================================
102
+ The record
103
+ ==========================================*/ /**
104
+ * Where a domain is in its verification lifecycle.
105
+ *
106
+ * Persisted, unlike the site-domain machine's live-computed
107
+ * `ProjectDomainState`. A site domain can be re-read from the hosting provider
108
+ * on every request; a sending domain's proof is a DKIM key the mail provider
109
+ * issued once, and the send path must be able to answer "may this leave?"
110
+ * without a network call on the critical path of every message.
111
+ */ /** The lifecycle in order, for a surface that renders progress. */ export const SENDING_DOMAIN_STATUSES = [
112
+ 'requested',
113
+ 'records-issued',
114
+ 'verified',
115
+ 'failed'
116
+ ];
117
+ /**
118
+ * The one {@link SendingDomainRecord.lastIssueError} value that is not a
119
+ * provider failure.
120
+ *
121
+ * A dedicated platform subdomain draws on an allowance the provider grants the
122
+ * whole account, so a claim made while that allowance is spent is refused
123
+ * before any call is made. It is stored in the same field as a provider
124
+ * refusal because it is the same fact from the record's point of view — no key
125
+ * was issued, and the domain stays `requested` — but it is a DIFFERENT
126
+ * sentence to the person reading it: nothing failed, nobody's DNS is wrong,
127
+ * and pressing the retry does not move it until there is room.
128
+ *
129
+ * Named here rather than at either end because the two ends are in different
130
+ * projects: the console's provisioning sweep writes it and the email plugin's
131
+ * status model reads it, and a string spelled out twice is one a rename
132
+ * silently breaks in the surface rather than in the writer.
133
+ */ export const SENDING_DOMAIN_AT_CAPACITY = 'at-capacity';
134
+ /**
135
+ * A provider's failure reduced to something safe to store, log and print.
136
+ *
137
+ * Provider error bodies are attacker-adjacent text we did not write, and the
138
+ * one thing that must never appear in a Firestore document, a log line or an
139
+ * admin surface is the credential that made the call. Vendor keys have a
140
+ * recognizable shape — a short prefix, an underscore, a long opaque body
141
+ * (`re_`, `sk_`, `rk_`, `whsec_`) — and an `Authorization` header echoed into
142
+ * an error message carries the whole thing.
143
+ *
144
+ * This is the LAST line rather than the only one: callers build their detail
145
+ * from a fixed vocabulary and never from response prose, so nothing should
146
+ * reach here that needs redacting. A guard that is only ever a no-op in
147
+ * practice is exactly the guard worth having on a secret.
148
+ */ export function safeProviderDetail(input) {
149
+ return String(input != null ? input : '').replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]').replace(/\b[A-Za-z]{2,8}_[A-Za-z0-9]{12,}\b/g, '[redacted]').trim().slice(0, 120);
150
+ }
151
+ /*==========================================
152
+ Domain validation
153
+ ==========================================*/ const DOMAIN_PATTERN = /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;
154
+ /**
155
+ * Domains whose mail nobody but their operator may authorize.
156
+ *
157
+ * Publishing our DKIM record in one of these zones is impossible, so a claim
158
+ * on one can never verify — but refusing it up front is a sentence the
159
+ * customer can act on rather than a verification that silently never
160
+ * completes.
161
+ */ const PUBLIC_EMAIL_DOMAINS = new Set([
162
+ 'gmail.com',
163
+ 'googlemail.com',
164
+ 'yahoo.com',
165
+ 'ymail.com',
166
+ 'hotmail.com',
167
+ 'outlook.com',
168
+ 'live.com',
169
+ 'msn.com',
170
+ 'aol.com',
171
+ 'icloud.com',
172
+ 'me.com',
173
+ 'mac.com',
174
+ 'proton.me',
175
+ 'protonmail.com',
176
+ 'gmx.com',
177
+ 'zoho.com',
178
+ 'fastmail.com',
179
+ 'hey.com',
180
+ 'qq.com',
181
+ '163.com',
182
+ '126.com'
183
+ ]);
184
+ export function normalizeSendingDomain(input) {
185
+ const raw = String(input != null ? input : '').trim().toLowerCase();
186
+ const at = raw.lastIndexOf('@');
187
+ return (at >= 0 ? raw.slice(at + 1) : raw).replace(/^@+/, '').replace(/\.$/, '');
188
+ }
189
+ export function validateSendingDomain(input) {
190
+ const domain = normalizeSendingDomain(input);
191
+ if (!domain || !DOMAIN_PATTERN.test(domain)) {
192
+ return {
193
+ domain: null,
194
+ error: 'Enter a valid domain, for example acme.com'
195
+ };
196
+ }
197
+ if (PUBLIC_EMAIL_DOMAINS.has(domain)) {
198
+ return {
199
+ domain: null,
200
+ error: 'Mailbox providers do not delegate sending for their own domains, so ' + 'this domain can never be verified. Use a domain you own.'
201
+ };
202
+ }
203
+ return {
204
+ domain,
205
+ error: null
206
+ };
207
+ }
208
+ /**
209
+ * The mailbox part of an address, validated so a stored `localPart` cannot
210
+ * smuggle a second address or a header into the `From:` line.
211
+ */ export function normalizeLocalPart(input) {
212
+ const raw = String(input != null ? input : '').trim().toLowerCase();
213
+ return /^[a-z0-9](?:[a-z0-9._+-]{0,62}[a-z0-9])?$/.test(raw) ? raw : '';
214
+ }
215
+ /*==========================================
216
+ The records the customer must publish
217
+ ==========================================*/ /**
218
+ * The subdomain the envelope sender and its bounce routing live on.
219
+ *
220
+ * A subdomain rather than the root for two reasons the customer feels: their
221
+ * existing root SPF is untouched, so their Workspace or Microsoft mail keeps
222
+ * authenticating; and this SPF does not spend any of the root record's
223
+ * ten-lookup budget, which is a limit that fails closed and is easy to reach.
224
+ */ export const SENDING_SUBDOMAIN = 'send';
225
+ /**
226
+ * `||` not `??`, matching the rest of the env reads in this repo: an empty
227
+ * string is a variable somebody set to nothing, not a configured value, and an
228
+ * empty SPF include would print an instruction that authorizes no one.
229
+ *
230
+ * Configurable because a self-host operator may front a different provider —
231
+ * the same reason `AGLYN_DOMAIN_PROVIDER` exists. The defaults describe the
232
+ * provider this deployment actually uses.
233
+ */ export function sendingSpfInclude() {
234
+ return process.env.AGLYN_EMAIL_SPF_INCLUDE || 'amazonses.com';
235
+ }
236
+ export function sendingReturnPathHost() {
237
+ return process.env.AGLYN_EMAIL_RETURN_PATH_HOST || 'feedback-smtp.us-east-1.amazonses.com';
238
+ }
239
+ /**
240
+ * The label the CLICK-TRACKING host hangs off the sending domain.
241
+ *
242
+ * An ESP measures clicks by rewriting every `<a href>` to point at this host
243
+ * and redirecting from it, so it needs a name of its own with a TLS
244
+ * certificate. `links` is what the platform's own domains already use, and
245
+ * keeping one label across every domain class means an operator reading a
246
+ * zone sees the same name everywhere.
247
+ */ export const SENDING_TRACKING_SUBDOMAIN = 'links';
248
+ /**
249
+ * The certificate authority the tracking host's TLS certificate comes from.
250
+ *
251
+ * Only ever consulted to build a CAA record, and only matters for a domain
252
+ * that ALREADY publishes CAA — see the note on the record itself. Configurable
253
+ * for the same reason {@link sendingSpfInclude} is: a self-host operator
254
+ * fronting a different provider fronts a different CA.
255
+ */ export function sendingTrackingCertAuthority() {
256
+ return process.env.AGLYN_EMAIL_TRACKING_CA || 'amazon.com';
257
+ }
258
+ /**
259
+ * How long a tracked domain is held before its provider object is released.
260
+ *
261
+ * Every link in every message a tracked domain has already sent points at
262
+ * that domain's tracking host, and the provider deletes the host with the
263
+ * domain — a tracking subdomain cannot even be removed on its own, precisely
264
+ * because live mail points at it. So a same-day teardown does not merely stop
265
+ * future tracking, it retroactively breaks the links in mail already
266
+ * delivered, for recipients who have done nothing.
267
+ *
268
+ * 30 days is the window most campaign clicks arrive in, and it costs one
269
+ * provider domain slot per released site for that long — the trade is
270
+ * deliberate and the ceiling is documented in `provision-sending-domain.ts`.
271
+ * `0` disables the hold for an operator who would rather have the slot back.
272
+ *
273
+ * ⚠️ It does NOT gate an erasure. A person asking to be erased outranks a
274
+ * link, and the caller passes `immediate` for that path.
275
+ */ export function sendingTrackingRetentionDays() {
276
+ const raw = Number(process.env.AGLYN_SENDING_TRACKING_RETENTION_DAYS);
277
+ return Number.isFinite(raw) && raw >= 0 ? raw : 30;
278
+ }
279
+ /**
280
+ * Every record for a domain, in the order a customer should create them.
281
+ *
282
+ * ONE function, so the records a card prints and the records the verifier
283
+ * accepts cannot drift apart. `tenant-dns.ts` carries the same invariant for
284
+ * site domains and documents what it cost to learn: a wizard printing one
285
+ * target while the route checked another produced a check that could not fail,
286
+ * then a check that could not pass, over three separate issues.
287
+ *
288
+ * The DKIM value is absent until the provider issues a key. That is a real
289
+ * state, not an error — {@link sendingDomainRequiredRecords} treats a record
290
+ * with no value as unpublishable and keeps the domain out of `records-issued`.
291
+ */ export function sendingDnsRecords(record) {
292
+ var _ref, _ref1;
293
+ const domain = normalizeSendingDomain(record == null ? void 0 : record.domain);
294
+ const selector = String((_ref = record == null ? void 0 : record.dkimSelector) != null ? _ref : '').trim() || 'aglyn';
295
+ const sendHost = `${SENDING_SUBDOMAIN}.${domain}`;
296
+ const returnPath = (record == null ? void 0 : record.returnPathHost) || sendingReturnPathHost();
297
+ const dkimKey = String((_ref1 = record == null ? void 0 : record.dkimPublicKey) != null ? _ref1 : '').trim();
298
+ return [
299
+ {
300
+ type: 'TXT',
301
+ name: sendHost,
302
+ value: `v=spf1 include:${sendingSpfInclude()} ~all`,
303
+ purpose: 'spf',
304
+ required: true,
305
+ note: `Authorizes our infrastructure for mail whose envelope sender is ` + `${sendHost}. It sits on this subdomain so your existing root SPF is ` + `untouched.`
306
+ },
307
+ {
308
+ type: 'TXT',
309
+ name: `${selector}._domainkey.${domain}`,
310
+ value: dkimKey ? `p=${dkimKey}` : '',
311
+ purpose: 'dkim',
312
+ required: true,
313
+ note: 'The signing key. This is the record DMARC alignment depends on — ' + 'without it, mail from your domain fails whatever policy you publish.'
314
+ },
315
+ {
316
+ type: 'MX',
317
+ name: sendHost,
318
+ value: returnPath,
319
+ priority: 10,
320
+ purpose: 'return-path',
321
+ required: true,
322
+ note: 'Routes bounces and spam complaints back to us so they can be ' + 'suppressed. It is on the send subdomain and does not affect mail ' + 'delivered to your normal inboxes.'
323
+ },
324
+ ...trackingRecords(domain, record == null ? void 0 : record.trackingTarget)
325
+ ];
326
+ }
327
+ /**
328
+ * The two records that make CLICK TRACKING work, and neither is required.
329
+ *
330
+ * An ESP measures a click by rewriting every `<a href>` in the HTML part to
331
+ * point at a tracking host on the sending domain, then redirecting. No host,
332
+ * no rewriting, and the click rate is structurally 0% — which reads on a
333
+ * dashboard as low engagement rather than as a wiring fault, so it stays
334
+ * unnoticed. That is the same shape as the `"html": ""` defect this library
335
+ * already carries a fix for, one layer up.
336
+ *
337
+ * ## Why `required: false`
338
+ *
339
+ * Verification is about AUTHENTICATION — can this domain prove it sent the
340
+ * mail. Tracking is measurement. A customer who publishes SPF, DKIM and the
341
+ * return path can send perfectly well and must not be held at `requested`
342
+ * over a record that only decides whether we can count clicks; and making
343
+ * these required would un-verify every domain already verified without them.
344
+ * {@link sendingDomainPublishableRecords} is what puts them in a zone we own.
345
+ *
346
+ * ## ⚠️ The CAA record is the one that can hurt
347
+ *
348
+ * CAA restricts which authorities may issue a certificate for a name, and the
349
+ * lookup stops at the FIRST name in the tree that publishes any. So:
350
+ *
351
+ * - A domain publishing NO CAA today needs nothing. Any authority may
352
+ * already issue, and adding this record would be the change that starts
353
+ * restricting them.
354
+ * - A domain that DOES publish CAA has to ADD this one alongside what it has,
355
+ * never in place of it. Replacing the set is how a zone stops its own web
356
+ * certificates renewing.
357
+ *
358
+ * It is emitted on the sending domain rather than on the registrable root so
359
+ * that a customer following it verbatim scopes the permission to the name we
360
+ * put a tracking host under, instead of widening it across everything they
361
+ * own. Deriving the registrable root would need a public-suffix list this
362
+ * library does not have, and a guess at it prints a record for the wrong name.
363
+ *
364
+ * @returns nothing at all until the provider has issued a tracking target.
365
+ * A CNAME with no value is a record that says nothing while looking
366
+ * published, the same rule the DKIM row follows.
367
+ */ function trackingRecords(domain, trackingTarget) {
368
+ const target = String(trackingTarget != null ? trackingTarget : '').trim();
369
+ if (!domain || !target) return [];
370
+ const authority = sendingTrackingCertAuthority();
371
+ return [
372
+ {
373
+ type: 'CNAME',
374
+ name: `${SENDING_TRACKING_SUBDOMAIN}.${domain}`,
375
+ value: target,
376
+ purpose: 'tracking',
377
+ required: false,
378
+ note: 'Counts link clicks. Every link in an email is rewritten to point ' + 'here and redirected, so without it clicks cannot be measured at ' + 'all — mail still sends, and the click rate reads a permanent 0%.'
379
+ },
380
+ {
381
+ type: 'CAA',
382
+ name: domain,
383
+ value: `0 issue "${authority}"`,
384
+ purpose: 'tracking-caa',
385
+ required: false,
386
+ note: `Only needed if this domain already publishes CAA records. Add this ` + `one ALONGSIDE them — replacing the set would stop your other ` + `certificates renewing. If you publish no CAA at all, skip it: any ` + `authority may already issue, and ${authority} is the one that ` + `issues the certificate for the tracking host above.`
387
+ }
388
+ ];
389
+ }
390
+ /** The subset verification waits on, and only those with a value to publish. */ export function sendingDomainRequiredRecords(record) {
391
+ return sendingDnsRecords(record).filter((entry)=>entry.required && Boolean(entry.value));
392
+ }
393
+ /**
394
+ * Everything worth WRITING into a zone this platform owns.
395
+ *
396
+ * Deliberately wider than {@link sendingDomainRequiredRecords}, and the gap
397
+ * between them is the whole point. `required` answers "does verification wait
398
+ * on this", which tracking must not; this answers "should we publish it",
399
+ * which tracking must be — a platform subdomain's zone is ours, so there is
400
+ * nobody to ask and no reason to leave the click rate at zero.
401
+ *
402
+ * Reusing the required set here is what left the platform's own subdomains
403
+ * untracked: one flag was being asked two different questions, and the
404
+ * conservative answer to the first silently decided the second.
405
+ *
406
+ * DMARC is still excluded — it carries `required: false` too, but a policy is
407
+ * the domain owner's to choose and this platform publishes one deliberately
408
+ * elsewhere, not as a side effect of provisioning.
409
+ */ export function sendingDomainPublishableRecords(record) {
410
+ return sendingDnsRecords(record).filter((entry)=>Boolean(entry.value) && (entry.required || entry.purpose === 'tracking'));
411
+ }
412
+ /**
413
+ * A stable key for one record, used to report which are still missing without
414
+ * putting a full DKIM public key into a status document or a log line.
415
+ */ export function sendingRecordKey(entry) {
416
+ return `${entry.type}:${entry.name}`;
417
+ }
418
+ /** `TXT send.acme.com → v=spf1 …`, matching `formatDnsInstruction`. */ export function formatSendingRecord(entry) {
419
+ const target = entry.priority ? `${entry.priority} ${entry.value}` : entry.value;
420
+ return `${String(entry.type).padEnd(5)} ${entry.name} → ${target}`;
421
+ }
422
+ /**
423
+ * Read a `_dmarc` TXT answer.
424
+ *
425
+ * Only records that actually begin `v=DMARC1` count. A zone often carries
426
+ * unrelated TXT records at any name, and treating the first string found as a
427
+ * policy would report a verification token as `p=none`.
428
+ */ export function assessDmarc(records) {
429
+ var _ref;
430
+ const found = (records != null ? records : []).map((entry)=>String(entry != null ? entry : '').trim()).find((entry)=>/^v\s*=\s*DMARC1\b/i.test(entry));
431
+ if (!found) {
432
+ return {
433
+ policy: 'absent',
434
+ record: null,
435
+ consequence: 'Your domain publishes no DMARC policy. Mail we cannot authenticate ' + 'will usually still be delivered, and anyone may send mail claiming ' + 'to be from your domain.'
436
+ };
437
+ }
438
+ const policy = /(^|;)\s*p\s*=\s*(reject|quarantine|none)\b/i.exec(found);
439
+ const value = ((_ref = policy == null ? void 0 : policy[2]) != null ? _ref : 'none').toLowerCase();
440
+ if (value === 'reject') {
441
+ return {
442
+ policy: 'reject',
443
+ record: found,
444
+ consequence: 'Your domain publishes p=reject. Until the DKIM record below is ' + 'live, every message we send from this domain is refused outright — ' + 'not filed as spam, refused.'
445
+ };
446
+ }
447
+ if (value === 'quarantine') {
448
+ return {
449
+ policy: 'quarantine',
450
+ record: found,
451
+ consequence: 'Your domain publishes p=quarantine. Until the DKIM record below is ' + 'live, our mail from this domain lands in spam, which reads as low ' + 'engagement rather than as a configuration problem.'
452
+ };
453
+ }
454
+ return {
455
+ policy: 'none',
456
+ record: found,
457
+ consequence: 'Your domain publishes p=none, which monitors but enforces nothing. ' + 'Mail we cannot authenticate is still delivered.'
458
+ };
459
+ }
460
+ /**
461
+ * The DMARC record we suggest to a domain that has none, offered and never
462
+ * required.
463
+ *
464
+ * `p=none` deliberately: it starts reporting without changing the delivery of
465
+ * any mail the customer already sends from other systems. Recommending
466
+ * `p=reject` to a domain whose other senders are unknown to us would break
467
+ * their invoicing or their helpdesk, and we would not find out.
468
+ */ export function dmarcRecommendation(domain) {
469
+ return {
470
+ type: 'TXT',
471
+ name: `_dmarc.${normalizeSendingDomain(domain)}`,
472
+ value: 'v=DMARC1; p=none; rua=mailto:dmarc@' + normalizeSendingDomain(domain),
473
+ purpose: 'dmarc',
474
+ required: false,
475
+ note: 'Recommended, not required, and yours to set. This starts DMARC in ' + 'report-only mode so you can see who sends as your domain before you ' + 'enforce anything. Point rua at a mailbox you read.'
476
+ };
477
+ }
478
+ /**
479
+ * Compare the records we asked for against the records that are live.
480
+ *
481
+ * Pure, and separated from the lookups for the reason `sso-drift-logic.ts` is
482
+ * separated from `sso-provisioning.ts`: the decision a customer's verification
483
+ * rests on should be reachable from a test without standing up DNS, so the
484
+ * route's spec can fake the I/O and run the REAL comparison.
485
+ *
486
+ * `inconclusive` is the load-bearing arm. A resolver outage must not be read
487
+ * as every customer deleting their records at the same instant, so it produces
488
+ * neither `verified` nor `failed` and the caller leaves the stored status
489
+ * alone. This is the same three-state discipline the SSO drift sweep uses, for
490
+ * the same reason.
491
+ *
492
+ * The SPF comparison is a `startsWith` on `v=spf1` plus a search for the
493
+ * include, not an exact match: a zone may legitimately carry a longer policy
494
+ * with extra mechanisms, and demanding our exact string would fail a
495
+ * configuration that works. The DKIM comparison IS exact on the key, because
496
+ * a key that is nearly right is a key that does not sign.
497
+ */ export function assessSendingRecords(record, observation) {
498
+ const required = sendingDomainRequiredRecords(record);
499
+ /*
500
+ * A domain with no issued DKIM key can never verify, whatever else is live.
501
+ *
502
+ * Checked on the DKIM record specifically rather than on the requirement set
503
+ * being empty: SPF and the return path both have values before a key is
504
+ * issued, so a domain with no signing key at all would otherwise satisfy
505
+ * every requirement in the set and reach the sending state. DKIM is the
506
+ * record that must align for DMARC — a domain that cannot sign is exactly
507
+ * the domain this feature must not let send.
508
+ */ const hasDkim = required.some((entry)=>entry.purpose === 'dkim');
509
+ if (!required.length || !hasDkim) {
510
+ return {
511
+ status: 'failed',
512
+ missing: [
513
+ 'dkim-key-not-issued'
514
+ ]
515
+ };
516
+ }
517
+ if (!(observation == null ? void 0 : observation.conclusive)) return {
518
+ status: 'inconclusive',
519
+ missing: []
520
+ };
521
+ const include = `include:${sendingSpfInclude()}`;
522
+ const missing = [];
523
+ for (const entry of required){
524
+ const key = sendingRecordKey(entry);
525
+ if (entry.purpose === 'spf') {
526
+ var _observation_spfTxt;
527
+ const found = ((_observation_spfTxt = observation.spfTxt) != null ? _observation_spfTxt : []).some((txt)=>/^v\s*=\s*spf1\b/i.test(String(txt != null ? txt : '').trim()) && String(txt).toLowerCase().includes(include));
528
+ if (!found) missing.push(key);
529
+ } else if (entry.purpose === 'dkim') {
530
+ var _observation_dkimTxt;
531
+ const expected = entry.value.replace(/^p=/, '');
532
+ const found = ((_observation_dkimTxt = observation.dkimTxt) != null ? _observation_dkimTxt : []).some((txt)=>String(txt != null ? txt : '').replace(/\s+/g, '').includes(expected.replace(/\s+/g, '')));
533
+ if (!found) missing.push(key);
534
+ } else if (entry.purpose === 'return-path') {
535
+ var _observation_mx;
536
+ const found = ((_observation_mx = observation.mx) != null ? _observation_mx : []).some((mx)=>{
537
+ var _ref;
538
+ return String((_ref = mx == null ? void 0 : mx.exchange) != null ? _ref : '').toLowerCase() === entry.value.toLowerCase();
539
+ });
540
+ if (!found) missing.push(key);
541
+ }
542
+ }
543
+ return missing.length ? {
544
+ status: 'failed',
545
+ missing
546
+ } : {
547
+ status: 'verified',
548
+ missing: []
549
+ };
550
+ }
551
+ /**
552
+ * What a merchant is told when bulk mail with no opt-out meets the pool.
553
+ *
554
+ * It names a DEFECT rather than a policy, because that is what it is: every
555
+ * marketing path in the product attaches an unsubscribe link, so a message
556
+ * arriving here without one has lost it somewhere a merchant cannot see. The
557
+ * text therefore points at support and at the workaround that is genuinely
558
+ * theirs to take, instead of asking them to fix something they did not break.
559
+ */ const POOLED_MARKETING_NO_UNSUBSCRIBE_MESSAGE = 'This marketing message has no unsubscribe link, so it was not sent on the ' + 'shared Aglyn address. That address is shared with other sites, and bulk ' + 'mail nobody can stop earns complaints that are charged against all of ' + 'them. Every campaign and automated email the product sends carries an ' + 'unsubscribe link, so this is a fault worth reporting. A sending domain of ' + 'this site’s own — added or requested at Emails → Sending — is not subject ' + 'to this check.';
560
+ /**
561
+ * Choose the identity a message leaves on, or refuse.
562
+ *
563
+ * The whole rule, and the reason this function exists rather than an inline
564
+ * `?:` at the send site:
565
+ *
566
+ * 1. Selection, `verified` → that identity.
567
+ * 2. Selection of a PLATFORM-ISSUED subdomain, not verified → treated as no
568
+ * selection, and resolved by arms 4-5.
569
+ * 3. Selection of a CUSTOMER'S OWN domain, not verified → **REFUSED**.
570
+ * 4. No selection, `tenant` audience, `marketing` → **REFUSED**.
571
+ * 5. No selection, `tenant` audience, `transactional` → the shared identity,
572
+ * if this deployment has one; **REFUSED** if it does not.
573
+ * 6. No selection, `platform` audience → the platform identity, named as such.
574
+ *
575
+ * There is no arm that reaches ANY other address from an unverified selection
576
+ * a CUSTOMER made. Somebody who has told us to send as their domain has made a
577
+ * statement about what their recipients will see; quietly sending as somebody
578
+ * else instead is not a degraded version of honoring it. That is arm 3, and
579
+ * neither the shared identity nor the platform one is consulted inside it.
580
+ *
581
+ * Arm 2 is the same sentence read the other way. A platform subdomain is a
582
+ * name WE chose, provisioned and pointed the site at — the merchant asked for
583
+ * a domain, not for that name and not for a DNS state — so there is no
584
+ * statement of theirs to contradict, and everything that can leave one
585
+ * unverified is ours: the provider's domain allowance, a zone write, a sweep
586
+ * that has not run. Refusing there would make the dedicated domain a single
587
+ * point of failure for receipts on exactly the tiers that pay for it.
588
+ *
589
+ * Arms 4 and 5 are the site that has chosen nothing. It is not the same case
590
+ * as arm 3 and must not get the same answer: there is no instruction to
591
+ * contradict, and a site that cannot send a receipt is not a site. So
592
+ * transactional mail goes, on the pooled identity, which is what the console
593
+ * has always disclosed. What does NOT go is marketing, because the pool is
594
+ * only usable while nobody is spending it on a list — and arm 2 lands here
595
+ * too, so a campaign from a site whose subdomain is unfinished still refuses.
596
+ *
597
+ * Arm 6 is the platform's own mail and is unreachable from a tenant audience,
598
+ * which is what keeps a merchant's list quality off `aglyn.com`. The `tenant`
599
+ * checks sit ABOVE the `platformFrom` read rather than inside it, so the
600
+ * platform address is not preferred-but-overridable for a tenant — it is
601
+ * simply not an address this audience can reach.
602
+ *
603
+ * Deciding all of it here rather than at the call sites is what makes it a
604
+ * property: `resolveHostSendingIdentity` passes `tenant` for every host-scoped
605
+ * send, so no individual caller has to remember.
606
+ */ export function resolveSendingIdentity(input) {
607
+ var _ref, _ref1, _ref2;
608
+ const selection = (_ref = input == null ? void 0 : input.selection) != null ? _ref : null;
609
+ const platformFrom = String((_ref1 = input == null ? void 0 : input.platformFrom) != null ? _ref1 : '').trim() || null;
610
+ const audience = (input == null ? void 0 : input.audience) === 'tenant' ? 'tenant' : 'platform';
611
+ const sharedFrom = String((_ref2 = input == null ? void 0 : input.sharedFrom) != null ? _ref2 : '').trim().toLowerCase();
612
+ // Defaulted to the arm that can never be blocked. See `SendingIdentityPurpose`.
613
+ const purpose = (input == null ? void 0 : input.purpose) === 'marketing' ? 'marketing' : 'transactional';
614
+ if (selection) {
615
+ var _selection_missing;
616
+ const domain = normalizeSendingDomain(selection.domain);
617
+ const localPart = normalizeLocalPart(selection.localPart);
618
+ const missing = ((_selection_missing = selection.missing) != null ? _selection_missing : []).map(String).filter(Boolean);
619
+ if (selection.status === 'verified' && domain && localPart) {
620
+ const from = `${localPart}@${domain}`;
621
+ return {
622
+ from,
623
+ source: 'custom',
624
+ domain,
625
+ summary: `Sending as ${from} on your verified domain ${domain}.`,
626
+ refusal: null
627
+ };
628
+ }
629
+ /*
630
+ * AN UNFINISHED PLATFORM SUBDOMAIN IS NOT A SELECTION, AND FALLS THROUGH.
631
+ *
632
+ * The dedicated subdomain is an OPTIMIZATION and the pool is the
633
+ * GUARANTEE, which is the property that lets the dedicated tier be
634
+ * rationed at all. Everything that can stop one arriving is ours: the
635
+ * provider's account-wide domain allowance, a zone write that failed, a
636
+ * provisioning sweep that has not run yet, DNS that has not propagated.
637
+ * None of it is anything the merchant can act on, and all of it would
638
+ * otherwise refuse their receipts — leaving a PAYING workspace unable to
639
+ * send while a free one, which is never issued a subdomain and therefore
640
+ * never has a selection, sends on the pool perfectly well.
641
+ *
642
+ * So this drops to the unselected arm below, which is the same code path a
643
+ * site with no domain of its own takes. That arm still refuses MARKETING,
644
+ * and it must: the reason a campaign may not leave on the pool — one
645
+ * merchant's complaint rate charged against every other site's password
646
+ * resets — is unrelated to why this site has no domain yet.
647
+ *
648
+ * A CUSTOMER'S OWN domain is the opposite case and is deliberately not
649
+ * included. Arm 2 below still refuses it outright, because there the
650
+ * merchant published DNS and told us what their recipients would see.
651
+ */ if (selection.platformIssued && audience === 'tenant') {
652
+ return resolveSendingIdentity({
653
+ selection: null,
654
+ platformFrom,
655
+ sharedFrom,
656
+ purpose,
657
+ audience
658
+ });
659
+ }
660
+ // A selected domain that is verified but has no usable address is a
661
+ // storage fault, not a customer one, and it is still not a reason to send
662
+ // as somebody else.
663
+ const code = selection.status === 'failed' ? 'domain-failed' : 'domain-unverified';
664
+ return {
665
+ from: null,
666
+ source: null,
667
+ domain: domain || null,
668
+ summary: `Blocked: ${domain || 'the selected domain'} is not verified.`,
669
+ refusal: {
670
+ code,
671
+ domain: domain || null,
672
+ missing,
673
+ message: code === 'domain-failed' ? `We checked the DNS for ${domain} and the required records are ` + `not published yet, so this send was refused rather than sent ` + `from a different address. Publish the records shown on the ` + `sending domain card, then verify.` : `${domain || 'The selected sending domain'} has not been ` + `verified yet, so this send was refused rather than sent from a ` + `different address. Publish the records shown on the sending ` + `domain card, then verify.`
674
+ }
675
+ };
676
+ }
677
+ /*
678
+ * TENANT mail with nothing selected. The PLATFORM identity is not consulted
679
+ * at all — not preferred-but-overridable, not a last resort. It is simply
680
+ * not an address this audience can reach, which is why these checks sit
681
+ * above the `platformFrom` read rather than inside it.
682
+ */ if (audience === 'tenant') {
683
+ /*
684
+ * Re-validated here rather than trusted, for the same reason `localPart` is
685
+ * on the selection branch: this is the one function that decides what goes
686
+ * into a `From:` header, and a malformed address reaching the provider is a
687
+ * failed send whose cause names the wrong layer.
688
+ */ const sharedAt = sharedFrom.lastIndexOf('@');
689
+ const sharedLocal = sharedAt > 0 ? normalizeLocalPart(sharedFrom.slice(0, sharedAt)) : '';
690
+ const sharedDomain = sharedAt > 0 ? normalizeSendingDomain(sharedFrom.slice(sharedAt + 1)) : '';
691
+ const shared = sharedLocal && sharedDomain ? `${sharedLocal}@${sharedDomain}` : '';
692
+ if (shared) {
693
+ return {
694
+ from: shared,
695
+ source: 'shared',
696
+ domain: sharedDomain,
697
+ summary: purpose === 'marketing' ? `Sending as ${shared} on a shared Aglyn domain. Delivery ` + 'reputation there is pooled with the other sites using it, so ' + 'campaigns are graded against the stricter complaint and ' + 'bounce limits until this site sends from a domain of its own.' : `Sending as ${shared} on a shared Aglyn domain. Delivery ` + 'reputation there is pooled with the other sites using it.',
698
+ refusal: null
699
+ };
700
+ }
701
+ return {
702
+ from: null,
703
+ source: null,
704
+ domain: null,
705
+ summary: 'Blocked: this deployment has no shared sending identity.',
706
+ refusal: {
707
+ code: 'tenant-identity-unprovisioned',
708
+ domain: null,
709
+ missing: [],
710
+ message: 'This site has no sending domain of its own, and this deployment ' + 'has no shared sending identity configured for it to fall back to, ' + 'so the message was refused rather than sent from the platform’s ' + 'own address. Nothing on this site can fix it and nothing about ' + 'the site is wrong — the shared identity is an operator setting. ' + 'Contact support, and note that no mail leaves this site until it ' + 'is set.'
711
+ }
712
+ };
713
+ }
714
+ if (!platformFrom) {
715
+ return {
716
+ from: null,
717
+ source: null,
718
+ domain: null,
719
+ summary: 'Blocked: no sending identity is configured.',
720
+ refusal: {
721
+ code: 'platform-unconfigured',
722
+ domain: null,
723
+ missing: [],
724
+ message: 'This deployment has no sending identity. Set USAGE_EMAIL_FROM, or ' + 'verify a custom sending domain.'
725
+ }
726
+ };
727
+ }
728
+ const platformDomain = normalizeSendingDomain(platformFrom);
729
+ return {
730
+ from: platformFrom,
731
+ source: 'platform',
732
+ domain: platformDomain || null,
733
+ summary: `Sending as ${platformFrom} on the shared platform domain.`,
734
+ refusal: null
735
+ };
736
+ }
737
+ /**
738
+ * The refusal on a verdict, or null.
739
+ *
740
+ * A function rather than `verdict.refusal` at each call site for the reason
741
+ * `rateLimitedRetryAtMs` is one: with `strictNullChecks` off, consumers cannot
742
+ * narrow this union reliably across the library boundary, and every call site
743
+ * would re-derive the same defensive read.
744
+ */ export function sendingIdentityRefusal(verdict) {
745
+ var _ref;
746
+ return (_ref = verdict == null ? void 0 : verdict.refusal) != null ? _ref : null;
747
+ }
748
+ /**
749
+ * The one thing the pool will not carry: MARKETING mail with no way out.
750
+ *
751
+ * ## What this is not
752
+ *
753
+ * It is not a marketing gate. A site with no domain of its own may run
754
+ * campaigns on the pool — that is the point of the pool, and refusing it made
755
+ * the whole marketing feature unreachable for every site that had not bought
756
+ * a domain. What bounds one site's ability to spend the pool's reputation is
757
+ * measurement rather than prohibition: the per-workspace reputation breaker
758
+ * grades seven days of complaints and bounces and stops the workspace that is
759
+ * earning them, the new-sender ramp keeps a first import off the pool, and the
760
+ * suppression, topic, cadence and frequency checks refuse the individual
761
+ * message. Those act on the site responsible, which a blanket refusal never
762
+ * did.
763
+ *
764
+ * ## Why THIS one still refuses
765
+ *
766
+ * Every one of those controls is downstream of the recipient being able to
767
+ * stop the mail. Bulk mail with no unsubscribe mechanism does not accumulate
768
+ * complaints slowly enough for a seven-day window to catch it, and on a shared
769
+ * member the complaints are charged to sites that did nothing. So it is
770
+ * refused outright, at the send, where the message is in hand.
771
+ *
772
+ * It is a DEFECT check rather than a policy: every marketing path in the
773
+ * product attaches a link, so this fires only when one has gone missing —
774
+ * an unset `EMAIL_UNSUBSCRIBE_SECRET`, a host with no public origin, or a new
775
+ * caller that forgot. On the pool that misconfiguration is charged to other
776
+ * people, which is what makes it a refusal here and a logged warning on a
777
+ * domain the merchant owns.
778
+ *
779
+ * Takes the verdict rather than the source string so a caller cannot pass the
780
+ * wrong field, and returns the same refusal shape every other arm produces so
781
+ * `sendEmail` has one thing to print.
782
+ *
783
+ * @param hasUnsubscribe whether the message carries an opt-out — a minted URL
784
+ * or a caller-supplied `List-Unsubscribe` header. Passed in because
785
+ * this module never sees the message.
786
+ */ export function pooledMarketingRefusal(verdict, hasUnsubscribe) {
787
+ var _ref;
788
+ if ((verdict == null ? void 0 : verdict.source) !== 'shared') return null;
789
+ if (hasUnsubscribe) return null;
790
+ return {
791
+ code: 'shared-identity-no-unsubscribe',
792
+ domain: (_ref = verdict == null ? void 0 : verdict.domain) != null ? _ref : null,
793
+ missing: [],
794
+ message: POOLED_MARKETING_NO_UNSUBSCRIBE_MESSAGE
795
+ };
796
+ }
797
+
798
+ //# sourceMappingURL=sending-domain.js.map