@upyo/maileroo 0.6.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 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,102 @@
1
+ <!-- deno-fmt-ignore-file -->
2
+
3
+ @upyo/maileroo
4
+ ==============
5
+
6
+ [![JSR][JSR badge]][JSR]
7
+ [![npm][npm badge]][npm]
8
+
9
+ [Maileroo] transport for the [Upyo] email library.
10
+
11
+ [JSR badge]: https://jsr.io/badges/@upyo/maileroo
12
+ [JSR]: https://jsr.io/@upyo/maileroo
13
+ [npm badge]: https://img.shields.io/npm/v/@upyo/maileroo?logo=npm
14
+ [npm]: https://www.npmjs.com/package/@upyo/maileroo
15
+ [Maileroo]: https://maileroo.com/
16
+ [Upyo]: https://upyo.org/
17
+
18
+
19
+ Features
20
+ --------
21
+
22
+ - Single email sending via Maileroo's JSON Email API
23
+ - Sequential `sendMany()` support through Upyo's transport interface
24
+ - Cross-runtime compatibility (Node.js, Deno, Bun, edge functions)
25
+ - Rich content support: HTML emails, attachments, inline images, and custom
26
+ headers
27
+ - Maileroo tags 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/maileroo
37
+ pnpm add @upyo/core @upyo/maileroo
38
+ yarn add @upyo/core @upyo/maileroo
39
+ deno add --jsr @upyo/core @upyo/maileroo
40
+ bun add @upyo/core @upyo/maileroo
41
+ ~~~~
42
+
43
+
44
+ Usage
45
+ -----
46
+
47
+ ~~~~ typescript
48
+ import { createMessage } from "@upyo/core";
49
+ import { MailerooTransport } from "@upyo/maileroo";
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
+ });
58
+
59
+ const transport = new MailerooTransport({
60
+ apiKey: process.env.MAILEROO_API_KEY!,
61
+ });
62
+
63
+ const receipt = await transport.send(message);
64
+ if (receipt.successful) {
65
+ console.log("Message sent with ID:", receipt.messageId);
66
+ } else {
67
+ console.error("Send failed:", receipt.errorMessages.join(", "));
68
+ }
69
+ ~~~~
70
+
71
+ ### Sending multiple emails
72
+
73
+ ~~~~ typescript
74
+ const messages = [message1, message2, message3];
75
+
76
+ for await (const receipt of transport.sendMany(messages)) {
77
+ if (receipt.successful) {
78
+ console.log(`Email sent with ID: ${receipt.messageId}`);
79
+ } else {
80
+ console.error(`Email failed: ${receipt.errorMessages.join(", ")}`);
81
+ }
82
+ }
83
+ ~~~~
84
+
85
+
86
+ Configuration
87
+ -------------
88
+
89
+ See the [Maileroo docs] for more information about configuration options.
90
+
91
+ [Maileroo docs]: https://maileroo.com/docs/email-api/introduction/
92
+
93
+ ### Available options
94
+
95
+ - `apiKey`: Your Maileroo sending key
96
+ - `baseUrl`: Maileroo Email API base URL (default:
97
+ `https://smtp.maileroo.com/api/v2`)
98
+ - `timeout`: Request timeout in milliseconds (default: `30000`)
99
+ - `retries`: Number of retry attempts (default: `3`)
100
+ - `headers`: Additional HTTP request headers
101
+ - `tracking`: Whether to enable Maileroo open and click tracking
102
+ - `tags`: Default Maileroo tags for sent messages
package/dist/index.cjs ADDED
@@ -0,0 +1,462 @@
1
+ //#region rolldown:runtime
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
10
+ key = keys[i];
11
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
12
+ get: ((k) => from[k]).bind(null, key),
13
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
14
+ });
15
+ }
16
+ return to;
17
+ };
18
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
19
+ value: mod,
20
+ enumerable: true
21
+ }) : target, mod));
22
+
23
+ //#endregion
24
+ const __upyo_core = __toESM(require("@upyo/core"));
25
+
26
+ //#region src/config.ts
27
+ /**
28
+ * Creates a resolved Maileroo configuration by applying default values.
29
+ *
30
+ * @param config The Maileroo configuration with optional fields.
31
+ * @returns A resolved configuration with all defaults applied.
32
+ * @since 0.6.0
33
+ */
34
+ function createMailerooConfig(config) {
35
+ return {
36
+ apiKey: config.apiKey,
37
+ baseUrl: normalizeBaseUrl(config.baseUrl ?? "https://smtp.maileroo.com/api/v2"),
38
+ timeout: config.timeout ?? 3e4,
39
+ retries: config.retries ?? 3,
40
+ headers: config.headers ?? {},
41
+ tracking: config.tracking,
42
+ tags: config.tags == null ? void 0 : { ...config.tags }
43
+ };
44
+ }
45
+ function normalizeBaseUrl(baseUrl) {
46
+ return baseUrl.replace(/\/+$/, "");
47
+ }
48
+
49
+ //#endregion
50
+ //#region src/http-client.ts
51
+ /**
52
+ * Maileroo API error class for API-specific failures.
53
+ *
54
+ * @since 0.6.0
55
+ */
56
+ var MailerooApiError = class extends Error {
57
+ statusCode;
58
+ retryAfterMilliseconds;
59
+ attempts;
60
+ /**
61
+ * Creates a Maileroo API error.
62
+ *
63
+ * @param message Error message.
64
+ * @param statusCode HTTP status code.
65
+ * @param retryAfterMilliseconds Retry delay from the response.
66
+ * @param attempts Number of attempts made before this error.
67
+ */
68
+ constructor(message, statusCode, retryAfterMilliseconds, attempts) {
69
+ super(message);
70
+ this.name = "MailerooApiError";
71
+ this.statusCode = statusCode;
72
+ this.retryAfterMilliseconds = retryAfterMilliseconds;
73
+ this.attempts = attempts;
74
+ }
75
+ };
76
+ /**
77
+ * Maileroo request timeout error.
78
+ *
79
+ * @since 0.6.0
80
+ */
81
+ var MailerooTimeoutError = class extends Error {
82
+ /**
83
+ * Request timeout in milliseconds.
84
+ *
85
+ * @since 0.6.0
86
+ */
87
+ timeout;
88
+ /**
89
+ * Number of attempts made before this error was produced.
90
+ *
91
+ * @since 0.6.0
92
+ */
93
+ attempts;
94
+ /**
95
+ * Creates a Maileroo request timeout error.
96
+ *
97
+ * @param timeout Request timeout in milliseconds.
98
+ * @param attempts Number of attempts made before this error.
99
+ */
100
+ constructor(timeout, attempts) {
101
+ super(`Maileroo API request timed out after ${timeout} ms.`);
102
+ this.name = "MailerooTimeoutError";
103
+ this.timeout = timeout;
104
+ this.attempts = attempts;
105
+ }
106
+ };
107
+ /**
108
+ * HTTP client wrapper for Maileroo API requests.
109
+ *
110
+ * @since 0.6.0
111
+ */
112
+ var MailerooHttpClient = class {
113
+ config;
114
+ /**
115
+ * Creates a new Maileroo HTTP client.
116
+ *
117
+ * @param config Resolved Maileroo configuration.
118
+ */
119
+ constructor(config) {
120
+ this.config = config;
121
+ }
122
+ /**
123
+ * Sends a single message via Maileroo API.
124
+ *
125
+ * @param messageData The JSON data to send to Maileroo.
126
+ * @param signal Optional AbortSignal for cancellation.
127
+ * @returns Promise that resolves to the Maileroo response.
128
+ */
129
+ sendMessage(messageData, signal) {
130
+ const url = `${this.config.baseUrl}/emails`;
131
+ return this.makeRequest(url, messageData, signal);
132
+ }
133
+ async makeRequest(url, body, signal) {
134
+ let lastError = null;
135
+ for (let attempt = 0; attempt <= this.config.retries; attempt++) {
136
+ signal?.throwIfAborted();
137
+ try {
138
+ const response = await this.fetchWithAuth(url, body, signal);
139
+ const text = await response.text();
140
+ if (!response.ok) throw new MailerooApiError(parseErrorMessage(text, response.status), response.status, (0, __upyo_core.parseRetryAfter)(response.headers.get("Retry-After")), attempt + 1);
141
+ try {
142
+ return JSON.parse(text);
143
+ } catch (error) {
144
+ throw new SyntaxError(`Invalid JSON response from Maileroo API: ${error instanceof Error ? error.message : String(error)}.`);
145
+ }
146
+ } catch (error) {
147
+ lastError = error instanceof Error ? error : new Error(String(error));
148
+ if (error instanceof MailerooApiError && !isRetryable(error)) throw error;
149
+ if (error instanceof Error && error.name === "AbortError" && signal?.aborted) throw error;
150
+ if (attempt === this.config.retries) throw withAttempts(lastError, attempt + 1);
151
+ await sleep(calculateRetryDelay(attempt), signal);
152
+ }
153
+ }
154
+ throw lastError ?? /* @__PURE__ */ new Error("Request failed after all retry attempts.");
155
+ }
156
+ async fetchWithAuth(url, body, signal) {
157
+ const headers = new Headers({
158
+ "Content-Type": "application/json",
159
+ "X-API-Key": this.config.apiKey
160
+ });
161
+ for (const [key, value] of Object.entries(this.config.headers)) headers.set(key, value);
162
+ const timeoutController = new AbortController();
163
+ const timeoutId = this.config.timeout > 0 ? setTimeout(() => timeoutController.abort(), this.config.timeout) : void 0;
164
+ const requestSignal = (0, __upyo_core.combineSignals)(timeoutController.signal, signal);
165
+ try {
166
+ return await globalThis.fetch(url, {
167
+ method: "POST",
168
+ headers,
169
+ body: JSON.stringify(body),
170
+ signal: requestSignal.signal
171
+ });
172
+ } catch (error) {
173
+ if (error instanceof Error && error.name === "AbortError" && timeoutController.signal.aborted && !signal?.aborted) throw new MailerooTimeoutError(this.config.timeout);
174
+ throw error;
175
+ } finally {
176
+ requestSignal.cleanup();
177
+ if (timeoutId !== void 0) clearTimeout(timeoutId);
178
+ }
179
+ }
180
+ };
181
+ function withAttempts(error, attempts) {
182
+ if (error instanceof MailerooTimeoutError) return new MailerooTimeoutError(error.timeout, attempts);
183
+ if (error instanceof MailerooApiError) return error;
184
+ return Object.assign(error, { attempts });
185
+ }
186
+ function isRetryable(error) {
187
+ return error.statusCode === 408 || error.statusCode === 429 || error.statusCode >= 500;
188
+ }
189
+ function calculateRetryDelay(attempt) {
190
+ const baseDelay = Math.min(1e3 * Math.pow(2, attempt), 1e4);
191
+ return Math.round(baseDelay / 2 + Math.random() * (baseDelay / 2));
192
+ }
193
+ function parseErrorMessage(text, statusCode) {
194
+ try {
195
+ const errorBody = JSON.parse(text);
196
+ if (typeof errorBody.message === "string" && errorBody.message !== "") return errorBody.message;
197
+ if (typeof errorBody.error === "string" && errorBody.error !== "") return errorBody.error;
198
+ if (Array.isArray(errorBody.errors) && errorBody.errors.length > 0) return JSON.stringify(errorBody.errors);
199
+ } catch {}
200
+ return text || `HTTP ${statusCode}`;
201
+ }
202
+ function sleep(ms, signal) {
203
+ return new Promise((resolve, reject) => {
204
+ if (signal?.aborted) {
205
+ reject(new DOMException("The operation was aborted.", "AbortError"));
206
+ return;
207
+ }
208
+ const onAbort = () => {
209
+ clearTimeout(timeoutId);
210
+ signal?.removeEventListener("abort", onAbort);
211
+ reject(new DOMException("The operation was aborted.", "AbortError"));
212
+ };
213
+ const timeoutId = setTimeout(() => {
214
+ signal?.removeEventListener("abort", onAbort);
215
+ resolve();
216
+ }, ms);
217
+ if (signal?.aborted) {
218
+ onAbort();
219
+ return;
220
+ }
221
+ signal?.addEventListener("abort", onAbort, { once: true });
222
+ });
223
+ }
224
+
225
+ //#endregion
226
+ //#region src/message-converter.ts
227
+ const STANDARD_HEADERS = new Set([
228
+ "from",
229
+ "to",
230
+ "cc",
231
+ "bcc",
232
+ "reply-to",
233
+ "subject",
234
+ "date",
235
+ "message-id",
236
+ "content-type",
237
+ "content-transfer-encoding",
238
+ "mime-version",
239
+ "x-priority"
240
+ ]);
241
+ /**
242
+ * Converts an Upyo message to Maileroo API JSON format.
243
+ *
244
+ * @param message The Upyo message to convert.
245
+ * @param config The resolved Maileroo configuration.
246
+ * @returns JSON object ready for Maileroo API submission.
247
+ * @since 0.6.0
248
+ */
249
+ async function convertMessage(message, config) {
250
+ const emailData = {
251
+ from: convertAddress(message.sender),
252
+ to: convertAddressList(message.recipients),
253
+ subject: message.subject
254
+ };
255
+ const cc = convertOptionalAddressList(message.ccRecipients);
256
+ if (cc !== void 0) emailData.cc = cc;
257
+ const bcc = convertOptionalAddressList(message.bccRecipients);
258
+ if (bcc !== void 0) emailData.bcc = bcc;
259
+ const replyTo = convertOptionalAddressList(message.replyRecipients);
260
+ if (replyTo !== void 0) emailData.reply_to = replyTo;
261
+ if ("html" in message.content) {
262
+ emailData.html = message.content.html;
263
+ if (message.content.text !== void 0) emailData.plain = message.content.text;
264
+ } else emailData.plain = message.content.text;
265
+ if (config.tracking !== void 0) emailData.tracking = config.tracking;
266
+ const tags = convertTags(message, config);
267
+ if (Object.keys(tags).length > 0) emailData.tags = tags;
268
+ const headers = convertHeaders(message);
269
+ if (Object.keys(headers).length > 0) emailData.headers = headers;
270
+ if (message.attachments.length > 0) emailData.attachments = await Promise.all(message.attachments.map(convertAttachment));
271
+ return emailData;
272
+ }
273
+ function convertAddress(address) {
274
+ return address.name == null || address.name === "" ? { address: address.address } : {
275
+ address: address.address,
276
+ display_name: address.name
277
+ };
278
+ }
279
+ function convertAddressList(addresses) {
280
+ const converted = addresses.map(convertAddress);
281
+ return converted.length === 1 ? converted[0] : converted;
282
+ }
283
+ function convertOptionalAddressList(addresses) {
284
+ return addresses.length === 0 ? void 0 : convertAddressList(addresses);
285
+ }
286
+ function convertTags(message, config) {
287
+ const tags = config.tags == null ? {} : { ...config.tags };
288
+ for (const [index, tag] of message.tags.entries()) tags[`tag${index + 1}`] = tag;
289
+ return tags;
290
+ }
291
+ function convertHeaders(message) {
292
+ const headers = {};
293
+ if (message.priority !== "normal") {
294
+ const priorityMap = {
295
+ "high": "1",
296
+ "normal": "3",
297
+ "low": "5"
298
+ };
299
+ headers["X-Priority"] = priorityMap[message.priority];
300
+ }
301
+ for (const [key, value] of message.headers.entries()) if (!isStandardHeader(key)) headers[key] = value;
302
+ return headers;
303
+ }
304
+ async function convertAttachment(attachment) {
305
+ const content = await attachment.content;
306
+ return {
307
+ file_name: attachment.filename,
308
+ content_type: attachment.contentType,
309
+ content: uint8ArrayToBase64(content),
310
+ inline: attachment.inline || void 0
311
+ };
312
+ }
313
+ function uint8ArrayToBase64(bytes) {
314
+ const nativeToBase64 = getNativeToBase64(bytes);
315
+ if (nativeToBase64 != null) return nativeToBase64();
316
+ const chunkSize = 32768;
317
+ const chunks = [];
318
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)));
319
+ return btoa(chunks.join(""));
320
+ }
321
+ function getNativeToBase64(bytes) {
322
+ const candidate = bytes;
323
+ const toBase64 = candidate.toBase64;
324
+ if (typeof toBase64 !== "function") return void 0;
325
+ return () => toBase64.call(bytes);
326
+ }
327
+ function isStandardHeader(headerName) {
328
+ return STANDARD_HEADERS.has(headerName.toLowerCase());
329
+ }
330
+
331
+ //#endregion
332
+ //#region src/maileroo-transport.ts
333
+ /**
334
+ * Maileroo transport implementation for sending emails via Maileroo API.
335
+ *
336
+ * @example
337
+ * ```typescript
338
+ * import { createMessage } from "@upyo/core";
339
+ * import { MailerooTransport } from "@upyo/maileroo";
340
+ *
341
+ * const transport = new MailerooTransport({
342
+ * apiKey: "your-sending-key",
343
+ * });
344
+ *
345
+ * const receipt = await transport.send(createMessage({
346
+ * from: "sender@example.com",
347
+ * to: "recipient@example.com",
348
+ * subject: "Hello from Maileroo",
349
+ * content: { text: "Hello!" },
350
+ * }));
351
+ * ```
352
+ *
353
+ * @since 0.6.0
354
+ */
355
+ var MailerooTransport = class {
356
+ id = "maileroo";
357
+ /**
358
+ * The resolved Maileroo configuration used by this transport.
359
+ */
360
+ config;
361
+ httpClient;
362
+ /**
363
+ * Creates a new Maileroo transport instance.
364
+ *
365
+ * @param config Maileroo configuration including API key and options.
366
+ */
367
+ constructor(config) {
368
+ this.config = createMailerooConfig(config);
369
+ this.httpClient = new MailerooHttpClient(this.config);
370
+ }
371
+ /**
372
+ * Sends a single email message via Maileroo API.
373
+ *
374
+ * @param message The email message to send.
375
+ * @param options Optional transport options including `AbortSignal`.
376
+ * @returns A receipt indicating success or failure.
377
+ */
378
+ async send(message, options) {
379
+ try {
380
+ options?.signal?.throwIfAborted();
381
+ const emailData = await convertMessage(message, this.config);
382
+ options?.signal?.throwIfAborted();
383
+ const response = await this.httpClient.sendMessage(emailData, options?.signal);
384
+ return responseToReceipt(response);
385
+ } catch (error) {
386
+ if (isCallerAbort(error, options?.signal)) throw error;
387
+ return createMailerooFailure(error instanceof Error ? error.message : String(error), error);
388
+ }
389
+ }
390
+ /**
391
+ * Sends multiple email messages sequentially via Maileroo API.
392
+ *
393
+ * @param messages An iterable or async iterable of messages to send.
394
+ * @param options Optional transport options including `AbortSignal`.
395
+ * @returns An async iterable of receipts, one for each message.
396
+ */
397
+ async *sendMany(messages, options) {
398
+ options?.signal?.throwIfAborted();
399
+ for await (const message of messages) {
400
+ options?.signal?.throwIfAborted();
401
+ yield await this.send(message, options);
402
+ }
403
+ }
404
+ };
405
+ function responseToReceipt(response) {
406
+ if (!response.success) return (0, __upyo_core.createFailedReceipt)(response.message ?? "Maileroo reported an unsuccessful response.", {
407
+ provider: "maileroo",
408
+ category: "rejected",
409
+ code: "maileroo.unsuccessful",
410
+ retryable: false,
411
+ providerDetails: response
412
+ });
413
+ const messageId = response.data?.reference_id;
414
+ if (messageId == null || messageId === "") return (0, __upyo_core.createFailedReceipt)("Maileroo response is missing a reference ID.", {
415
+ provider: "maileroo",
416
+ category: "unknown",
417
+ code: "maileroo.missing_reference_id",
418
+ retryable: false,
419
+ providerDetails: response
420
+ });
421
+ return {
422
+ successful: true,
423
+ messageId,
424
+ provider: "maileroo"
425
+ };
426
+ }
427
+ function createMailerooFailure(message, error) {
428
+ if (error instanceof MailerooApiError) return (0, __upyo_core.createFailedReceipt)(message, {
429
+ provider: "maileroo",
430
+ statusCode: error.statusCode,
431
+ retryAfterMilliseconds: error.retryAfterMilliseconds,
432
+ attempts: error.attempts
433
+ });
434
+ if (error instanceof MailerooTimeoutError) return (0, __upyo_core.createFailedReceipt)(message, {
435
+ provider: "maileroo",
436
+ category: "timeout",
437
+ code: "timeout",
438
+ retryable: true,
439
+ attempts: error.attempts
440
+ });
441
+ return (0, __upyo_core.createFailedReceipt)(message, {
442
+ provider: "maileroo",
443
+ attempts: getErrorAttempts(error)
444
+ });
445
+ }
446
+ function getErrorAttempts(error) {
447
+ if (typeof error !== "object" || error == null || !("attempts" in error)) return void 0;
448
+ const attempts = error.attempts;
449
+ return typeof attempts === "number" ? attempts : void 0;
450
+ }
451
+ function isAbortError(error) {
452
+ return error instanceof Error && error.name === "AbortError";
453
+ }
454
+ function isCallerAbort(error, signal) {
455
+ return signal?.aborted === true && (isAbortError(error) || error === signal.reason);
456
+ }
457
+
458
+ //#endregion
459
+ exports.MailerooApiError = MailerooApiError;
460
+ exports.MailerooTimeoutError = MailerooTimeoutError;
461
+ exports.MailerooTransport = MailerooTransport;
462
+ exports.createMailerooConfig = createMailerooConfig;