@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.
- 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-deliverability.d.ts +9 -2
- package/src/lib/email-deliverability.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/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/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.js +7 -1
- 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 +36 -58
- package/src/lib/send-email.js +102 -133
- 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/system-email-catalog.js +3 -3
- 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 +594 -0
- 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
|