@aglyn/shared-util-email 1.0.0-beta.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +104 -0
  3. package/package.json +37 -0
  4. package/src/index.d.ts +37 -0
  5. package/src/index.js +46 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/email-delivery-events.d.ts +198 -0
  8. package/src/lib/email-delivery-events.js +310 -0
  9. package/src/lib/email-delivery-events.js.map +1 -0
  10. package/src/lib/email-health.d.ts +154 -0
  11. package/src/lib/email-health.js +264 -0
  12. package/src/lib/email-health.js.map +1 -0
  13. package/src/lib/email-media-src.d.ts +33 -0
  14. package/src/lib/email-media-src.js +113 -0
  15. package/src/lib/email-media-src.js.map +1 -0
  16. package/src/lib/email-merge.d.ts +31 -0
  17. package/src/lib/email-merge.js +36 -0
  18. package/src/lib/email-merge.js.map +1 -0
  19. package/src/lib/email-render.d.ts +114 -0
  20. package/src/lib/email-render.js +293 -0
  21. package/src/lib/email-render.js.map +1 -0
  22. package/src/lib/email-revenue-window.d.ts +55 -0
  23. package/src/lib/email-revenue-window.js +58 -0
  24. package/src/lib/email-revenue-window.js.map +1 -0
  25. package/src/lib/host-email-render.d.ts +99 -0
  26. package/src/lib/host-email-render.js +126 -0
  27. package/src/lib/host-email-render.js.map +1 -0
  28. package/src/lib/host-sender.d.ts +86 -0
  29. package/src/lib/host-sender.js +131 -0
  30. package/src/lib/host-sender.js.map +1 -0
  31. package/src/lib/marketing-send.d.ts +466 -0
  32. package/src/lib/marketing-send.js +459 -0
  33. package/src/lib/marketing-send.js.map +1 -0
  34. package/src/lib/platform-sending-domain.d.ts +362 -0
  35. package/src/lib/platform-sending-domain.js +697 -0
  36. package/src/lib/platform-sending-domain.js.map +1 -0
  37. package/src/lib/received-email.d.ts +86 -0
  38. package/src/lib/received-email.js +124 -0
  39. package/src/lib/received-email.js.map +1 -0
  40. package/src/lib/send-ceilings.d.ts +394 -0
  41. package/src/lib/send-ceilings.js +341 -0
  42. package/src/lib/send-ceilings.js.map +1 -0
  43. package/src/lib/send-email.d.ts +385 -0
  44. package/src/lib/send-email.js +586 -0
  45. package/src/lib/send-email.js.map +1 -0
  46. package/src/lib/send-rate.d.ts +298 -0
  47. package/src/lib/send-rate.js +310 -0
  48. package/src/lib/send-rate.js.map +1 -0
  49. package/src/lib/sender-reputation.d.ts +357 -0
  50. package/src/lib/sender-reputation.js +392 -0
  51. package/src/lib/sender-reputation.js.map +1 -0
  52. package/src/lib/sending-domain.d.ts +744 -0
  53. package/src/lib/sending-domain.js +798 -0
  54. package/src/lib/sending-domain.js.map +1 -0
  55. package/src/lib/sending-mailbox.d.ts +132 -0
  56. package/src/lib/sending-mailbox.js +192 -0
  57. package/src/lib/sending-mailbox.js.map +1 -0
  58. package/src/lib/stored-email-nodes.d.ts +38 -0
  59. package/src/lib/stored-email-nodes.js +78 -0
  60. package/src/lib/stored-email-nodes.js.map +1 -0
  61. package/src/lib/svix-signature.d.ts +38 -0
  62. package/src/lib/svix-signature.js +55 -0
  63. package/src/lib/svix-signature.js.map +1 -0
  64. package/src/lib/system-email-catalog.d.ts +170 -0
  65. package/src/lib/system-email-catalog.js +828 -0
  66. package/src/lib/system-email-catalog.js.map +1 -0
  67. package/src/lib/tenant-email-catalog.d.ts +78 -0
  68. package/src/lib/tenant-email-catalog.js +452 -0
  69. package/src/lib/tenant-email-catalog.js.map +1 -0
  70. package/src/lib/text-email-html.d.ts +32 -0
  71. package/src/lib/text-email-html.js +119 -0
  72. package/src/lib/text-email-html.js.map +1 -0
@@ -0,0 +1,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