@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,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;
|