@aglyn/shared-util-email 1.0.0-beta.218 → 1.0.0-beta.220
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/README.md +58 -19
- package/package.json +3 -3
- package/src/index.d.ts +2 -0
- package/src/index.js +9 -1
- package/src/index.js.map +1 -1
- package/src/lib/email-delivery-events.d.ts +41 -48
- package/src/lib/email-delivery-events.js +18 -247
- package/src/lib/email-delivery-events.js.map +1 -1
- package/src/lib/email-health.d.ts +30 -43
- package/src/lib/email-health.js +45 -141
- package/src/lib/email-health.js.map +1 -1
- package/src/lib/hosted-page-screen.d.ts +27 -0
- package/src/lib/hosted-page-screen.js +228 -23
- package/src/lib/hosted-page-screen.js.map +1 -1
- package/src/lib/mail-provider-resend.d.ts +194 -0
- package/src/lib/mail-provider-resend.js +649 -0
- package/src/lib/mail-provider-resend.js.map +1 -0
- package/src/lib/mail-provider-webhook.d.ts +80 -0
- package/src/lib/mail-provider-webhook.js +156 -0
- package/src/lib/mail-provider-webhook.js.map +1 -0
- package/src/lib/mail-provider.d.ts +148 -0
- package/src/lib/mail-provider.js +84 -0
- package/src/lib/mail-provider.js.map +1 -0
- package/src/lib/mail-providers.d.ts +99 -0
- package/src/lib/mail-providers.js +182 -0
- package/src/lib/mail-providers.js.map +1 -0
- package/src/lib/outbound-phishing-screen.d.ts +80 -3
- package/src/lib/outbound-phishing-screen.js +457 -29
- package/src/lib/outbound-phishing-screen.js.map +1 -1
- package/src/lib/received-email.d.ts +7 -44
- package/src/lib/received-email.js +8 -104
- package/src/lib/received-email.js.map +1 -1
- package/src/lib/risk-notice-catalog.d.ts +32 -2
- package/src/lib/risk-notice-catalog.js +86 -55
- package/src/lib/risk-notice-catalog.js.map +1 -1
- package/src/lib/send-ceilings.d.ts +0 -57
- package/src/lib/send-ceilings.js +0 -47
- package/src/lib/send-ceilings.js.map +1 -1
- package/src/lib/send-email.d.ts +52 -58
- package/src/lib/send-email.js +128 -134
- package/src/lib/send-email.js.map +1 -1
- package/src/lib/send-rate.js +4 -3
- package/src/lib/send-rate.js.map +1 -1
- package/src/lib/sending-domain.d.ts +15 -0
- package/src/lib/sending-domain.js.map +1 -1
- package/src/lib/system-email-catalog.js +37 -11
- package/src/lib/system-email-catalog.js.map +1 -1
- package/src/lib/tenant-email-catalog.d.ts +7 -0
- package/src/lib/tenant-email-catalog.js +10 -591
- package/src/lib/tenant-email-catalog.js.map +1 -1
- package/src/lib/tenant-emails.generated.d.ts +11 -0
- package/src/lib/tenant-emails.generated.js +649 -0
- package/src/lib/tenant-emails.generated.js.map +1 -0
package/src/lib/send-email.js
CHANGED
|
@@ -15,8 +15,11 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ /**
|
|
18
|
-
* The
|
|
19
|
-
*
|
|
18
|
+
* The send seam. Every outbound application email in Aglyn goes through
|
|
19
|
+
* here — invites, receipts, usage summaries, campaigns, staff alerts — and
|
|
20
|
+
* everything that decides whether, as whom and to whom a message leaves is
|
|
21
|
+
* decided here. The provider that then carries it is chosen by
|
|
22
|
+
* `mail-providers.ts` and only carries it.
|
|
20
23
|
*
|
|
21
24
|
* Auth email (verification, password reset) is Firebase's job and does NOT
|
|
22
25
|
* come through this module.
|
|
@@ -27,7 +30,8 @@ import { appendUnsubscribeHtml, appendUnsubscribeText, getMarketingSendGate, isM
|
|
|
27
30
|
import { EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS, getEmailDeliverabilityPreflight } from "./email-deliverability.js";
|
|
28
31
|
import { bareSenderAddress } from "./email-delivery-events.js";
|
|
29
32
|
import { screenTenantMessage } from "./outbound-screen-gate.js";
|
|
30
|
-
|
|
33
|
+
import { providerRetryAtMs } from "./mail-provider.js";
|
|
34
|
+
import { mailProvider, mailProviderProblem } from "./mail-providers.js";
|
|
31
35
|
/**
|
|
32
36
|
* The `context` tag, attached to every send (AGL-2407).
|
|
33
37
|
*
|
|
@@ -45,8 +49,8 @@ export const RESEND_SEND_ENDPOINT = 'https://api.resend.com/emails';
|
|
|
45
49
|
* and it is exactly the right value: it names the sender. So the tag is
|
|
46
50
|
* derived here, once, and no caller changes.
|
|
47
51
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
52
|
+
* Providers restrict tag values — to ASCII letters, digits, `_` and `-` at
|
|
53
|
+
* the strictest — and one that rejects a value fails the whole send. Every `context` in
|
|
50
54
|
* the tree is already a plain slug, but this is mail delivery — a value that
|
|
51
55
|
* makes the send fail is far worse than a value that is sanitised — so the
|
|
52
56
|
* label is normalised rather than trusted, and a context that sanitises to
|
|
@@ -76,42 +80,6 @@ export const RESEND_SEND_ENDPOINT = 'https://api.resend.com/emails';
|
|
|
76
80
|
const retryAtMs = Number(failure.retryAtMs);
|
|
77
81
|
return Number.isFinite(retryAtMs) ? retryAtMs : 0;
|
|
78
82
|
}
|
|
79
|
-
/**
|
|
80
|
-
* How long to wait when the provider names no interval of its own.
|
|
81
|
-
*
|
|
82
|
-
* One second, because Resend's rate limit is counted per second — so a wait
|
|
83
|
-
* of a whole window is the shortest one that is certain to have cleared it.
|
|
84
|
-
*/ const PROVIDER_RETRY_FALLBACK_MS = 1000;
|
|
85
|
-
/**
|
|
86
|
-
* The longest wait a provider header may ask for.
|
|
87
|
-
*
|
|
88
|
-
* A `retry-after` is read off the network and reaches a scheduler, so it is
|
|
89
|
-
* clamped rather than trusted: a header of `86400` would park a campaign for
|
|
90
|
-
* a day on one response nobody saw. An hour is past every documented window
|
|
91
|
-
* and short enough that a wrong one costs a run rather than a day.
|
|
92
|
-
*/ const PROVIDER_RETRY_MAX_MS = 3600000;
|
|
93
|
-
/** One header as whole seconds, or null when it is absent or unreadable. */ function headerSeconds(headers, name) {
|
|
94
|
-
var _headers_get;
|
|
95
|
-
const raw = headers == null ? void 0 : (_headers_get = headers.get) == null ? void 0 : _headers_get.call(headers, name);
|
|
96
|
-
// `Number(null)` and `Number('')` are both 0, which would read as "retry
|
|
97
|
-
// immediately" for a header that is not there at all.
|
|
98
|
-
if (raw === null || raw === undefined || String(raw).trim() === '') return null;
|
|
99
|
-
const seconds = Number(raw);
|
|
100
|
-
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* When the provider says a refused request may be repeated.
|
|
104
|
-
*
|
|
105
|
-
* Read from the two headers Resend documents beside a 429, both in whole
|
|
106
|
-
* seconds: `retry-after` first because it is the direct answer to this
|
|
107
|
-
* question, then `ratelimit-reset`, which names when the window rolls. A
|
|
108
|
-
* response carrying neither falls back to one window.
|
|
109
|
-
*/ export function providerRetryAtMs(headers, nowMs = Date.now()) {
|
|
110
|
-
var _headerSeconds;
|
|
111
|
-
const seconds = (_headerSeconds = headerSeconds(headers, 'retry-after')) != null ? _headerSeconds : headerSeconds(headers, 'ratelimit-reset');
|
|
112
|
-
const waitMs = seconds === null ? PROVIDER_RETRY_FALLBACK_MS : Math.min(seconds * 1000, PROVIDER_RETRY_MAX_MS);
|
|
113
|
-
return nowMs + waitMs;
|
|
114
|
-
}
|
|
115
83
|
/**
|
|
116
84
|
* Why a send did not happen, or `null` when it did.
|
|
117
85
|
*
|
|
@@ -190,6 +158,31 @@ export const RESEND_SEND_ENDPOINT = 'https://api.resend.com/emails';
|
|
|
190
158
|
if (timer) clearTimeout(timer);
|
|
191
159
|
}
|
|
192
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* The display name a message leaves under (AGL-3442).
|
|
163
|
+
*
|
|
164
|
+
* A site's mail goes out under the site's own name — the business name its
|
|
165
|
+
* emails render as `{{host.businessName}}`, else its display name, else the
|
|
166
|
+
* org's branding default — which `hostSendingIdentity` resolves with the
|
|
167
|
+
* address from the host and org documents it already reads. That is decided
|
|
168
|
+
* here rather than at each sender because every site sender passes the org's
|
|
169
|
+
* branding default as `fromName`, and to a visitor that is the platform's or
|
|
170
|
+
* the agency's name rather than the business they dealt with. So the default
|
|
171
|
+
* is replaced, and only the default: a name of the caller's own, such as the
|
|
172
|
+
* person writing a CRM email, is still the name the message leaves under.
|
|
173
|
+
*
|
|
174
|
+
* Mail on any other identity — the platform's own, or a campaign's, which
|
|
175
|
+
* resolves its identity without a host stamp and records the name its
|
|
176
|
+
* composer chose — keeps the caller's `fromName` unchanged.
|
|
177
|
+
*/ export function messageFromName(options) {
|
|
178
|
+
var _options_fromName, _ref, _ref1;
|
|
179
|
+
const given = String((_options_fromName = options.fromName) != null ? _options_fromName : '').trim();
|
|
180
|
+
const identity = options.sendingIdentity;
|
|
181
|
+
const siteName = String((_ref = identity == null ? void 0 : identity.fromName) != null ? _ref : '').trim();
|
|
182
|
+
if (!siteName) return given || undefined;
|
|
183
|
+
const brandDefault = String((_ref1 = identity == null ? void 0 : identity.brandFromName) != null ? _ref1 : '').trim();
|
|
184
|
+
return given && given !== brandDefault ? given : siteName;
|
|
185
|
+
}
|
|
193
186
|
/**
|
|
194
187
|
* The send seam's half of the outbound phishing screen (AGL-3356): every
|
|
195
188
|
* message a SITE sends whose identity carries the workspace
|
|
@@ -197,14 +190,16 @@ export const RESEND_SEND_ENDPOINT = 'https://api.resend.com/emails';
|
|
|
197
190
|
* pure screen, its tiers, and the installed review store. `null` when the
|
|
198
191
|
* message may go; fails open, as that function documents.
|
|
199
192
|
*/ export async function askOutboundScreen(options, label = 'email') {
|
|
200
|
-
var
|
|
193
|
+
var _messageFromName, _ref, _options_replyTo, _options_context, _options_releasedReviewId;
|
|
201
194
|
var _options_sendingIdentity, _options_sendingIdentity1;
|
|
202
195
|
const workspace = (_options_sendingIdentity = options.sendingIdentity) == null ? void 0 : _options_sendingIdentity.workspace;
|
|
203
196
|
if (options.audience !== 'tenant' || !(workspace == null ? void 0 : workspace.hostId)) return null;
|
|
204
197
|
const refusal = await screenTenantMessage({
|
|
205
198
|
workspace,
|
|
206
199
|
subject: options.subject,
|
|
207
|
-
|
|
200
|
+
// The name the message actually leaves under, so a site named after
|
|
201
|
+
// somebody else's brand is screened on the `From:` line it would send.
|
|
202
|
+
fromName: (_messageFromName = messageFromName(options)) != null ? _messageFromName : null,
|
|
208
203
|
// The resolved address, never anything the caller passed: the only
|
|
209
204
|
// `From:` this message can leave on (AGL-3362).
|
|
210
205
|
fromAddress: (_ref = (_options_sendingIdentity1 = options.sendingIdentity) == null ? void 0 : _options_sendingIdentity1.from) != null ? _ref : null,
|
|
@@ -233,20 +228,22 @@ export const RESEND_SEND_ENDPOINT = 'https://api.resend.com/emails';
|
|
|
233
228
|
* in serverless handlers where the module may be evaluated during a build,
|
|
234
229
|
* long before the runtime env exists.
|
|
235
230
|
*/ export function getEmailConfig() {
|
|
231
|
+
const provider = mailProvider();
|
|
236
232
|
return {
|
|
237
|
-
|
|
233
|
+
provider: provider.id,
|
|
234
|
+
missingSettings: provider.missingSettings(),
|
|
238
235
|
from: process.env.USAGE_EMAIL_FROM || undefined
|
|
239
236
|
};
|
|
240
237
|
}
|
|
241
238
|
/**
|
|
242
|
-
* True when
|
|
239
|
+
* True when the provider has its settings and `USAGE_EMAIL_FROM` is present.
|
|
243
240
|
*
|
|
244
241
|
* Callers that answer an HTTP request (rather than firing best-effort mail)
|
|
245
242
|
* use this to return a 501 with an actionable message instead of pretending
|
|
246
243
|
* to have sent something.
|
|
247
244
|
*/ export function isEmailConfigured() {
|
|
248
|
-
const {
|
|
249
|
-
return
|
|
245
|
+
const { missingSettings, from } = getEmailConfig();
|
|
246
|
+
return !missingSettings.length && Boolean(from);
|
|
250
247
|
}
|
|
251
248
|
/**
|
|
252
249
|
* Applies a white-label display name to a configured sender while keeping
|
|
@@ -271,42 +268,7 @@ function normalizeRecipients(to) {
|
|
|
271
268
|
return list.map((address)=>String(address != null ? address : '').trim()).filter((address)=>address.includes('@'));
|
|
272
269
|
}
|
|
273
270
|
/**
|
|
274
|
-
*
|
|
275
|
-
* standing between a payload and the network.
|
|
276
|
-
*
|
|
277
|
-
* A payload carrying no recipient cannot become a message. Resend answers it
|
|
278
|
-
* `422 missing_required_field`, which costs an API call and then shows up in
|
|
279
|
-
* the vendor dashboard as a red line indistinguishable from mail that
|
|
280
|
-
* genuinely failed to deliver — carrying no subject, no recipient and nothing
|
|
281
|
-
* naming the code that produced it. Diagnosing that means reading a log
|
|
282
|
-
* outside the deployment and guessing. So the refusal happens here, before
|
|
283
|
-
* the fetch, and names the caller's `context`.
|
|
284
|
-
*
|
|
285
|
-
* It throws rather than returning a `SendEmailResult`: this is a programming
|
|
286
|
-
* error, not a delivery outcome. `sendEmail` filters recipients well before
|
|
287
|
-
* it reaches this call, so nothing on the ordinary path can trip it. The
|
|
288
|
-
* guard exists because `RESEND_SEND_ENDPOINT` is exported and any module can
|
|
289
|
-
* therefore reach the send endpoint on its own, bypassing every check
|
|
290
|
-
* `sendEmail` owns.
|
|
291
|
-
*/ export async function postResendEmail(apiKey, payload, context) {
|
|
292
|
-
const raw = payload == null ? void 0 : payload.to;
|
|
293
|
-
const recipients = (Array.isArray(raw) ? raw : raw == null ? [] : [
|
|
294
|
-
raw
|
|
295
|
-
]).map((address)=>String(address != null ? address : '').trim()).filter(Boolean);
|
|
296
|
-
if (!recipients.length) {
|
|
297
|
-
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');
|
|
298
|
-
}
|
|
299
|
-
return fetch(RESEND_SEND_ENDPOINT, {
|
|
300
|
-
method: 'POST',
|
|
301
|
-
headers: {
|
|
302
|
-
Authorization: `Bearer ${apiKey}`,
|
|
303
|
-
'Content-Type': 'application/json'
|
|
304
|
-
},
|
|
305
|
-
body: JSON.stringify(payload)
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
/**
|
|
309
|
-
* Sends one email through Resend.
|
|
271
|
+
* Sends one email through the deployment's mail provider.
|
|
310
272
|
*
|
|
311
273
|
* **Never throws and never rejects.** Outbound mail is best-effort across
|
|
312
274
|
* every caller in this codebase — a checkout must not fail because a receipt
|
|
@@ -316,11 +278,12 @@ function normalizeRecipients(to) {
|
|
|
316
278
|
*
|
|
317
279
|
* When the env vars are missing this warns once per call and returns
|
|
318
280
|
* `{ sent: false, reason: 'unconfigured' }` rather than failing, so local and
|
|
319
|
-
* preview environments keep working without a
|
|
281
|
+
* preview environments keep working without a mail provider.
|
|
320
282
|
*/ export async function sendEmail(options) {
|
|
321
283
|
var _ref, _ref1, _ref2, _ref3, _ref4, _options_text;
|
|
322
284
|
var _options_sendingIdentity, _options_sendingIdentity1, _options_marketing, _preflight_refused, _preflight_held, _options_marketing1, _options_marketing2, _options_headers;
|
|
323
|
-
const
|
|
285
|
+
const provider = mailProvider();
|
|
286
|
+
const configuredFrom = process.env.USAGE_EMAIL_FROM || undefined;
|
|
324
287
|
const label = options.context ? `${options.context} email` : 'email';
|
|
325
288
|
/*
|
|
326
289
|
* THE SENDING-IDENTITY REFUSAL.
|
|
@@ -331,7 +294,7 @@ function normalizeRecipients(to) {
|
|
|
331
294
|
* A governor is injectable and a route is skippable, so neither may be the
|
|
332
295
|
* only thing standing between an unverified domain and a send.
|
|
333
296
|
*
|
|
334
|
-
* Placed above the
|
|
297
|
+
* Placed above the provider/`from` gate so a refusal cannot be reported as
|
|
335
298
|
* `unconfigured` — the two have different owners and different fixes.
|
|
336
299
|
*/ const identityRefusal = sendingIdentityRefusal(options.sendingIdentity);
|
|
337
300
|
if (identityRefusal) {
|
|
@@ -374,9 +337,28 @@ function normalizeRecipients(to) {
|
|
|
374
337
|
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.'
|
|
375
338
|
};
|
|
376
339
|
}
|
|
377
|
-
const from = applyFromName(resolvedFrom != null ? resolvedFrom : configuredFrom, options
|
|
378
|
-
|
|
379
|
-
|
|
340
|
+
const from = applyFromName(resolvedFrom != null ? resolvedFrom : configuredFrom, messageFromName(options));
|
|
341
|
+
const missing = [
|
|
342
|
+
...provider.missingSettings(),
|
|
343
|
+
...from ? [] : [
|
|
344
|
+
'USAGE_EMAIL_FROM'
|
|
345
|
+
]
|
|
346
|
+
];
|
|
347
|
+
if (missing.length) {
|
|
348
|
+
/*
|
|
349
|
+
* An operator who chose a provider this process does not have is told
|
|
350
|
+
* so as an ERROR: that is not a deployment without mail, it is mail
|
|
351
|
+
* somebody set up going nowhere. See `mail-providers.ts`.
|
|
352
|
+
*/ const problem = mailProviderProblem(provider);
|
|
353
|
+
if (problem) {
|
|
354
|
+
console.error(`${label} not sent — ${problem}`);
|
|
355
|
+
return {
|
|
356
|
+
sent: false,
|
|
357
|
+
reason: 'unconfigured',
|
|
358
|
+
detail: problem
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
console.warn(`${label} skipped — set ${missing.join(' and ')} to deliver mail`);
|
|
380
362
|
return {
|
|
381
363
|
sent: false,
|
|
382
364
|
reason: 'unconfigured'
|
|
@@ -650,43 +632,51 @@ function normalizeRecipients(to) {
|
|
|
650
632
|
*/ const text = unsubscribeUrl ? appendUnsubscribeText((_options_text = options.text) != null ? _options_text : '', unsubscribeUrl) : options.text;
|
|
651
633
|
const html = unsubscribeUrl ? options.html ? appendUnsubscribeHtml(options.html, unsubscribeUrl) : renderTextEmailHtml(text != null ? text : '', options.subject) : options.html;
|
|
652
634
|
try {
|
|
653
|
-
var
|
|
654
|
-
|
|
635
|
+
var _options_headers1, _options_tags, _outcome_id;
|
|
636
|
+
// The HTML part, from the caller when it has one and otherwise
|
|
637
|
+
// synthesized from `text`. A message with no HTML part carries no
|
|
638
|
+
// anchors, so its links are not links in the inbox AND the provider has
|
|
639
|
+
// nothing to rewrite for click tracking — see `text-email-html.ts`. The
|
|
640
|
+
// caller always wins: this can only fill a gap, never override a
|
|
641
|
+
// designed template.
|
|
642
|
+
const body = html || renderTextEmailHtml(text != null ? text : '', options.subject);
|
|
643
|
+
// The caller's headers plus the RFC 8058 pair for a marketing send.
|
|
644
|
+
// Caller-first, so the campaign sender's own pair is the one that ships
|
|
645
|
+
// and a merchant-authored header is never silently replaced.
|
|
646
|
+
const headers = _extends({}, unsubscribeHeaders(oneClickUrl || unsubscribeUrl), (_options_headers1 = options.headers) != null ? _options_headers1 : {});
|
|
647
|
+
// The caller's tags plus the `context` tag (AGL-2407). Caller-first so a
|
|
648
|
+
// sender that stamps its own `context` keeps it: a tag list with two
|
|
649
|
+
// entries of one name is not a shape worth discovering in production,
|
|
650
|
+
// and the explicit one is the more specific.
|
|
651
|
+
const callerTags = (_options_tags = options.tags) != null ? _options_tags : [];
|
|
652
|
+
const tags = [
|
|
653
|
+
...callerTags,
|
|
654
|
+
...callerTags.some((tag)=>(tag == null ? void 0 : tag.name) === 'context') ? [] : contextTag(options.context)
|
|
655
|
+
];
|
|
656
|
+
const outcome = await provider.send(_extends({
|
|
655
657
|
from,
|
|
656
658
|
to,
|
|
657
659
|
subject: options.subject
|
|
658
660
|
}, text ? {
|
|
659
661
|
text
|
|
660
|
-
} : {},
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
return Object.keys(headers).length ? {
|
|
669
|
-
headers
|
|
670
|
-
} : {};
|
|
671
|
-
})(), (()=>{
|
|
672
|
-
var _options_tags;
|
|
673
|
-
const caller = (_options_tags = options.tags) != null ? _options_tags : [];
|
|
674
|
-
const derived = caller.some((tag)=>(tag == null ? void 0 : tag.name) === 'context') ? [] : contextTag(options.context);
|
|
675
|
-
const tags = [
|
|
676
|
-
...caller,
|
|
677
|
-
...derived
|
|
678
|
-
];
|
|
679
|
-
return tags.length ? {
|
|
680
|
-
tags
|
|
681
|
-
} : {};
|
|
682
|
-
})(), options.replyTo ? {
|
|
683
|
-
reply_to: options.replyTo
|
|
662
|
+
} : {}, body ? {
|
|
663
|
+
html: body
|
|
664
|
+
} : {}, Object.keys(headers).length ? {
|
|
665
|
+
headers
|
|
666
|
+
} : {}, tags.length ? {
|
|
667
|
+
tags
|
|
668
|
+
} : {}, options.replyTo ? {
|
|
669
|
+
replyTo: options.replyTo
|
|
684
670
|
} : {}), options.context);
|
|
685
|
-
if (!
|
|
686
|
-
|
|
671
|
+
if (!outcome.accepted) {
|
|
672
|
+
var _refusal_detail;
|
|
673
|
+
// `strictNullChecks` is off repo-wide, so the union does not narrow on
|
|
674
|
+
// its boolean discriminant; the cast says what the branch already knows.
|
|
675
|
+
const refusal = outcome;
|
|
676
|
+
const detail = String((_refusal_detail = refusal.detail) != null ? _refusal_detail : '');
|
|
687
677
|
/*
|
|
688
|
-
* A
|
|
689
|
-
* as one. See the `rate-limited` member of
|
|
678
|
+
* A REQUEST TO SLOW DOWN IS NOT A STATEMENT ABOUT THIS RECIPIENT, so it
|
|
679
|
+
* does not report as one. See the `rate-limited` member of
|
|
690
680
|
* {@link SendEmailFailureReason} for why the distinction is load-bearing
|
|
691
681
|
* rather than cosmetic — every caller in the tree already has a branch
|
|
692
682
|
* for a deferral, and none of them has one for "rejected, but try this
|
|
@@ -695,37 +685,41 @@ function normalizeRecipients(to) {
|
|
|
695
685
|
* A warning rather than an error: the provider asking for a slower pace
|
|
696
686
|
* is a normal thing to be told, and logging it at the level a failed
|
|
697
687
|
* delivery uses is what teaches an operator to skim past both.
|
|
698
|
-
*/ if (
|
|
699
|
-
|
|
688
|
+
*/ if (refusal.rateLimited) {
|
|
689
|
+
var _refusal_retryAtMs;
|
|
690
|
+
const retryAtMs = (_refusal_retryAtMs = refusal.retryAtMs) != null ? _refusal_retryAtMs : providerRetryAtMs(null);
|
|
700
691
|
console.warn(`${label} deferred — the provider is rate limiting; retry in ` + `${Math.max(0, Math.round((retryAtMs - Date.now()) / 1000))}s`);
|
|
701
|
-
return {
|
|
692
|
+
return _extends({
|
|
702
693
|
sent: false,
|
|
703
|
-
reason: 'rate-limited'
|
|
704
|
-
|
|
694
|
+
reason: 'rate-limited'
|
|
695
|
+
}, refusal.status !== undefined ? {
|
|
696
|
+
status: refusal.status
|
|
697
|
+
} : {}, {
|
|
705
698
|
retryAtMs,
|
|
706
699
|
detail: detail.slice(0, 500)
|
|
707
|
-
};
|
|
700
|
+
});
|
|
708
701
|
}
|
|
709
|
-
console.error(`${label} failed`,
|
|
710
|
-
return {
|
|
702
|
+
console.error(`${label} failed`, refusal.status, detail);
|
|
703
|
+
return _extends({
|
|
711
704
|
sent: false,
|
|
712
|
-
reason: 'rejected'
|
|
713
|
-
|
|
705
|
+
reason: 'rejected'
|
|
706
|
+
}, refusal.status !== undefined ? {
|
|
707
|
+
status: refusal.status
|
|
708
|
+
} : {}, {
|
|
714
709
|
detail: detail.slice(0, 500)
|
|
715
|
-
};
|
|
710
|
+
});
|
|
716
711
|
}
|
|
717
|
-
const body = await response.json().catch(()=>null);
|
|
718
712
|
return {
|
|
719
713
|
sent: true,
|
|
720
|
-
id: (
|
|
714
|
+
id: (_outcome_id = outcome.id) != null ? _outcome_id : null
|
|
721
715
|
};
|
|
722
716
|
} catch (error) {
|
|
723
|
-
var
|
|
717
|
+
var _ref7;
|
|
724
718
|
console.error(`${label} failed`, error);
|
|
725
719
|
return {
|
|
726
720
|
sent: false,
|
|
727
721
|
reason: 'network',
|
|
728
|
-
detail: String((
|
|
722
|
+
detail: String((_ref7 = error == null ? void 0 : error.message) != null ? _ref7 : error).slice(0, 500)
|
|
729
723
|
};
|
|
730
724
|
}
|
|
731
725
|
}
|