UNPKG

@upyo/plunk

Version:

Plunk transport for Upyo email library

433 lines (429 loc) 13.7 kB
//#region src/config.ts /** * Creates a resolved Plunk configuration by applying default values to optional fields. * * This function takes a partial Plunk configuration and returns a complete * configuration with all optional fields filled with sensible defaults. * It is used internally by the Plunk transport. * * @param config - The Plunk configuration with optional fields * @returns A resolved configuration with all defaults applied * @internal */ function createPlunkConfig(config) { return { apiKey: config.apiKey, baseUrl: config.baseUrl ?? "https://api.useplunk.com", timeout: config.timeout ?? 3e4, retries: config.retries ?? 3, validateSsl: config.validateSsl ?? true, headers: config.headers ?? {} }; } //#endregion //#region src/http-client.ts /** * HTTP client wrapper for Plunk API requests. * * This class handles authentication, request formatting, error handling, * and retry logic for the Plunk HTTP API. */ var PlunkHttpClient = class { config; /** * Creates a new Plunk HTTP client instance. * * @param config - Resolved Plunk configuration */ constructor(config) { this.config = config; } /** * Sends a message via the Plunk API. * * This method makes a POST request to the `/v1/send` endpoint with proper * authentication, retry logic, and error handling. * * @param emailData - The email data in Plunk API format * @param signal - Optional AbortSignal for request cancellation * @returns Promise that resolves to Plunk API response * @throws PlunkError if the request fails after all retries */ async sendMessage(emailData, signal) { const url = `${this.config.baseUrl}/v1/send`; let lastError = null; for (let attempt = 0; attempt <= this.config.retries; attempt++) { signal?.throwIfAborted(); try { const response = await this.makeRequest(url, emailData, signal); return await this.parseResponse(response); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (error instanceof Error) { if (error.name === "AbortError") throw error; if (error.message.includes("status: 4")) throw this.createPlunkError(error.message, 400); } if (attempt === this.config.retries) break; const delay = Math.min(1e3 * Math.pow(2, attempt), 1e4); await this.sleep(delay); } } const errorMessage = lastError?.message ?? "Unknown error occurred"; throw this.createPlunkError(errorMessage); } /** * Makes an HTTP request to the Plunk API. * * @param url - The request URL * @param emailData - The email data to send * @param signal - Optional AbortSignal for cancellation * @returns Promise that resolves to the Response object */ async makeRequest(url, emailData, signal) { const headers = { "Authorization": `Bearer ${this.config.apiKey}`, "Content-Type": "application/json", ...this.config.headers }; const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(emailData), signal, ...this.config.timeout > 0 && typeof globalThis.AbortSignal?.timeout === "function" ? { signal: AbortSignal.any([signal, AbortSignal.timeout(this.config.timeout)].filter(Boolean)) } : {} }); if (!response.ok) { let errorBody; try { errorBody = await response.text(); } catch { errorBody = "Failed to read error response"; } throw new Error(`HTTP ${response.status}: ${response.statusText}. ${errorBody}`); } return response; } /** * Parses the response from the Plunk API. * * @param response - The Response object from fetch * @returns Promise that resolves to parsed PlunkResponse */ async parseResponse(response) { try { const data = await response.json(); if (typeof data !== "object" || data === null) throw new Error("Invalid response format: expected object"); if (typeof data.success !== "boolean") throw new Error("Invalid response format: missing success field"); if (!data.success) throw new Error(data.message ?? "Send operation failed without error details"); return data; } catch (error) { if (error instanceof SyntaxError) throw new Error("Invalid JSON response from Plunk API"); throw error; } } /** * Creates a PlunkError from an error message and optional status code. * * @param message - The error message * @param statusCode - Optional HTTP status code * @returns PlunkError instance */ createPlunkError(message, statusCode) { return { message, statusCode }; } /** * Sleeps for the specified number of milliseconds. * * @param ms - Milliseconds to sleep * @returns Promise that resolves after the delay */ sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } }; //#endregion //#region src/message-converter.ts /** * Converts a Upyo message to Plunk API format. * * This function transforms the standardized Upyo message format into the * format expected by the Plunk HTTP API, handling email addresses, * content conversion, attachments, and headers. * * @param message - The Upyo message to convert * @param config - Resolved Plunk configuration * @returns Promise that resolves to Plunk-formatted email data */ async function convertMessage(message, _config) { const recipients = message.recipients.map((addr) => addr.address); const to = recipients.length === 1 ? recipients[0] : recipients; const senderName = message.sender.name; const senderEmail = message.sender.address; const replyTo = message.replyRecipients.length > 0 ? message.replyRecipients[0].address : void 0; let body; if ("html" in message.content && message.content.html) body = message.content.html; else if ("text" in message.content && message.content.text) body = message.content.text; else body = ""; const customHeaders = {}; if (message.headers) for (const [key, value] of message.headers.entries()) { const lowerKey = key.toLowerCase(); if (![ "to", "from", "reply-to", "subject", "content-type" ].includes(lowerKey)) customHeaders[lowerKey] = value; } const attachments = []; const maxAttachments = Math.min(message.attachments.length, 5); for (let i = 0; i < maxAttachments; i++) { const attachment = message.attachments[i]; const convertedAttachment = await convertAttachment(attachment); if (convertedAttachment) attachments.push(convertedAttachment); } const plunkEmail = { to, subject: message.subject, body, subscribed: false }; if (senderName) plunkEmail.name = senderName; if (senderEmail) plunkEmail.from = senderEmail; if (replyTo) plunkEmail.reply = replyTo; if (Object.keys(customHeaders).length > 0) plunkEmail.headers = customHeaders; if (attachments.length > 0) plunkEmail.attachments = attachments; return plunkEmail; } /** * Converts a Upyo attachment to Plunk format. * * @param attachment - The Upyo attachment to convert * @returns Promise that resolves to Plunk attachment or null if conversion fails */ async function convertAttachment(attachment) { try { const content = await attachment.content; const base64Content = arrayBufferToBase64(content); return { filename: attachment.filename, content: base64Content, type: attachment.contentType }; } catch (error) { console.warn(`Failed to convert attachment ${attachment.filename}:`, error); return null; } } /** * Converts an ArrayBuffer or Uint8Array to base64 string. * * @param buffer - The buffer to convert * @returns Base64 encoded string */ function arrayBufferToBase64(buffer) { if (typeof btoa !== "undefined") { const binaryString = Array.from(buffer, (byte) => String.fromCharCode(byte)).join(""); return btoa(binaryString); } const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let result = ""; let i = 0; while (i < buffer.length) { const a = buffer[i++]; const b = i < buffer.length ? buffer[i++] : 0; const c = i < buffer.length ? buffer[i++] : 0; const bitmap = a << 16 | b << 8 | c; result += chars.charAt(bitmap >> 18 & 63); result += chars.charAt(bitmap >> 12 & 63); result += i - 2 < buffer.length ? chars.charAt(bitmap >> 6 & 63) : "="; result += i - 1 < buffer.length ? chars.charAt(bitmap & 63) : "="; } return result; } //#endregion //#region src/plunk-transport.ts /** * Plunk transport implementation for sending emails via Plunk API. * * This transport provides efficient email delivery using Plunk's HTTP API, * with support for both cloud-hosted and self-hosted instances, authentication, * retry logic, and batch sending capabilities. * * @example * ```typescript * import { PlunkTransport } from '@upyo/plunk'; * * const transport = new PlunkTransport({ * apiKey: 'your-plunk-api-key', * baseUrl: 'https://api.useplunk.com', // or self-hosted URL * timeout: 30000, * retries: 3 * }); * * const receipt = await transport.send(message); * if (receipt.successful) { * console.log('Message sent with ID:', receipt.messageId); * } else { * console.error('Send failed:', receipt.errorMessages.join(', ')); * } * ``` */ var PlunkTransport = class { /** * The resolved Plunk configuration used by this transport. */ config; httpClient; /** * Creates a new Plunk transport instance. * * @param config Plunk configuration including API key and options. */ constructor(config) { this.config = createPlunkConfig(config); this.httpClient = new PlunkHttpClient(this.config); } /** * Sends a single email message via Plunk API. * * This method converts the message to Plunk format, makes an HTTP request * to the Plunk API, and returns a receipt with the result. * * @example * ```typescript * const receipt = await transport.send({ * sender: { address: 'from@example.com' }, * recipients: [{ address: 'to@example.com' }], * ccRecipients: [], * bccRecipients: [], * replyRecipients: [], * subject: 'Hello', * content: { text: 'Hello World!' }, * attachments: [], * priority: 'normal', * tags: [], * headers: new Headers() * }); * * if (receipt.successful) { * console.log('Message sent successfully'); * } * ``` * * @param message The email message to send. * @param options Optional transport options including `AbortSignal` for * cancellation. * @returns A promise that resolves to a receipt indicating success or * failure. */ async send(message, options) { try { options?.signal?.throwIfAborted(); const emailData = await convertMessage(message, this.config); options?.signal?.throwIfAborted(); const response = await this.httpClient.sendMessage(emailData, options?.signal); const messageId = this.extractMessageId(response, message); return { successful: true, messageId }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { successful: false, errorMessages: [errorMessage] }; } } /** * Sends multiple email messages efficiently via Plunk API. * * This method sends each message individually but provides a streamlined * interface for processing multiple messages. Each message is sent as a * separate API request to Plunk. * * @example * ```typescript * const messages = [ * { * sender: { address: 'from@example.com' }, * recipients: [{ address: 'user1@example.com' }], * ccRecipients: [], * bccRecipients: [], * replyRecipients: [], * subject: 'Message 1', * content: { text: 'Hello User 1!' }, * attachments: [], * priority: 'normal', * tags: [], * headers: new Headers() * }, * { * sender: { address: 'from@example.com' }, * recipients: [{ address: 'user2@example.com' }], * ccRecipients: [], * bccRecipients: [], * replyRecipients: [], * subject: 'Message 2', * content: { text: 'Hello User 2!' }, * attachments: [], * priority: 'normal', * tags: [], * headers: new Headers() * } * ]; * * for await (const receipt of transport.sendMany(messages)) { * if (receipt.successful) { * console.log('Sent:', receipt.messageId); * } else { * console.error('Failed:', receipt.errorMessages); * } * } * ``` * * @param messages An iterable or async iterable of messages to send. * @param options Optional transport options including `AbortSignal` for * cancellation. * @returns An async iterable of receipts, one for each message. */ async *sendMany(messages, options) { options?.signal?.throwIfAborted(); const isAsyncIterable = Symbol.asyncIterator in messages; if (isAsyncIterable) for await (const message of messages) { options?.signal?.throwIfAborted(); yield await this.send(message, options); } else for (const message of messages) { options?.signal?.throwIfAborted(); yield await this.send(message, options); } } /** * Extracts or generates a message ID from the Plunk response. * * Plunk returns email details in the response, so we can use the contact ID * and timestamp to create a meaningful message ID. * * @param response The Plunk API response. * @param message The original message for fallback ID generation. * @returns A message ID string. */ extractMessageId(response, message) { if (response.emails && response.emails.length > 0) { const contactId = response.emails[0].contact?.id; const timestamp$1 = response.timestamp; if (contactId && timestamp$1) return `plunk-${contactId}-${new Date(timestamp$1).getTime()}`; } const timestamp = Date.now(); const recipientHash = message.recipients[0]?.address.split("@")[0].substring(0, 8) ?? "unknown"; const random = Math.random().toString(36).substring(2, 8); return `plunk-${timestamp}-${recipientHash}-${random}`; } }; //#endregion export { PlunkTransport };