@upyo/lettermint 0.5.0-dev.0
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/LICENSE +20 -0
- package/README.md +105 -0
- package/dist/index.cjs +496 -0
- package/dist/index.d.cts +234 -0
- package/dist/index.d.ts +234 -0
- package/dist/index.js +492 -0
- package/package.json +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright 2025 Hong Minhee
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<!-- deno-fmt-ignore-file -->
|
|
2
|
+
|
|
3
|
+
@upyo/lettermint
|
|
4
|
+
================
|
|
5
|
+
|
|
6
|
+
[![JSR][JSR badge]][JSR]
|
|
7
|
+
[![npm][npm badge]][npm]
|
|
8
|
+
|
|
9
|
+
[Lettermint] transport for the [Upyo] email library.
|
|
10
|
+
|
|
11
|
+
[JSR badge]: https://jsr.io/badges/@upyo/lettermint
|
|
12
|
+
[JSR]: https://jsr.io/@upyo/lettermint
|
|
13
|
+
[npm badge]: https://img.shields.io/npm/v/@upyo/lettermint?logo=npm
|
|
14
|
+
[npm]: https://www.npmjs.com/package/@upyo/lettermint
|
|
15
|
+
[Lettermint]: https://lettermint.co/
|
|
16
|
+
[Upyo]: https://upyo.org/
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Features
|
|
20
|
+
--------
|
|
21
|
+
|
|
22
|
+
- Single and batch email sending via Lettermint's HTTP API
|
|
23
|
+
- Idempotency key support through `Message.idempotencyKey`
|
|
24
|
+
- Cross-runtime compatibility (Node.js, Deno, Bun, edge functions)
|
|
25
|
+
- Rich content support: HTML emails, attachments, inline images, and custom
|
|
26
|
+
headers
|
|
27
|
+
- Lettermint route, tag, metadata, and tracking settings
|
|
28
|
+
- Retry logic with exponential backoff
|
|
29
|
+
- Type-safe configuration with sensible defaults
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
Installation
|
|
33
|
+
------------
|
|
34
|
+
|
|
35
|
+
~~~~ sh
|
|
36
|
+
npm add @upyo/core @upyo/lettermint
|
|
37
|
+
pnpm add @upyo/core @upyo/lettermint
|
|
38
|
+
yarn add @upyo/core @upyo/lettermint
|
|
39
|
+
deno add --jsr @upyo/core @upyo/lettermint
|
|
40
|
+
bun add @upyo/core @upyo/lettermint
|
|
41
|
+
~~~~
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
Usage
|
|
45
|
+
-----
|
|
46
|
+
|
|
47
|
+
~~~~ typescript
|
|
48
|
+
import { createMessage } from "@upyo/core";
|
|
49
|
+
import { LettermintTransport } from "@upyo/lettermint";
|
|
50
|
+
import process from "node:process";
|
|
51
|
+
|
|
52
|
+
const message = createMessage({
|
|
53
|
+
from: "sender@example.com",
|
|
54
|
+
to: "recipient@example.net",
|
|
55
|
+
subject: "Hello from Upyo!",
|
|
56
|
+
content: { text: "This is a test email." },
|
|
57
|
+
idempotencyKey: "welcome-recipient-example-net",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const transport = new LettermintTransport({
|
|
61
|
+
apiToken: process.env.LETTERMINT_API_TOKEN!,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const receipt = await transport.send(message);
|
|
65
|
+
if (receipt.successful) {
|
|
66
|
+
console.log("Message sent with ID:", receipt.messageId);
|
|
67
|
+
} else {
|
|
68
|
+
console.error("Send failed:", receipt.errorMessages.join(", "));
|
|
69
|
+
}
|
|
70
|
+
~~~~
|
|
71
|
+
|
|
72
|
+
### Sending multiple emails
|
|
73
|
+
|
|
74
|
+
~~~~ typescript
|
|
75
|
+
const messages = [message1, message2, message3];
|
|
76
|
+
|
|
77
|
+
for await (const receipt of transport.sendMany(messages)) {
|
|
78
|
+
if (receipt.successful) {
|
|
79
|
+
console.log(`Email sent with ID: ${receipt.messageId}`);
|
|
80
|
+
} else {
|
|
81
|
+
console.error(`Email failed: ${receipt.errorMessages.join(", ")}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
~~~~
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
Configuration
|
|
88
|
+
-------------
|
|
89
|
+
|
|
90
|
+
See the [Lettermint docs] for more information about configuration options.
|
|
91
|
+
|
|
92
|
+
[Lettermint docs]: https://docs.lettermint.co/
|
|
93
|
+
|
|
94
|
+
### Available options
|
|
95
|
+
|
|
96
|
+
- `apiToken`: Your Lettermint project sending API token
|
|
97
|
+
- `baseUrl`: Lettermint API base URL (default:
|
|
98
|
+
`https://api.lettermint.co`)
|
|
99
|
+
- `timeout`: Request timeout in milliseconds (default: `30000`)
|
|
100
|
+
- `retries`: Number of retry attempts (default: `3`)
|
|
101
|
+
- `headers`: Additional HTTP headers
|
|
102
|
+
- `route`: Lettermint route for sent messages
|
|
103
|
+
- `tag`: Default Lettermint tag when a message has no `Message.tags`
|
|
104
|
+
- `metadata`: Metadata to track with sent messages
|
|
105
|
+
- `settings`: Lettermint tracking settings (`trackOpens`, `trackClicks`)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/config.ts
|
|
3
|
+
/**
|
|
4
|
+
* Creates a resolved Lettermint configuration by applying default values.
|
|
5
|
+
*
|
|
6
|
+
* @param config The Lettermint configuration with optional fields.
|
|
7
|
+
* @returns A resolved configuration with all defaults applied.
|
|
8
|
+
* @since 0.5.0
|
|
9
|
+
*/
|
|
10
|
+
function createLettermintConfig(config) {
|
|
11
|
+
return {
|
|
12
|
+
apiToken: config.apiToken,
|
|
13
|
+
baseUrl: config.baseUrl ?? "https://api.lettermint.co",
|
|
14
|
+
timeout: config.timeout ?? 3e4,
|
|
15
|
+
retries: config.retries ?? 3,
|
|
16
|
+
headers: config.headers ?? {},
|
|
17
|
+
route: config.route,
|
|
18
|
+
tag: config.tag,
|
|
19
|
+
metadata: config.metadata == null ? void 0 : { ...config.metadata },
|
|
20
|
+
settings: config.settings == null ? void 0 : { ...config.settings }
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/http-client.ts
|
|
26
|
+
/**
|
|
27
|
+
* Lettermint API error class for API-specific failures.
|
|
28
|
+
*
|
|
29
|
+
* @since 0.5.0
|
|
30
|
+
*/
|
|
31
|
+
var LettermintApiError = class extends Error {
|
|
32
|
+
statusCode;
|
|
33
|
+
/**
|
|
34
|
+
* Creates a Lettermint API error.
|
|
35
|
+
*
|
|
36
|
+
* @param message Error message.
|
|
37
|
+
* @param statusCode HTTP status code.
|
|
38
|
+
*/
|
|
39
|
+
constructor(message, statusCode) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "LettermintApiError";
|
|
42
|
+
this.statusCode = statusCode;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Lettermint request timeout error.
|
|
47
|
+
*
|
|
48
|
+
* @since 0.5.0
|
|
49
|
+
*/
|
|
50
|
+
var LettermintTimeoutError = class extends Error {
|
|
51
|
+
timeout;
|
|
52
|
+
/**
|
|
53
|
+
* Creates a Lettermint request timeout error.
|
|
54
|
+
*
|
|
55
|
+
* @param timeout Request timeout in milliseconds.
|
|
56
|
+
*/
|
|
57
|
+
constructor(timeout) {
|
|
58
|
+
super(`Lettermint API request timed out after ${timeout} ms.`);
|
|
59
|
+
this.name = "LettermintTimeoutError";
|
|
60
|
+
this.timeout = timeout;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* HTTP client wrapper for Lettermint API requests.
|
|
65
|
+
*
|
|
66
|
+
* @since 0.5.0
|
|
67
|
+
*/
|
|
68
|
+
var LettermintHttpClient = class {
|
|
69
|
+
config;
|
|
70
|
+
/**
|
|
71
|
+
* Creates a new Lettermint HTTP client.
|
|
72
|
+
*
|
|
73
|
+
* @param config Resolved Lettermint configuration.
|
|
74
|
+
*/
|
|
75
|
+
constructor(config) {
|
|
76
|
+
this.config = config;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Sends a single message via Lettermint API.
|
|
80
|
+
*
|
|
81
|
+
* @param messageData The JSON data to send to Lettermint.
|
|
82
|
+
* @param signal Optional AbortSignal for cancellation.
|
|
83
|
+
* @param idempotencyKey Optional idempotency key for request deduplication.
|
|
84
|
+
* @returns Promise that resolves to the Lettermint response.
|
|
85
|
+
*/
|
|
86
|
+
sendMessage(messageData, signal, idempotencyKey) {
|
|
87
|
+
const url = `${this.config.baseUrl}/v1/send`;
|
|
88
|
+
return this.makeRequest(url, messageData, signal, idempotencyKey);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Sends multiple messages via Lettermint batch API.
|
|
92
|
+
*
|
|
93
|
+
* @param messagesData The messages to send to Lettermint.
|
|
94
|
+
* @param signal Optional AbortSignal for cancellation.
|
|
95
|
+
* @param idempotencyKey Optional idempotency key for request deduplication.
|
|
96
|
+
* @returns Promise that resolves to the Lettermint batch response.
|
|
97
|
+
*/
|
|
98
|
+
sendBatch(messagesData, signal, idempotencyKey) {
|
|
99
|
+
const url = `${this.config.baseUrl}/v1/send/batch`;
|
|
100
|
+
return this.makeRequest(url, messagesData, signal, idempotencyKey);
|
|
101
|
+
}
|
|
102
|
+
async makeRequest(url, body, signal, idempotencyKey) {
|
|
103
|
+
let lastError = null;
|
|
104
|
+
for (let attempt = 0; attempt <= this.config.retries; attempt++) {
|
|
105
|
+
signal?.throwIfAborted();
|
|
106
|
+
try {
|
|
107
|
+
const response = await this.fetchWithAuth(url, body, signal, idempotencyKey);
|
|
108
|
+
const text = await response.text();
|
|
109
|
+
if (!response.ok) throw new LettermintApiError(parseErrorMessage(text, response.status), response.status);
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(text);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
throw new SyntaxError(`Invalid JSON response from Lettermint API: ${error instanceof Error ? error.message : String(error)}.`);
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
117
|
+
if (error instanceof LettermintApiError && !isRetryable(error)) throw error;
|
|
118
|
+
if (error instanceof Error && error.name === "AbortError" && signal?.aborted) throw error;
|
|
119
|
+
if (attempt === this.config.retries) throw lastError;
|
|
120
|
+
await sleep(calculateRetryDelay(attempt), signal);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
throw lastError ?? /* @__PURE__ */ new Error("Request failed after all retry attempts.");
|
|
124
|
+
}
|
|
125
|
+
async fetchWithAuth(url, body, signal, idempotencyKey) {
|
|
126
|
+
const headers = new Headers({
|
|
127
|
+
"Content-Type": "application/json",
|
|
128
|
+
"x-lettermint-token": this.config.apiToken
|
|
129
|
+
});
|
|
130
|
+
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
|
|
131
|
+
for (const [key, value] of Object.entries(this.config.headers)) headers.set(key, value);
|
|
132
|
+
const timeoutController = new AbortController();
|
|
133
|
+
const timeoutId = this.config.timeout > 0 ? setTimeout(() => timeoutController.abort(), this.config.timeout) : void 0;
|
|
134
|
+
const requestSignal = combineSignals(timeoutController.signal, signal);
|
|
135
|
+
try {
|
|
136
|
+
return await globalThis.fetch(url, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers,
|
|
139
|
+
body: JSON.stringify(body),
|
|
140
|
+
signal: requestSignal.signal
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof Error && error.name === "AbortError" && timeoutController.signal.aborted && !signal?.aborted) throw new LettermintTimeoutError(this.config.timeout);
|
|
144
|
+
throw error;
|
|
145
|
+
} finally {
|
|
146
|
+
requestSignal.cleanup();
|
|
147
|
+
if (timeoutId !== void 0) clearTimeout(timeoutId);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
function isRetryable(error) {
|
|
152
|
+
return error.statusCode === 408 || error.statusCode === 429 || error.statusCode >= 500;
|
|
153
|
+
}
|
|
154
|
+
function calculateRetryDelay(attempt) {
|
|
155
|
+
const baseDelay = Math.min(1e3 * Math.pow(2, attempt), 1e4);
|
|
156
|
+
return Math.round(baseDelay / 2 + Math.random() * (baseDelay / 2));
|
|
157
|
+
}
|
|
158
|
+
function parseErrorMessage(text, statusCode) {
|
|
159
|
+
try {
|
|
160
|
+
const errorBody = JSON.parse(text);
|
|
161
|
+
if (typeof errorBody.message === "string" && errorBody.message !== "") return errorBody.message;
|
|
162
|
+
if (typeof errorBody.error === "string" && errorBody.error !== "") return errorBody.error;
|
|
163
|
+
if (Array.isArray(errorBody.errors) && errorBody.errors.length > 0) return JSON.stringify(errorBody.errors);
|
|
164
|
+
} catch {}
|
|
165
|
+
return text || `HTTP ${statusCode}`;
|
|
166
|
+
}
|
|
167
|
+
function combineSignals(timeoutSignal, externalSignal) {
|
|
168
|
+
if (externalSignal == null) return {
|
|
169
|
+
signal: timeoutSignal,
|
|
170
|
+
cleanup: () => {}
|
|
171
|
+
};
|
|
172
|
+
if (typeof AbortSignal.any === "function") return {
|
|
173
|
+
signal: AbortSignal.any([timeoutSignal, externalSignal]),
|
|
174
|
+
cleanup: () => {}
|
|
175
|
+
};
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
const abort = () => controller.abort();
|
|
178
|
+
timeoutSignal.addEventListener("abort", abort, { once: true });
|
|
179
|
+
externalSignal.addEventListener("abort", abort, { once: true });
|
|
180
|
+
if (timeoutSignal.aborted || externalSignal.aborted) controller.abort();
|
|
181
|
+
return {
|
|
182
|
+
signal: controller.signal,
|
|
183
|
+
cleanup: () => {
|
|
184
|
+
timeoutSignal.removeEventListener("abort", abort);
|
|
185
|
+
externalSignal.removeEventListener("abort", abort);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function sleep(ms, signal) {
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
if (signal?.aborted) {
|
|
192
|
+
reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const onAbort = () => {
|
|
196
|
+
clearTimeout(timeoutId);
|
|
197
|
+
signal?.removeEventListener("abort", onAbort);
|
|
198
|
+
reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
199
|
+
};
|
|
200
|
+
const timeoutId = setTimeout(() => {
|
|
201
|
+
signal?.removeEventListener("abort", onAbort);
|
|
202
|
+
resolve();
|
|
203
|
+
}, ms);
|
|
204
|
+
if (signal?.aborted) {
|
|
205
|
+
onAbort();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/message-converter.ts
|
|
214
|
+
const STANDARD_HEADERS = new Set([
|
|
215
|
+
"from",
|
|
216
|
+
"to",
|
|
217
|
+
"cc",
|
|
218
|
+
"bcc",
|
|
219
|
+
"reply-to",
|
|
220
|
+
"subject",
|
|
221
|
+
"date",
|
|
222
|
+
"message-id",
|
|
223
|
+
"content-type",
|
|
224
|
+
"content-transfer-encoding",
|
|
225
|
+
"mime-version",
|
|
226
|
+
"x-priority"
|
|
227
|
+
]);
|
|
228
|
+
/**
|
|
229
|
+
* Converts an Upyo message to Lettermint API JSON format.
|
|
230
|
+
*
|
|
231
|
+
* @param message The Upyo message to convert.
|
|
232
|
+
* @param config The resolved Lettermint configuration.
|
|
233
|
+
* @returns JSON object ready for Lettermint API submission.
|
|
234
|
+
* @throws {RangeError} If the message has more than one tag.
|
|
235
|
+
* @since 0.5.0
|
|
236
|
+
*/
|
|
237
|
+
async function convertMessage(message, config) {
|
|
238
|
+
if (message.tags.length > 1) throw new RangeError("Lettermint supports at most one tag per message.");
|
|
239
|
+
const emailData = {
|
|
240
|
+
from: formatAddress(message.sender),
|
|
241
|
+
to: message.recipients.map(formatAddress),
|
|
242
|
+
subject: message.subject
|
|
243
|
+
};
|
|
244
|
+
if (message.ccRecipients.length > 0) emailData.cc = message.ccRecipients.map(formatAddress);
|
|
245
|
+
if (message.bccRecipients.length > 0) emailData.bcc = message.bccRecipients.map(formatAddress);
|
|
246
|
+
if (message.replyRecipients.length > 0) emailData.reply_to = message.replyRecipients.map(formatAddress);
|
|
247
|
+
if ("html" in message.content) {
|
|
248
|
+
emailData.html = message.content.html;
|
|
249
|
+
if (message.content.text) emailData.text = message.content.text;
|
|
250
|
+
} else emailData.text = message.content.text;
|
|
251
|
+
if (config.route) emailData.route = config.route;
|
|
252
|
+
if (message.tags.length === 1) emailData.tag = message.tags[0];
|
|
253
|
+
else if (config.tag !== void 0) emailData.tag = config.tag;
|
|
254
|
+
if (config.metadata != null && Object.keys(config.metadata).length > 0) emailData.metadata = { ...config.metadata };
|
|
255
|
+
const settings = convertSettings(config);
|
|
256
|
+
if (settings != null) emailData.settings = settings;
|
|
257
|
+
const headers = {};
|
|
258
|
+
if (message.priority !== "normal") {
|
|
259
|
+
const priorityMap = {
|
|
260
|
+
"high": "1",
|
|
261
|
+
"normal": "3",
|
|
262
|
+
"low": "5"
|
|
263
|
+
};
|
|
264
|
+
headers["X-Priority"] = priorityMap[message.priority];
|
|
265
|
+
}
|
|
266
|
+
for (const [key, value] of message.headers.entries()) if (!isStandardHeader(key)) headers[key] = value;
|
|
267
|
+
if (Object.keys(headers).length > 0) emailData.headers = headers;
|
|
268
|
+
if (message.attachments.length > 0) emailData.attachments = await Promise.all(message.attachments.map(convertAttachment));
|
|
269
|
+
return emailData;
|
|
270
|
+
}
|
|
271
|
+
function convertSettings(config) {
|
|
272
|
+
if (config.settings == null) return void 0;
|
|
273
|
+
const settings = {};
|
|
274
|
+
if (config.settings.trackOpens !== void 0) settings.track_opens = config.settings.trackOpens;
|
|
275
|
+
if (config.settings.trackClicks !== void 0) settings.track_clicks = config.settings.trackClicks;
|
|
276
|
+
return Object.keys(settings).length > 0 ? settings : void 0;
|
|
277
|
+
}
|
|
278
|
+
function formatAddress(address) {
|
|
279
|
+
if (address.name) {
|
|
280
|
+
const escapedName = address.name.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
281
|
+
return `"${escapedName}" <${address.address}>`;
|
|
282
|
+
}
|
|
283
|
+
return address.address;
|
|
284
|
+
}
|
|
285
|
+
async function convertAttachment(attachment) {
|
|
286
|
+
const content = await attachment.content;
|
|
287
|
+
const converted = {
|
|
288
|
+
filename: attachment.filename,
|
|
289
|
+
content: uint8ArrayToBase64(content),
|
|
290
|
+
content_type: attachment.contentType
|
|
291
|
+
};
|
|
292
|
+
if (attachment.inline && attachment.contentId) converted.content_id = attachment.contentId;
|
|
293
|
+
return converted;
|
|
294
|
+
}
|
|
295
|
+
function uint8ArrayToBase64(bytes) {
|
|
296
|
+
const chunkSize = 32768;
|
|
297
|
+
const chunks = [];
|
|
298
|
+
for (let offset = 0; offset < bytes.length; offset += chunkSize) chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)));
|
|
299
|
+
return btoa(chunks.join(""));
|
|
300
|
+
}
|
|
301
|
+
function isStandardHeader(headerName) {
|
|
302
|
+
return STANDARD_HEADERS.has(headerName.toLowerCase());
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Generates a random idempotency key for request deduplication.
|
|
306
|
+
*
|
|
307
|
+
* @returns A unique string suitable for use as an idempotency key.
|
|
308
|
+
* @since 0.5.0
|
|
309
|
+
*/
|
|
310
|
+
function generateIdempotencyKey() {
|
|
311
|
+
return globalThis.crypto.randomUUID();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/lettermint-transport.ts
|
|
316
|
+
const MAX_BATCH_SIZE = 500;
|
|
317
|
+
/**
|
|
318
|
+
* Lettermint transport implementation for sending emails via Lettermint API.
|
|
319
|
+
*
|
|
320
|
+
* @example
|
|
321
|
+
* ```typescript
|
|
322
|
+
* import { createMessage } from "@upyo/core";
|
|
323
|
+
* import { LettermintTransport } from "@upyo/lettermint";
|
|
324
|
+
*
|
|
325
|
+
* const transport = new LettermintTransport({
|
|
326
|
+
* apiToken: "your-project-api-token",
|
|
327
|
+
* });
|
|
328
|
+
*
|
|
329
|
+
* const receipt = await transport.send(createMessage({
|
|
330
|
+
* from: "sender@example.com",
|
|
331
|
+
* to: "recipient@example.com",
|
|
332
|
+
* subject: "Hello from Lettermint",
|
|
333
|
+
* content: { text: "Hello!" },
|
|
334
|
+
* }));
|
|
335
|
+
* ```
|
|
336
|
+
*
|
|
337
|
+
* @since 0.5.0
|
|
338
|
+
*/
|
|
339
|
+
var LettermintTransport = class {
|
|
340
|
+
/**
|
|
341
|
+
* The resolved Lettermint configuration used by this transport.
|
|
342
|
+
*/
|
|
343
|
+
config;
|
|
344
|
+
httpClient;
|
|
345
|
+
/**
|
|
346
|
+
* Creates a new Lettermint transport instance.
|
|
347
|
+
*
|
|
348
|
+
* @param config Lettermint configuration including API token and options.
|
|
349
|
+
*/
|
|
350
|
+
constructor(config) {
|
|
351
|
+
this.config = createLettermintConfig(config);
|
|
352
|
+
this.httpClient = new LettermintHttpClient(this.config);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Sends a single email message via Lettermint API.
|
|
356
|
+
*
|
|
357
|
+
* @param message The email message to send.
|
|
358
|
+
* @param options Optional transport options including `AbortSignal`.
|
|
359
|
+
* @returns A receipt indicating success or failure.
|
|
360
|
+
*/
|
|
361
|
+
async send(message, options) {
|
|
362
|
+
try {
|
|
363
|
+
options?.signal?.throwIfAborted();
|
|
364
|
+
const emailData = await convertMessage(message, this.config);
|
|
365
|
+
const idempotencyKey = normalizeIdempotencyKey(message.idempotencyKey);
|
|
366
|
+
options?.signal?.throwIfAborted();
|
|
367
|
+
const response = await this.httpClient.sendMessage(emailData, options?.signal, idempotencyKey);
|
|
368
|
+
return responseToReceipt(response);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (isAbortError(error)) throw error;
|
|
371
|
+
return {
|
|
372
|
+
successful: false,
|
|
373
|
+
errorMessages: [error instanceof Error ? error.message : String(error)]
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Sends multiple email messages via Lettermint batch API.
|
|
379
|
+
*
|
|
380
|
+
* Messages are chunked into Lettermint's maximum batch size of 500 messages.
|
|
381
|
+
*
|
|
382
|
+
* @param messages An iterable or async iterable of messages to send.
|
|
383
|
+
* @param options Optional transport options including `AbortSignal`.
|
|
384
|
+
* @returns An async iterable of receipts, one for each message.
|
|
385
|
+
*/
|
|
386
|
+
async *sendMany(messages, options) {
|
|
387
|
+
options?.signal?.throwIfAborted();
|
|
388
|
+
let chunk = [];
|
|
389
|
+
for await (const message of messages) {
|
|
390
|
+
options?.signal?.throwIfAborted();
|
|
391
|
+
chunk.push(message);
|
|
392
|
+
if (chunk.length === MAX_BATCH_SIZE) {
|
|
393
|
+
yield* this.sendBatch(chunk, options);
|
|
394
|
+
chunk = [];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
yield* this.sendBatch(chunk, options);
|
|
398
|
+
}
|
|
399
|
+
async *sendBatch(messages, options) {
|
|
400
|
+
if (messages.length === 0) return;
|
|
401
|
+
if (messages.some(hasIdempotencyKey)) {
|
|
402
|
+
for (const message of messages) yield await this.send(message, options);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const idempotencyKey = normalizeIdempotencyKey(messages[0]?.idempotencyKey);
|
|
406
|
+
const batchData = [];
|
|
407
|
+
const receipts = [];
|
|
408
|
+
for (const message of messages) try {
|
|
409
|
+
batchData.push(await convertMessage(message, this.config));
|
|
410
|
+
receipts.push(void 0);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
receipts.push({
|
|
413
|
+
successful: false,
|
|
414
|
+
errorMessages: [error instanceof Error ? error.message : String(error)]
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
if (batchData.length === 0) {
|
|
418
|
+
for (const receipt of receipts) if (receipt !== void 0) yield receipt;
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
options?.signal?.throwIfAborted();
|
|
423
|
+
const response = await this.httpClient.sendBatch(batchData, options?.signal, idempotencyKey);
|
|
424
|
+
let responseIndex = 0;
|
|
425
|
+
for (let index = 0; index < receipts.length; index++) {
|
|
426
|
+
const receipt = receipts[index];
|
|
427
|
+
if (receipt !== void 0) {
|
|
428
|
+
yield receipt;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const result = response[responseIndex++];
|
|
432
|
+
yield responseToReceipt(result);
|
|
433
|
+
}
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (isAbortError(error)) throw error;
|
|
436
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
437
|
+
for (const receipt of receipts) {
|
|
438
|
+
if (receipt !== void 0) {
|
|
439
|
+
yield receipt;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
yield {
|
|
443
|
+
successful: false,
|
|
444
|
+
errorMessages: [errorMessage]
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
function normalizeIdempotencyKey(idempotencyKey) {
|
|
451
|
+
return idempotencyKey != null && idempotencyKey !== "" ? idempotencyKey : generateIdempotencyKey();
|
|
452
|
+
}
|
|
453
|
+
function hasIdempotencyKey(message) {
|
|
454
|
+
return message.idempotencyKey != null && message.idempotencyKey !== "";
|
|
455
|
+
}
|
|
456
|
+
function responseToReceipt(response) {
|
|
457
|
+
if (response?.message_id == null || response.message_id === "") return {
|
|
458
|
+
successful: false,
|
|
459
|
+
errorMessages: ["Lettermint response is missing a message ID."]
|
|
460
|
+
};
|
|
461
|
+
if (isSuccessfulStatus(response.status)) return {
|
|
462
|
+
successful: true,
|
|
463
|
+
messageId: response.message_id
|
|
464
|
+
};
|
|
465
|
+
return {
|
|
466
|
+
successful: false,
|
|
467
|
+
errorMessages: [`Lettermint reported message status "${response.status}".`]
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
function isSuccessfulStatus(status) {
|
|
471
|
+
switch (status) {
|
|
472
|
+
case "pending":
|
|
473
|
+
case "queued":
|
|
474
|
+
case "processed":
|
|
475
|
+
case "delivered":
|
|
476
|
+
case "opened":
|
|
477
|
+
case "clicked": return true;
|
|
478
|
+
case "suppressed":
|
|
479
|
+
case "soft_bounced":
|
|
480
|
+
case "hard_bounced":
|
|
481
|
+
case "spam_complaint":
|
|
482
|
+
case "failed":
|
|
483
|
+
case "blocked":
|
|
484
|
+
case "policy_rejected":
|
|
485
|
+
case "unsubscribed": return false;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function isAbortError(error) {
|
|
489
|
+
return error instanceof Error && error.name === "AbortError";
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
//#endregion
|
|
493
|
+
exports.LettermintApiError = LettermintApiError;
|
|
494
|
+
exports.LettermintTimeoutError = LettermintTimeoutError;
|
|
495
|
+
exports.LettermintTransport = LettermintTransport;
|
|
496
|
+
exports.createLettermintConfig = createLettermintConfig;
|