@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
@@ -0,0 +1,649 @@
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
+ * RESEND, as one {@link MailProvider} — the only module in the platform that
19
+ * knows Resend's endpoints, payloads, event names and error names.
20
+ *
21
+ * It is the provider a deployment gets when it names none, which is how
22
+ * every deployment that set `RESEND_API_KEY` before the contract existed
23
+ * keeps sending exactly as it did. Two credentials, on purpose:
24
+ *
25
+ * - `RESEND_API_KEY` sends. It is provisioned sending-scoped, so a leaked
26
+ * copy cannot enumerate everyone the platform ever wrote to.
27
+ * - `RESEND_READ_API_KEY` is full-access and only ever reads: history, one
28
+ * message's body, received mail and the account's sending domains. A
29
+ * sending-scoped key answers every such read `401 restricted_api_key`.
30
+ *
31
+ * Pure but for the `fetch` calls, each inside a function that must be called
32
+ * to do anything, so a client component that reaches this module through the
33
+ * library's barrel executes nothing on load.
34
+ */ import { bareSenderAddress, normalizeEventTags } from "./email-delivery-events.js";
35
+ import { readBounceText } from "./mail-bounce.js";
36
+ import { providerRetryAtMs } from "./mail-provider.js";
37
+ /** The slug stored on every delivery record this provider produces. */ export const RESEND_PROVIDER_ID = 'resend';
38
+ /** The sending key's environment variable. */ export const RESEND_SEND_KEY_SETTING = 'RESEND_API_KEY';
39
+ /** The full-access read key's environment variable. */ export const RESEND_READ_KEY_SETTING = 'RESEND_READ_API_KEY';
40
+ function setting(env, name) {
41
+ var _env_name;
42
+ return String((_env_name = env[name]) != null ? _env_name : '').trim();
43
+ }
44
+ /*==========================================
45
+ * SENDING.
46
+ *=========================================*/ /** The send endpoint. */ export const RESEND_SEND_ENDPOINT = 'https://api.resend.com/emails';
47
+ /**
48
+ * The one place that POSTs to Resend's send endpoint, and the last thing
49
+ * standing between a payload and the network.
50
+ *
51
+ * A payload carrying no recipient cannot become a message. Resend answers it
52
+ * `422 missing_required_field`, which costs an API call and then shows up in
53
+ * the vendor dashboard as a red line indistinguishable from mail that
54
+ * genuinely failed to deliver — carrying no subject, no recipient and nothing
55
+ * naming the code that produced it. Diagnosing that means reading a log
56
+ * outside the deployment and guessing. So the refusal happens here, before
57
+ * the fetch, and names the caller's `context`.
58
+ *
59
+ * It throws rather than returning a result: this is a programming error,
60
+ * not a delivery outcome. `sendEmail` filters recipients well before it
61
+ * reaches this call, so nothing on the ordinary path can trip it. The guard
62
+ * exists because `RESEND_SEND_ENDPOINT` is exported and any module can
63
+ * therefore reach the send endpoint on its own, bypassing every check
64
+ * `sendEmail` owns.
65
+ */ export async function postResendEmail(apiKey, payload, context) {
66
+ const raw = payload == null ? void 0 : payload.to;
67
+ const recipients = (Array.isArray(raw) ? raw : raw == null ? [] : [
68
+ raw
69
+ ]).map((address)=>String(address != null ? address : '').trim()).filter(Boolean);
70
+ if (!recipients.length) {
71
+ 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');
72
+ }
73
+ return fetch(RESEND_SEND_ENDPOINT, {
74
+ method: 'POST',
75
+ headers: {
76
+ Authorization: `Bearer ${apiKey}`,
77
+ 'Content-Type': 'application/json'
78
+ },
79
+ body: JSON.stringify(payload)
80
+ });
81
+ }
82
+ /** One message as Resend's send endpoint takes it. */ export function resendSendPayload(message) {
83
+ var _message_tags;
84
+ return _extends({
85
+ from: message.from,
86
+ to: message.to,
87
+ subject: message.subject
88
+ }, message.text ? {
89
+ text: message.text
90
+ } : {}, message.html ? {
91
+ html: message.html
92
+ } : {}, message.headers && Object.keys(message.headers).length ? {
93
+ headers: message.headers
94
+ } : {}, ((_message_tags = message.tags) == null ? void 0 : _message_tags.length) ? {
95
+ tags: message.tags
96
+ } : {}, message.replyTo ? {
97
+ reply_to: message.replyTo
98
+ } : {});
99
+ }
100
+ /**
101
+ * Sends one message and reads the answer.
102
+ *
103
+ * A 429 is a request to slow down, not a statement about the recipient, so
104
+ * it comes back `rateLimited` with the wait Resend asked for. Its quota
105
+ * errors — `daily_quota_exceeded`, `monthly_quota_exceeded` — are 429s as
106
+ * well, and clear with time the same way.
107
+ */ async function sendThroughResend(apiKey, message, context) {
108
+ var _ref;
109
+ const response = await postResendEmail(apiKey, resendSendPayload(message), context);
110
+ if (!response.ok) {
111
+ const detail = await response.text().catch(()=>'');
112
+ if (response.status === 429) {
113
+ return {
114
+ accepted: false,
115
+ rateLimited: true,
116
+ retryAtMs: providerRetryAtMs(response.headers),
117
+ status: response.status,
118
+ detail
119
+ };
120
+ }
121
+ return {
122
+ accepted: false,
123
+ rateLimited: false,
124
+ status: response.status,
125
+ detail
126
+ };
127
+ }
128
+ const body = await response.json().catch(()=>null);
129
+ return {
130
+ accepted: true,
131
+ id: (_ref = body == null ? void 0 : body.id) != null ? _ref : null
132
+ };
133
+ }
134
+ /*==========================================
135
+ * THE CREDENTIAL PROBE.
136
+ *=========================================*/ /**
137
+ * Where the credential probe and the domain read ask their questions.
138
+ *
139
+ * Deliberately NOT the send endpoint. A probe aimed at `/emails` is a send
140
+ * attempt however empty its body is: it consumes an API call, and Resend
141
+ * records it in the account's logs as a `422` on `POST /emails` with no
142
+ * recipient, no subject and nothing identifying the caller — a line an
143
+ * operator reading that dashboard has to treat as failed mail. A domain read
144
+ * cannot create a message and cannot be mistaken for one.
145
+ */ export const RESEND_DOMAINS_ENDPOINT = 'https://api.resend.com/domains';
146
+ /**
147
+ * Error names that mean the key itself was not accepted, as opposed to a key
148
+ * that authenticated and merely lacks read scope. Matched by NAME, not
149
+ * status: `401` and `403` each cover both meanings.
150
+ */ const REFUSED_KEY_ERRORS = new Set([
151
+ 'missing_api_key',
152
+ 'validation_error',
153
+ 'suspended_api_key'
154
+ ]);
155
+ /**
156
+ * Error names that mean the key authenticated and was then denied this
157
+ * particular read. A sending-scoped key — the shape Aglyn provisions — always
158
+ * lands here, and reaching this answer at all required Resend to recognize
159
+ * the credential, which is exactly what the probe is asking.
160
+ */ const AUTHENTICATED_BUT_UNSCOPED_ERRORS = new Set([
161
+ 'restricted_api_key',
162
+ 'invalid_permission'
163
+ ]);
164
+ /** The `name` Resend puts on an error body, or `''` for anything else. */ function resendErrorName(body) {
165
+ try {
166
+ const parsed = JSON.parse(body);
167
+ return typeof (parsed == null ? void 0 : parsed.name) === 'string' ? parsed.name : '';
168
+ } catch (unused) {
169
+ return '';
170
+ }
171
+ }
172
+ /**
173
+ * Whether the sending key is accepted — without sending anything to anybody,
174
+ * and without leaving anything behind that reads as failed mail.
175
+ *
176
+ * How: a `GET` of the domains collection. The question is only ever "does
177
+ * Resend recognize this credential", so the probe reads the ERROR NAME rather
178
+ * than the status, because `401` and `403` each carry both meanings:
179
+ *
180
+ * - `2xx` — the key is accepted and has read scope → `ok`
181
+ * - `restricted_api_key` / `invalid_permission` — Resend authenticated the
182
+ * key and then denied it this read. A sending-scoped key always answers
183
+ * this way, and getting the answer proves the credential works → `ok`
184
+ * - `missing_api_key` / `validation_error` / `suspended_api_key` — the key
185
+ * itself was refused → `invalid-key`
186
+ * - anything else → `unknown`
187
+ *
188
+ * An unrecognized rejection is `unknown`, never `invalid-key`: this feeds a
189
+ * staff diagnostics screen whose whole value is that a red line means
190
+ * something, and a shape we have not seen before is not evidence that a
191
+ * working key is broken.
192
+ */ export async function checkResendCredentials(apiKey) {
193
+ if (!apiKey) return {
194
+ status: 'unconfigured'
195
+ };
196
+ try {
197
+ const response = await fetch(RESEND_DOMAINS_ENDPOINT, {
198
+ method: 'GET',
199
+ headers: {
200
+ Authorization: `Bearer ${apiKey}`
201
+ }
202
+ });
203
+ const detail = (await response.text().catch(()=>'')).slice(0, 300);
204
+ if (response.status >= 200 && response.status < 300) {
205
+ return {
206
+ status: 'ok',
207
+ probeStatus: response.status
208
+ };
209
+ }
210
+ if (response.status === 401 || response.status === 403) {
211
+ const name = resendErrorName(detail);
212
+ if (AUTHENTICATED_BUT_UNSCOPED_ERRORS.has(name)) {
213
+ return {
214
+ status: 'ok',
215
+ probeStatus: response.status
216
+ };
217
+ }
218
+ if (REFUSED_KEY_ERRORS.has(name)) {
219
+ return {
220
+ status: 'invalid-key',
221
+ probeStatus: response.status,
222
+ detail
223
+ };
224
+ }
225
+ }
226
+ return {
227
+ status: 'unknown',
228
+ probeStatus: response.status,
229
+ detail
230
+ };
231
+ } catch (error) {
232
+ var _ref;
233
+ return {
234
+ status: 'unknown',
235
+ detail: String((_ref = error == null ? void 0 : error.message) != null ? _ref : error).slice(0, 300)
236
+ };
237
+ }
238
+ }
239
+ /**
240
+ * Every sending domain on the account, with its verification and tracking
241
+ * state. Throws with the status when Resend does not answer 2xx, so a caller
242
+ * never reads "I could not look" as "there is nothing there".
243
+ */ export async function readResendSendingDomains(apiKey, endpoint = RESEND_DOMAINS_ENDPOINT) {
244
+ var _ref;
245
+ const response = await fetch(endpoint, {
246
+ method: 'GET',
247
+ headers: {
248
+ Authorization: `Bearer ${apiKey}`
249
+ }
250
+ });
251
+ if (response.status < 200 || response.status >= 300) {
252
+ throw new Error(`The provider answered ${response.status} to the domain read.`);
253
+ }
254
+ const payload = await response.json().catch(()=>null);
255
+ /**
256
+ * A tri-state, and the third state matters: a listing that does not carry
257
+ * the field must not be reported as the field being false. Off is a fault
258
+ * somebody should fix; unknown is a question this read could not ask.
259
+ */ const flag = (value)=>typeof value === 'boolean' ? value : null;
260
+ const domains = [];
261
+ for (const row of (_ref = payload == null ? void 0 : payload.data) != null ? _ref : []){
262
+ var _ref1, _ref2;
263
+ const name = String((_ref1 = row == null ? void 0 : row.name) != null ? _ref1 : '').toLowerCase();
264
+ if (!name) continue;
265
+ domains.push({
266
+ name,
267
+ status: String((_ref2 = row == null ? void 0 : row.status) != null ? _ref2 : 'unknown'),
268
+ clickTracking: flag(row == null ? void 0 : row.click_tracking),
269
+ openTracking: flag(row == null ? void 0 : row.open_tracking)
270
+ });
271
+ }
272
+ return domains;
273
+ }
274
+ /*==========================================
275
+ * THE DELIVERY FEED.
276
+ *=========================================*/ /** Epoch ms from an ISO string or a number, or `null` when unreadable. */ function eventTimeMs(value) {
277
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
278
+ const parsed = Date.parse(String(value != null ? value : ''));
279
+ return Number.isFinite(parsed) ? parsed : null;
280
+ }
281
+ function normalizeBounceType(value) {
282
+ const lowered = String(value != null ? value : '').trim().toLowerCase();
283
+ if (lowered === 'permanent') return 'permanent';
284
+ if (lowered === 'transient') return 'transient';
285
+ return lowered ? 'undetermined' : null;
286
+ }
287
+ /** The Resend event names we understand, mapped onto ours. */ const RESEND_EVENT_TYPES = {
288
+ 'email.sent': 'sent',
289
+ 'email.delivered': 'delivered',
290
+ 'email.delivery_delayed': 'delayed',
291
+ 'email.opened': 'opened',
292
+ 'email.clicked': 'clicked',
293
+ 'email.bounced': 'bounced',
294
+ 'email.complained': 'complained',
295
+ 'email.failed': 'failed'
296
+ };
297
+ /**
298
+ * Turns one Resend webhook payload into zero or more of our events.
299
+ *
300
+ * One event per recipient, not per message: a send addressed to three people
301
+ * produces one webhook, and a staff view keyed on a person has to be able to
302
+ * find it under each of them.
303
+ *
304
+ * Returns an empty array for anything unrecognised — a contact or domain
305
+ * event, an inbound `email.received`, a type added after this was written.
306
+ * Silence rather than a throw, because a webhook handler that 500s on an
307
+ * unfamiliar event teaches the provider to retry it forever.
308
+ *
309
+ * @param payload The parsed webhook body.
310
+ * @param receivedAtMs Fallback timestamp for a payload that carries none.
311
+ */ export function normalizeResendDeliveryEvents(payload, receivedAtMs) {
312
+ var _event_type, _event_data, _ref, _data_email_id, _ref1, _ref2, _ref3, _eventTimeMs, _data_subject;
313
+ var _data_click, _data_open, _data_bounce, _data_bounce1, _data_bounce2, _data_bounce3, _data_bounce4;
314
+ const event = payload != null ? payload : {};
315
+ const type = RESEND_EVENT_TYPES[String((_event_type = event.type) != null ? _event_type : '')];
316
+ if (!type) return [];
317
+ const data = (_event_data = event.data) != null ? _event_data : {};
318
+ const providerMessageId = String((_ref = (_data_email_id = data.email_id) != null ? _data_email_id : data.id) != null ? _ref : '').trim();
319
+ if (!providerMessageId) return [];
320
+ const recipients = (Array.isArray(data.to) ? data.to : [
321
+ data.to
322
+ ]).map((address)=>String(address != null ? address : '').trim().toLowerCase()).filter((address)=>address.includes('@'));
323
+ if (!recipients.length) return [];
324
+ // The per-state timestamp when the provider gives one, because an open
325
+ // three days after the send is the whole point of recording an open.
326
+ const at = (_ref1 = (_ref2 = (_ref3 = (_eventTimeMs = eventTimeMs((_data_click = data.click) == null ? void 0 : _data_click.timestamp)) != null ? _eventTimeMs : eventTimeMs((_data_open = data.open) == null ? void 0 : _data_open.timestamp)) != null ? _ref3 : eventTimeMs(event.created_at)) != null ? _ref2 : eventTimeMs(data.created_at)) != null ? _ref1 : receivedAtMs;
327
+ const subject = String((_data_subject = data.subject) != null ? _data_subject : '').trim() || null;
328
+ const tags = normalizeEventTags(data.tags);
329
+ const from = bareSenderAddress(data.from);
330
+ // The provider folds a DSN's fields into one sentence; the structured
331
+ // ones are read first where a payload carries them.
332
+ const bounceText = type === 'bounced' ? readBounceText([
333
+ (_data_bounce = data.bounce) == null ? void 0 : _data_bounce.diagnosticCode,
334
+ (_data_bounce1 = data.bounce) == null ? void 0 : _data_bounce1.diagnostic_code,
335
+ (_data_bounce2 = data.bounce) == null ? void 0 : _data_bounce2.remoteMta,
336
+ (_data_bounce3 = data.bounce) == null ? void 0 : _data_bounce3.remote_mta,
337
+ (_data_bounce4 = data.bounce) == null ? void 0 : _data_bounce4.message
338
+ ].filter((part)=>typeof part === 'string' && part.trim()).join(' ')) : null;
339
+ return recipients.map((to)=>{
340
+ var _ref, _ref1, _ref2;
341
+ var _data_click, _data_bounce, _data_bounce1, _data_failed;
342
+ return _extends({
343
+ type,
344
+ at,
345
+ provider: RESEND_PROVIDER_ID,
346
+ providerMessageId,
347
+ to,
348
+ subject,
349
+ context: tags['context'] || null,
350
+ tags,
351
+ link: type === 'clicked' ? String((_ref = (_data_click = data.click) == null ? void 0 : _data_click.link) != null ? _ref : '') || null : null,
352
+ bounceType: type === 'bounced' ? normalizeBounceType((_data_bounce = data.bounce) == null ? void 0 : _data_bounce.type) : null,
353
+ detail: String((_ref1 = (_ref2 = (_data_bounce1 = data.bounce) == null ? void 0 : _data_bounce1.message) != null ? _ref2 : (_data_failed = data.failed) == null ? void 0 : _data_failed.reason) != null ? _ref1 : '').trim() || null
354
+ }, from ? {
355
+ from
356
+ } : {}, (bounceText == null ? void 0 : bounceText.status) ? {
357
+ bounceStatus: bounceText.status
358
+ } : {}, (bounceText == null ? void 0 : bounceText.remoteMta) ? {
359
+ remoteMta: bounceText.remoteMta
360
+ } : {});
361
+ });
362
+ }
363
+ /*==========================================
364
+ * THE ACCOUNT'S HISTORY.
365
+ *
366
+ * Resend's list endpoint reports a single `last_event` per message and no
367
+ * engagement detail, so a snapshot can say "this was opened at least once"
368
+ * and can never say "three times" — see `EmailDeliverySnapshot`.
369
+ *=========================================*/ /**
370
+ * A `last_event` string, mapped onto our lifecycle.
371
+ *
372
+ * Deliberately the bare state names rather than the `email.*` event names:
373
+ * the list endpoint reports `"delivered"`, the webhook reports
374
+ * `"email.delivered"`, and they are two different vocabularies for one
375
+ * concept.
376
+ */ const RESEND_LAST_EVENTS = {
377
+ sent: 'sent',
378
+ delivered: 'delivered',
379
+ delivery_delayed: 'delayed',
380
+ opened: 'opened',
381
+ clicked: 'clicked',
382
+ bounced: 'bounced',
383
+ complained: 'complained',
384
+ failed: 'failed',
385
+ canceled: 'failed',
386
+ queued: 'sent',
387
+ scheduled: 'sent'
388
+ };
389
+ /**
390
+ * Turns one entry from Resend's `GET /emails` list into zero or more
391
+ * snapshots — one per recipient, for the same reason the event adapter fans
392
+ * out: the staff view is keyed on a person.
393
+ *
394
+ * An unrecognised `last_event` falls back to `sent` rather than being
395
+ * dropped. The message demonstrably exists and was addressed to somebody, and
396
+ * "we sent this and cannot characterise what happened next" is a far more
397
+ * useful row than no row — which is the state that sent a staffer to the
398
+ * vendor dashboard in the first place.
399
+ */ export function normalizeResendSentEmails(raw) {
400
+ var _record_id, _record_created_at, _RESEND_LAST_EVENTS_String_trim_toLowerCase, _record_last_event;
401
+ const record = raw != null ? raw : {};
402
+ const providerMessageId = String((_record_id = record.id) != null ? _record_id : '').trim();
403
+ if (!providerMessageId) return [];
404
+ const recipients = (Array.isArray(record.to) ? record.to : [
405
+ record.to
406
+ ]).map((address)=>String(address != null ? address : '').trim().toLowerCase()).filter((address)=>address.includes('@'));
407
+ if (!recipients.length) return [];
408
+ const parsed = Date.parse(String((_record_created_at = record.created_at) != null ? _record_created_at : ''));
409
+ const sentAt = Number.isFinite(parsed) ? parsed : 0;
410
+ // A snapshot with no timestamp cannot be ordered, and the log's read drops
411
+ // any document missing its sort key — so it is refused rather than written
412
+ // somewhere nothing will look for it.
413
+ if (!sentAt) return [];
414
+ const status = (_RESEND_LAST_EVENTS_String_trim_toLowerCase = RESEND_LAST_EVENTS[String((_record_last_event = record.last_event) != null ? _record_last_event : '').trim().toLowerCase()]) != null ? _RESEND_LAST_EVENTS_String_trim_toLowerCase : 'sent';
415
+ return recipients.map((to)=>{
416
+ var _record_subject;
417
+ return {
418
+ provider: RESEND_PROVIDER_ID,
419
+ providerMessageId,
420
+ to,
421
+ subject: String((_record_subject = record.subject) != null ? _record_subject : '').trim() || null,
422
+ sentAt,
423
+ status
424
+ };
425
+ });
426
+ }
427
+ /** The list endpoint. Paginates with `after=<id>`; caps at 100. */ export const RESEND_EMAILS_ENDPOINT = 'https://api.resend.com/emails';
428
+ /**
429
+ * {@link EmailDeliveryHistorySource} for Resend. Needs the full-access key.
430
+ *
431
+ * The list endpoint takes `limit`, `after` and `before` and no recipient
432
+ * filter, which is why the source is a cursor-paged sweep.
433
+ */ export function resendDeliveryHistorySource(apiKey) {
434
+ return async ({ cursor, limit } = {})=>{
435
+ var _ref;
436
+ var _entries_;
437
+ const params = new URLSearchParams({
438
+ limit: String(Math.min(Math.max(1, limit != null ? limit : 100), 100))
439
+ });
440
+ if (cursor) params.set('after', cursor);
441
+ const response = await fetch(`${RESEND_EMAILS_ENDPOINT}?${params}`, {
442
+ headers: {
443
+ Authorization: `Bearer ${apiKey}`
444
+ }
445
+ });
446
+ if (!response.ok) {
447
+ const detail = await response.text().catch(()=>'');
448
+ throw new Error(`email history read failed: HTTP ${response.status} ${detail.slice(0, 200)}`);
449
+ }
450
+ const body = await response.json();
451
+ const entries = Array.isArray(body == null ? void 0 : body.data) ? body.data : [];
452
+ const snapshots = entries.flatMap((entry)=>normalizeResendSentEmails(entry));
453
+ // The cursor is the LAST RAW entry's id, not the last snapshot's: a page
454
+ // whose final entry fanned out to zero snapshots (no recipient, no
455
+ // timestamp) would otherwise rewind the cursor to an earlier message and
456
+ // loop over the same page forever.
457
+ const lastId = String((_ref = (_entries_ = entries[entries.length - 1]) == null ? void 0 : _entries_.id) != null ? _ref : '').trim();
458
+ return {
459
+ snapshots,
460
+ nextCursor: (body == null ? void 0 : body.has_more) && lastId ? lastId : null
461
+ };
462
+ };
463
+ }
464
+ function addressList(raw) {
465
+ return (Array.isArray(raw) ? raw : raw == null ? [] : [
466
+ raw
467
+ ]).map((address)=>String(address != null ? address : '').trim()).filter(Boolean);
468
+ }
469
+ /** Resend's `GET /emails/:id` payload, in our vocabulary. */ export function normalizeResendMessage(raw) {
470
+ var _record_id, _record_created_at, _record_from, _record_subject, _RESEND_LAST_EVENTS_String_trim_toLowerCase, _record_last_event;
471
+ const record = raw != null ? raw : {};
472
+ const providerMessageId = String((_record_id = record.id) != null ? _record_id : '').trim();
473
+ if (!providerMessageId) return null;
474
+ const parsed = Date.parse(String((_record_created_at = record.created_at) != null ? _record_created_at : ''));
475
+ return {
476
+ provider: RESEND_PROVIDER_ID,
477
+ providerMessageId,
478
+ to: addressList(record.to),
479
+ cc: addressList(record.cc),
480
+ bcc: addressList(record.bcc),
481
+ from: String((_record_from = record.from) != null ? _record_from : '').trim() || null,
482
+ replyTo: addressList(record.reply_to).length ? addressList(record.reply_to) : null,
483
+ subject: String((_record_subject = record.subject) != null ? _record_subject : '').trim() || null,
484
+ // Empty string is NOT null here, and the difference is the point: a
485
+ // message that went out text-only really does have an empty HTML part,
486
+ // and a reader has to be able to tell that from "we could not fetch it".
487
+ html: typeof record.html === 'string' ? record.html : null,
488
+ text: typeof record.text === 'string' ? record.text : null,
489
+ sentAt: Number.isFinite(parsed) ? parsed : null,
490
+ status: (_RESEND_LAST_EVENTS_String_trim_toLowerCase = RESEND_LAST_EVENTS[String((_record_last_event = record.last_event) != null ? _record_last_event : '').trim().toLowerCase()]) != null ? _RESEND_LAST_EVENTS_String_trim_toLowerCase : null
491
+ };
492
+ }
493
+ /** Resend's single-message endpoint. Needs the full-access key. */ export function resendDeliveryMessageSource(apiKey) {
494
+ return async (providerMessageId)=>{
495
+ const response = await fetch(`${RESEND_EMAILS_ENDPOINT}/${encodeURIComponent(providerMessageId)}`, {
496
+ headers: {
497
+ Authorization: `Bearer ${apiKey}`
498
+ }
499
+ });
500
+ // A message the provider has aged out is a 404, and that is an ANSWER —
501
+ // "we know this was sent and the body is gone" — not a failure to report
502
+ // as an error the staffer must act on.
503
+ if (response.status === 404) return null;
504
+ if (!response.ok) {
505
+ const detail = await response.text().catch(()=>'');
506
+ throw new Error(`message read failed: HTTP ${response.status} ${detail.slice(0, 200)}`);
507
+ }
508
+ return normalizeResendMessage(await response.json());
509
+ };
510
+ }
511
+ /*==========================================
512
+ * RECEIVED MAIL.
513
+ *
514
+ * Resend announces a received message as an `email.received` webhook that
515
+ * carries only its metadata — who it was from and to, its subject, its
516
+ * `Message-ID` — and the message itself is read afterwards from the
517
+ * receiving API by the id the event named.
518
+ *=========================================*/ /** The webhook event type a received message arrives as. */ export const RESEND_RECEIVED_EVENT = 'email.received';
519
+ /** `GET {endpoint}/{id}` — one received message with its text and headers. */ export const RESEND_RECEIVING_ENDPOINT = 'https://api.resend.com/emails/receiving';
520
+ const list = (value)=>Array.isArray(value) ? value.map((entry)=>String(entry != null ? entry : '').trim()).filter((entry)=>entry !== '') : typeof value === 'string' && value.trim() ? [
521
+ value.trim()
522
+ ] : [];
523
+ const text = (value)=>typeof value === 'string' ? value : '';
524
+ /** A header by name, case-insensitively, off whatever map the provider sent. */ function header(headers, name) {
525
+ if (!headers || typeof headers !== 'object') return '';
526
+ const wanted = name.toLowerCase();
527
+ for (const [key, value] of Object.entries(headers)){
528
+ if (key.toLowerCase() === wanted) {
529
+ var _value_;
530
+ return Array.isArray(value) ? String((_value_ = value[0]) != null ? _value_ : '').trim() : String(value != null ? value : '').trim();
531
+ }
532
+ }
533
+ return '';
534
+ }
535
+ /**
536
+ * The provider's id of the message an `email.received` event announces,
537
+ * or `null` for any other event — including a malformed one, which is
538
+ * acknowledged and dropped rather than retried forever.
539
+ */ export function resendReceivedEventId(event) {
540
+ var _ref;
541
+ var _record_data;
542
+ const record = event;
543
+ if (!record || record.type !== RESEND_RECEIVED_EVENT) return null;
544
+ const id = String((_ref = (_record_data = record.data) == null ? void 0 : _record_data.email_id) != null ? _ref : '').trim();
545
+ return id || null;
546
+ }
547
+ /**
548
+ * Every recipient the event names — To, Cc, Bcc and the addresses the
549
+ * message was received for — so a route can find what it is looking for in
550
+ * the metadata before it spends a read on the body.
551
+ */ export function resendReceivedEventRecipients(event) {
552
+ var _ref;
553
+ const data = (_ref = event == null ? void 0 : event.data) != null ? _ref : {};
554
+ return [
555
+ ...list(data['to']),
556
+ ...list(data['cc']),
557
+ ...list(data['bcc']),
558
+ ...list(data['received_for'])
559
+ ];
560
+ }
561
+ /**
562
+ * One received message as the receiving API answers it, in the neutral
563
+ * shape. `null` for a payload with no id, which is not a message.
564
+ */ export function normalizeResendReceivedEmail(raw, nowMs = Date.now()) {
565
+ var _record_id, _record_created_at, _record_message_id, _record_from, _record_subject;
566
+ const record = raw && typeof raw === 'object' ? raw : {};
567
+ const id = String((_record_id = record['id']) != null ? _record_id : '').trim();
568
+ if (!id) return null;
569
+ const headers = record['headers'];
570
+ const created = Date.parse(String((_record_created_at = record['created_at']) != null ? _record_created_at : ''));
571
+ return {
572
+ id,
573
+ messageId: String((_record_message_id = record['message_id']) != null ? _record_message_id : '').trim() || header(headers, 'message-id'),
574
+ inReplyTo: header(headers, 'in-reply-to'),
575
+ from: String((_record_from = record['from']) != null ? _record_from : '').trim(),
576
+ to: list(record['to']),
577
+ cc: list(record['cc']),
578
+ bcc: list(record['bcc']),
579
+ receivedFor: list(record['received_for']),
580
+ subject: String((_record_subject = record['subject']) != null ? _record_subject : '').trim(),
581
+ text: text(record['text']),
582
+ html: text(record['html']),
583
+ receivedAtMs: Number.isFinite(created) && created > 0 ? created : nowMs
584
+ };
585
+ }
586
+ /**
587
+ * {@link ReceivedEmailSource} for Resend. Needs the full-access key. A `404`
588
+ * is `null` — the message expired or never existed — and anything else
589
+ * throws with the status, so the caller decides whether the provider should
590
+ * retry.
591
+ */ export function resendReceivedEmailSource(apiKey, fetchImpl = fetch) {
592
+ return async (id)=>{
593
+ const response = await fetchImpl(`${RESEND_RECEIVING_ENDPOINT}/${encodeURIComponent(id)}`, {
594
+ headers: {
595
+ Authorization: `Bearer ${apiKey}`
596
+ }
597
+ });
598
+ if (response.status === 404) return null;
599
+ if (!response.ok) {
600
+ const detail = await response.text().catch(()=>'');
601
+ throw new Error(`received email read failed: HTTP ${response.status} ${detail.slice(0, 200)}`);
602
+ }
603
+ return normalizeResendReceivedEmail(await response.json());
604
+ };
605
+ }
606
+ /** A received-mail notification, or `null` for any other event. */ export function resendInboundEvent(event) {
607
+ const id = resendReceivedEventId(event);
608
+ return id ? {
609
+ id,
610
+ recipients: resendReceivedEventRecipients(event)
611
+ } : null;
612
+ }
613
+ /*==========================================
614
+ * THE PROVIDER.
615
+ *=========================================*/ /**
616
+ * Resend as a {@link MailProvider}, reading its two keys from `env` at each
617
+ * call — never at module load, because these run in serverless handlers
618
+ * whose module may be evaluated during a build, long before the runtime
619
+ * environment exists.
620
+ */ export function resendMailProvider(env = process.env) {
621
+ const sendKey = ()=>setting(env, RESEND_SEND_KEY_SETTING);
622
+ const readKey = ()=>{
623
+ const key = setting(env, RESEND_READ_KEY_SETTING);
624
+ if (!key) throw new Error(`${RESEND_READ_KEY_SETTING} is not set`);
625
+ return key;
626
+ };
627
+ const reads = {
628
+ unmet: ()=>setting(env, RESEND_READ_KEY_SETTING) ? null : `Set ${RESEND_READ_KEY_SETTING} to a full-access key. The sending ` + 'key cannot read mail.',
629
+ // Async, so a missing key REJECTS like every other failed read rather
630
+ // than throwing before the caller has a promise to catch.
631
+ history: async (options)=>resendDeliveryHistorySource(readKey())(options),
632
+ message: async (id)=>resendDeliveryMessageSource(readKey())(id),
633
+ received: async (id)=>resendReceivedEmailSource(readKey())(id),
634
+ sendingDomains: async ()=>readResendSendingDomains(readKey())
635
+ };
636
+ return {
637
+ id: RESEND_PROVIDER_ID,
638
+ missingSettings: ()=>sendKey() ? [] : [
639
+ RESEND_SEND_KEY_SETTING
640
+ ],
641
+ send: (message, context)=>sendThroughResend(sendKey(), message, context),
642
+ checkCredentials: ()=>checkResendCredentials(sendKey()),
643
+ deliveryEvents: normalizeResendDeliveryEvents,
644
+ inboundEvent: resendInboundEvent,
645
+ reads
646
+ };
647
+ }
648
+
649
+ //# sourceMappingURL=mail-provider-resend.js.map