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