@upyo/plunk
Version:
Plunk transport for Upyo email library
434 lines (429 loc) • 13.7 kB
JavaScript
//#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
exports.PlunkTransport = PlunkTransport;