@upyo/plunk 0.3.0-dev.35

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,93 @@
1
+ <!-- deno-fmt-ignore-file -->
2
+
3
+ @upyo/plunk
4
+ ===========
5
+
6
+ [![JSR][JSR badge]][JSR]
7
+ [![npm][npm badge]][npm]
8
+
9
+ [Plunk] transport for the [Upyo] email library.
10
+
11
+ [JSR]: https://jsr.io/@upyo/plunk
12
+ [JSR badge]: https://jsr.io/badges/@upyo/plunk
13
+ [npm]: https://www.npmjs.com/package/@upyo/plunk
14
+ [npm badge]: https://img.shields.io/npm/v/@upyo/plunk?logo=npm
15
+ [Plunk]: https://www.useplunk.com/
16
+ [Upyo]: https://upyo.org/
17
+
18
+
19
+ Installation
20
+ ------------
21
+
22
+ ~~~~ sh
23
+ npm add @upyo/core @upyo/plunk
24
+ pnpm add @upyo/core @upyo/plunk
25
+ yarn add @upyo/core @upyo/plunk
26
+ deno add --jsr @upyo/core @upyo/plunk
27
+ bun add @upyo/core @upyo/plunk
28
+ ~~~~
29
+
30
+
31
+ Usage
32
+ -----
33
+
34
+ ~~~~ typescript
35
+ import { createMessage } from "@upyo/core";
36
+ import { PlunkTransport } from "@upyo/plunk";
37
+ import fs from "node:fs/promises";
38
+ import process from "node:process";
39
+
40
+ const message = createMessage({
41
+ from: "sender@example.com",
42
+ to: "recipient@example.net",
43
+ subject: "Hello from Upyo!",
44
+ content: { text: "This is a test email." },
45
+ attachments: [
46
+ new File(
47
+ [await fs.readFile("image.jpg"), "image.jpg", { type: "image/jpeg" }]
48
+ )
49
+ ],
50
+ });
51
+
52
+ const transport = new PlunkTransport({
53
+ apiKey: process.env.PLUNK_API_KEY!,
54
+ });
55
+
56
+ const receipt = await transport.send(message);
57
+ if (receipt.successful) {
58
+ console.log("Message sent with ID:", receipt.messageId);
59
+ } else {
60
+ console.error("Send failed:", receipt.errorMessages.join(", "));
61
+ }
62
+ ~~~~
63
+
64
+
65
+ Configuration
66
+ -------------
67
+
68
+ See the [Plunk docs] for more information about configuration options.
69
+
70
+ [Plunk docs]: https://docs.useplunk.com/
71
+
72
+ ### Available Options
73
+
74
+ - `apiKey`: Your Plunk API key
75
+ - `baseUrl`: API base URL (default: `https://api.useplunk.com`)
76
+ - `timeout`: Request timeout in milliseconds (default: `30000`)
77
+ - `retries`: Number of retry attempts (default: `3`)
78
+ - `validateSsl`: Whether to validate SSL certificates (default: `true`)
79
+ - `headers`: Additional HTTP headers (default: `{}`)
80
+
81
+ ### Self-hosted instances
82
+
83
+ This transport supports self-hosted Plunk instances. Set the `baseUrl` to your
84
+ domain followed by `/api`:
85
+
86
+ ~~~~ typescript
87
+ import { PlunkTransport } from "@upyo/plunk";
88
+
89
+ const transport = new PlunkTransport({
90
+ apiKey: "your-api-key",
91
+ baseUrl: "https://mail.yourcompany.com/api",
92
+ });
93
+ ~~~~
package/dist/index.cjs ADDED
@@ -0,0 +1,434 @@
1
+
2
+ //#region src/config.ts
3
+ /**
4
+ * Creates a resolved Plunk configuration by applying default values to optional fields.
5
+ *
6
+ * This function takes a partial Plunk configuration and returns a complete
7
+ * configuration with all optional fields filled with sensible defaults.
8
+ * It is used internally by the Plunk transport.
9
+ *
10
+ * @param config - The Plunk configuration with optional fields
11
+ * @returns A resolved configuration with all defaults applied
12
+ * @internal
13
+ */
14
+ function createPlunkConfig(config) {
15
+ return {
16
+ apiKey: config.apiKey,
17
+ baseUrl: config.baseUrl ?? "https://api.useplunk.com",
18
+ timeout: config.timeout ?? 3e4,
19
+ retries: config.retries ?? 3,
20
+ validateSsl: config.validateSsl ?? true,
21
+ headers: config.headers ?? {}
22
+ };
23
+ }
24
+
25
+ //#endregion
26
+ //#region src/http-client.ts
27
+ /**
28
+ * HTTP client wrapper for Plunk API requests.
29
+ *
30
+ * This class handles authentication, request formatting, error handling,
31
+ * and retry logic for the Plunk HTTP API.
32
+ */
33
+ var PlunkHttpClient = class {
34
+ config;
35
+ /**
36
+ * Creates a new Plunk HTTP client instance.
37
+ *
38
+ * @param config - Resolved Plunk configuration
39
+ */
40
+ constructor(config) {
41
+ this.config = config;
42
+ }
43
+ /**
44
+ * Sends a message via the Plunk API.
45
+ *
46
+ * This method makes a POST request to the `/v1/send` endpoint with proper
47
+ * authentication, retry logic, and error handling.
48
+ *
49
+ * @param emailData - The email data in Plunk API format
50
+ * @param signal - Optional AbortSignal for request cancellation
51
+ * @returns Promise that resolves to Plunk API response
52
+ * @throws PlunkError if the request fails after all retries
53
+ */
54
+ async sendMessage(emailData, signal) {
55
+ const url = `${this.config.baseUrl}/v1/send`;
56
+ let lastError = null;
57
+ for (let attempt = 0; attempt <= this.config.retries; attempt++) {
58
+ signal?.throwIfAborted();
59
+ try {
60
+ const response = await this.makeRequest(url, emailData, signal);
61
+ return await this.parseResponse(response);
62
+ } catch (error) {
63
+ lastError = error instanceof Error ? error : new Error(String(error));
64
+ if (error instanceof Error) {
65
+ if (error.name === "AbortError") throw error;
66
+ if (error.message.includes("status: 4")) throw this.createPlunkError(error.message, 400);
67
+ }
68
+ if (attempt === this.config.retries) break;
69
+ const delay = Math.min(1e3 * Math.pow(2, attempt), 1e4);
70
+ await this.sleep(delay);
71
+ }
72
+ }
73
+ const errorMessage = lastError?.message ?? "Unknown error occurred";
74
+ throw this.createPlunkError(errorMessage);
75
+ }
76
+ /**
77
+ * Makes an HTTP request to the Plunk API.
78
+ *
79
+ * @param url - The request URL
80
+ * @param emailData - The email data to send
81
+ * @param signal - Optional AbortSignal for cancellation
82
+ * @returns Promise that resolves to the Response object
83
+ */
84
+ async makeRequest(url, emailData, signal) {
85
+ const headers = {
86
+ "Authorization": `Bearer ${this.config.apiKey}`,
87
+ "Content-Type": "application/json",
88
+ ...this.config.headers
89
+ };
90
+ const response = await fetch(url, {
91
+ method: "POST",
92
+ headers,
93
+ body: JSON.stringify(emailData),
94
+ signal,
95
+ ...this.config.timeout > 0 && typeof globalThis.AbortSignal?.timeout === "function" ? { signal: AbortSignal.any([signal, AbortSignal.timeout(this.config.timeout)].filter(Boolean)) } : {}
96
+ });
97
+ if (!response.ok) {
98
+ let errorBody;
99
+ try {
100
+ errorBody = await response.text();
101
+ } catch {
102
+ errorBody = "Failed to read error response";
103
+ }
104
+ throw new Error(`HTTP ${response.status}: ${response.statusText}. ${errorBody}`);
105
+ }
106
+ return response;
107
+ }
108
+ /**
109
+ * Parses the response from the Plunk API.
110
+ *
111
+ * @param response - The Response object from fetch
112
+ * @returns Promise that resolves to parsed PlunkResponse
113
+ */
114
+ async parseResponse(response) {
115
+ try {
116
+ const data = await response.json();
117
+ if (typeof data !== "object" || data === null) throw new Error("Invalid response format: expected object");
118
+ if (typeof data.success !== "boolean") throw new Error("Invalid response format: missing success field");
119
+ if (!data.success) throw new Error(data.message ?? "Send operation failed without error details");
120
+ return data;
121
+ } catch (error) {
122
+ if (error instanceof SyntaxError) throw new Error("Invalid JSON response from Plunk API");
123
+ throw error;
124
+ }
125
+ }
126
+ /**
127
+ * Creates a PlunkError from an error message and optional status code.
128
+ *
129
+ * @param message - The error message
130
+ * @param statusCode - Optional HTTP status code
131
+ * @returns PlunkError instance
132
+ */
133
+ createPlunkError(message, statusCode) {
134
+ return {
135
+ message,
136
+ statusCode
137
+ };
138
+ }
139
+ /**
140
+ * Sleeps for the specified number of milliseconds.
141
+ *
142
+ * @param ms - Milliseconds to sleep
143
+ * @returns Promise that resolves after the delay
144
+ */
145
+ sleep(ms) {
146
+ return new Promise((resolve) => setTimeout(resolve, ms));
147
+ }
148
+ };
149
+
150
+ //#endregion
151
+ //#region src/message-converter.ts
152
+ /**
153
+ * Converts a Upyo message to Plunk API format.
154
+ *
155
+ * This function transforms the standardized Upyo message format into the
156
+ * format expected by the Plunk HTTP API, handling email addresses,
157
+ * content conversion, attachments, and headers.
158
+ *
159
+ * @param message - The Upyo message to convert
160
+ * @param config - Resolved Plunk configuration
161
+ * @returns Promise that resolves to Plunk-formatted email data
162
+ */
163
+ async function convertMessage(message, _config) {
164
+ const recipients = message.recipients.map((addr) => addr.address);
165
+ const to = recipients.length === 1 ? recipients[0] : recipients;
166
+ const senderName = message.sender.name;
167
+ const senderEmail = message.sender.address;
168
+ const replyTo = message.replyRecipients.length > 0 ? message.replyRecipients[0].address : void 0;
169
+ let body;
170
+ if ("html" in message.content && message.content.html) body = message.content.html;
171
+ else if ("text" in message.content && message.content.text) body = message.content.text;
172
+ else body = "";
173
+ const customHeaders = {};
174
+ if (message.headers) for (const [key, value] of message.headers.entries()) {
175
+ const lowerKey = key.toLowerCase();
176
+ if (![
177
+ "to",
178
+ "from",
179
+ "reply-to",
180
+ "subject",
181
+ "content-type"
182
+ ].includes(lowerKey)) customHeaders[lowerKey] = value;
183
+ }
184
+ const attachments = [];
185
+ const maxAttachments = Math.min(message.attachments.length, 5);
186
+ for (let i = 0; i < maxAttachments; i++) {
187
+ const attachment = message.attachments[i];
188
+ const convertedAttachment = await convertAttachment(attachment);
189
+ if (convertedAttachment) attachments.push(convertedAttachment);
190
+ }
191
+ const plunkEmail = {
192
+ to,
193
+ subject: message.subject,
194
+ body,
195
+ subscribed: false
196
+ };
197
+ if (senderName) plunkEmail.name = senderName;
198
+ if (senderEmail) plunkEmail.from = senderEmail;
199
+ if (replyTo) plunkEmail.reply = replyTo;
200
+ if (Object.keys(customHeaders).length > 0) plunkEmail.headers = customHeaders;
201
+ if (attachments.length > 0) plunkEmail.attachments = attachments;
202
+ return plunkEmail;
203
+ }
204
+ /**
205
+ * Converts a Upyo attachment to Plunk format.
206
+ *
207
+ * @param attachment - The Upyo attachment to convert
208
+ * @returns Promise that resolves to Plunk attachment or null if conversion fails
209
+ */
210
+ async function convertAttachment(attachment) {
211
+ try {
212
+ const content = await attachment.content;
213
+ const base64Content = arrayBufferToBase64(content);
214
+ return {
215
+ filename: attachment.filename,
216
+ content: base64Content,
217
+ type: attachment.contentType
218
+ };
219
+ } catch (error) {
220
+ console.warn(`Failed to convert attachment ${attachment.filename}:`, error);
221
+ return null;
222
+ }
223
+ }
224
+ /**
225
+ * Converts an ArrayBuffer or Uint8Array to base64 string.
226
+ *
227
+ * @param buffer - The buffer to convert
228
+ * @returns Base64 encoded string
229
+ */
230
+ function arrayBufferToBase64(buffer) {
231
+ if (typeof btoa !== "undefined") {
232
+ const binaryString = Array.from(buffer, (byte) => String.fromCharCode(byte)).join("");
233
+ return btoa(binaryString);
234
+ }
235
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
236
+ let result = "";
237
+ let i = 0;
238
+ while (i < buffer.length) {
239
+ const a = buffer[i++];
240
+ const b = i < buffer.length ? buffer[i++] : 0;
241
+ const c = i < buffer.length ? buffer[i++] : 0;
242
+ const bitmap = a << 16 | b << 8 | c;
243
+ result += chars.charAt(bitmap >> 18 & 63);
244
+ result += chars.charAt(bitmap >> 12 & 63);
245
+ result += i - 2 < buffer.length ? chars.charAt(bitmap >> 6 & 63) : "=";
246
+ result += i - 1 < buffer.length ? chars.charAt(bitmap & 63) : "=";
247
+ }
248
+ return result;
249
+ }
250
+
251
+ //#endregion
252
+ //#region src/plunk-transport.ts
253
+ /**
254
+ * Plunk transport implementation for sending emails via Plunk API.
255
+ *
256
+ * This transport provides efficient email delivery using Plunk's HTTP API,
257
+ * with support for both cloud-hosted and self-hosted instances, authentication,
258
+ * retry logic, and batch sending capabilities.
259
+ *
260
+ * @example
261
+ * ```typescript
262
+ * import { PlunkTransport } from '@upyo/plunk';
263
+ *
264
+ * const transport = new PlunkTransport({
265
+ * apiKey: 'your-plunk-api-key',
266
+ * baseUrl: 'https://api.useplunk.com', // or self-hosted URL
267
+ * timeout: 30000,
268
+ * retries: 3
269
+ * });
270
+ *
271
+ * const receipt = await transport.send(message);
272
+ * if (receipt.successful) {
273
+ * console.log('Message sent with ID:', receipt.messageId);
274
+ * } else {
275
+ * console.error('Send failed:', receipt.errorMessages.join(', '));
276
+ * }
277
+ * ```
278
+ */
279
+ var PlunkTransport = class {
280
+ /**
281
+ * The resolved Plunk configuration used by this transport.
282
+ */
283
+ config;
284
+ httpClient;
285
+ /**
286
+ * Creates a new Plunk transport instance.
287
+ *
288
+ * @param config Plunk configuration including API key and options.
289
+ */
290
+ constructor(config) {
291
+ this.config = createPlunkConfig(config);
292
+ this.httpClient = new PlunkHttpClient(this.config);
293
+ }
294
+ /**
295
+ * Sends a single email message via Plunk API.
296
+ *
297
+ * This method converts the message to Plunk format, makes an HTTP request
298
+ * to the Plunk API, and returns a receipt with the result.
299
+ *
300
+ * @example
301
+ * ```typescript
302
+ * const receipt = await transport.send({
303
+ * sender: { address: 'from@example.com' },
304
+ * recipients: [{ address: 'to@example.com' }],
305
+ * ccRecipients: [],
306
+ * bccRecipients: [],
307
+ * replyRecipients: [],
308
+ * subject: 'Hello',
309
+ * content: { text: 'Hello World!' },
310
+ * attachments: [],
311
+ * priority: 'normal',
312
+ * tags: [],
313
+ * headers: new Headers()
314
+ * });
315
+ *
316
+ * if (receipt.successful) {
317
+ * console.log('Message sent successfully');
318
+ * }
319
+ * ```
320
+ *
321
+ * @param message The email message to send.
322
+ * @param options Optional transport options including `AbortSignal` for
323
+ * cancellation.
324
+ * @returns A promise that resolves to a receipt indicating success or
325
+ * failure.
326
+ */
327
+ async send(message, options) {
328
+ try {
329
+ options?.signal?.throwIfAborted();
330
+ const emailData = await convertMessage(message, this.config);
331
+ options?.signal?.throwIfAborted();
332
+ const response = await this.httpClient.sendMessage(emailData, options?.signal);
333
+ const messageId = this.extractMessageId(response, message);
334
+ return {
335
+ successful: true,
336
+ messageId
337
+ };
338
+ } catch (error) {
339
+ const errorMessage = error instanceof Error ? error.message : String(error);
340
+ return {
341
+ successful: false,
342
+ errorMessages: [errorMessage]
343
+ };
344
+ }
345
+ }
346
+ /**
347
+ * Sends multiple email messages efficiently via Plunk API.
348
+ *
349
+ * This method sends each message individually but provides a streamlined
350
+ * interface for processing multiple messages. Each message is sent as a
351
+ * separate API request to Plunk.
352
+ *
353
+ * @example
354
+ * ```typescript
355
+ * const messages = [
356
+ * {
357
+ * sender: { address: 'from@example.com' },
358
+ * recipients: [{ address: 'user1@example.com' }],
359
+ * ccRecipients: [],
360
+ * bccRecipients: [],
361
+ * replyRecipients: [],
362
+ * subject: 'Message 1',
363
+ * content: { text: 'Hello User 1!' },
364
+ * attachments: [],
365
+ * priority: 'normal',
366
+ * tags: [],
367
+ * headers: new Headers()
368
+ * },
369
+ * {
370
+ * sender: { address: 'from@example.com' },
371
+ * recipients: [{ address: 'user2@example.com' }],
372
+ * ccRecipients: [],
373
+ * bccRecipients: [],
374
+ * replyRecipients: [],
375
+ * subject: 'Message 2',
376
+ * content: { text: 'Hello User 2!' },
377
+ * attachments: [],
378
+ * priority: 'normal',
379
+ * tags: [],
380
+ * headers: new Headers()
381
+ * }
382
+ * ];
383
+ *
384
+ * for await (const receipt of transport.sendMany(messages)) {
385
+ * if (receipt.successful) {
386
+ * console.log('Sent:', receipt.messageId);
387
+ * } else {
388
+ * console.error('Failed:', receipt.errorMessages);
389
+ * }
390
+ * }
391
+ * ```
392
+ *
393
+ * @param messages An iterable or async iterable of messages to send.
394
+ * @param options Optional transport options including `AbortSignal` for
395
+ * cancellation.
396
+ * @returns An async iterable of receipts, one for each message.
397
+ */
398
+ async *sendMany(messages, options) {
399
+ options?.signal?.throwIfAborted();
400
+ const isAsyncIterable = Symbol.asyncIterator in messages;
401
+ if (isAsyncIterable) for await (const message of messages) {
402
+ options?.signal?.throwIfAborted();
403
+ yield await this.send(message, options);
404
+ }
405
+ else for (const message of messages) {
406
+ options?.signal?.throwIfAborted();
407
+ yield await this.send(message, options);
408
+ }
409
+ }
410
+ /**
411
+ * Extracts or generates a message ID from the Plunk response.
412
+ *
413
+ * Plunk returns email details in the response, so we can use the contact ID
414
+ * and timestamp to create a meaningful message ID.
415
+ *
416
+ * @param response The Plunk API response.
417
+ * @param message The original message for fallback ID generation.
418
+ * @returns A message ID string.
419
+ */
420
+ extractMessageId(response, message) {
421
+ if (response.emails && response.emails.length > 0) {
422
+ const contactId = response.emails[0].contact?.id;
423
+ const timestamp$1 = response.timestamp;
424
+ if (contactId && timestamp$1) return `plunk-${contactId}-${new Date(timestamp$1).getTime()}`;
425
+ }
426
+ const timestamp = Date.now();
427
+ const recipientHash = message.recipients[0]?.address.split("@")[0].substring(0, 8) ?? "unknown";
428
+ const random = Math.random().toString(36).substring(2, 8);
429
+ return `plunk-${timestamp}-${recipientHash}-${random}`;
430
+ }
431
+ };
432
+
433
+ //#endregion
434
+ exports.PlunkTransport = PlunkTransport;