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