@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,744 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * CUSTOM SENDING DOMAINS — policy half.
19
+ *
20
+ * Everything the platform sends leaves from one verified identity
21
+ * (`USAGE_EMAIL_FROM`) on one domain. A tenant's campaign and another tenant's
22
+ * password reset therefore share a DKIM `d=` and a reputation: one merchant's
23
+ * complaint rate is charged against every merchant's authentication mail. A
24
+ * custom sending domain is the control that moves a tenant's reputation onto a
25
+ * domain that tenant owns.
26
+ *
27
+ * ## Pure, and dependency-free, for the same reason `send-rate.ts` is
28
+ *
29
+ * The decision — which identity does this send leave on, and may it leave at
30
+ * all — is a pure function of a stored record and the environment. It is
31
+ * unit-testable with no Firestore harness, no DNS resolver and no route. The
32
+ * durable half (the record, the DNS probe, the verification sweep) lives in
33
+ * `@aglyn/tenant-data-admin`, which is the only layer that may hold the Admin
34
+ * SDK. `@aglyn/shared-util-email` is `scope:shared` and may not import it.
35
+ *
36
+ * ## THE BOUNDARY THAT MATTERS MOST
37
+ *
38
+ * **A CUSTOMER'S OWN sending domain that is not verified refuses the send. It
39
+ * never falls back to any other identity.** {@link resolveSendingIdentity} has
40
+ * no arm that reaches another address from such a domain, and
41
+ * {@link sendingIdentityRefusal} is what a caller must print.
42
+ *
43
+ * Silent fallback would be wrong three ways, and each is independently
44
+ * disqualifying: the customer believes their DNS is finished when it is not;
45
+ * the recipient sees a `From:` they did not expect from a brand they did;
46
+ * and the tenant's reputation risk lands back on the shared domain the custom
47
+ * domain existed to move it off.
48
+ *
49
+ * **WHOSE DOMAIN IT IS carries the whole rule**, and it is the distinction
50
+ * `SendingDomainSelection.platformIssued` records. There are three cases and
51
+ * only the first refuses:
52
+ *
53
+ * - A domain the CUSTOMER owns, unverified. An instruction of theirs that we
54
+ * would be ignoring. Refused.
55
+ * - A platform subdomain we issued, unverified. The merchant asked for a
56
+ * domain; we chose the name, wrote the records and pointed the site at
57
+ * it, so there is nothing of theirs to contradict and everything that can
58
+ * leave it unfinished is ours to fix. Sends on the pool.
59
+ * - Nothing selected. Likewise no statement to contradict, and the site
60
+ * still has receipts to send. Sends on the pool, which is what the console
61
+ * has always told merchants happens.
62
+ *
63
+ * The last two are why the DEDICATED subdomain can be rationed safely: it is
64
+ * an optimization on top of a floor that always holds, so exhausting the
65
+ * provider's domain allowance degrades delivery reputation rather than
66
+ * stopping the mail.
67
+ *
68
+ * ## The shared identity carries marketing too, and is GRADED for it
69
+ *
70
+ * The shared identity pools reputation across every site using it, so one
71
+ * merchant's imported list is charged against every other site's password
72
+ * resets. The answer to that is measurement, not exclusion: refusing marketing
73
+ * outright made campaigns unreachable for every site that had not bought a
74
+ * domain, while doing nothing about the pooled site whose receipts were already
75
+ * bouncing.
76
+ *
77
+ * So a pooled campaign is graded against the STRICTER reputation thresholds —
78
+ * the watch levels in `sender-reputation.ts` rather than the trip levels — and
79
+ * the site earning the complaints is the one that stops. `sendEmail` refuses
80
+ * outright only the case a seven-day window cannot catch in time: bulk mail
81
+ * carrying no unsubscribe link, which is {@link pooledMarketingRefusal}.
82
+ *
83
+ * That is a REPUTATION rule and not a pricing one. Which plans may run a
84
+ * campaign at all is an entitlement decided elsewhere, and nothing here reads
85
+ * a plan.
86
+ *
87
+ * The failure mode this guards against is the house one. `USAGE_EMAIL_FROM`
88
+ * was empty in production for weeks; because mail is best-effort at every
89
+ * call site, every send returned `{sent: false, reason: 'unconfigured'}`,
90
+ * nothing threw, and no surface said anything was wrong. A refusal that is
91
+ * only a log line is that same defect wearing a new reason string — so the
92
+ * refusal carries the domain, the missing records, and a sentence a person can
93
+ * act on, and the campaign route answers it as a `409` rather than a no-op.
94
+ *
95
+ * ## Scope: this module issues records and decides. It does not verify.
96
+ *
97
+ * Reading live DNS belongs to the durable half, which reuses the pinned-
98
+ * resolver probe that already backs SSO domain verification — the one that
99
+ * distinguishes "the record is absent" from "nobody answered". That third
100
+ * state is why {@link SendingDomainStatus} has no arm meaning "checked and we
101
+ * are not sure": an unreachable resolver leaves the stored status alone.
102
+ */
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
+ */
112
+ export type SendingDomainStatus =
113
+ /**
114
+ * The customer asked for the domain. No records exist to publish yet,
115
+ * because issuing them needs a provider credential that may be absent.
116
+ */
117
+ 'requested'
118
+ /** Records are issued and shown. The customer has DNS work to do. */
119
+ | 'records-issued'
120
+ /** A lookup has seen every required record. This is the only sending state. */
121
+ | 'verified'
122
+ /**
123
+ * A lookup got a conclusive answer and the records were wrong or absent.
124
+ * Distinct from `records-issued` so a surface can say "we looked, and it is
125
+ * not there" rather than leaving a customer to wonder whether we ever
126
+ * checked. Never reached from an unreachable resolver.
127
+ */
128
+ | 'failed';
129
+ /** The lifecycle in order, for a surface that renders progress. */
130
+ export declare const SENDING_DOMAIN_STATUSES: readonly SendingDomainStatus[];
131
+ /**
132
+ * The stored record, one per domain per org.
133
+ *
134
+ * Per-ORG rather than per-host: DNS control is proved once for a name, and an
135
+ * agency running four sites on `client.com` should publish the DKIM record
136
+ * once, not four times. Which identity a given site *uses* is a separate,
137
+ * per-host choice ({@link SendingDomainSelection}) — that split is what lets
138
+ * one verification serve the agency case without making every site repeat the
139
+ * DNS chore.
140
+ */
141
+ export interface SendingDomainRecord {
142
+ /** Normalized, lowercased, no trailing dot. Also the document id. */
143
+ domain: string;
144
+ status: SendingDomainStatus;
145
+ /**
146
+ * The DKIM selector this domain signs with. Per-org rather than a shared
147
+ * `resend`, so two orgs verifying the same name cannot collide on one
148
+ * record — and so revoking one org's identity cannot invalidate another's.
149
+ *
150
+ * Requested per-org, but ISSUED by the provider: a provider that signs on a
151
+ * selector of its own choosing overwrites this when the key is recorded,
152
+ * because the record the customer publishes has to be the record the
153
+ * provider will actually sign under. See `sendingDomainProvider`.
154
+ */
155
+ dkimSelector: string;
156
+ /** The public key the provider issued, base64, without the `p=` prefix. */
157
+ dkimPublicKey?: string | null;
158
+ /** The provider's return-path host for bounce and complaint routing. */
159
+ returnPathHost?: string | null;
160
+ /**
161
+ * The provider's own id for the domain object it created.
162
+ *
163
+ * Stored so a re-request can recognize a domain this deployment already
164
+ * created rather than creating a second one, and so an operator can find
165
+ * the object in the provider's dashboard. Never a credential.
166
+ */
167
+ providerDomainId?: string | null;
168
+ /**
169
+ * The host the provider redirects tracked link clicks through, as a CNAME
170
+ * target (`links1.resend-dns.com` and the like).
171
+ *
172
+ * From the PROVIDER, like the DKIM key and unlike the SPF include: the
173
+ * target names infrastructure the provider chose and can move, and a value
174
+ * of ours would point a customer's zone at a host we do not operate. Absent
175
+ * until a domain is issued with tracking on, and absent forever for one
176
+ * issued before it was.
177
+ */
178
+ trackingTarget?: string | null;
179
+ createdAtMs?: number | null;
180
+ verifiedAtMs?: number | null;
181
+ lastCheckedAtMs?: number | null;
182
+ /**
183
+ * Why the last issuing attempt did not produce a key.
184
+ *
185
+ * A REASON on a record still at `requested`, never a half-written
186
+ * `records-issued`. A provider that answered `4xx` has issued nothing, and
187
+ * a domain whose status says records exist while its DKIM value is empty
188
+ * would print a blank record for the customer to publish — which reads as
189
+ * our bug and cannot ever verify.
190
+ */
191
+ lastIssueError?: string | null;
192
+ lastIssueAtMs?: number | null;
193
+ /** What the last conclusive lookup saw, for a surface that shows the gap. */
194
+ lastMissing?: string[] | null;
195
+ }
196
+ /**
197
+ * The one {@link SendingDomainRecord.lastIssueError} value that is not a
198
+ * provider failure.
199
+ *
200
+ * A dedicated platform subdomain draws on an allowance the provider grants the
201
+ * whole account, so a claim made while that allowance is spent is refused
202
+ * before any call is made. It is stored in the same field as a provider
203
+ * refusal because it is the same fact from the record's point of view — no key
204
+ * was issued, and the domain stays `requested` — but it is a DIFFERENT
205
+ * sentence to the person reading it: nothing failed, nobody's DNS is wrong,
206
+ * and pressing the retry does not move it until there is room.
207
+ *
208
+ * Named here rather than at either end because the two ends are in different
209
+ * projects: the console's provisioning sweep writes it and the email plugin's
210
+ * status model reads it, and a string spelled out twice is one a rename
211
+ * silently breaks in the surface rather than in the writer.
212
+ */
213
+ export declare const SENDING_DOMAIN_AT_CAPACITY = "at-capacity";
214
+ /**
215
+ * A provider's failure reduced to something safe to store, log and print.
216
+ *
217
+ * Provider error bodies are attacker-adjacent text we did not write, and the
218
+ * one thing that must never appear in a Firestore document, a log line or an
219
+ * admin surface is the credential that made the call. Vendor keys have a
220
+ * recognizable shape — a short prefix, an underscore, a long opaque body
221
+ * (`re_`, `sk_`, `rk_`, `whsec_`) — and an `Authorization` header echoed into
222
+ * an error message carries the whole thing.
223
+ *
224
+ * This is the LAST line rather than the only one: callers build their detail
225
+ * from a fixed vocabulary and never from response prose, so nothing should
226
+ * reach here that needs redacting. A guard that is only ever a no-op in
227
+ * practice is exactly the guard worth having on a secret.
228
+ */
229
+ export declare function safeProviderDetail(input: string | null | undefined): string;
230
+ /**
231
+ * Which sending identity one site uses, resolved for a send.
232
+ *
233
+ * The `localPart` is stored, never taken from a request: an address assembled
234
+ * from user input is a `From:` override, and the one invariant `applyFromName`
235
+ * was built to hold is that the address cannot move off a verified identity.
236
+ */
237
+ export interface SendingDomainSelection {
238
+ domain: string;
239
+ status: SendingDomainStatus;
240
+ /** Mailbox to send as, for example `hello`. */
241
+ localPart: string;
242
+ /** Required records not seen by the last conclusive lookup. */
243
+ missing?: string[] | null;
244
+ /**
245
+ * Whether this domain is one the PLATFORM issued the site — a subdomain of
246
+ * our own mail apex, whose records we write into our own zone — rather than
247
+ * a name the customer owns and published DNS for.
248
+ *
249
+ * It decides what an UNVERIFIED selection means, which is the one place the
250
+ * two kinds of domain must not be treated alike. A customer's own domain is
251
+ * an instruction about the NAME: they published DNS for it and told us what
252
+ * their recipients would see, so sending as anything else contradicts them.
253
+ * A platform subdomain carries no such instruction even though the merchant
254
+ * asked for one — they asked for a domain of their own, and we picked the
255
+ * name, wrote the records and pointed the site at it. So a subdomain that
256
+ * has not finished provisioning is our unfinished work rather than their
257
+ * unfinished DNS. See {@link resolveSendingIdentity}.
258
+ *
259
+ * Carried on the selection rather than derived here because the apex lives
260
+ * in `platform-sending-domain.ts`, which imports from this module; reading
261
+ * it here would be a cycle. The durable half sets it with
262
+ * `isPlatformSendingDomain`.
263
+ *
264
+ * Absent means "a domain the customer owns", which is the direction that
265
+ * refuses. A caller that forgets gets the strict answer.
266
+ */
267
+ platformIssued?: boolean;
268
+ }
269
+ /**
270
+ * `{ value, error }` rather than a discriminated union, matching
271
+ * `validateSsoDomain`: `strictNullChecks` is off repo-wide, so an
272
+ * `{ ok: true } | { ok: false }` union does not narrow across a library
273
+ * boundary and the caller ends up unable to reach either arm's fields. Both
274
+ * keys always present, exactly one of them null.
275
+ */
276
+ export interface SendingDomainCheck {
277
+ domain: string | null;
278
+ error: string | null;
279
+ }
280
+ export declare function normalizeSendingDomain(input: string): string;
281
+ export declare function validateSendingDomain(input: string): SendingDomainCheck;
282
+ /**
283
+ * The mailbox part of an address, validated so a stored `localPart` cannot
284
+ * smuggle a second address or a header into the `From:` line.
285
+ */
286
+ export declare function normalizeLocalPart(input: string): string;
287
+ /**
288
+ * The subdomain the envelope sender and its bounce routing live on.
289
+ *
290
+ * A subdomain rather than the root for two reasons the customer feels: their
291
+ * existing root SPF is untouched, so their Workspace or Microsoft mail keeps
292
+ * authenticating; and this SPF does not spend any of the root record's
293
+ * ten-lookup budget, which is a limit that fails closed and is easy to reach.
294
+ */
295
+ export declare const SENDING_SUBDOMAIN = "send";
296
+ /**
297
+ * `||` not `??`, matching the rest of the env reads in this repo: an empty
298
+ * string is a variable somebody set to nothing, not a configured value, and an
299
+ * empty SPF include would print an instruction that authorizes no one.
300
+ *
301
+ * Configurable because a self-host operator may front a different provider —
302
+ * the same reason `AGLYN_DOMAIN_PROVIDER` exists. The defaults describe the
303
+ * provider this deployment actually uses.
304
+ */
305
+ export declare function sendingSpfInclude(): string;
306
+ export declare function sendingReturnPathHost(): string;
307
+ /**
308
+ * The label the CLICK-TRACKING host hangs off the sending domain.
309
+ *
310
+ * An ESP measures clicks by rewriting every `<a href>` to point at this host
311
+ * and redirecting from it, so it needs a name of its own with a TLS
312
+ * certificate. `links` is what the platform's own domains already use, and
313
+ * keeping one label across every domain class means an operator reading a
314
+ * zone sees the same name everywhere.
315
+ */
316
+ export declare const SENDING_TRACKING_SUBDOMAIN = "links";
317
+ /**
318
+ * The certificate authority the tracking host's TLS certificate comes from.
319
+ *
320
+ * Only ever consulted to build a CAA record, and only matters for a domain
321
+ * that ALREADY publishes CAA — see the note on the record itself. Configurable
322
+ * for the same reason {@link sendingSpfInclude} is: a self-host operator
323
+ * fronting a different provider fronts a different CA.
324
+ */
325
+ export declare function sendingTrackingCertAuthority(): string;
326
+ /**
327
+ * How long a tracked domain is held before its provider object is released.
328
+ *
329
+ * Every link in every message a tracked domain has already sent points at
330
+ * that domain's tracking host, and the provider deletes the host with the
331
+ * domain — a tracking subdomain cannot even be removed on its own, precisely
332
+ * because live mail points at it. So a same-day teardown does not merely stop
333
+ * future tracking, it retroactively breaks the links in mail already
334
+ * delivered, for recipients who have done nothing.
335
+ *
336
+ * 30 days is the window most campaign clicks arrive in, and it costs one
337
+ * provider domain slot per released site for that long — the trade is
338
+ * deliberate and the ceiling is documented in `provision-sending-domain.ts`.
339
+ * `0` disables the hold for an operator who would rather have the slot back.
340
+ *
341
+ * ⚠️ It does NOT gate an erasure. A person asking to be erased outranks a
342
+ * link, and the caller passes `immediate` for that path.
343
+ */
344
+ export declare function sendingTrackingRetentionDays(): number;
345
+ /** What a record is for, so a surface can group and explain rather than dump. */
346
+ export type SendingRecordPurpose = 'spf' | 'dkim' | 'return-path' | 'dmarc' | 'tracking' | 'tracking-caa';
347
+ /** One DNS record, as the customer's registrar labels it. */
348
+ export interface SendingDnsRecord {
349
+ type: 'TXT' | 'MX' | 'CNAME' | 'CAA';
350
+ /** Fully-qualified name the record goes on. */
351
+ name: string;
352
+ value: string;
353
+ /** `MX` only. */
354
+ priority?: number;
355
+ purpose: SendingRecordPurpose;
356
+ /**
357
+ * Whether verification waits on it. DMARC never blocks: it is the
358
+ * customer's policy about their own domain and we must not make publishing
359
+ * one a condition of using our product.
360
+ */
361
+ required: boolean;
362
+ /** Why this record exists, in a sentence aimed at whoever edits the zone. */
363
+ note: string;
364
+ }
365
+ /**
366
+ * Every record for a domain, in the order a customer should create them.
367
+ *
368
+ * ONE function, so the records a card prints and the records the verifier
369
+ * accepts cannot drift apart. `tenant-dns.ts` carries the same invariant for
370
+ * site domains and documents what it cost to learn: a wizard printing one
371
+ * target while the route checked another produced a check that could not fail,
372
+ * then a check that could not pass, over three separate issues.
373
+ *
374
+ * The DKIM value is absent until the provider issues a key. That is a real
375
+ * state, not an error — {@link sendingDomainRequiredRecords} treats a record
376
+ * with no value as unpublishable and keeps the domain out of `records-issued`.
377
+ */
378
+ export declare function sendingDnsRecords(record: Pick<SendingDomainRecord, 'domain' | 'dkimSelector' | 'dkimPublicKey' | 'returnPathHost' | 'trackingTarget'>): SendingDnsRecord[];
379
+ /** The subset verification waits on, and only those with a value to publish. */
380
+ export declare function sendingDomainRequiredRecords(record: Parameters<typeof sendingDnsRecords>[0]): SendingDnsRecord[];
381
+ /**
382
+ * Everything worth WRITING into a zone this platform owns.
383
+ *
384
+ * Deliberately wider than {@link sendingDomainRequiredRecords}, and the gap
385
+ * between them is the whole point. `required` answers "does verification wait
386
+ * on this", which tracking must not; this answers "should we publish it",
387
+ * which tracking must be — a platform subdomain's zone is ours, so there is
388
+ * nobody to ask and no reason to leave the click rate at zero.
389
+ *
390
+ * Reusing the required set here is what left the platform's own subdomains
391
+ * untracked: one flag was being asked two different questions, and the
392
+ * conservative answer to the first silently decided the second.
393
+ *
394
+ * DMARC is still excluded — it carries `required: false` too, but a policy is
395
+ * the domain owner's to choose and this platform publishes one deliberately
396
+ * elsewhere, not as a side effect of provisioning.
397
+ */
398
+ export declare function sendingDomainPublishableRecords(record: Parameters<typeof sendingDnsRecords>[0]): SendingDnsRecord[];
399
+ /**
400
+ * A stable key for one record, used to report which are still missing without
401
+ * putting a full DKIM public key into a status document or a log line.
402
+ */
403
+ export declare function sendingRecordKey(entry: SendingDnsRecord): string;
404
+ /** `TXT send.acme.com → v=spf1 …`, matching `formatDnsInstruction`. */
405
+ export declare function formatSendingRecord(entry: SendingDnsRecord): string;
406
+ /**
407
+ * A customer's DMARC policy is theirs. We read it because it changes what an
408
+ * unverified domain does to their mail, and we must never ask them to weaken
409
+ * it to accommodate us.
410
+ */
411
+ export type DmarcPolicy = 'reject' | 'quarantine' | 'none' | 'absent';
412
+ export interface DmarcAssessment {
413
+ policy: DmarcPolicy;
414
+ /** The record as published, or null when there is none. */
415
+ record: string | null;
416
+ /**
417
+ * What this policy does to mail we cannot authenticate. Phrased as a
418
+ * consequence rather than as the record's contents: a customer deciding
419
+ * whether to finish their DNS needs the outcome, not the syntax.
420
+ */
421
+ consequence: string;
422
+ }
423
+ /**
424
+ * Read a `_dmarc` TXT answer.
425
+ *
426
+ * Only records that actually begin `v=DMARC1` count. A zone often carries
427
+ * unrelated TXT records at any name, and treating the first string found as a
428
+ * policy would report a verification token as `p=none`.
429
+ */
430
+ export declare function assessDmarc(records: readonly string[]): DmarcAssessment;
431
+ /**
432
+ * The DMARC record we suggest to a domain that has none, offered and never
433
+ * required.
434
+ *
435
+ * `p=none` deliberately: it starts reporting without changing the delivery of
436
+ * any mail the customer already sends from other systems. Recommending
437
+ * `p=reject` to a domain whose other senders are unknown to us would break
438
+ * their invoicing or their helpdesk, and we would not find out.
439
+ */
440
+ export declare function dmarcRecommendation(domain: string): SendingDnsRecord;
441
+ /** What a set of lookups saw. Assembled by the durable half, compared here. */
442
+ export interface SendingDnsObservation {
443
+ /** TXT at `send.<domain>`. */
444
+ spfTxt: readonly string[];
445
+ /** TXT at `<selector>._domainkey.<domain>`. */
446
+ dkimTxt: readonly string[];
447
+ /** MX at `send.<domain>`. */
448
+ mx: readonly {
449
+ exchange: string;
450
+ priority: number;
451
+ }[];
452
+ /**
453
+ * False when ANY of the three lookups failed to get an answer.
454
+ *
455
+ * One unreachable lookup poisons the whole observation rather than being
456
+ * treated as an empty one: a partial read cannot distinguish a customer who
457
+ * published two of three records from a resolver that answered twice.
458
+ */
459
+ conclusive: boolean;
460
+ }
461
+ export type SendingVerificationStatus = 'verified'
462
+ /** We got answers, and at least one required record is not there. */
463
+ | 'failed'
464
+ /** Nobody answered. Not evidence in either direction. */
465
+ | 'inconclusive';
466
+ export interface SendingVerification {
467
+ status: SendingVerificationStatus;
468
+ /** Keys of the required records not seen. Empty when verified. */
469
+ missing: string[];
470
+ }
471
+ /**
472
+ * Compare the records we asked for against the records that are live.
473
+ *
474
+ * Pure, and separated from the lookups for the reason `sso-drift-logic.ts` is
475
+ * separated from `sso-provisioning.ts`: the decision a customer's verification
476
+ * rests on should be reachable from a test without standing up DNS, so the
477
+ * route's spec can fake the I/O and run the REAL comparison.
478
+ *
479
+ * `inconclusive` is the load-bearing arm. A resolver outage must not be read
480
+ * as every customer deleting their records at the same instant, so it produces
481
+ * neither `verified` nor `failed` and the caller leaves the stored status
482
+ * alone. This is the same three-state discipline the SSO drift sweep uses, for
483
+ * the same reason.
484
+ *
485
+ * The SPF comparison is a `startsWith` on `v=spf1` plus a search for the
486
+ * include, not an exact match: a zone may legitimately carry a longer policy
487
+ * with extra mechanisms, and demanding our exact string would fail a
488
+ * configuration that works. The DKIM comparison IS exact on the key, because
489
+ * a key that is nearly right is a key that does not sign.
490
+ */
491
+ export declare function assessSendingRecords(record: Parameters<typeof sendingDnsRecords>[0], observation: SendingDnsObservation): SendingVerification;
492
+ /**
493
+ * WHO IS SPEAKING in this message, which is the whole of what decides whether
494
+ * `USAGE_EMAIL_FROM` is reachable.
495
+ *
496
+ * `platform` — Aglyn talking to its own customers. Billing, account notices,
497
+ * console password resets, staff alerts. These belong on `aglyn.com` and are
498
+ * the only mail that does.
499
+ *
500
+ * `tenant` — a site talking to its visitors. Marketing AND transactional: a
501
+ * receipt, a booking reminder, a membership password reset are all the tenant
502
+ * speaking, and all of them carry the tenant's list quality. These leave on
503
+ * the site's own domain or they do not leave at all.
504
+ *
505
+ * The distinction is not promotional-versus-not. A merchant who imports a
506
+ * purchased list and mails it produces complaints; if that merchant's receipts
507
+ * ride the same domain as the platform's invoices, one merchant's import
508
+ * degrades every other merchant's account mail. Splitting by CONTENT would put
509
+ * the receipts on the safe domain and leave the reputation shared anyway,
510
+ * because the complaints follow the domain and not the subject line.
511
+ */
512
+ export type SendingIdentityAudience = 'platform' | 'tenant';
513
+ /**
514
+ * WHAT KIND OF MESSAGE this is, which decides whether the shared tenant
515
+ * identity is reachable.
516
+ *
517
+ * `transactional` — the recipient's own action produced it, or a fact about
518
+ * their order or account did. A receipt, a password reset, a booking
519
+ * confirmation, a supplier notice. **Never blocked**, on any tier, by anything
520
+ * in this module: a merchant who cannot send a receipt does not have a
521
+ * degraded product, they have no product.
522
+ *
523
+ * `marketing` — the merchant chose to send it. A campaign, an abandoned-cart
524
+ * sweep, a restock alert, a flow step. It carries the merchant's list quality,
525
+ * so it may only leave on a domain whose reputation is that merchant's own.
526
+ *
527
+ * Orthogonal to {@link SendingIdentityAudience}, which asks WHO is speaking.
528
+ * Both axes are needed and neither implies the other: platform mail is all
529
+ * transactional today, tenant mail is both, and the answer to "may this leave
530
+ * on the pooled address" needs the second axis specifically.
531
+ *
532
+ * Defaults to `transactional` wherever it is unset, and the polarity is
533
+ * deliberate — the same one `resolveSendPriority` chose for the same reason.
534
+ * Enumerating what is RESTRICTED means a caller who forgets sends a receipt
535
+ * that goes; enumerating what is permitted means a caller who forgets drops
536
+ * one. The forgotten-marketing case is caught structurally instead, in
537
+ * `sendEmail`, which derives the answer from fields a marketing send is
538
+ * already required to carry rather than from one more thing to remember.
539
+ */
540
+ export type SendingIdentityPurpose = 'transactional' | 'marketing';
541
+ export interface SendingIdentityInput {
542
+ /**
543
+ * The site's chosen sending domain, or null when it has not chosen one.
544
+ */
545
+ selection?: SendingDomainSelection | null;
546
+ /** `USAGE_EMAIL_FROM`, the platform's own verified identity. */
547
+ platformFrom?: string | null;
548
+ /**
549
+ * The shared tenant identity — `sharedTenantSendingFrom()`, an address on
550
+ * the mail apex.
551
+ *
552
+ * Passed in rather than read from the environment here, so this module stays
553
+ * pure and free of a dependency on `platform-sending-domain.ts` (which
554
+ * imports from this file; reading it here would be a cycle). The durable
555
+ * half supplies it, which is also the layer that knows whether this
556
+ * deployment has one.
557
+ */
558
+ sharedFrom?: string | null;
559
+ /** See {@link SendingIdentityPurpose}. Defaults to `transactional`. */
560
+ purpose?: SendingIdentityPurpose;
561
+ /**
562
+ * Whose mail this is. Defaults to `platform`, which is what every caller
563
+ * that resolves a PLATFORM identity means and never has to say.
564
+ *
565
+ * A default is safe here only because the tenant side never reaches this
566
+ * function directly: `resolveHostSendingIdentity` is the single door for
567
+ * host-scoped mail and it passes `tenant` unconditionally. A tenant caller
568
+ * cannot forget the flag, because it is not the tenant caller that sets it.
569
+ */
570
+ audience?: SendingIdentityAudience;
571
+ }
572
+ export type SendingIdentitySource =
573
+ /** A domain this site has verified — its own name, or one inside our apex. */
574
+ 'custom'
575
+ /**
576
+ * The pooled tenant identity on the mail apex. Every kind of mail from a
577
+ * site that has no domain of its own, campaigns included — graded more
578
+ * tightly, because the member is shared with other sites.
579
+ */
580
+ | 'shared'
581
+ /** `USAGE_EMAIL_FROM`. Aglyn's own mail, never a tenant's. */
582
+ | 'platform';
583
+ /**
584
+ * Why a send was refused. Every arm is a state a customer can be walked out
585
+ * of, which is the test for whether a refusal is worth having.
586
+ */
587
+ export type SendingIdentityRefusalCode =
588
+ /** A domain is selected and its DNS is not finished. */
589
+ 'domain-unverified'
590
+ /** A domain is selected and a lookup found its records wrong or absent. */
591
+ | 'domain-failed'
592
+ /** No custom domain, and the platform identity is not configured either. */
593
+ | 'platform-unconfigured'
594
+ /**
595
+ * TENANT mail from a site with no sending domain, on a deployment that has
596
+ * no shared identity configured either.
597
+ *
598
+ * An OPERATOR fault, not a customer one — the shared identity is derived
599
+ * from the mail apex and needs no tenant action — so it is the tenant-side
600
+ * twin of `platform-unconfigured` rather than something a merchant can fix.
601
+ * It stays a refusal and not a fallback for the same reason that one does:
602
+ * `aglyn.com` carries the platform's own billing and account mail, and a
603
+ * tenant's list quality must never be charged against it.
604
+ */
605
+ | 'tenant-identity-unprovisioned'
606
+ /**
607
+ * MARKETING mail on the pooled identity, carrying no way out.
608
+ *
609
+ * The pool is shared with every other site that has no domain of its own, so
610
+ * a complaint earned here is charged against their mail as well. Nothing
611
+ * earns complaints faster than bulk mail a recipient cannot stop, which is
612
+ * why this is the one thing the pool will not carry.
613
+ *
614
+ * Deliberately narrow. Marketing on the pool is allowed — a merchant with no
615
+ * domain of their own may run campaigns — and it is bounded by the
616
+ * per-workspace reputation breaker, the new-sender ramp and the suppression
617
+ * and cadence checks. This refuses only the message that has defeated the
618
+ * unsubscribe mechanism those controls assume.
619
+ *
620
+ * On a domain the merchant owns the same message sends: there the complaint
621
+ * lands on their own name, and how they spend it is theirs to decide.
622
+ */
623
+ | 'shared-identity-no-unsubscribe';
624
+ export interface SendingIdentityRefusal {
625
+ code: SendingIdentityRefusalCode;
626
+ /** The domain at fault, or null for `platform-unconfigured`. */
627
+ domain: string | null;
628
+ /** One sentence naming the cause and the next action. */
629
+ message: string;
630
+ /** Record keys the last conclusive lookup did not see. */
631
+ missing: string[];
632
+ }
633
+ export interface SendingIdentityVerdict {
634
+ /** Null whenever `refusal` is set. */
635
+ from: string | null;
636
+ /** Null whenever `refusal` is set. */
637
+ source: SendingIdentitySource | null;
638
+ domain: string | null;
639
+ /**
640
+ * What a surface prints, in every outcome including refusal. Requirement:
641
+ * the surface must always be able to say which identity is in use, so this
642
+ * is never empty and never needs the caller to compose it.
643
+ */
644
+ summary: string;
645
+ /** Null on success. Both keys always present — `strictNullChecks` is off. */
646
+ refusal: SendingIdentityRefusal | null;
647
+ }
648
+ /**
649
+ * Choose the identity a message leaves on, or refuse.
650
+ *
651
+ * The whole rule, and the reason this function exists rather than an inline
652
+ * `?:` at the send site:
653
+ *
654
+ * 1. Selection, `verified` → that identity.
655
+ * 2. Selection of a PLATFORM-ISSUED subdomain, not verified → treated as no
656
+ * selection, and resolved by arms 4-5.
657
+ * 3. Selection of a CUSTOMER'S OWN domain, not verified → **REFUSED**.
658
+ * 4. No selection, `tenant` audience, `marketing` → **REFUSED**.
659
+ * 5. No selection, `tenant` audience, `transactional` → the shared identity,
660
+ * if this deployment has one; **REFUSED** if it does not.
661
+ * 6. No selection, `platform` audience → the platform identity, named as such.
662
+ *
663
+ * There is no arm that reaches ANY other address from an unverified selection
664
+ * a CUSTOMER made. Somebody who has told us to send as their domain has made a
665
+ * statement about what their recipients will see; quietly sending as somebody
666
+ * else instead is not a degraded version of honoring it. That is arm 3, and
667
+ * neither the shared identity nor the platform one is consulted inside it.
668
+ *
669
+ * Arm 2 is the same sentence read the other way. A platform subdomain is a
670
+ * name WE chose, provisioned and pointed the site at — the merchant asked for
671
+ * a domain, not for that name and not for a DNS state — so there is no
672
+ * statement of theirs to contradict, and everything that can leave one
673
+ * unverified is ours: the provider's domain allowance, a zone write, a sweep
674
+ * that has not run. Refusing there would make the dedicated domain a single
675
+ * point of failure for receipts on exactly the tiers that pay for it.
676
+ *
677
+ * Arms 4 and 5 are the site that has chosen nothing. It is not the same case
678
+ * as arm 3 and must not get the same answer: there is no instruction to
679
+ * contradict, and a site that cannot send a receipt is not a site. So
680
+ * transactional mail goes, on the pooled identity, which is what the console
681
+ * has always disclosed. What does NOT go is marketing, because the pool is
682
+ * only usable while nobody is spending it on a list — and arm 2 lands here
683
+ * too, so a campaign from a site whose subdomain is unfinished still refuses.
684
+ *
685
+ * Arm 6 is the platform's own mail and is unreachable from a tenant audience,
686
+ * which is what keeps a merchant's list quality off `aglyn.com`. The `tenant`
687
+ * checks sit ABOVE the `platformFrom` read rather than inside it, so the
688
+ * platform address is not preferred-but-overridable for a tenant — it is
689
+ * simply not an address this audience can reach.
690
+ *
691
+ * Deciding all of it here rather than at the call sites is what makes it a
692
+ * property: `resolveHostSendingIdentity` passes `tenant` for every host-scoped
693
+ * send, so no individual caller has to remember.
694
+ */
695
+ export declare function resolveSendingIdentity(input: SendingIdentityInput): SendingIdentityVerdict;
696
+ /**
697
+ * The refusal on a verdict, or null.
698
+ *
699
+ * A function rather than `verdict.refusal` at each call site for the reason
700
+ * `rateLimitedRetryAtMs` is one: with `strictNullChecks` off, consumers cannot
701
+ * narrow this union reliably across the library boundary, and every call site
702
+ * would re-derive the same defensive read.
703
+ */
704
+ export declare function sendingIdentityRefusal(verdict: SendingIdentityVerdict | null | undefined): SendingIdentityRefusal | null;
705
+ /**
706
+ * The one thing the pool will not carry: MARKETING mail with no way out.
707
+ *
708
+ * ## What this is not
709
+ *
710
+ * It is not a marketing gate. A site with no domain of its own may run
711
+ * campaigns on the pool — that is the point of the pool, and refusing it made
712
+ * the whole marketing feature unreachable for every site that had not bought
713
+ * a domain. What bounds one site's ability to spend the pool's reputation is
714
+ * measurement rather than prohibition: the per-workspace reputation breaker
715
+ * grades seven days of complaints and bounces and stops the workspace that is
716
+ * earning them, the new-sender ramp keeps a first import off the pool, and the
717
+ * suppression, topic, cadence and frequency checks refuse the individual
718
+ * message. Those act on the site responsible, which a blanket refusal never
719
+ * did.
720
+ *
721
+ * ## Why THIS one still refuses
722
+ *
723
+ * Every one of those controls is downstream of the recipient being able to
724
+ * stop the mail. Bulk mail with no unsubscribe mechanism does not accumulate
725
+ * complaints slowly enough for a seven-day window to catch it, and on a shared
726
+ * member the complaints are charged to sites that did nothing. So it is
727
+ * refused outright, at the send, where the message is in hand.
728
+ *
729
+ * It is a DEFECT check rather than a policy: every marketing path in the
730
+ * product attaches a link, so this fires only when one has gone missing —
731
+ * an unset `EMAIL_UNSUBSCRIBE_SECRET`, a host with no public origin, or a new
732
+ * caller that forgot. On the pool that misconfiguration is charged to other
733
+ * people, which is what makes it a refusal here and a logged warning on a
734
+ * domain the merchant owns.
735
+ *
736
+ * Takes the verdict rather than the source string so a caller cannot pass the
737
+ * wrong field, and returns the same refusal shape every other arm produces so
738
+ * `sendEmail` has one thing to print.
739
+ *
740
+ * @param hasUnsubscribe whether the message carries an opt-out — a minted URL
741
+ * or a caller-supplied `List-Unsubscribe` header. Passed in because
742
+ * this module never sees the message.
743
+ */
744
+ export declare function pooledMarketingRefusal(verdict: SendingIdentityVerdict | null | undefined, hasUnsubscribe: boolean): SendingIdentityRefusal | null;