@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,586 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /**
|
|
18
|
+
* The Resend send endpoint. Every outbound application email in Aglyn goes
|
|
19
|
+
* through here — invites, receipts, usage summaries, campaigns, staff alerts.
|
|
20
|
+
*
|
|
21
|
+
* Auth email (verification, password reset) is Firebase's job and does NOT
|
|
22
|
+
* come through this module.
|
|
23
|
+
*/ import { emailSendRateWindowStartMs, getEmailSendGovernor, isRefusablePriority, resolveSendPriority } from "./send-rate.js";
|
|
24
|
+
import { renderTextEmailHtml } from "./text-email-html.js";
|
|
25
|
+
import { sendingIdentityRefusal, pooledMarketingRefusal } from "./sending-domain.js";
|
|
26
|
+
import { appendUnsubscribeHtml, appendUnsubscribeText, getMarketingSendGate, isMarketingMessage, unsubscribeHeaders } from "./marketing-send.js";
|
|
27
|
+
export const RESEND_SEND_ENDPOINT = 'https://api.resend.com/emails';
|
|
28
|
+
/**
|
|
29
|
+
* The `context` tag, attached to every send (AGL-2407).
|
|
30
|
+
*
|
|
31
|
+
* ## Why this is here and not at 37 call sites
|
|
32
|
+
*
|
|
33
|
+
* Until now `tags` were set by exactly one sender, `campaign-send.ts`, which
|
|
34
|
+
* stamps `hostId` and `campaignId` for the opens/clicks webhook. Everything
|
|
35
|
+
* else went out with NO tags at all, so a bounce on an invite, a password
|
|
36
|
+
* reset, a receipt or a usage summary reached the webhook carrying nothing to
|
|
37
|
+
* identify it, and was dropped.
|
|
38
|
+
*
|
|
39
|
+
* The obvious fix — thread an identifier through every call site — asks 37
|
|
40
|
+
* places to remember, which is the shape that produces the 38th that does
|
|
41
|
+
* not. But `context` is ALREADY threaded through 35 of the 37 for logging,
|
|
42
|
+
* and it is exactly the right value: it names the sender. So the tag is
|
|
43
|
+
* derived here, once, and no caller changes.
|
|
44
|
+
*
|
|
45
|
+
* Resend tag values are restricted to ASCII letters, digits, `_` and `-`;
|
|
46
|
+
* anything else is rejected and would fail the whole send. Every `context` in
|
|
47
|
+
* the tree is already a plain slug, but this is mail delivery — a value that
|
|
48
|
+
* makes the send fail is far worse than a value that is sanitised — so the
|
|
49
|
+
* label is normalised rather than trusted, and a context that sanitises to
|
|
50
|
+
* nothing yields no tag rather than an invalid one.
|
|
51
|
+
*/ export function contextTag(context) {
|
|
52
|
+
const value = String(context != null ? context : '').trim().replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
|
|
53
|
+
return value ? [
|
|
54
|
+
{
|
|
55
|
+
name: 'context',
|
|
56
|
+
value
|
|
57
|
+
}
|
|
58
|
+
] : [];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The retry instant when the platform send-rate governor deferred this
|
|
62
|
+
* message, or `null` for every other outcome (AGL-2409).
|
|
63
|
+
*
|
|
64
|
+
* A FUNCTION rather than `result.reason === 'rate-limited'` at each call site,
|
|
65
|
+
* because `strictNullChecks` is OFF repo-wide and TypeScript will not narrow a
|
|
66
|
+
* boolean-literal discriminant without it: `if (result.sent) … else
|
|
67
|
+
* result.reason` does not compile, in every consumer, for a reason that has
|
|
68
|
+
* nothing to do with this union. One helper is also one place to change if the
|
|
69
|
+
* shape of a deferral ever moves.
|
|
70
|
+
*/ export function rateLimitedRetryAtMs(result) {
|
|
71
|
+
const failure = result;
|
|
72
|
+
if (!failure || failure.reason !== 'rate-limited') return null;
|
|
73
|
+
const retryAtMs = Number(failure.retryAtMs);
|
|
74
|
+
return Number.isFinite(retryAtMs) ? retryAtMs : 0;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* How long to wait when the provider names no interval of its own.
|
|
78
|
+
*
|
|
79
|
+
* One second, because Resend's rate limit is counted per second — so a wait
|
|
80
|
+
* of a whole window is the shortest one that is certain to have cleared it.
|
|
81
|
+
*/ const PROVIDER_RETRY_FALLBACK_MS = 1000;
|
|
82
|
+
/**
|
|
83
|
+
* The longest wait a provider header may ask for.
|
|
84
|
+
*
|
|
85
|
+
* A `retry-after` is read off the network and reaches a scheduler, so it is
|
|
86
|
+
* clamped rather than trusted: a header of `86400` would park a campaign for
|
|
87
|
+
* a day on one response nobody saw. An hour is past every documented window
|
|
88
|
+
* and short enough that a wrong one costs a run rather than a day.
|
|
89
|
+
*/ const PROVIDER_RETRY_MAX_MS = 3600000;
|
|
90
|
+
/** One header as whole seconds, or null when it is absent or unreadable. */ function headerSeconds(headers, name) {
|
|
91
|
+
var _headers_get;
|
|
92
|
+
const raw = headers == null ? void 0 : (_headers_get = headers.get) == null ? void 0 : _headers_get.call(headers, name);
|
|
93
|
+
// `Number(null)` and `Number('')` are both 0, which would read as "retry
|
|
94
|
+
// immediately" for a header that is not there at all.
|
|
95
|
+
if (raw === null || raw === undefined || String(raw).trim() === '') return null;
|
|
96
|
+
const seconds = Number(raw);
|
|
97
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* When the provider says a refused request may be repeated.
|
|
101
|
+
*
|
|
102
|
+
* Read from the two headers Resend documents beside a 429, both in whole
|
|
103
|
+
* seconds: `retry-after` first because it is the direct answer to this
|
|
104
|
+
* question, then `ratelimit-reset`, which names when the window rolls. A
|
|
105
|
+
* response carrying neither falls back to one window.
|
|
106
|
+
*/ export function providerRetryAtMs(headers, nowMs = Date.now()) {
|
|
107
|
+
var _headerSeconds;
|
|
108
|
+
const seconds = (_headerSeconds = headerSeconds(headers, 'retry-after')) != null ? _headerSeconds : headerSeconds(headers, 'ratelimit-reset');
|
|
109
|
+
const waitMs = seconds === null ? PROVIDER_RETRY_FALLBACK_MS : Math.min(seconds * 1000, PROVIDER_RETRY_MAX_MS);
|
|
110
|
+
return nowMs + waitMs;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Why a send did not happen, or `null` when it did.
|
|
114
|
+
*
|
|
115
|
+
* The same accessor `rateLimitedRetryAtMs` is, generalized: `strictNullChecks`
|
|
116
|
+
* is OFF repo-wide, so TypeScript will not narrow the union on `result.sent`
|
|
117
|
+
* and reading `result.reason` at a call site does not compile.
|
|
118
|
+
*/ export function sendFailureReason(result) {
|
|
119
|
+
var _failure_reason;
|
|
120
|
+
const failure = result;
|
|
121
|
+
if (!failure || failure.sent) return null;
|
|
122
|
+
return (_failure_reason = failure.reason) != null ? _failure_reason : null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Whether this outcome is worth coming back for.
|
|
126
|
+
*
|
|
127
|
+
* TRUE only for the two refusals a later attempt can pass: the platform hour
|
|
128
|
+
* rolls, and so does the marketing frequency window. Everything else is
|
|
129
|
+
* either a delivery that happened or a failure a retry repeats — a
|
|
130
|
+
* suppression most of all, since retrying is the exact behavior a suppression
|
|
131
|
+
* exists to stop, and a sunset for the same reason at one remove: it clears
|
|
132
|
+
* when the recipient engages, which no amount of further mail from us brings
|
|
133
|
+
* about.
|
|
134
|
+
*
|
|
135
|
+
* A resumable sweep uses this to decide whether to leave its subject
|
|
136
|
+
* unstamped. Stamping on a deferrable refusal discards a message; NOT
|
|
137
|
+
* stamping on a terminal one re-reads the same doomed row on every beat until
|
|
138
|
+
* it crowds out the work that could succeed. Both are silent, so the
|
|
139
|
+
* distinction lives here instead of at each sweep.
|
|
140
|
+
*
|
|
141
|
+
* A FUNCTION rather than `result.reason === …` at each call site, because
|
|
142
|
+
* `strictNullChecks` is OFF repo-wide and TypeScript will not narrow a
|
|
143
|
+
* boolean-literal discriminant without it — the same reason
|
|
144
|
+
* {@link rateLimitedRetryAtMs} beside it is one.
|
|
145
|
+
*/ export function isDeferrableSendResult(result) {
|
|
146
|
+
const reason = sendFailureReason(result);
|
|
147
|
+
return reason === 'rate-limited' || reason === 'frequency-capped';
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Reads the email environment.
|
|
151
|
+
*
|
|
152
|
+
* Deliberately read per call rather than captured at module load: these run
|
|
153
|
+
* in serverless handlers where the module may be evaluated during a build,
|
|
154
|
+
* long before the runtime env exists.
|
|
155
|
+
*/ export function getEmailConfig() {
|
|
156
|
+
return {
|
|
157
|
+
apiKey: process.env.RESEND_API_KEY || undefined,
|
|
158
|
+
from: process.env.USAGE_EMAIL_FROM || undefined
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* True when both `RESEND_API_KEY` and `USAGE_EMAIL_FROM` are present.
|
|
163
|
+
*
|
|
164
|
+
* Callers that answer an HTTP request (rather than firing best-effort mail)
|
|
165
|
+
* use this to return a 501 with an actionable message instead of pretending
|
|
166
|
+
* to have sent something.
|
|
167
|
+
*/ export function isEmailConfigured() {
|
|
168
|
+
const { apiKey, from } = getEmailConfig();
|
|
169
|
+
return Boolean(apiKey && from);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Applies a white-label display name to a configured sender while keeping
|
|
173
|
+
* its verified address (White-Label Phase 1). Accepts either a bare address
|
|
174
|
+
* (`noreply@aglyn.com`) or an RFC-5322 `Name <addr>` header and returns
|
|
175
|
+
* `"<fromName>" <addr>`. A blank name, or a value with no extractable
|
|
176
|
+
* address, yields the original `from` untouched — the sender identity is
|
|
177
|
+
* never dropped on the floor.
|
|
178
|
+
*/ export function applyFromName(from, fromName) {
|
|
179
|
+
const name = (fromName != null ? fromName : '').trim();
|
|
180
|
+
if (!from || !name) return from;
|
|
181
|
+
const angle = from.match(/<([^>]+)>/);
|
|
182
|
+
const address = (angle ? angle[1] : from).trim();
|
|
183
|
+
if (!address.includes('@')) return from;
|
|
184
|
+
// Quote the display name so commas/specials stay inside one mailbox.
|
|
185
|
+
return `"${name.replace(/"/g, '')}" <${address}>`;
|
|
186
|
+
}
|
|
187
|
+
function normalizeRecipients(to) {
|
|
188
|
+
const list = Array.isArray(to) ? to : [
|
|
189
|
+
to
|
|
190
|
+
];
|
|
191
|
+
return list.map((address)=>String(address != null ? address : '').trim()).filter((address)=>address.includes('@'));
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The one place that POSTs to Resend's send endpoint, and the last thing
|
|
195
|
+
* standing between a payload and the network.
|
|
196
|
+
*
|
|
197
|
+
* A payload carrying no recipient cannot become a message. Resend answers it
|
|
198
|
+
* `422 missing_required_field`, which costs an API call and then shows up in
|
|
199
|
+
* the vendor dashboard as a red line indistinguishable from mail that
|
|
200
|
+
* genuinely failed to deliver — carrying no subject, no recipient and nothing
|
|
201
|
+
* naming the code that produced it. Diagnosing that means reading a log
|
|
202
|
+
* outside the deployment and guessing. So the refusal happens here, before
|
|
203
|
+
* the fetch, and names the caller's `context`.
|
|
204
|
+
*
|
|
205
|
+
* It throws rather than returning a `SendEmailResult`: this is a programming
|
|
206
|
+
* error, not a delivery outcome. `sendEmail` filters recipients well before
|
|
207
|
+
* it reaches this call, so nothing on the ordinary path can trip it. The
|
|
208
|
+
* guard exists because `RESEND_SEND_ENDPOINT` is exported and any module can
|
|
209
|
+
* therefore reach the send endpoint on its own, bypassing every check
|
|
210
|
+
* `sendEmail` owns.
|
|
211
|
+
*/ export async function postResendEmail(apiKey, payload, context) {
|
|
212
|
+
const raw = payload == null ? void 0 : payload.to;
|
|
213
|
+
const recipients = (Array.isArray(raw) ? raw : raw == null ? [] : [
|
|
214
|
+
raw
|
|
215
|
+
]).map((address)=>String(address != null ? address : '').trim()).filter(Boolean);
|
|
216
|
+
if (!recipients.length) {
|
|
217
|
+
throw new Error(`${context ? `${context} ` : ''}send refused before the network — a ` + 'Resend payload with no `to` field cannot become a message, and the ' + 'attempt would surface only as a 422 in the Resend dashboard');
|
|
218
|
+
}
|
|
219
|
+
return fetch(RESEND_SEND_ENDPOINT, {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
headers: {
|
|
222
|
+
Authorization: `Bearer ${apiKey}`,
|
|
223
|
+
'Content-Type': 'application/json'
|
|
224
|
+
},
|
|
225
|
+
body: JSON.stringify(payload)
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Sends one email through Resend.
|
|
230
|
+
*
|
|
231
|
+
* **Never throws and never rejects.** Outbound mail is best-effort across
|
|
232
|
+
* every caller in this codebase — a checkout must not fail because a receipt
|
|
233
|
+
* bounced — so every outcome comes back as a `SendEmailResult` instead. The
|
|
234
|
+
* one thing callers must not do is ignore the result: `sent` is what tells
|
|
235
|
+
* the user whether a message actually went out (AGL-708).
|
|
236
|
+
*
|
|
237
|
+
* When the env vars are missing this warns once per call and returns
|
|
238
|
+
* `{ sent: false, reason: 'unconfigured' }` rather than failing, so local and
|
|
239
|
+
* preview environments keep working without a Resend account.
|
|
240
|
+
*/ export async function sendEmail(options) {
|
|
241
|
+
var _ref, _ref1, _ref2, _options_text;
|
|
242
|
+
var _options_sendingIdentity, _options_marketing, _options_marketing1, _options_headers;
|
|
243
|
+
const { apiKey, from: configuredFrom } = getEmailConfig();
|
|
244
|
+
const label = options.context ? `${options.context} email` : 'email';
|
|
245
|
+
/*
|
|
246
|
+
* THE SENDING-IDENTITY REFUSAL.
|
|
247
|
+
*
|
|
248
|
+
* Enforced here AND at the campaign route, independently, for the reason
|
|
249
|
+
* the send-rate governor is enforced twice: the route's check is the one a
|
|
250
|
+
* person sees, and this one is what holds when a caller does not make it.
|
|
251
|
+
* A governor is injectable and a route is skippable, so neither may be the
|
|
252
|
+
* only thing standing between an unverified domain and a send.
|
|
253
|
+
*
|
|
254
|
+
* Placed above the `apiKey`/`from` gate so a refusal cannot be reported as
|
|
255
|
+
* `unconfigured` — the two have different owners and different fixes.
|
|
256
|
+
*/ const identityRefusal = sendingIdentityRefusal(options.sendingIdentity);
|
|
257
|
+
if (identityRefusal) {
|
|
258
|
+
console.warn(`${label} refused — ${identityRefusal.message}`);
|
|
259
|
+
return {
|
|
260
|
+
sent: false,
|
|
261
|
+
reason: 'unverified-domain',
|
|
262
|
+
detail: identityRefusal.message
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// A resolved identity outranks the configured sender: it is the server's
|
|
266
|
+
// answer to which verified address this message leaves on. Without one, the
|
|
267
|
+
// white-label display name is applied to the configured verified sender
|
|
268
|
+
// (White-Label Phase 1).
|
|
269
|
+
//
|
|
270
|
+
// Two sources, and `options` is neither of them. Nothing the caller passes
|
|
271
|
+
// reaches the address — only the display name in front of it.
|
|
272
|
+
const resolvedFrom = (_ref = (_options_sendingIdentity = options.sendingIdentity) == null ? void 0 : _options_sendingIdentity.from) != null ? _ref : null;
|
|
273
|
+
/*
|
|
274
|
+
* THE PLATFORM DOMAIN IS NOT A FALLBACK FOR TENANT MAIL.
|
|
275
|
+
*
|
|
276
|
+
* `configuredFrom` is `USAGE_EMAIL_FROM` — an address on `aglyn.com`, where
|
|
277
|
+
* Aglyn's own billing, account and console mail leaves from. A site's mail
|
|
278
|
+
* reaching it means that site's list quality is charged against the domain
|
|
279
|
+
* every other customer's password reset depends on.
|
|
280
|
+
*
|
|
281
|
+
* `resolveHostSendingIdentity` already refuses above, so a tenant caller
|
|
282
|
+
* that resolved an identity never arrives here with `resolvedFrom` null.
|
|
283
|
+
* This is the arm for a tenant caller that resolved NOTHING — the shape a
|
|
284
|
+
* new send site takes when its author does not know an identity is owed —
|
|
285
|
+
* and it is checked here rather than left to the call sites because ninety
|
|
286
|
+
* of them cannot each be relied on to remember.
|
|
287
|
+
*/ if (options.audience === 'tenant' && !resolvedFrom) {
|
|
288
|
+
console.warn(`${label} refused — a site's mail cannot leave on the shared platform ` + 'domain, and no sending identity was resolved for it');
|
|
289
|
+
return {
|
|
290
|
+
sent: false,
|
|
291
|
+
reason: 'unverified-domain',
|
|
292
|
+
detail: 'This message belongs to a site and no sending identity was ' + 'resolved for it, so it was refused rather than sent from the ' + 'shared Aglyn address.'
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
const from = applyFromName(resolvedFrom != null ? resolvedFrom : configuredFrom, options.fromName);
|
|
296
|
+
if (!apiKey || !from) {
|
|
297
|
+
console.warn(`${label} skipped — set RESEND_API_KEY and USAGE_EMAIL_FROM to ` + 'deliver mail');
|
|
298
|
+
return {
|
|
299
|
+
sent: false,
|
|
300
|
+
reason: 'unconfigured'
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const to = normalizeRecipients(options.to);
|
|
304
|
+
if (!to.length) {
|
|
305
|
+
console.warn(`${label} skipped — no valid recipient address`);
|
|
306
|
+
return {
|
|
307
|
+
sent: false,
|
|
308
|
+
reason: 'no-recipient'
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/*
|
|
312
|
+
* THE MARKETING GATE.
|
|
313
|
+
*
|
|
314
|
+
* Everything a marketing message owes, asked once, here — because the four
|
|
315
|
+
* merchant-triggered bulk paths that owed it discharged none of it, and
|
|
316
|
+
* asking four call sites to remember is the shape that produces the fifth
|
|
317
|
+
* that does not.
|
|
318
|
+
*
|
|
319
|
+
* Ahead of the send-rate governor deliberately. A refusal here is a message
|
|
320
|
+
* that must never leave, so spending platform hourly budget deciding that
|
|
321
|
+
* would be budget the rest of the hour's mail no longer has.
|
|
322
|
+
*
|
|
323
|
+
* Nothing installed is UNGATED. Same posture as the governor: the durable
|
|
324
|
+
* half lives in another library, and a deployment that never installs it
|
|
325
|
+
* must still send.
|
|
326
|
+
*/ /*
|
|
327
|
+
* TWO URLS, and which one goes where is the whole RFC 8058 story.
|
|
328
|
+
*
|
|
329
|
+
* `unsubscribeUrl` is what a PERSON clicks in the footer below, and the
|
|
330
|
+
* gate points it at the preference page so the stream this message belongs
|
|
331
|
+
* to is one of the things they can stop instead of all of it.
|
|
332
|
+
* `oneClickUrl` is what `List-Unsubscribe` names, and it must stay on the
|
|
333
|
+
* route whose POST writes immediately — a mailbox provider POSTs it with
|
|
334
|
+
* nobody present and reads the 200 as "done", which a page of checkboxes
|
|
335
|
+
* cannot honor.
|
|
336
|
+
*
|
|
337
|
+
* It falls back to `unsubscribeUrl` for a caller that minted one link and
|
|
338
|
+
* named no page, which is the only shape that was ever possible before the
|
|
339
|
+
* gate minted the pair.
|
|
340
|
+
*/ let unsubscribeUrl = (_ref1 = (_options_marketing = options.marketing) == null ? void 0 : _options_marketing.unsubscribeUrl) != null ? _ref1 : '';
|
|
341
|
+
let oneClickUrl = (_ref2 = (_options_marketing1 = options.marketing) == null ? void 0 : _options_marketing1.oneClickUrl) != null ? _ref2 : '';
|
|
342
|
+
if (options.marketing) {
|
|
343
|
+
if (to.length !== 1) {
|
|
344
|
+
// Not a delivery outcome — a caller error, and one that would put the
|
|
345
|
+
// first recipient's signed unsubscribe link in everybody else's copy.
|
|
346
|
+
console.error(`${label} refused — a marketing send addresses exactly one ` + `recipient, and this one names ${to.length}`);
|
|
347
|
+
return {
|
|
348
|
+
sent: false,
|
|
349
|
+
reason: 'no-recipient',
|
|
350
|
+
detail: 'A marketing send addresses exactly one recipient.'
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const gate = getMarketingSendGate();
|
|
354
|
+
if (gate) {
|
|
355
|
+
let verdict;
|
|
356
|
+
try {
|
|
357
|
+
verdict = await gate(_extends({
|
|
358
|
+
hostId: options.marketing.hostId,
|
|
359
|
+
siteBase: options.marketing.siteBase,
|
|
360
|
+
email: to[0],
|
|
361
|
+
context: options.context,
|
|
362
|
+
capped: options.marketing.capped !== false
|
|
363
|
+
}, options.marketing.topicId ? {
|
|
364
|
+
topicId: options.marketing.topicId
|
|
365
|
+
} : {}));
|
|
366
|
+
} catch (error) {
|
|
367
|
+
/*
|
|
368
|
+
* FAILS OPEN, and the asymmetry with `filterSendableForHost` is
|
|
369
|
+
* deliberate rather than an oversight. That helper fails CLOSED
|
|
370
|
+
* because a suppression list it could not read is not a list that
|
|
371
|
+
* said an address is safe to mail — and it keeps doing so, inside
|
|
372
|
+
* the gate. What is being caught here is the gate itself being
|
|
373
|
+
* unreachable or throwing, which is an outage on the control; an
|
|
374
|
+
* outage on a control that becomes an outage on the product is the
|
|
375
|
+
* worse of the two bugs, and it is the posture `sendEmail` takes
|
|
376
|
+
* everywhere else.
|
|
377
|
+
*/ console.error(`${label} marketing gate failed — allowing`, error);
|
|
378
|
+
verdict = null;
|
|
379
|
+
}
|
|
380
|
+
if (verdict && !verdict.allowed) {
|
|
381
|
+
var _verdict_detail;
|
|
382
|
+
/*
|
|
383
|
+
* A cadence refusal reports as `frequency-capped` rather than earning
|
|
384
|
+
* a value of its own in {@link SendEmailFailureReason}.
|
|
385
|
+
*
|
|
386
|
+
* That union is what {@link isDeferrableSendResult} switches on, and
|
|
387
|
+
* the two are deferrable for exactly the same reason: a later attempt
|
|
388
|
+
* passes because time went by. A third value would have to be added
|
|
389
|
+
* to that predicate as well, and a sweep built against the older
|
|
390
|
+
* vocabulary would silently treat the recipient's own request as
|
|
391
|
+
* terminal and stamp the subject — discarding a message the recipient
|
|
392
|
+
* asked to receive later rather than never. Which of the two it was
|
|
393
|
+
* is in `detail`, where a person reading a log needs it.
|
|
394
|
+
*/ const reason = verdict.refusal === 'frequency-capped' || verdict.refusal === 'cadence-limited' ? 'frequency-capped' : verdict.refusal === 'unengaged' ? 'unengaged' : 'suppressed';
|
|
395
|
+
console.warn(`${label} not sent — ${(_verdict_detail = verdict.detail) != null ? _verdict_detail : reason}`);
|
|
396
|
+
return {
|
|
397
|
+
sent: false,
|
|
398
|
+
reason,
|
|
399
|
+
detail: verdict.detail
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
unsubscribeUrl = unsubscribeUrl || (verdict == null ? void 0 : verdict.unsubscribeUrl) || '';
|
|
403
|
+
oneClickUrl = oneClickUrl || (verdict == null ? void 0 : verdict.oneClickUrl) || '';
|
|
404
|
+
}
|
|
405
|
+
if (!unsubscribeUrl) {
|
|
406
|
+
// A marketing message with no way out is the defect this gate exists to
|
|
407
|
+
// close, so it is said out loud rather than shipped quietly. Not a
|
|
408
|
+
// refusal: the cause is a missing `EMAIL_UNSUBSCRIBE_SECRET` or a host
|
|
409
|
+
// with no public origin — an operator's configuration, not the
|
|
410
|
+
// recipient's problem — and refusing here would turn it into silence.
|
|
411
|
+
console.warn(`${label} carries no unsubscribe link — set ` + 'EMAIL_UNSUBSCRIBE_SECRET and publish the site on a domain');
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/*
|
|
415
|
+
* BULK MAIL WITH NO WAY OUT DOES NOT LEAVE ON THE POOLED IDENTITY.
|
|
416
|
+
*
|
|
417
|
+
* The pool carries marketing for every site that has no domain of its own,
|
|
418
|
+
* and what keeps that survivable is that a recipient can always stop it: the
|
|
419
|
+
* complaint that would otherwise be charged to every other site on the member
|
|
420
|
+
* has a cheaper alternative one click away. A message that lost its
|
|
421
|
+
* unsubscribe link removes that alternative, so it is refused here rather
|
|
422
|
+
* than sent at other people's expense.
|
|
423
|
+
*
|
|
424
|
+
* Asked AFTER the gate, because only here is the answer known. The URL may
|
|
425
|
+
* arrive from the caller, from the gate that mints one per recipient, or —
|
|
426
|
+
* for a campaign, which composes its own one-click pair upstream — as a
|
|
427
|
+
* `List-Unsubscribe` header and no marketing context at all. Reading only one
|
|
428
|
+
* of the three would refuse the senders that are behaving.
|
|
429
|
+
*
|
|
430
|
+
* The classification is DERIVED, never declared. See `isMarketingMessage`.
|
|
431
|
+
*/ const pooledRefusal = isMarketingMessage(options) ? pooledMarketingRefusal(options.sendingIdentity, Boolean(unsubscribeUrl) || Boolean((_options_headers = options.headers) == null ? void 0 : _options_headers['List-Unsubscribe'])) : null;
|
|
432
|
+
if (pooledRefusal) {
|
|
433
|
+
console.warn(`${label} refused — ${pooledRefusal.message}`);
|
|
434
|
+
return {
|
|
435
|
+
sent: false,
|
|
436
|
+
reason: 'unverified-domain',
|
|
437
|
+
detail: pooledRefusal.message
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/*
|
|
441
|
+
* THE PLATFORM SEND-RATE GOVERNOR (AGL-2409).
|
|
442
|
+
*
|
|
443
|
+
* Asked on EVERY send, including transactional ones, because the ceiling is
|
|
444
|
+
* about total volume on one sending domain — a governor that only saw
|
|
445
|
+
* campaigns would report a quiet hour while ten thousand receipts went out.
|
|
446
|
+
* The governor counts what it grants.
|
|
447
|
+
*
|
|
448
|
+
* Two properties this block must have, in order:
|
|
449
|
+
*
|
|
450
|
+
* 1. **A refusal is honoured only for a refusable priority.** This is the
|
|
451
|
+
* second of the two enforcement points described in `send-rate.ts`.
|
|
452
|
+
* `emailSendRateVerdict` already cannot refuse a transactional send;
|
|
453
|
+
* the governor is INJECTABLE, so a wrong one is reachable, and the send
|
|
454
|
+
* path must still be unable to drop a password reset. Anything that is
|
|
455
|
+
* not explicitly a campaign or a bulk sweep sends regardless of the
|
|
456
|
+
* answer.
|
|
457
|
+
* 2. **It fails open.** A governor that throws — Firestore unreachable, no
|
|
458
|
+
* Admin app, a bug — must not stop mail. The counter being unavailable
|
|
459
|
+
* is an outage on the control, and an outage on a control that turns
|
|
460
|
+
* into an outage on the product is a worse bug than the burst it was
|
|
461
|
+
* guarding. The same posture `sendEmail` takes everywhere else: it
|
|
462
|
+
* never throws, and neither does this.
|
|
463
|
+
*/ const priority = resolveSendPriority(options.context, options.priority);
|
|
464
|
+
const governor = getEmailSendGovernor();
|
|
465
|
+
if (governor) {
|
|
466
|
+
let verdict;
|
|
467
|
+
try {
|
|
468
|
+
verdict = await governor({
|
|
469
|
+
priority,
|
|
470
|
+
count: to.length,
|
|
471
|
+
context: options.context
|
|
472
|
+
});
|
|
473
|
+
} catch (error) {
|
|
474
|
+
console.error(`${label} send-rate governor failed — allowing`, error);
|
|
475
|
+
verdict = null;
|
|
476
|
+
}
|
|
477
|
+
if (verdict && !verdict.allowed && isRefusablePriority(priority)) {
|
|
478
|
+
var _verdict_retryAtMs, _verdict_used, _verdict_ceiling, _verdict_ceiling1;
|
|
479
|
+
const retryAtMs = (_verdict_retryAtMs = verdict.retryAtMs) != null ? _verdict_retryAtMs : emailSendRateWindowStartMs(Date.now());
|
|
480
|
+
console.warn(`${label} deferred — platform send rate reached ` + `(${(_verdict_used = verdict.used) != null ? _verdict_used : '?'}/${(_verdict_ceiling = verdict.ceiling) != null ? _verdict_ceiling : '?'} this hour)`);
|
|
481
|
+
return {
|
|
482
|
+
sent: false,
|
|
483
|
+
reason: 'rate-limited',
|
|
484
|
+
retryAtMs,
|
|
485
|
+
detail: `Platform hourly send rate reached (${(_verdict_ceiling1 = verdict.ceiling) != null ? _verdict_ceiling1 : '?'}/hour). ` + 'Transactional mail is unaffected.'
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/*
|
|
490
|
+
* THE VISIBLE OPT-OUT, on both parts.
|
|
491
|
+
*
|
|
492
|
+
* The header pair below is for the mailbox provider; this is for the person
|
|
493
|
+
* — CAN-SPAM asks for a mechanism the recipient can see and use, and most
|
|
494
|
+
* clients render no control for the header at all. Both helpers are
|
|
495
|
+
* idempotent by URL, so a sender that placed its own link (a designed
|
|
496
|
+
* template rendering `{{unsubscribeUrl}}`, the campaign body's footer)
|
|
497
|
+
* keeps its own placement and does not get a second one.
|
|
498
|
+
*
|
|
499
|
+
* `text` first and `html` from the result, so the synthesized HTML part
|
|
500
|
+
* that stands in for a text-only message carries the link as an anchor
|
|
501
|
+
* rather than as characters.
|
|
502
|
+
*/ const text = unsubscribeUrl ? appendUnsubscribeText((_options_text = options.text) != null ? _options_text : '', unsubscribeUrl) : options.text;
|
|
503
|
+
const html = unsubscribeUrl ? options.html ? appendUnsubscribeHtml(options.html, unsubscribeUrl) : renderTextEmailHtml(text != null ? text : '', options.subject) : options.html;
|
|
504
|
+
try {
|
|
505
|
+
var _ref3;
|
|
506
|
+
const response = await postResendEmail(apiKey, _extends({
|
|
507
|
+
from,
|
|
508
|
+
to,
|
|
509
|
+
subject: options.subject
|
|
510
|
+
}, text ? {
|
|
511
|
+
text
|
|
512
|
+
} : {}, (()=>{
|
|
513
|
+
const body = html || renderTextEmailHtml(text != null ? text : '', options.subject);
|
|
514
|
+
return body ? {
|
|
515
|
+
html: body
|
|
516
|
+
} : {};
|
|
517
|
+
})(), (()=>{
|
|
518
|
+
var _options_headers;
|
|
519
|
+
const headers = _extends({}, unsubscribeHeaders(oneClickUrl || unsubscribeUrl), (_options_headers = options.headers) != null ? _options_headers : {});
|
|
520
|
+
return Object.keys(headers).length ? {
|
|
521
|
+
headers
|
|
522
|
+
} : {};
|
|
523
|
+
})(), (()=>{
|
|
524
|
+
var _options_tags;
|
|
525
|
+
const caller = (_options_tags = options.tags) != null ? _options_tags : [];
|
|
526
|
+
const derived = caller.some((tag)=>(tag == null ? void 0 : tag.name) === 'context') ? [] : contextTag(options.context);
|
|
527
|
+
const tags = [
|
|
528
|
+
...caller,
|
|
529
|
+
...derived
|
|
530
|
+
];
|
|
531
|
+
return tags.length ? {
|
|
532
|
+
tags
|
|
533
|
+
} : {};
|
|
534
|
+
})(), options.replyTo ? {
|
|
535
|
+
reply_to: options.replyTo
|
|
536
|
+
} : {}), options.context);
|
|
537
|
+
if (!response.ok) {
|
|
538
|
+
const detail = await response.text().catch(()=>'');
|
|
539
|
+
/*
|
|
540
|
+
* A 429 IS NOT A STATEMENT ABOUT THIS RECIPIENT, so it does not report
|
|
541
|
+
* as one. See the `rate-limited` member of
|
|
542
|
+
* {@link SendEmailFailureReason} for why the distinction is load-bearing
|
|
543
|
+
* rather than cosmetic — every caller in the tree already has a branch
|
|
544
|
+
* for a deferral, and none of them has one for "rejected, but try this
|
|
545
|
+
* exact address again later".
|
|
546
|
+
*
|
|
547
|
+
* A warning rather than an error: the provider asking for a slower pace
|
|
548
|
+
* is a normal thing to be told, and logging it at the level a failed
|
|
549
|
+
* delivery uses is what teaches an operator to skim past both.
|
|
550
|
+
*/ if (response.status === 429) {
|
|
551
|
+
const retryAtMs = providerRetryAtMs(response.headers);
|
|
552
|
+
console.warn(`${label} deferred — the provider is rate limiting; retry in ` + `${Math.max(0, Math.round((retryAtMs - Date.now()) / 1000))}s`);
|
|
553
|
+
return {
|
|
554
|
+
sent: false,
|
|
555
|
+
reason: 'rate-limited',
|
|
556
|
+
status: response.status,
|
|
557
|
+
retryAtMs,
|
|
558
|
+
detail: detail.slice(0, 500)
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
console.error(`${label} failed`, response.status, detail);
|
|
562
|
+
return {
|
|
563
|
+
sent: false,
|
|
564
|
+
reason: 'rejected',
|
|
565
|
+
status: response.status,
|
|
566
|
+
detail: detail.slice(0, 500)
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
const body = await response.json().catch(()=>null);
|
|
570
|
+
return {
|
|
571
|
+
sent: true,
|
|
572
|
+
id: (_ref3 = body == null ? void 0 : body.id) != null ? _ref3 : null
|
|
573
|
+
};
|
|
574
|
+
} catch (error) {
|
|
575
|
+
var _ref4;
|
|
576
|
+
console.error(`${label} failed`, error);
|
|
577
|
+
return {
|
|
578
|
+
sent: false,
|
|
579
|
+
reason: 'network',
|
|
580
|
+
detail: String((_ref4 = error == null ? void 0 : error.message) != null ? _ref4 : error).slice(0, 500)
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
export default sendEmail;
|
|
585
|
+
|
|
586
|
+
//# sourceMappingURL=send-email.js.map
|