This release is 4 versions behind 0.4.0 — the latest version of @upyo/resend. Jump to latest
Built and signed on GitHub ActionsBuilt and signed on GitHub Actions
Built and signed on GitHub Actions
Works with
•JSR Score100%•This package works with Cloudflare Workers, Node.js, Deno, Bun, Browsers




Downloads1/wk
•Published2 months ago (0.3.1)
Resend transport for Upyo email library
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239import type { ResolvedResendConfig } from "./config.ts"; /** * Response from Resend API for sending a single message. */ export interface ResendResponse { /** * The message ID returned by Resend. */ id: string; } /** * Response from Resend API for sending batch messages. */ export interface ResendBatchResponse { /** * Array of message objects with IDs. */ data: Array<{ id: string }>; } /** * Error response from Resend API. */ export interface ResendError { /** * Error message from Resend. */ message: string; /** * Error name/type. */ name?: string; } /** * Resend API error class for handling API-specific errors. */ export class ResendApiError extends Error { readonly statusCode: number; constructor(message: string, statusCode: number) { super(message); this.name = "ResendApiError"; this.statusCode = statusCode; } } /** * HTTP client wrapper for Resend API requests. * * This class handles authentication, request formatting, error handling, * and retry logic for Resend API calls. */ export class ResendHttpClient { private config: ResolvedResendConfig; constructor(config: ResolvedResendConfig) { this.config = config; } /** * Sends a single message via Resend API. * * @param messageData The JSON data to send to Resend. * @param signal Optional AbortSignal for cancellation. * @returns Promise that resolves to the Resend response. */ sendMessage( messageData: Record<string, unknown>, signal?: AbortSignal, ): Promise<ResendResponse> { const url = `${this.config.baseUrl}/emails`; return this.makeRequest(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(messageData), signal, }); } /** * Sends multiple messages via Resend batch API. * * @param messagesData Array of message data objects to send. * @param signal Optional AbortSignal for cancellation. * @returns Promise that resolves to the Resend batch response. */ sendBatch( messagesData: Array<Record<string, unknown>>, signal?: AbortSignal, ): Promise<ResendBatchResponse> { const url = `${this.config.baseUrl}/emails/batch`; return this.makeRequest(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(messagesData), signal, }); } /** * Makes an HTTP request to Resend API with retry logic. * * @param url The URL to make the request to. * @param options Fetch options. * @returns Promise that resolves to the parsed response. */ private async makeRequest<T = ResendResponse | ResendBatchResponse>( url: string, options: RequestInit, ): Promise<T> { let lastError: Error | null = null; for (let attempt = 0; attempt <= this.config.retries; attempt++) { try { const response = await this.fetchWithAuth(url, options); const text = await response.text(); if (!response.ok) { let errorMessage: string | undefined; try { const errorBody = JSON.parse(text) as ResendError; errorMessage = errorBody.message; } catch { // Ignore if JSON parsing fails, as the body may be non-JSON } // Fallback logic for creating a meaningful error message. // 1. Use the parsed `errorMessage` if available. // 2. Otherwise, use the raw `text` response. // 3. If the raw `text` is also empty, fall back to the HTTP status. // Using `||` is intentional here to treat empty strings as falsy // and ensure a non-empty error message for the tests. throw new ResendApiError( errorMessage || text || `HTTP ${response.status}`, response.status, ); } try { return JSON.parse(text) as T; } catch (parseError) { throw new Error( `Invalid JSON response from Resend API: ${ parseError instanceof Error ? parseError.message : String(parseError) }`, ); } } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); // Don't retry on client errors (4xx) or AbortError if ( error instanceof ResendApiError && error.statusCode >= 400 && error.statusCode < 500 ) { throw error; } if (error instanceof Error && error.name === "AbortError") { throw error; } // If this is the last attempt, throw the error if (attempt === this.config.retries) { throw lastError; } // Wait before retrying with exponential backoff const backoffMs = Math.min(1000 * Math.pow(2, attempt), 10000); await new Promise((resolve) => setTimeout(resolve, backoffMs)); } } throw lastError || new Error("Request failed after all retry attempts"); } /** * Makes a fetch request with authentication headers. * * @param url The URL to fetch. * @param options Fetch options. * @returns Promise that resolves to the Response. */ private async fetchWithAuth( url: string, options: RequestInit, ): Promise<Response> { const headers = new Headers(options.headers); // Add authentication headers.set("Authorization", `Bearer ${this.config.apiKey}`); // Add custom headers from config for (const [key, value] of Object.entries(this.config.headers)) { headers.set(key, value); } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); // Combine signals if one is provided let signal: AbortSignal; if (options.signal) { const combinedController = new AbortController(); const onAbort = () => combinedController.abort(); options.signal.addEventListener("abort", onAbort, { once: true }); controller.signal.addEventListener("abort", onAbort, { once: true }); signal = combinedController.signal; } else { signal = controller.signal; } try { const response = await fetch(url, { ...options, headers, signal, }); return response; } finally { clearTimeout(timeoutId); } } }