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