@aglyn/shared-util-email 1.0.0-beta.217 → 1.0.0-beta.219

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 (46) hide show
  1. package/README.md +58 -19
  2. package/package.json +3 -3
  3. package/src/index.d.ts +2 -0
  4. package/src/index.js +9 -1
  5. package/src/index.js.map +1 -1
  6. package/src/lib/email-deliverability.d.ts +9 -2
  7. package/src/lib/email-deliverability.js.map +1 -1
  8. package/src/lib/email-delivery-events.d.ts +41 -48
  9. package/src/lib/email-delivery-events.js +18 -247
  10. package/src/lib/email-delivery-events.js.map +1 -1
  11. package/src/lib/email-health.d.ts +30 -43
  12. package/src/lib/email-health.js +45 -141
  13. package/src/lib/email-health.js.map +1 -1
  14. package/src/lib/mail-provider-resend.d.ts +194 -0
  15. package/src/lib/mail-provider-resend.js +649 -0
  16. package/src/lib/mail-provider-resend.js.map +1 -0
  17. package/src/lib/mail-provider-webhook.d.ts +80 -0
  18. package/src/lib/mail-provider-webhook.js +156 -0
  19. package/src/lib/mail-provider-webhook.js.map +1 -0
  20. package/src/lib/mail-provider.d.ts +148 -0
  21. package/src/lib/mail-provider.js +84 -0
  22. package/src/lib/mail-provider.js.map +1 -0
  23. package/src/lib/mail-providers.d.ts +99 -0
  24. package/src/lib/mail-providers.js +182 -0
  25. package/src/lib/mail-providers.js.map +1 -0
  26. package/src/lib/received-email.d.ts +7 -44
  27. package/src/lib/received-email.js +8 -104
  28. package/src/lib/received-email.js.map +1 -1
  29. package/src/lib/risk-notice-catalog.js +7 -1
  30. package/src/lib/risk-notice-catalog.js.map +1 -1
  31. package/src/lib/send-ceilings.d.ts +0 -57
  32. package/src/lib/send-ceilings.js +0 -47
  33. package/src/lib/send-ceilings.js.map +1 -1
  34. package/src/lib/send-email.d.ts +36 -58
  35. package/src/lib/send-email.js +102 -133
  36. package/src/lib/send-email.js.map +1 -1
  37. package/src/lib/send-rate.js +4 -3
  38. package/src/lib/send-rate.js.map +1 -1
  39. package/src/lib/system-email-catalog.js +3 -3
  40. package/src/lib/system-email-catalog.js.map +1 -1
  41. package/src/lib/tenant-email-catalog.d.ts +7 -0
  42. package/src/lib/tenant-email-catalog.js +10 -591
  43. package/src/lib/tenant-email-catalog.js.map +1 -1
  44. package/src/lib/tenant-emails.generated.d.ts +11 -0
  45. package/src/lib/tenant-emails.generated.js +594 -0
  46. package/src/lib/tenant-emails.generated.js.map +1 -0
@@ -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 Resend send endpoint. Every outbound application email in Aglyn goes
19
- * through here — invites, receipts, usage summaries, campaigns, staff alerts.
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
- export const RESEND_SEND_ENDPOINT = 'https://api.resend.com/emails';
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
- * Resend tag values are restricted to ASCII letters, digits, `_` and `-`;
49
- * anything else is rejected and would fail the whole send. Every `context` in
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
  *
@@ -233,20 +201,22 @@ export const RESEND_SEND_ENDPOINT = 'https://api.resend.com/emails';
233
201
  * in serverless handlers where the module may be evaluated during a build,
234
202
  * long before the runtime env exists.
235
203
  */ export function getEmailConfig() {
204
+ const provider = mailProvider();
236
205
  return {
237
- apiKey: process.env.RESEND_API_KEY || undefined,
206
+ provider: provider.id,
207
+ missingSettings: provider.missingSettings(),
238
208
  from: process.env.USAGE_EMAIL_FROM || undefined
239
209
  };
240
210
  }
241
211
  /**
242
- * True when both `RESEND_API_KEY` and `USAGE_EMAIL_FROM` are present.
212
+ * True when the provider has its settings and `USAGE_EMAIL_FROM` is present.
243
213
  *
244
214
  * Callers that answer an HTTP request (rather than firing best-effort mail)
245
215
  * use this to return a 501 with an actionable message instead of pretending
246
216
  * to have sent something.
247
217
  */ export function isEmailConfigured() {
248
- const { apiKey, from } = getEmailConfig();
249
- return Boolean(apiKey && from);
218
+ const { missingSettings, from } = getEmailConfig();
219
+ return !missingSettings.length && Boolean(from);
250
220
  }
251
221
  /**
252
222
  * Applies a white-label display name to a configured sender while keeping
@@ -271,42 +241,7 @@ function normalizeRecipients(to) {
271
241
  return list.map((address)=>String(address != null ? address : '').trim()).filter((address)=>address.includes('@'));
272
242
  }
273
243
  /**
274
- * The one place that POSTs to Resend's send endpoint, and the last thing
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.
244
+ * Sends one email through the deployment's mail provider.
310
245
  *
311
246
  * **Never throws and never rejects.** Outbound mail is best-effort across
312
247
  * every caller in this codebase — a checkout must not fail because a receipt
@@ -316,11 +251,12 @@ function normalizeRecipients(to) {
316
251
  *
317
252
  * When the env vars are missing this warns once per call and returns
318
253
  * `{ sent: false, reason: 'unconfigured' }` rather than failing, so local and
319
- * preview environments keep working without a Resend account.
254
+ * preview environments keep working without a mail provider.
320
255
  */ export async function sendEmail(options) {
321
256
  var _ref, _ref1, _ref2, _ref3, _ref4, _options_text;
322
257
  var _options_sendingIdentity, _options_sendingIdentity1, _options_marketing, _preflight_refused, _preflight_held, _options_marketing1, _options_marketing2, _options_headers;
323
- const { apiKey, from: configuredFrom } = getEmailConfig();
258
+ const provider = mailProvider();
259
+ const configuredFrom = process.env.USAGE_EMAIL_FROM || undefined;
324
260
  const label = options.context ? `${options.context} email` : 'email';
325
261
  /*
326
262
  * THE SENDING-IDENTITY REFUSAL.
@@ -331,7 +267,7 @@ function normalizeRecipients(to) {
331
267
  * A governor is injectable and a route is skippable, so neither may be the
332
268
  * only thing standing between an unverified domain and a send.
333
269
  *
334
- * Placed above the `apiKey`/`from` gate so a refusal cannot be reported as
270
+ * Placed above the provider/`from` gate so a refusal cannot be reported as
335
271
  * `unconfigured` — the two have different owners and different fixes.
336
272
  */ const identityRefusal = sendingIdentityRefusal(options.sendingIdentity);
337
273
  if (identityRefusal) {
@@ -375,8 +311,27 @@ function normalizeRecipients(to) {
375
311
  };
376
312
  }
377
313
  const from = applyFromName(resolvedFrom != null ? resolvedFrom : configuredFrom, options.fromName);
378
- if (!apiKey || !from) {
379
- console.warn(`${label} skipped — set RESEND_API_KEY and USAGE_EMAIL_FROM to ` + 'deliver mail');
314
+ const missing = [
315
+ ...provider.missingSettings(),
316
+ ...from ? [] : [
317
+ 'USAGE_EMAIL_FROM'
318
+ ]
319
+ ];
320
+ if (missing.length) {
321
+ /*
322
+ * An operator who chose a provider this process does not have is told
323
+ * so as an ERROR: that is not a deployment without mail, it is mail
324
+ * somebody set up going nowhere. See `mail-providers.ts`.
325
+ */ const problem = mailProviderProblem(provider);
326
+ if (problem) {
327
+ console.error(`${label} not sent — ${problem}`);
328
+ return {
329
+ sent: false,
330
+ reason: 'unconfigured',
331
+ detail: problem
332
+ };
333
+ }
334
+ console.warn(`${label} skipped — set ${missing.join(' and ')} to deliver mail`);
380
335
  return {
381
336
  sent: false,
382
337
  reason: 'unconfigured'
@@ -417,14 +372,16 @@ function normalizeRecipients(to) {
417
372
  * to the rest: one colleague's dead address must not stop the others'
418
373
  * notification.
419
374
  */ const sendingAddress = bareSenderAddress(from);
420
- const preflight = await askDeliverabilityPreflight({
375
+ const preflight = await askDeliverabilityPreflight(_extends({
421
376
  recipients: to.map((address)=>address.toLowerCase()),
422
377
  purpose: emailSendPurpose(options),
423
378
  sendingDomain: sendingAddress ? sendingAddress.slice(sendingAddress.lastIndexOf('@') + 1) : null,
424
379
  sendingSource: (_ref1 = (_options_sendingIdentity1 = options.sendingIdentity) == null ? void 0 : _options_sendingIdentity1.source) != null ? _ref1 : 'platform',
425
380
  context: options.context,
426
381
  hostId: (_ref2 = (_options_marketing = options.marketing) == null ? void 0 : _options_marketing.hostId) != null ? _ref2 : null
427
- }, label);
382
+ }, options.accountBanExempt ? {
383
+ accountBanExempt: true
384
+ } : {}), label);
428
385
  if (preflight && (((_preflight_refused = preflight.refused) == null ? void 0 : _preflight_refused.length) || ((_preflight_held = preflight.held) == null ? void 0 : _preflight_held.length))) {
429
386
  var _preflight_refused1, _preflight_held1;
430
387
  const stopped = new Set([
@@ -648,43 +605,51 @@ function normalizeRecipients(to) {
648
605
  */ const text = unsubscribeUrl ? appendUnsubscribeText((_options_text = options.text) != null ? _options_text : '', unsubscribeUrl) : options.text;
649
606
  const html = unsubscribeUrl ? options.html ? appendUnsubscribeHtml(options.html, unsubscribeUrl) : renderTextEmailHtml(text != null ? text : '', options.subject) : options.html;
650
607
  try {
651
- var _ref7;
652
- const response = await postResendEmail(apiKey, _extends({
608
+ var _options_headers1, _options_tags, _outcome_id;
609
+ // The HTML part, from the caller when it has one and otherwise
610
+ // synthesized from `text`. A message with no HTML part carries no
611
+ // anchors, so its links are not links in the inbox AND the provider has
612
+ // nothing to rewrite for click tracking — see `text-email-html.ts`. The
613
+ // caller always wins: this can only fill a gap, never override a
614
+ // designed template.
615
+ const body = html || renderTextEmailHtml(text != null ? text : '', options.subject);
616
+ // The caller's headers plus the RFC 8058 pair for a marketing send.
617
+ // Caller-first, so the campaign sender's own pair is the one that ships
618
+ // and a merchant-authored header is never silently replaced.
619
+ const headers = _extends({}, unsubscribeHeaders(oneClickUrl || unsubscribeUrl), (_options_headers1 = options.headers) != null ? _options_headers1 : {});
620
+ // The caller's tags plus the `context` tag (AGL-2407). Caller-first so a
621
+ // sender that stamps its own `context` keeps it: a tag list with two
622
+ // entries of one name is not a shape worth discovering in production,
623
+ // and the explicit one is the more specific.
624
+ const callerTags = (_options_tags = options.tags) != null ? _options_tags : [];
625
+ const tags = [
626
+ ...callerTags,
627
+ ...callerTags.some((tag)=>(tag == null ? void 0 : tag.name) === 'context') ? [] : contextTag(options.context)
628
+ ];
629
+ const outcome = await provider.send(_extends({
653
630
  from,
654
631
  to,
655
632
  subject: options.subject
656
633
  }, text ? {
657
634
  text
658
- } : {}, (()=>{
659
- const body = html || renderTextEmailHtml(text != null ? text : '', options.subject);
660
- return body ? {
661
- html: body
662
- } : {};
663
- })(), (()=>{
664
- var _options_headers;
665
- const headers = _extends({}, unsubscribeHeaders(oneClickUrl || unsubscribeUrl), (_options_headers = options.headers) != null ? _options_headers : {});
666
- return Object.keys(headers).length ? {
667
- headers
668
- } : {};
669
- })(), (()=>{
670
- var _options_tags;
671
- const caller = (_options_tags = options.tags) != null ? _options_tags : [];
672
- const derived = caller.some((tag)=>(tag == null ? void 0 : tag.name) === 'context') ? [] : contextTag(options.context);
673
- const tags = [
674
- ...caller,
675
- ...derived
676
- ];
677
- return tags.length ? {
678
- tags
679
- } : {};
680
- })(), options.replyTo ? {
681
- reply_to: options.replyTo
635
+ } : {}, body ? {
636
+ html: body
637
+ } : {}, Object.keys(headers).length ? {
638
+ headers
639
+ } : {}, tags.length ? {
640
+ tags
641
+ } : {}, options.replyTo ? {
642
+ replyTo: options.replyTo
682
643
  } : {}), options.context);
683
- if (!response.ok) {
684
- const detail = await response.text().catch(()=>'');
644
+ if (!outcome.accepted) {
645
+ var _refusal_detail;
646
+ // `strictNullChecks` is off repo-wide, so the union does not narrow on
647
+ // its boolean discriminant; the cast says what the branch already knows.
648
+ const refusal = outcome;
649
+ const detail = String((_refusal_detail = refusal.detail) != null ? _refusal_detail : '');
685
650
  /*
686
- * A 429 IS NOT A STATEMENT ABOUT THIS RECIPIENT, so it does not report
687
- * as one. See the `rate-limited` member of
651
+ * A REQUEST TO SLOW DOWN IS NOT A STATEMENT ABOUT THIS RECIPIENT, so it
652
+ * does not report as one. See the `rate-limited` member of
688
653
  * {@link SendEmailFailureReason} for why the distinction is load-bearing
689
654
  * rather than cosmetic — every caller in the tree already has a branch
690
655
  * for a deferral, and none of them has one for "rejected, but try this
@@ -693,37 +658,41 @@ function normalizeRecipients(to) {
693
658
  * A warning rather than an error: the provider asking for a slower pace
694
659
  * is a normal thing to be told, and logging it at the level a failed
695
660
  * delivery uses is what teaches an operator to skim past both.
696
- */ if (response.status === 429) {
697
- const retryAtMs = providerRetryAtMs(response.headers);
661
+ */ if (refusal.rateLimited) {
662
+ var _refusal_retryAtMs;
663
+ const retryAtMs = (_refusal_retryAtMs = refusal.retryAtMs) != null ? _refusal_retryAtMs : providerRetryAtMs(null);
698
664
  console.warn(`${label} deferred — the provider is rate limiting; retry in ` + `${Math.max(0, Math.round((retryAtMs - Date.now()) / 1000))}s`);
699
- return {
665
+ return _extends({
700
666
  sent: false,
701
- reason: 'rate-limited',
702
- status: response.status,
667
+ reason: 'rate-limited'
668
+ }, refusal.status !== undefined ? {
669
+ status: refusal.status
670
+ } : {}, {
703
671
  retryAtMs,
704
672
  detail: detail.slice(0, 500)
705
- };
673
+ });
706
674
  }
707
- console.error(`${label} failed`, response.status, detail);
708
- return {
675
+ console.error(`${label} failed`, refusal.status, detail);
676
+ return _extends({
709
677
  sent: false,
710
- reason: 'rejected',
711
- status: response.status,
678
+ reason: 'rejected'
679
+ }, refusal.status !== undefined ? {
680
+ status: refusal.status
681
+ } : {}, {
712
682
  detail: detail.slice(0, 500)
713
- };
683
+ });
714
684
  }
715
- const body = await response.json().catch(()=>null);
716
685
  return {
717
686
  sent: true,
718
- id: (_ref7 = body == null ? void 0 : body.id) != null ? _ref7 : null
687
+ id: (_outcome_id = outcome.id) != null ? _outcome_id : null
719
688
  };
720
689
  } catch (error) {
721
- var _ref8;
690
+ var _ref7;
722
691
  console.error(`${label} failed`, error);
723
692
  return {
724
693
  sent: false,
725
694
  reason: 'network',
726
- detail: String((_ref8 = error == null ? void 0 : error.message) != null ? _ref8 : error).slice(0, 500)
695
+ detail: String((_ref7 = error == null ? void 0 : error.message) != null ? _ref7 : error).slice(0, 500)
727
696
  };
728
697
  }
729
698
  }