--- url: /why.md description: >- Learn why Upyo is the ideal choice for email sending: cross-runtime compatibility, zero dependencies, simple API, built-in testing tools, and provider independence. --- # Why Upyo? Upyo\[^1] is a simple and modern email library that works across multiple runtimes including Node.js, Deno, Bun, and edge functions. It provides a universal interface for email delivery, making it easy to send emails with minimal setup. \[^1]: Upyo (pronounced /oo-pee-oh/) comes from the Sino-Korean word [郵票] (upyo), meaning *postage stamp*. The name reflects the library's purpose: just as postage stamps enable mail delivery across different postal systems, Upyo enables email delivery across different runtime environments and service providers. [郵票]: https://en.wiktionary.org/wiki/%E9%83%B5%E7%A5%A8#Noun_2 ## Cross-runtime compatibility Upyo is designed to work seamlessly across different JavaScript runtimes. Whether you're using Node.js, Deno, Bun, or deploying to edge functions, Upyo provides a consistent API for sending emails. This means you can write your email sending code once and run it anywhere without worrying about runtime-specific details. ## Lightweight and dependency-free Upyo has zero dependencies, making it lightweight and easy to integrate into your projects. You don't have to worry about managing additional packages or bloat. Upyo is designed to be minimalistic, focusing solely on email delivery without unnecessary complexity. ## Dead simple API Upyo provides a straightforward and intuitive API for sending emails. You can send emails with just a few lines of code, without needing to understand complex configurations or setups. The API is designed to be easy to use, so you can focus on building your application rather than dealing with email delivery intricacies. Here's a quick example of sending an email with Upyo: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MailgunTransport } from "@upyo/mailgun"; import process from "node:process"; const message = createMessage({ from: "sender@example.com", to: "recipient@example.net", subject: "Hello from Upyo!", content: { text: "This is a test email." }, }); const transport = new MailgunTransport({ apiKey: process.env.MAILGUN_KEY!, domain: process.env.MAILGUN_DOMAIN!, region: process.env.MAILGUN_REGION as "us" | "eu", }); 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(", ")); } ``` ## Built for testing Upyo is designed with testing in mind. The [*@upyo/mock*](./transports/mock.md) transport provides a comprehensive testing solution that lets you verify email functionality without sending real emails. You can inspect sent messages, simulate network delays and failures, and test complex async email workflows with confidence. The mock transport implements the same interface as real transports, making it a drop-in replacement for testing. This means you can write reliable tests that verify your email logic works correctly across all scenarios: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MockTransport } from "@upyo/mock"; // Use mock transport in tests - same interface, no real emails const transport = new MockTransport(); const message = createMessage({ from: "sender@example.com", to: "recipient@example.net", subject: "Test Email", content: { text: "This is a test." }, }); const receipt = await transport.send(message); // Verify the email was "sent" successfully console.log(receipt.successful); // true // Inspect what was sent for testing assertions const sentMessages = transport.getSentMessages(); console.log(sentMessages[0].subject); // "Test Email" console.log(sentMessages[0].recipients[0].address); // "recipient@example.net" ``` ## Provider independence Upyo's transport abstraction means you're never locked into a single email service provider. Whether you use [SMTP](./transports/smtp.md), [Mailgun](./transports/mailgun.md), [Resend](./transports/resend.md), [SendGrid](./transports/sendgrid.md), [Amazon SES](./transports/ses.md), or any future provider, your application code stays exactly the same. Switch providers in minutes, not days: ```typescript twoslash import { createMessage } from "@upyo/core"; import { SmtpTransport } from "@upyo/smtp"; import { MailgunTransport } from "@upyo/mailgun"; const message = createMessage({ from: "sender@example.com", to: "recipient@example.net", subject: "Hello from Upyo!", content: { text: "This works with any transport!" }, }); // Start with SMTP for development const smtpTransport = new SmtpTransport({ host: "localhost", port: 1025, }); // Switch to Mailgun for production - same interface! const mailgunTransport = new MailgunTransport({ apiKey: "your-api-key", domain: "your-domain.com", }); // Your application code never changes async function sendEmail(transport: any) { const receipt = await transport.send(message); return receipt.successful ? receipt.messageId : receipt.errorMessages; } // Works identically with any transport await sendEmail(smtpTransport); // ✅ Works await sendEmail(mailgunTransport); // ✅ Works ``` ## Observability Upyo integrates seamlessly with [OpenTelemetry](./transports/opentelemetry.md) to provide comprehensive observability for your email operations. Monitor delivery rates, track performance, and debug issues with distributed tracing—all without changing your existing code: ```typescript twoslash import { createMessage } from "@upyo/core"; import { SmtpTransport } from "@upyo/smtp"; import { createOpenTelemetryTransport } from "@upyo/opentelemetry"; // Wrap any transport with OpenTelemetry instrumentation const baseTransport = new SmtpTransport({ host: "smtp.example.com" }); const transport = createOpenTelemetryTransport(baseTransport, { serviceName: "email-service", tracing: { enabled: true }, metrics: { enabled: true }, }); // Your email code stays exactly the same const message = createMessage({ from: "sender@example.com", to: "recipient@example.net", subject: "Production Email", content: { text: "Now with full observability!" }, }); await transport.send(message); // Automatically creates traces and records metrics: // - Email delivery success/failure rates // - Send operation latency histograms // - Error classification by type // - Distributed tracing for debugging ``` Key observability features: Zero-code instrumentation : Add observability to any transport without modifying your email logic Comprehensive metric : Track delivery rates, latency, batch sizes, and error distributions Distributed tracing : Follow email operations across your entire system with OpenTelemetry spans Smart error classification : Automatically categorize failures (auth, network, validation, etc.) for better alerting Production-tested : Built on OpenTelemetry standards used by major observability platforms Whether you're using Jaeger, Prometheus, Grafana, or commercial APM solutions, Upyo's OpenTelemetry support ensures you have the insights needed to run email services reliably at scale. ## Comparison with alternatives Upyo is not trying to replace every email library out there. Different tools excel in different scenarios. Here's an honest comparison to help you decide if Upyo is the right choice for your project. ### Feature comparison The following tables compare Upyo with other popular JavaScript/TypeScript email libraries. Legend: ✅ : Supported ❌ : Not supported 🔜 : Planned for future release N/A : Not applicable (architecture doesn't require this feature) #### Runtime support | Runtime | Upyo | [Nodemailer] | [Resend] | [SendGrid] | [Mailgun] | | -------------- | :--: | :----------: | :------: | :--------: | :-------: | | Node.js | ✅ | ✅ | ✅ | ✅ | ✅ | | Deno | ✅ | ❌ | ✅ | ✅ | ✅ | | Bun | ✅ | ❌ | ✅ | ✅ | ✅ | | Edge functions | ✅ | ❌ | ✅ | ✅ | ✅ | #### Transport options | Transport | Upyo | [Nodemailer] | [Resend] | [SendGrid] | [Mailgun] | | --------- | :--: | :----------: | :------: | :--------: | :-------: | | SMTP | ✅ | ✅ | ❌ | ❌ | ❌ | | HTTP API | ✅ | ❌\[^2] | ✅ | ✅ | ✅ | #### Core features | Feature | Upyo | [Nodemailer] | [Resend] | [SendGrid] | [Mailgun] | | -------------------- | :--: | :----------: | :------: | :--------: | :-------: | | Connection pooling | ✅ | ✅ | N/A | N/A | N/A | | Attachments | ✅ | ✅ | ✅ | ✅ | ✅ | | Inline images | ✅ | ✅ | ✅ | ✅ | ✅ | | HTML and plain text | ✅ | ✅ | ✅ | ✅ | ✅ | | Calendar invitations | ✅ | ✅ | ❌ | ❌ | ❌ | | Batch sending | ✅ | ❌ | ✅ | ✅ | ✅ | #### Advanced features | Feature | Upyo | [Nodemailer] | [Resend] | [SendGrid] | [Mailgun] | | ------------------------ | :--: | :----------: | :------: | :--------: | :-------: | | DKIM signing | ✅ | ✅ | N/A | N/A | N/A | | OAuth 2.0 authentication | ✅ | ✅ | N/A | N/A | N/A | | Template engine | ❌ | ❌\[^2] | ✅ | ✅ | ✅ | #### Developer experience | Feature | Upyo | [Nodemailer] | [Resend] | [SendGrid] | [Mailgun] | | ------------------------- | :--: | :----------: | :------: | :--------: | :-------: | | Built-in mock transport | ✅ | ❌\[^3] | ❌ | ❌ | ❌ | | OpenTelemetry integration | ✅ | ❌ | ❌ | ❌ | ❌ | | Provider abstraction | ✅ | ❌ | ❌ | ❌ | ❌ | | Zero dependencies | ✅ | ✅ | ❌ | ❌ | ❌ | | Native TypeScript | ✅ | ❌\[^4] | ✅ | ✅ | ✅ | \[^2]: Available via community plugins. \[^3]: Stream transport can be used for similar purposes. \[^4]: Requires `@types/nodemailer` package. [Nodemailer]: https://nodemailer.com/ [Resend]: https://resend.com/ [SendGrid]: https://sendgrid.com/ [Mailgun]: https://www.mailgun.com/ ### When to use Upyo Upyo is a great choice when you need: * *Cross-runtime compatibility*: Your code runs on Node.js, Deno, Bun, or edge functions. * *Provider flexibility*: You want to switch between email providers without changing application code. * *Testing-first development*: Built-in mock transport makes testing straightforward. * *Observability*: OpenTelemetry integration for monitoring and debugging. * *Minimal footprint*: Zero dependencies keep your bundle size small. ### When to consider alternatives Consider other libraries when you need: * *Built-in templating*: Resend (React), SendGrid, and Mailgun offer integrated template engines. * *Node.js-only deployment*: Nodemailer's extensive plugin ecosystem may offer more flexibility. * *Provider-specific features*: Official SDKs (Resend, SendGrid, Mailgun) expose provider-specific capabilities that Upyo's unified interface may not cover. --- --- url: /start.md description: >- A quick guide to getting started with Upyo, including installation, choosing a transport, and sending your first email. --- # Getting started This guide will help you set up Upyo in your project and send your first email. ## Installation To install Upyo, you can use your preferred package manager. Upyo is available on JSR and npm, so you can choose the one that fits your project best: ::: code-group ```sh [npm] npm add @upyo/core ``` ```sh [pnpm] pnpm add @upyo/core ``` ```sh [Yarn] yarn add @upyo/core ``` ```sh [Deno] deno add jsr:@upyo/core ``` ```sh [Bun] bun add @upyo/core ``` ::: ## Choosing a transport Upyo supports multiple transports for sending emails, each with different strengths and use cases: [SMTP](./transports/smtp.md) : Universal email protocol, works with any SMTP server [Mailgun](./transports/mailgun.md) : HTTP API service with advanced features and analytics [Lettermint](./transports/lettermint.md) : Transactional email API with routes, metadata, idempotency, and batch sending [Maileroo](./transports/maileroo.md) : JSON Email API with attachments, tags, custom headers, and tracking [Mailtrap](./transports/mailtrap.md) : Email API and Email Sandbox for production delivery and test inbox capture [Resend](./transports/resend.md) : Modern email service provider that offers a developer-friendly API [SendGrid](./transports/sendgrid.md) : Popular email API with deliverability focus [Plunk](./transports/plunk.md) : Modern, developer-friendly email service that offers both cloud-based and self-hosted solutions for transactional email delivery [Amazon SES](./transports/ses.md) : Cost-effective email service for AWS users [Mock transport](./transports/mock.md) : Testing utility that captures emails without sending If none of these fit your needs, you can also [create a custom transport](./transports/custom.md) to integrate with any email service or add specialized functionality. To get started, install the transport package for your chosen option. For example, to use the SMTP transport, you would install the *@upyo/smtp* package: ::: code-group ```sh [npm] npm add @upyo/smtp ``` ```sh [pnpm] pnpm add @upyo/smtp ``` ```sh [Yarn] yarn add @upyo/smtp ``` ```sh [Deno] deno add jsr:@upyo/smtp ``` ```sh [Bun] bun add @upyo/smtp ``` ::: > \[!CAUTION] > The SMTP transport currently does not support edge functions or web browsers. > If you need to use Upyo in these environments, consider using other transports > like [Mailgun](./transports/mailgun.md) or similar services that provide HTTP > APIs. ## Sending your first email Once you have installed the core package and the transport you want to use, you can start sending emails. Here's a basic example using Gmail through SMTP: ```typescript twoslash import { createMessage } from "@upyo/core"; import { SmtpTransport } from "@upyo/smtp"; const transport = new SmtpTransport({ host: "smtp.gmail.com", port: 465, secure: true, // Use TLS auth: { user: "your@gmail.com", pass: "your-app-password", } }); const message = createMessage({ from: "sender@example.com", to: "recipient@example.net", subject: "Hello from Upyo!", content: { text: "This is a test email." }, }); 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(", ")); } ``` That's it! You have sent your first email using Upyo. You can customize the message with HTML content, attachments, and more. --- --- url: /messages/compose.md description: >- Learn how to create email messages with Upyo's createMessage() function, including multiple recipients, rich content, priority settings, and custom headers. --- # Composing messages Creating email messages in Upyo is straightforward and flexible. The library provides the `createMessage()` function from the *@upyo/core* package, which accepts various input formats and automatically handles validation and type conversion for you. To serialize a message without sending it, use the [MIME composition API](./mime.md). ## Basic message creation To create a simple email message, you need to provide at minimum a sender address, recipient address, subject, and content. The `createMessage()` function accepts these in a convenient object format: ```typescript twoslash import { createMessage } from "@upyo/core"; const message = createMessage({ from: "sender@example.com", to: "recipient@example.net", subject: "Hello from Upyo!", content: { text: "This is a test email." }, }); ``` The function automatically converts string email addresses to proper `Address` objects and validates the input. You can provide email addresses as simple strings like `"user@example.com"` or with display names using the format `"Name "` like `"John Doe "`. You can also provide `Address` objects directly if you prefer to work with the structured format. ## Multiple recipients When you need to send an email to multiple recipients, you can provide arrays for the `to`, `cc`, and `bcc` fields. Each field accepts either a single address or an array of addresses, and you can mix different formats including plain email addresses, display name formats, and `Address` objects: ```typescript twoslash import { createMessage } from "@upyo/core"; const message = createMessage({ from: "Support Team ", to: ["recipient1@example.com", "John Smith "], cc: { name: "Manager", address: "manager@example.com" }, bcc: ["archive@example.com", "backup@example.com"], subject: "Team Update", content: { text: "Here's the latest team update." }, }); ``` You can also specify a custom reply-to address using the `replyTo` field, which is useful when you want replies to go to a different address than the sender: ```typescript twoslash import { createMessage } from "@upyo/core"; const message = createMessage({ from: "noreply@example.com", to: "customer@example.com", replyTo: "support@example.com", subject: "Welcome to our service", content: { text: "Thank you for signing up!" }, }); ``` ## Internationalized addresses *This feature is introduced in Upyo 0.6.0.* Upyo accepts internationalized mailbox addresses with UTF-8 local parts and Unicode domains, as defined by the internationalized email framework in [RFC 6530]: ```typescript twoslash import { createMessage } from "@upyo/core"; const message = createMessage({ from: "josé@example.com", to: "用户@例子.广告", subject: "Hello", content: { text: "Welcome!" }, }); ``` The local part is limited to 64 UTF-8 octets. Upyo preserves the spelling and Unicode normalization supplied by the caller because changing a local part can change the mailbox it identifies. Provider support still depends on the selected transport. The [SMTP transport](../transports/smtp.md#internationalized-addresses) negotiates the `SMTPUTF8` extension automatically and reports a failed receipt when the server cannot accept an internationalized address. [RFC 6530]: https://www.rfc-editor.org/rfc/rfc6530 ## Rich content Upyo supports both plain text and HTML email content. You can provide just text content, just HTML content, or both. When you provide both, email clients will choose the appropriate format to display: ```typescript twoslash import { createMessage } from "@upyo/core"; const message = createMessage({ from: "newsletter@example.com", to: "subscriber@example.com", subject: "Monthly Newsletter", content: { html: "

Welcome to our newsletter!

This month we have exciting updates.

", text: "Welcome to our newsletter! This month we have exciting updates.", }, }); ``` A message can also carry an iCalendar object alongside the text and HTML, which is what turns it into a meeting invitation rather than an email that mentions a meeting. See [Calendar invitations](./calendar.md). If you only need plain text, you can simply provide the `text` property: ```typescript twoslash import { createMessage } from "@upyo/core"; const message = createMessage({ from: "system@example.com", to: "user@example.com", subject: "System Notification", content: { text: "Your backup has completed successfully." }, }); ``` ## Message priority and organization You can set the priority level of your messages to help recipients understand their importance. Upyo supports three priority levels: `"high"`, `"normal"`, and `"low"`: ```typescript twoslash import { createMessage } from "@upyo/core"; const message = createMessage({ from: "alerts@example.com", to: "admin@example.com", subject: "Server Alert", content: { text: "The server is experiencing high load." }, priority: "high", }); ``` For better organization and filtering, you can add tags to your messages. Tags are simple strings that can help you categorize and search your emails later: ```typescript twoslash import { createMessage } from "@upyo/core"; const message = createMessage({ from: "support@example.com", to: "customer@example.com", subject: "Ticket Update", content: { text: "Your support ticket has been updated." }, tags: ["support", "customer-service", "urgent"], }); ``` ## Custom headers Sometimes you need to include custom email headers for specific functionality or compliance requirements. You can add custom headers using the `headers` field as a simple object, a standard [`Headers`] instance, or an `ImmutableHeaders` instance (which is an immutable version compatible with the standard [`Headers`] interface): ```typescript twoslash import { createMessage } from "@upyo/core"; // Using a simple object const message1 = createMessage({ from: "app@example.com", to: "user@example.com", subject: "Password Reset", content: { text: "Click the link to reset your password." }, headers: { "X-Mailer": "Upyo Email Library", "X-Priority": "1", "List-Unsubscribe": "", }, }); ``` ```typescript twoslash import { createMessage } from "@upyo/core"; // ---cut-before--- // Using standard Headers object const headers = new Headers(); headers.set("X-Mailer", "Upyo Email Library"); headers.set("X-Priority", "1"); const message2 = createMessage({ from: "app@example.com", to: "user@example.com", subject: "Password Reset", content: { text: "Click the link to reset your password." }, headers, }); ``` The `createMessage()` function handles all the complexity of email message construction, ensuring that your messages are properly formatted and valid before sending them through your chosen transport. [`Headers`]: https://developer.mozilla.org/en-US/docs/Web/API/Headers ### Headers the transport owns A few header fields come from the message itself rather than from `headers`, so a transport that composes the message ignores custom headers that would collide with them: `From`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `MIME-Version`, `Content-Type`, and `Content-Transfer-Encoding`. Use the corresponding `createMessage()` fields to set those. Note that `Bcc` in particular is carried in the envelope and never written into the message, so blind recipients stay hidden from everyone who receives it. `Date`, `Message-ID`, `In-Reply-To`, and `References` have dedicated fields too, described in the next section. They differ from the fields above in that a custom header is still honored when the dedicated field is left unset, so the header form keeps working. ## Message identity and reply threading *This feature is introduced in Upyo 0.6.0.* Applications that correlate replies, such as a helpdesk matching an incoming answer back to a ticket, need to choose the outgoing message identifier rather than discover it afterwards. `createMessage()` takes four fields for that: `messageId` : The RFC 5322 message identifier. The enclosing angle brackets are optional and stripped, so `` and `abc@example.com` mean the same thing. `date` : The origination date. With neither this nor a custom `Date` header set, a transport that composes the message uses the time of conversion, so a retry carries a later date. `inReplyTo` : The identifier, or identifiers, of the messages this one replies to. `references` : The identifiers of the conversation, oldest first. A reply usually carries the parent's references followed by the parent's own identifier, which is how a mail client reconstructs a thread. Mint an identifier with `generateMessageId()` before sending, store it with whatever the message is about, and the value survives conversion and any delivery retry, because a retry re-sends the very same message: ```typescript twoslash declare function storeTicketMessageId(id: string): Promise; // ---cut-before--- import { createMessage, generateMessageId } from "@upyo/core"; const messageId = generateMessageId("example.com"); await storeTicketMessageId(messageId); const message = createMessage({ from: "support@example.com", to: "customer@example.net", subject: "Re: Your request", content: { text: "Thanks for getting in touch." }, messageId, date: new Date("2026-09-01T10:00:00Z"), }); ``` When the customer answers, their reply carries `In-Reply-To: `, which is what ties the answer back to the ticket. Composing the next message in the thread is the mirror image: ```typescript twoslash interface InboundMessage { readonly messageId: string; readonly references: readonly string[]; } declare const inbound: InboundMessage; // ---cut-before--- import { createMessage } from "@upyo/core"; const reply = createMessage({ from: "support@example.com", to: "customer@example.net", subject: "Re: Your request", content: { text: "Here is the answer." }, inReplyTo: inbound.messageId, references: [...inbound.references, inbound.messageId], }); ``` An identifier that is not valid is rejected rather than repaired, since these fields are written into the message as given: ```typescript twoslash import { createMessage } from "@upyo/core"; try { createMessage({ from: "support@example.com", to: "customer@example.net", subject: "Re: Your request", content: { text: "Thanks for getting in touch." }, messageId: "not an identifier", }); } catch (error) { console.error(error); // TypeError: Invalid message ID: "not an identifier" } ``` `parseMessageId()` normalizes a single identifier and returns `undefined` instead of throwing, which is convenient for values arriving from elsewhere. It parses one identifier, so an `In-Reply-To` field carrying several has to be split first. ### Dropping an inherited header `inReplyTo` and `references` distinguish three states, which matters when a message is assembled from a template that already carries these headers: * Leaving the field unset defers to a custom `In-Reply-To` or `References` header. * Setting it to a non-empty array replaces that header. * Setting it to an empty array suppresses the header, so the message deliberately starts a new thread. ### Transport support `Message-ID` and `Date` are only as durable as the transport carrying them. A provider that composes the message on its own side may assign or rewrite both, whatever the message asked for. | Transport | `Message-ID` and `Date` | `In-Reply-To` and `References` | | ------------------ | ----------------------- | ------------------------------ | | *@upyo/smtp* | Written as given | Written as given | | *@upyo/jmap* | Written as given | Written as given | | *@upyo/mailgun* | Not sent | Sent as a custom header | | *@upyo/sendgrid* | Not sent | Sent as a custom header | | *@upyo/mailtrap* | Not sent | Sent as a custom header | | *@upyo/maileroo* | Not sent | Sent as a custom header | | *@upyo/lettermint* | Not sent | Sent as a custom header | | *@upyo/resend* | Not sent | Sent as a custom header | | *@upyo/plunk* | Not sent | Sent as a custom header | | *@upyo/ses* | Not sent | Not sent | “Not sent” describes Upyo, not the provider. Most of these APIs accept a custom `Message-ID` without documenting whether the value survives their pipeline; Lettermint documents that it replaces one unless a separate opt-in header accompanies it, and Amazon SES documents that it overrides both fields even for a raw MIME message. Rather than promise preservation Upyo cannot verify, those transports leave the fields alone. *@upyo/ses* sends no custom headers at all. Providers also cap header values well below the length a long thread produces: 768 characters for Maileroo, around 995 for Plunk and Amazon SES. Upyo does not truncate a `References` chain, so an over-long one surfaces as a provider error. > \[!NOTE] > A message identifier supports *correlation*. It is not a capability, so > receiving one proves nothing about who sent it; it does not deduplicate > anything, nor make delivery exactly-once; and a recipient's mail client may > thread by subject regardless. ### `Message-ID` is not `Receipt.messageId` The `messageId` on a successful [`Receipt`] is the delivery handle the transport or the provider reports back: an SMTP queue identifier, a provider UUID, a JMAP submission id. It is chosen by the far side, differs in shape between transports, and is not the RFC 5322 `Message-ID` the message carries. Use it to look a delivery up in a provider's dashboard; use `message.messageId` to correlate a reply. [`Receipt`]: https://jsr.io/@upyo/core/doc/receipt/~/Receipt --- --- url: /messages/mime.md --- # Composing MIME *This API is available since Upyo 0.6.0.* `composeMessage()` from *@upyo/mime* turns a structured message into replayable MIME bytes without opening a transport connection. Use it to save an *.eml* file, inspect a message before delivery, or supply the same serialized message to a raw-message transport. It works in Node.js, Deno, Bun, and edge runtimes with Web Crypto, including Cloudflare Workers without Node.js compatibility. ```typescript twoslash import { createMessage, readAttachmentContent } from "@upyo/core"; import { composeMessage } from "@upyo/mime"; const message = createMessage({ from: "sender@example.com", to: "recipient@example.com", bcc: "archive@example.com", subject: "A copy for your records", content: { text: "Hello!", html: "

Hello!

" }, }); const composed = await composeMessage(message); const eml = await readAttachmentContent(composed.content); // Save eml with your runtime's file or object-storage API. ``` The result implements `RawMessage`. Its envelope contains the sender and all To, Cc, and Bcc recipients; the MIME has no Bcc header. The generated date, message identifier, and multipart boundaries stay the same on every read. Typed identity and threading fields follow the same precedence as [structured messages](./compose.md). The bytes use CRLF and include the final line ending. They contain neither SMTP dot-stuffing nor the DATA terminator. Collecting them as shown above uses memory proportional to the complete message. For large messages, consume the chunks: ```typescript twoslash import type { ComposedMessage } from "@upyo/mime"; declare const composed: ComposedMessage; declare function writeChunk(bytes: Uint8Array): Promise; // ---cut-before--- for await (const chunk of composed.content()) { await writeChunk(chunk); } ``` Only treat a saved message as complete after iteration finishes successfully. An attachment failure, cancellation, or replay error can occur after some bytes have been written. ## Sending composed bytes Both [SMTP](../transports/smtp.md) and [JMAP](../transports/jmap.md) accept the result directly through `sendRaw()`: ```typescript twoslash import type { ComposedMessage } from "@upyo/mime"; import type { SmtpTransport } from "@upyo/smtp"; import type { JmapTransport } from "@upyo/jmap"; declare const composed: ComposedMessage; declare const smtp: SmtpTransport; declare const jmap: JmapTransport; // ---cut-before--- await smtp.sendRaw(composed); // Or, with a JMAP transport: await jmap.sendRaw(composed); ``` `encoding` is `"7bit"` or `"utf8"`, based on all MIME headers, including nested parts and DKIM signatures. Internationalized envelope addresses have their own transport requirements. The result has a factory source and an explicit encoding, so raw SMTP delivery does not perform a size-analysis pass or announce a known `SIZE` before DATA. Server size limits still apply while sending. To request the existing analysis pass, pass `{ ...composed, encoding: undefined }` to `sendRaw()`, which reads the source an extra time. Alternatively, collect bytes first and replace `content` with that byte array. To change delivery addresses without changing the MIME, pass a copy with a new `envelope`. SMTP transport DKIM settings do not sign raw messages; use the composition option below. A JMAP server may modify imported MIME during submission, so its recipients are not guaranteed to receive identical bytes or an intact original DKIM signature. ## Signing and attachment lifetime Pass `dkim` to sign during composition. It accepts the same signature settings as the [SMTP DKIM configuration](../transports/smtp.md#dkim-signing). ```typescript twoslash import type { Message } from "@upyo/core"; declare const message: Message; declare const privateKey: string; // ---cut-before--- import { composeMessage } from "@upyo/mime"; const composed = await composeMessage(message, { dkim: { bodyMode: "streaming", signatures: [{ signingDomain: "example.com", selector: "mail", privateKey, }], }, }); ``` Without signing, composition neither awaits promised attachment bytes nor opens attachment factories. Each content reader reads the attachments when it reaches them. Keep byte arrays immutable, and make factories return fresh, independent readers with identical bytes. Metadata and signing options are copied during composition; attachment bytes remain caller-owned. The default DKIM body mode, `"buffered"`, reads the body once before composition resolves and retains it for subsequent readers. `"streaming"` hashes it once before resolving, then reads it again for every content reader. Multiple signatures share those passes. A changed streaming body throws `MimeAttachmentReplayError`, a `RawMessageValidationError` whose `field` is `"content"`. Raw transports report it as a nonretryable raw-message validation failure. Signing failures throw by default. `onSigningFailure: "send-unsigned"` logs a warning and continues; signatures completed before a later signing failure remain on the message. Attachment-read failures and cancellation still throw. ## Cancellation `composeMessage(message, { signal })` uses the signal during preparation and for every future reader. Calling `composed.content(readerSignal)` additionally cancels that reader alone. Readers can run concurrently; cancelling one does not cancel the others. Cancellation preserves the signal's reason. Stopping a `for await` loop closes the active attachment reader. Factories should honor their signal during acquisition and reading so they can release resources promptly. No reader stays open after composition itself finishes, and the composed result does not need disposal. --- --- url: /messages/attachments.md description: >- Guide to adding file attachments to emails using JavaScript File objects or custom Attachment objects, including inline attachments and binary content. --- # Attachments Email attachments in Upyo are handled seamlessly through the `createMessage()` function from the *@upyo/core* package. You can attach files to your messages using either JavaScript [`File`] objects or custom `Attachment` objects, giving you flexibility in how you handle file content and metadata. [`File`]: https://developer.mozilla.org/en-US/docs/Web/API/File ## Attaching files The simplest way to add attachments to your email is by using JavaScript [`File`] objects. This is particularly useful when working with file uploads in web applications or when you have file data available as [`File`] instances: ```typescript twoslash import { createMessage } from "@upyo/core"; import { readFile } from "node:fs/promises"; // Read a PDF file from the filesystem const fileContent = await readFile("./reports/monthly-report.pdf"); const file = new File([fileContent], "monthly-report.pdf", { type: "application/pdf" }); const message = createMessage({ from: "finance@example.com", to: "manager@example.com", subject: "Monthly Report - October 2024", content: { text: "Please find the October monthly report attached for your review." }, attachments: file, }); ``` When you provide a [`File`] object, Upyo extracts its filename and content type and retains the file without reading it. The attachment will be included as a regular (non-inline) attachment. The `readFile()` call in this example allocates the complete file before constructing the message; use a content factory below when the file should be read incrementally. ## Multiple attachments You can attach multiple files to a single message by providing an array of [`File`] objects or mixing different attachment types: ```typescript twoslash import { createMessage } from "@upyo/core"; import { readFile } from "node:fs/promises"; // Read multiple files from the filesystem const contractContent = await readFile("./legal/Q4-contract.pdf"); const budgetContent = await readFile("./finance/Q4-budget.xlsx"); const document = new File([contractContent], "Q4-contract.pdf", { type: "application/pdf" }); const spreadsheet = new File([budgetContent], "Q4-budget.xlsx", { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); const message = createMessage({ from: "finance@example.com", to: "ceo@example.com", subject: "Q4 Financial Documents", content: { text: "Please find the Q4 contract and budget documents attached for your review." }, attachments: [document, spreadsheet], }); ``` ## Custom attachment objects For more control over attachment behavior, you can create custom `Attachment` objects instead of using [`File`] instances. This approach is useful when you need to specify additional metadata or when working with inline attachments for HTML emails: ```typescript twoslash import { createMessage, type Attachment } from "@upyo/core"; import { readFile } from "node:fs/promises"; // Read company logo for inline attachment const logoContent = await readFile("./assets/company-logo.png"); const logoAttachment: Attachment = { filename: "company-logo.png", content: logoContent, contentType: "image/png", contentId: "company-logo", inline: true, }; const message = createMessage({ from: "marketing@example.com", to: "customer@example.com", subject: "Welcome to Acme Corp!", content: { html: `

Welcome to Acme Corp!

We are excited to have you on board.

Acme Corp Logo `, text: "Welcome to Acme Corp! We are excited to have you on board.", }, attachments: logoAttachment, }); ``` In this example, the attachment is marked as inline (`inline: true`) and referenced in the HTML content using the `contentId` as `cid:company-logo`. This allows the image to be displayed directly within the email body rather than as a separate downloadable attachment. ## Working with binary content *Blob attachments, replayable content factories, and SMTP attachment streaming are available since Upyo 0.6.0.* When working with binary file content, you can provide the attachment data as a [`Uint8Array`], a `Promise`, a `Blob`, or a replayable content factory. A promise represents a read that has already started; it does not defer that read until sending and still allocates the whole attachment. A factory opens a fresh reader when the transport needs the bytes: ```typescript twoslash import { createMessage, type Attachment } from "@upyo/core"; import { createReadStream } from "node:fs"; const attachment: Attachment = { filename: "customer-data-2024.csv", content: (signal) => createReadStream( "./data/exports/customer-data-2024.csv", { signal }, ), contentType: "text/csv", contentId: "customer-dataset", inline: false, }; const message = createMessage({ from: "data@example.com", to: "analyst@example.com", subject: "Customer Data Export - 2024", content: { text: "The 2024 customer dataset you requested is attached. This file contains anonymized customer analytics data." }, attachments: attachment, }); ``` The factory receives an optional `AbortSignal` and returns an `AsyncIterable` or a promise for one. Every invocation must return an independent reader producing identical bytes. Retries, concurrent sends, and streaming DKIM may open the same attachment more than once. Do not return an already-open stream from a factory, or change the underlying file while a send is pending. A rejected byte-array promise cannot restart its read. Factories should honor cancellation during acquisition and reading, and release resources in `finally` when implemented as async generators. Upyo requests iterator cleanup on early exit but does not wait indefinitely for a producer that ignores cancellation. Yield bounded chunks that remain valid until the next read; Upyo copies them when collecting a complete attachment. Unsigned SMTP streams attachments through MIME encoding to the socket. Its additional attachment memory is bounded by fixed processing buffers and the largest source chunk, excluding caller-owned data, text/HTML, headers, and runtime/socket buffers. HTTP transports accept the same inputs but collect the bytes for their provider payloads. Plunk retains its existing behavior of omitting attachments whose reads fail, except that caller cancellation aborts the send. See [SMTP DKIM body modes](../transports/smtp.md#body-processing) for the signing tradeoffs. [`Uint8Array`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array ### Reading attachment content in custom transports Since Upyo 0.6.0, `Attachment.content` is an `AttachmentContent` union, and `createMessage()` retains `File` objects instead of converting them to byte-array promises. Code that previously used `await attachment.content` should use `readAttachmentContent()` when it needs a complete byte array: ```typescript twoslash import { type Attachment, readAttachmentContent } from "@upyo/core"; async function readFileAttachment( attachment: Attachment, signal?: AbortSignal, ): Promise { return await readAttachmentContent(attachment.content, signal); } ``` Use `iterateAttachmentContent(content, signal)` instead when a custom transport can consume chunks incrementally. Neither helper caches factory results. ## Content type considerations When creating custom attachments, it's important to specify accurate content types (media types) to ensure proper handling by email clients. Common content types include `"application/pdf"` for PDF files, `"image/png"` for PNG images, `"text/csv"` for CSV files, and `"application/zip"` for ZIP archives. The content type helps email clients determine how to display or handle the attachment appropriately. If the content type comes from outside your application, pass it to `createMessage()` rather than to a `Message` object you build yourself. The SMTP transport writes the content type and the content ID into the MIME part headers as given, so a value carrying a carriage return or line feed would end that header field and turn the rest into further header fields. `createMessage()` rejects both characters with a `TypeError`. A browser's declared upload type is a common source of such a value. Whether you're working with simple file uploads or complex inline attachments for rich HTML emails, Upyo's attachment system provides the flexibility you need while handling the underlying complexity of email attachment encoding and formatting. --- --- url: /messages/calendar.md description: >- Learn how to send meeting invitations, replies, and cancellations with Upyo by attaching an iCalendar object to a message, and which transports compose it as a calendar alternative. --- # Calendar invitations *This feature is introduced in Upyo 0.6.0.* An appointment confirmation, a reservation, or a meeting invitation is more than an email with a date in it. A calendar client offers to add the event, or shows accept and decline buttons, when the message carries a `text/calendar` body part whose `method` parameter repeats the iCalendar object's own `METHOD` property, as [RFC 6047] describes. An *.ics* attachment reaches the same place only if the part keeps that media type and that parameter; the attachment API leaves both to you, and a file typed `application/octet-stream` is just a download. The `calendar` field on `createMessage()` takes the iCalendar object and lets the transport compose the rest: ```typescript twoslash declare const ics: string; // ---cut-before--- import { createMessage } from "@upyo/core"; const message = createMessage({ from: "organizer@example.com", to: "attendee@example.net", subject: "Lunch on Wednesday", content: { text: "Lunch on Wednesday at noon. Details are in the invitation.", }, calendar: { content: ics }, }); ``` [RFC 6047]: https://www.rfc-editor.org/rfc/rfc6047 ## Upyo does not generate iCalendar objects The `content` is an iCalendar object your application already has, whether from a template, from your own code, or from a library such as [ical-generator]. Upyo checks the syntax of every content line before composing the message. The event's meaning and scheduling requirements stay yours: * `ORGANIZER` and `ATTENDEE`, which decide who the invitation is from and who may reply to it. These are separate fields from the message's `from` and `to`, but they have to agree: [RFC 6047] §2.2 has the receiving side authorize a scheduling message by matching the sender against `ORGANIZER` for a `REQUEST` or a `CANCEL`, and against `ATTENDEE` for a `REPLY`. An invitation sent from a no-reply address on behalf of an organizer is well formed and still gets treated as forwarded, or refused an RSVP. * A `UID` that stays the same across the whole life of the event, and a `SEQUENCE` that increases every time you change it. A client matches an update or a cancellation to the original by `UID`, and ignores one whose `SEQUENCE` has not advanced. * `DTSTAMP`, `DTSTART`, and any `VTIMEZONE` the event needs. [RFC 5546] specifies what each method requires. Getting this wrong produces a message that is well formed and still does nothing useful, so it is worth reading before sending invitations to real people. [ical-generator]: https://github.com/sebbo2002/ical-generator [RFC 5546]: https://www.rfc-editor.org/rfc/rfc5546 ## The method comes from the object Upyo takes the method from the object's own `METHOD` property, so the object has to declare one. This is not a formality: where the MIME parameter and the `METHOD` property disagree, Outlook uses the property and ignores the parameter, so a method supplied anywhere else could not change what a recipient sees. The optional `method` field asserts what the object says rather than supplying it. Use it when you want the mismatch caught: ```typescript twoslash import { createMessage } from "@upyo/core"; const invitation = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Example//Booking//EN", "METHOD:REQUEST", "BEGIN:VEVENT", "UID:booking-42@example.com", "SEQUENCE:0", "DTSTAMP:20260901T090000Z", "DTSTART:20260902T120000Z", "DTEND:20260902T130000Z", "ORGANIZER:mailto:organizer@example.com", "ATTENDEE;RSVP=TRUE:mailto:attendee@example.net", "SUMMARY:Lunch", "END:VEVENT", "END:VCALENDAR", ].join("\r\n"); const message = createMessage({ from: "organizer@example.com", to: "attendee@example.net", subject: "Lunch on Wednesday", content: { text: "Lunch on Wednesday at noon." }, calendar: { method: "REQUEST", content: invitation }, }); ``` An object declaring a different method, or none at all, is rejected with a `TypeError` rather than sent as something a client will mishandle. Line endings are normalized to the CRLF [RFC 5545] requires, so an object held with plain newlines works as it is. Upyo checks every content line against the grammar in [RFC 5545] §3.1, including property names, parameters, and the characters allowed in values. For example, `SUMMARY;BROKEN:Lunch` is refused because a parameter needs an `=` and a value, which may be empty. A double quote inside an unquoted parameter value or a forbidden control character is refused too. Correct these errors in the template or generator that produced the object. The content must also be one `VCALENDAR` object with balanced components and exactly one supported top-level `METHOD`. This is not full iCalendar or iTIP validation: Upyo does not check property-specific value syntax, required event fields, or whether the event itself makes sense. [RFC 5545]: https://www.rfc-editor.org/rfc/rfc5545 ## Replies and cancellations The three methods an application usually needs are `REQUEST` for an invitation, `REPLY` for an answer to one, and `CANCEL` for calling it off. `PUBLISH`, `ADD`, `REFRESH`, `COUNTER`, and `DECLINECOUNTER` are accepted too. A cancellation is an ordinary message carrying an object with `METHOD:CANCEL`, the same `UID` as the invitation, and a higher `SEQUENCE`: ```typescript twoslash declare const cancellation: string; // ---cut-before--- import { createMessage } from "@upyo/core"; const message = createMessage({ from: "organizer@example.com", to: "attendee@example.net", subject: "Cancelled: Lunch on Wednesday", content: { text: "Wednesday's lunch is cancelled." }, calendar: { method: "CANCEL", content: cancellation }, }); ``` A reply reverses the direction: the attendee sends it to the organizer, and the object carries `METHOD:REPLY` with that one attendee's `PARTSTAT`. Nothing about the transport changes. ## Write the human-readable body too `content` is still required, and it still matters. A recipient whose client knows nothing about scheduling sees that and nothing else, so it should say what the invitation says. There is a second reason to keep them consistent: on import, Outlook replaces the calendar object's `DESCRIPTION` with the first `text/html` alternative in the message. A mismatch between the two shows up as an event whose description contradicts the mail it arrived in. ## Transport support Composing a `text/calendar` alternative requires a transport that builds the message structure itself. Only two do; the rest hand a provider a subject, a body, and a list of files. | Transport | Calendar handling | | ------------------ | ----------------------------------------- | | *@upyo/smtp* | Composed as a `text/calendar` alternative | | *@upyo/jmap* | Composed as a `text/calendar` alternative | | *@upyo/mailgun* | Sent as an *invite.ics* attachment | | *@upyo/sendgrid* | Sent as an *invite.ics* attachment | | *@upyo/mailtrap* | Sent as an *invite.ics* attachment | | *@upyo/maileroo* | Sent as an *invite.ics* attachment | | *@upyo/lettermint* | Sent as an *invite.ics* attachment | | *@upyo/resend* | Sent as an *invite.ics* attachment | | *@upyo/plunk* | Sent as an *invite.ics* attachment | | *@upyo/ses* | Sent as an *invite.ics* attachment | The composing transports place the calendar last inside a `multipart/alternative`, after the text and HTML bodies, which is the order [RFC 2046] §5.1.4 gives for increasing preference. *@upyo/smtp* encodes the part as Base64, which keeps the object's line structure exactly as written; *@upyo/jmap* hands the object to the server, which picks the transfer encoding itself. *@upyo/jmap* depends on one thing the standard does not promise. [RFC 8621] §4.1.4 defines a body part's `type` as the media type with its parameters stripped, and gives no property for the `method` the invitation needs, so Upyo writes the whole field into `type` and relies on the server passing it through. Stalwart does, and the end-to-end test downloads the composed message to check it; a server that strips the parameter instead would deliver an invitation no client can act on. The same reliance is not new to calendars, since the text and HTML parts already carry their charset the same way, but it is worth knowing before pointing this transport at an unfamiliar server. “Sent as an attachment” describes Upyo, not the provider. Those APIs take a text body, an HTML body, and files; none of them offers a third body alternative, so the object travels as a part named *invite.ics* whose content type keeps the `method` parameter. That is a real degradation rather than a different spelling of the same thing: whether a given provider preserves the parameter, and whether a given client then treats the part as an invitation rather than as a download, has not been verified per provider. If the scheduling semantics matter, send through *@upyo/smtp* or *@upyo/jmap*. The attachment is placed ahead of your own files, so a client looking for the first calendar part in the message finds the invitation. *@upyo/plunk* carries at most five attachments, and rather than quietly drop one of yours to make room it refuses a calendar message that would exceed the limit. *@upyo/resend* sends a calendar message individually rather than through its batch API, which accepts no attachments. > \[!NOTE] > Upyo's tests assert the MIME structure, and the JMAP composition is verified > against a real server. They do not assert what Gmail, Outlook, or Apple Mail > do with the result. Send yourself a test invitation before promising anyone > that RSVP works. [RFC 2046]: https://www.rfc-editor.org/rfc/rfc2046 [RFC 8621]: https://www.rfc-editor.org/rfc/rfc8621 ## Recipients and privacy The addresses in the message and the addresses in the calendar object are separate, and only the first are hidden by `bcc`. Upyo never writes a `Bcc` header, so a blind recipient stays hidden from the others—but every recipient receives the same iCalendar object, and any address written into an `ATTENDEE` property is in it. An invitation whose attendee list is private therefore needs a separate message per recipient, each carrying an object naming only that attendee. This is also what `REPLY` needs anyway, since a reply speaks for one attendee. --- --- url: /transports/smtp.md description: >- Complete guide to using Upyo's SMTP transport for universal email delivery, including connection pooling, TLS security, authentication methods, and bulk sending. --- # SMTP SMTP (Simple Mail Transfer Protocol) is the standard protocol for sending emails across networks, as defined in [RFC 5321]. Most email providers offer SMTP servers alongside their proprietary APIs, making SMTP a universal fallback option when specific transports aren't available for your email provider. The SMTP protocol provides reliable, widely-supported email delivery with features like authentication, encryption, and delivery confirmation. Upyo provides a comprehensive SMTP transport through the *@upyo/smtp* package, offering connection pooling, TLS support, multiple authentication methods, and efficient bulk sending capabilities. > \[!CAUTION] > The SMTP transport currently does not support edge functions or web browsers. > If you need to use Upyo in these environments, consider using other transports > like [Mailgun](./mailgun.md) or similar services that provide HTTP APIs. [RFC 5321]: https://datatracker.ietf.org/doc/html/rfc5321 ## Installation To use the SMTP transport, you need to install the *@upyo/smtp* package: ::: code-group ```sh [npm] npm add @upyo/smtp ``` ```sh [pnpm] pnpm add @upyo/smtp ``` ```sh [Yarn] yarn add @upyo/smtp ``` ```sh [Deno] deno add jsr:@upyo/smtp ``` ```sh [Bun] bun add @upyo/smtp ``` ::: ## Basic usage The SMTP transport requires connection details for your SMTP server, including the hostname, port, and authentication credentials. Most email providers offer SMTP access through their settings or developer documentation. ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; import { createMessage } from "@upyo/core"; // Create transport with basic configuration const transport = new SmtpTransport({ host: "smtp.gmail.com", port: 465, secure: true, auth: { user: "your-email@gmail.com", pass: "your-app-password", }, }); const message = createMessage({ from: "sender@example.com", to: "recipient@example.com", subject: "Hello from Upyo SMTP", content: { text: "This email was sent using the SMTP transport." }, }); 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(", ")); } // Clean up connections when done await transport.closeAllConnections(); ``` The transport automatically handles connection management, protocol negotiation, and message formatting. When you're finished sending emails, it's important to close connections to free up resources. ## Automatic resource management Modern JavaScript environments support automatic resource cleanup using the [`await using`] statement, which automatically closes SMTP connections when the transport goes out of scope: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; import { createMessage } from "@upyo/core"; await using transport = new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, auth: { user: "username", pass: "password", }, }); const message = createMessage({ from: "system@example.com", to: "user@example.com", subject: "System Notification", content: { text: "Your backup completed successfully." }, }); await transport.send(message); // Connections are automatically closed when transport goes out of scope ``` This approach eliminates the need to manually call `~SmtpTransport.closeAllConnections()` and ensures proper cleanup even if errors occur. [`await using`]: https://github.com/tc39/proposal-async-explicit-resource-management#await-using-declarations ## Connection configuration The SMTP transport offers extensive configuration options to work with different email providers and security requirements. Connection settings control timeouts, pooling, and protocol behavior: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; const transport = new SmtpTransport({ host: "mail.example.com", port: 587, secure: false, requireTls: true, auth: { user: "user@example.com", pass: "secure-password", method: "plain", }, connectionTimeout: 30000, socketTimeout: 60000, localName: "mail.mycompany.com", pool: true, poolSize: 10, }); ``` The `~SmtpConfig.host` and `~SmtpConfig.port` specify your SMTP server details, while `~SmtpConfig.secure` determines whether to use implicit TLS and `~SmtpConfig.requireTls` makes a STARTTLS upgrade mandatory for plaintext connections. Connection and socket timeouts prevent hanging connections, and the `~SmtpConfig.localName` identifies your server during the SMTP handshake. Connection pooling improves performance by reusing connections across multiple messages. `~SmtpConfig.poolSize` caps how many connections one transport may have open at the same time. The cap counts connections that are being established, connections that are currently sending, and idle connections kept for reuse, so it applies whether or not `~SmtpConfig.pool` is enabled. Concurrent `~SmtpTransport.send()` and `~SmtpTransport.sendMany()` calls that arrive once the cap is reached wait for a connection to be handed back rather than opening another one, which is what lets you match `~SmtpConfig.poolSize` to the simultaneous-connection limit your provider enforces. A waiting call still honours its `AbortSignal`, so cancelling it rejects without sending the message. Setting `~SmtpConfig.poolSize` to `Infinity` opts out of the limit entirely. > \[!NOTE] > Because a `~SmtpTransport.sendMany()` call holds its connection until the > iteration ends, running more concurrent `~SmtpTransport.sendMany()` calls than > `~SmtpConfig.poolSize` makes the extra ones wait for an earlier iteration to > finish. Raise `~SmtpConfig.poolSize`, or use separate transports, when you > need more bulk streams at once. ## Command pipelining *This feature is introduced in Upyo 0.6.0.* When a server advertises the `PIPELINING` extension defined by [RFC 2920], Upyo sends `MAIL FROM` and all `RCPT TO` commands together instead of waiting for a reply after each command. This cuts the number of network round trips for messages with multiple recipients. Upyo then reads every reply in command order, including multiline replies, before continuing with `DATA`. Pipelining is negotiated automatically and does not require a configuration option. Servers that do not advertise it keep the standard sequential command flow. A rejected recipient is still reported through `~SmtpReceipt.rejectedRecipients` when at least one other recipient accepts the message. [RFC 2920]: https://www.rfc-editor.org/rfc/rfc2920 ## Message size declaration *This feature is introduced in Upyo 0.6.0.* Upyo automatically uses the `SIZE` extension defined by [RFC 1870] when the server advertises it. The transport adds the encoded message size in octets to `MAIL FROM`, allowing the server to reject the message before its content is uploaded. If the server advertises a fixed maximum, Upyo returns a failed receipt without sending `MAIL FROM` when the message exceeds that limit. A bare `SIZE` capability, or `SIZE 0`, means that no fixed maximum was advertised, so Upyo still declares the message size without rejecting it locally. Servers that do not advertise `SIZE` retain the existing SMTP flow. The declared size covers the headers, encoded body, and line endings sent after the server accepts `DATA`. It does not include the DATA terminator or dots added for SMTP transparency. [RFC 1870]: https://www.rfc-editor.org/rfc/rfc1870 ## Enhanced status codes *This feature is introduced in Upyo 0.6.0.* SMTP servers can prefix reply text with an enhanced status code such as `5.1.1`, as defined by [RFC 2034] and [RFC 3463]. When a failure contains a valid code, Upyo preserves the final reply line's text in `providerDetails.response` and exposes the parsed value through `providerDetails.enhancedStatusCode`. The parsed `~SmtpEnhancedStatusCode` contains the complete `code` and numeric `class`, `subject`, and `detail` fields. Use `~isSmtpResponseProviderDetails()` to narrow the provider-specific details: ```typescript twoslash import type { SmtpReceipt } from "@upyo/smtp"; declare const receipt: SmtpReceipt; // ---cut-before--- import { isSmtpResponseProviderDetails } from "@upyo/smtp"; const error = receipt.successful ? undefined : receipt.errors?.[0]; if (isSmtpResponseProviderDetails(error?.providerDetails)) { const status = error.providerDetails.enhancedStatusCode; if (status != null) { console.log(status.code, status.class, status.subject, status.detail); } } ``` If delivery succeeds for at least one recipient, each rejected entry in `~SmtpReceipt.rejectedRecipients` exposes its enhanced code through `~SmtpRejectedRecipient.enhancedStatusCode`. Upyo uses the enhanced subject to refine categories where its meaning is unambiguous. Address and message-content statuses use `validation`, while network/routing statuses use `network`. Other subjects retain the category derived from the traditional reply class. A `4.x.x` code remains retryable and a `5.x.x` code remains non-retryable. The enhanced code must appear at the start of the reply text, use fields of one to three digits without leading zeroes, and have the same class as the three-digit SMTP reply. If any condition is not met, Upyo preserves the reply line's text but ignores the enhanced code. Servers that return only traditional replies continue to work unchanged. Because the code space is extensible through the [IANA registry], Upyo does not reject an otherwise valid code merely because its subject or detail is unknown. [RFC 2034]: https://www.rfc-editor.org/rfc/rfc2034 [RFC 3463]: https://www.rfc-editor.org/rfc/rfc3463 [IANA registry]: https://www.iana.org/assignments/smtp-enhanced-status-codes/ ## Envelope overrides *This feature is introduced in Upyo 0.6.0.* Use the `envelope` send option when delivery errors or recipient routing need addresses that differ from the visible message headers. The following message still displays `billing@example.com` as its From address and `customer@example.net` as its To address: ```typescript twoslash import { createMessage } from "@upyo/core"; import { SmtpTransport } from "@upyo/smtp"; const transport = new SmtpTransport({ host: "smtp.example.com", port: 465, secure: true, }); const message = createMessage({ from: "billing@example.com", to: "customer@example.net", subject: "Invoice", content: { text: "Your invoice is attached." }, }); await transport.send(message, { envelope: { from: "bounces+customer-42@bounce.example.com", to: ["delivery@example.net"], }, }); ``` `~SmtpEnvelopeOptions.from` controls `MAIL FROM`, while `~SmtpEnvelopeOptions.to` supplies the addresses for `RCPT TO`. Omit either field to derive that side from the message as before. Set `from` to `null` for the null reverse-path used by delivery notifications: ```typescript twoslash import { createMessage } from "@upyo/core"; import { SmtpTransport } from "@upyo/smtp"; const transport = new SmtpTransport({ host: "smtp.example.com", port: 465, secure: true, }); const notification = createMessage({ from: "postmaster@example.com", to: "sender@example.net", subject: "Delivery status notification", content: { text: "The message could not be delivered." }, }); await transport.send(notification, { envelope: { from: null }, }); ``` The option also accepts a resolver for bulk VERP delivery. The resolver receives each message and its zero-based position in the send operation: ```typescript twoslash import { createMessage } from "@upyo/core"; import { SmtpTransport } from "@upyo/smtp"; const transport = new SmtpTransport({ host: "smtp.example.com", port: 465, secure: true, }); const messages = [ createMessage({ from: "newsletter@example.com", to: "first@example.net", subject: "Newsletter", content: { text: "Hello, first subscriber." }, }), createMessage({ from: "newsletter@example.com", to: "second@example.net", subject: "Newsletter", content: { text: "Hello, second subscriber." }, }), ]; for await (const receipt of transport.sendMany(messages, { envelope: (_message, index) => ({ from: `bounces+${index}@bounce.example.com`, }), })) { console.log(receipt); } ``` A plain override applies to every message passed to `~SmtpTransport.sendMany()`. Invalid addresses and empty recipient lists produce a non-retryable failed receipt with the code `smtp.envelope-invalid`. Upyo rejects them before sending `MAIL FROM`, and a bad item in `sendMany()` does not prevent later messages from using the same connection. The effective envelope drives DSN recipient validation and SMTPUTF8 negotiation. Mailbox addresses written to visible From, To, Cc, and Reply-To headers can still require SMTPUTF8 even when the envelope overrides them. The override does not alter message headers or DKIM signatures. ## Internationalized addresses *This feature is introduced in Upyo 0.6.0.* Upyo automatically uses the `SMTPUTF8` extension defined by [RFC 6531] when the effective SMTP envelope or a visible From, To, Cc, or Reply-To mailbox contains a non-ASCII character. The default envelope includes the message's Bcc addresses. The transport requires the server to advertise both `SMTPUTF8` and `8BITMIME`, then adds `BODY=8BITMIME SMTPUTF8` to `MAIL FROM`. This supports UTF-8 local parts and Unicode domain labels without another configuration option. If either required extension is missing, Upyo returns a non-retryable failed receipt with the code `smtp.smtputf8-unsupported` before sending `MAIL FROM`. The connection remains available for a later ASCII-only message. Unicode display names and subjects do not by themselves require SMTPUTF8. Upyo continues to encode those values as RFC 2047 encoded words, so an address such as `José ` follows the ordinary ASCII SMTP flow. An ASCII A-label domain such as `xn--r8jz45g.xn--zckzah` likewise does not require SMTPUTF8, while its Unicode U-label form does. [RFC 6531]: https://www.rfc-editor.org/rfc/rfc6531 ## Delivery status notifications *This feature is introduced in Upyo 0.6.0.* Use the `dsn` send option to request delivery status notifications through the SMTP `DSN` extension defined by [RFC 3461]. These settings become parameters on `MAIL FROM` and `RCPT TO`; they are not message headers. ```typescript twoslash import { createMessage } from "@upyo/core"; import { SmtpTransport } from "@upyo/smtp"; const transport = new SmtpTransport({ host: "smtp.example.com", port: 465, secure: true, }); const message = createMessage({ from: "sender@example.com", to: ["first@example.com", "second@example.com"], subject: "Delivery report", content: { text: "Track this delivery." }, }); const receipt = await transport.send(message, { dsn: { envelopeId: "campaign+42", return: "headers", recipients: { "first@example.com": { notify: ["success", "failure", "delay"], originalRecipient: "first@example.com", }, "second@example.com": { notify: ["never"], }, }, }, }); ``` `~SmtpDsnOptions.envelopeId` sets `ENVID`, a non-empty identifier copied into a later notification. `~SmtpDsnOptions.return` sets `RET=FULL` or `RET=HDRS` and controls how much of a failed message may be returned. Each key in `~SmtpDsnOptions.recipients` must exactly match an address in the effective SMTP envelope. When `~SmtpTransportOptions.envelope` replaces the recipients, the DSN keys must match the replacement addresses rather than the message's To, Cc, or Bcc fields. The `~SmtpDsnRecipientOptions.notify` array accepts `"success"`, `"failure"`, and `"delay"`. Use `["never"]` by itself to suppress notifications for one recipient. If `notify` is omitted, the server keeps its default failure and optional delay behavior. `~SmtpDsnRecipientOptions.originalRecipient` sets an `ORCPT` value with the `rfc822` address type. On initial submission, RFC 3461 requires this value to equal the corresponding envelope recipient. Upyo validates notification combinations, recipient keys, parameter lengths, and the printable US-ASCII range before sending the SMTP envelope. It also applies RFC 3461 `xtext` escaping to spaces, plus signs, and equals signs in `ENVID` and `ORCPT`. Upyo emits the RFC 3461 `rfc822` form and does not implement the UTF-8 address type or encodings defined by [RFC 6533]. If any DSN parameter is requested but the server does not advertise `DSN`, the send returns a non-retryable failed receipt with the code `smtp.dsn-unsupported`; Upyo does not send `MAIL FROM`. Invalid settings use the code `smtp.dsn-invalid`. An empty `dsn` object has no effect, preserving the ordinary SMTP flow. The SMTP server sends a requested notification later as a separate message in the [RFC 3464] format. A successful `~SmtpTransport.send()` receipt confirms only that the server accepted the original message; it is not the later DSN. When passed to `~SmtpTransport.sendMany()`, one `dsn` option applies to every message. Every configured recipient key must therefore be present in each effective envelope. Call `~SmtpTransport.send()` separately when messages need different DSN settings. [RFC 3461]: https://www.rfc-editor.org/rfc/rfc3461 [RFC 6533]: https://www.rfc-editor.org/rfc/rfc6533 [RFC 3464]: https://www.rfc-editor.org/rfc/rfc3464 ## Authentication methods The SMTP transport supports multiple authentication mechanisms commonly used by email providers. The most widely supported method is PLAIN authentication, which works with virtually all SMTP servers: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; // PLAIN authentication (most common) const gmailTransport = new SmtpTransport({ host: "smtp.gmail.com", port: 465, secure: true, auth: { user: "your-email@gmail.com", pass: "your-app-password", method: "plain", }, }); // LOGIN authentication for older servers const outlookTransport = new SmtpTransport({ host: "smtp-mail.outlook.com", port: 587, secure: false, auth: { user: "your-email@outlook.com", pass: "your-password", method: "login", }, }); ``` When using services like Gmail, you'll need to generate an app-specific password rather than using your regular account password. The transport automatically detects server capabilities and chooses the appropriate authentication method if you don't specify one. > \[!IMPORTANT] > SMTP authentication requires a secure connection (`secure: true`, or a > successful STARTTLS upgrade on port 587). To protect passwords and access > tokens, authentication over a cleartext connection to a non-loopback host is > refused. Loopback hosts remain available for local development. ### OAuth 2.0 authentication Many providers—including Gmail and Outlook—now require OAuth 2.0 instead of passwords. The transport supports the SASL *XOAUTH2* mechanism (the de-facto standard used by Google and Microsoft) and *OAUTHBEARER* ([RFC 7628]). Instead of `pass`, provide an `accessToken`: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; const transport = new SmtpTransport({ host: "smtp.gmail.com", port: 465, secure: true, auth: { user: "your-email@gmail.com", accessToken: "ya29.a0Af…your-access-token", }, }); ``` When `method` is omitted, the transport selects a mechanism advertised by the server, preferring *XOAUTH2*. Set `method: "oauthbearer"` to force OAUTHBEARER. #### Refreshing tokens automatically Access tokens are short-lived, so a static string is rarely enough. To refresh tokens transparently, pass a callback as `accessToken`. It is invoked each time a new connection authenticates, which lets you delegate to an OAuth client such as [google-auth-library] (Gmail) or [msal-node] (Outlook): ```typescript twoslash declare function getFreshAccessToken(): Promise; // ---cut-before--- import { SmtpTransport } from "@upyo/smtp"; const transport = new SmtpTransport({ host: "smtp.gmail.com", port: 465, secure: true, auth: { user: "your-email@gmail.com", // Called for every new connection; obtain a fresh token here. accessToken: () => getFreshAccessToken(), }, }); ``` Alternatively, let the transport run the `refresh_token` grant itself. Provide your client credentials, a refresh token, and the token endpoint; the transport exchanges them for an access token and caches it until shortly before it expires, sharing the cached token across all pooled connections: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; const transport = new SmtpTransport({ host: "smtp.gmail.com", port: 465, secure: true, auth: { user: "your-email@gmail.com", clientId: "…apps.googleusercontent.com", clientSecret: "GOCSPX-…", refreshToken: "1//…", tokenEndpoint: "https://oauth2.googleapis.com/token", }, }); ``` Because a connection authenticates once when it is established, the callback or refresh runs per new connection rather than per message. [RFC 7628]: https://www.rfc-editor.org/rfc/rfc7628 [google-auth-library]: https://github.com/googleapis/google-auth-library-nodejs [msal-node]: https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-node ## TLS and security configuration Security is crucial for email transmission, and the SMTP transport provides comprehensive TLS configuration options. You can control encryption, certificate validation, and TLS protocol versions: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; const transport = new SmtpTransport({ host: "secure-smtp.example.com", port: 465, secure: true, auth: { user: "secure@example.com", pass: "password", }, tls: { rejectUnauthorized: true, minVersion: "TLSv1.2", maxVersion: "TLSv1.3", ca: ["-----BEGIN CERTIFICATE-----\n..."], }, }); ``` Setting `secure: true` establishes a TLS connection from the start, while `rejectUnauthorized: true` ensures certificate validation. You can specify custom certificate authorities, client certificates, and acceptable TLS versions based on your security requirements. ### STARTTLS support *The `requireTls` option is available since Upyo 0.6.0.* The SMTP transport automatically supports STARTTLS, which allows upgrading a plain connection to an encrypted TLS connection. When `secure` is set to `false` and the server advertises STARTTLS capability, the transport will automatically upgrade the connection before authentication. Set `requireTls` to `true` when the connection must be encrypted even if the server does not advertise STARTTLS: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; // STARTTLS will be used automatically with port 587 const transport = new SmtpTransport({ host: "smtp.example.com", port: 587, // Standard submission port with STARTTLS secure: false, // Start with plain connection requireTls: true, // Fail unless the STARTTLS upgrade succeeds auth: { user: "user@example.com", pass: "password", }, }); ``` This configuration is commonly used with port 587 (mail submission port) and is required by many modern email providers including Protonmail, Office 365, and others that enforce encryption via STARTTLS. When `requireTls` is `true`, the transport issues `STARTTLS` even if the server does not advertise the capability and fails delivery if the upgrade is rejected or cannot be completed. This also protects message content on connections that do not use SMTP authentication. The transport follows [RFC 3207] for STARTTLS negotiation and automatically re-negotiates capabilities after the connection is upgraded. > \[!TIP] > Use `secure: false` with `requireTls: true` on port 587 for mandatory > STARTTLS, or `secure: true` on port 465 for direct TLS connections. Even when > `requireTls` is `false`, the transport refuses to authenticate to a > non-loopback server over cleartext. [RFC 3207]: https://datatracker.ietf.org/doc/html/rfc3207 ## DKIM signing *This feature is introduced in Upyo 0.4.0.* DKIM (DomainKeys Identified Mail) is an email authentication method that allows the sender to attach a digital signature to outgoing emails. This helps recipients verify that the email was actually sent from the claimed domain and hasn't been modified in transit, improving deliverability and reducing the chance of emails being marked as spam. The SMTP transport supports DKIM signing through the `~SmtpConfig.dkim` configuration option. DKIM signatures are generated using the standard Web Crypto API, ensuring cross-runtime compatibility (Node.js, Deno, Bun). > \[!NOTE] > The DKIM implementation follows [RFC 6376] and [RFC 8463], supporting both > `rsa-sha256` (most widely used) and `ed25519-sha256` (shorter keys) > algorithms. [RFC 6376]: https://www.rfc-editor.org/rfc/rfc6376 [RFC 8463]: https://www.rfc-editor.org/rfc/rfc8463 ### Basic DKIM configuration To enable DKIM signing, provide a `dkim` configuration with your private key and domain information: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; import { readFileSync } from "node:fs"; const transport = new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, auth: { user: "user@example.com", pass: "password", }, dkim: { signatures: [{ signingDomain: "example.com", selector: "mail", privateKey: readFileSync("./dkim-private.pem", "utf8"), }], }, }); ``` The `signingDomain` should match your email's From address domain, and the `selector` is used to look up the public key in DNS (e.g., `mail._domainkey.example.com`). ### DkimSignature options Each signature in the `signatures` array can have the following options: | Option | Type | Default | Description | | ------------------ | ---------------------------------- | ----------------------------------- | ------------------------------------- | | `signingDomain` | `string` | (required) | Domain for DKIM key (d= tag) | | `selector` | `string` | (required) | DKIM selector (s= tag) | | `privateKey` | `string \| CryptoKey` | (required) | Private key (PEM string or CryptoKey) | | `algorithm` | `"rsa-sha256" \| "ed25519-sha256"` | `"rsa-sha256"` | Signing algorithm (a= tag) | | `canonicalization` | `string` | `"relaxed/relaxed"` | Header/body canonicalization (c= tag) | | `headerFields` | `string[]` | `["from", "to", "subject", "date"]` | Headers to sign (h= tag) | ### Using Ed25519 keys Ed25519 offers shorter keys than RSA while providing equivalent security. This is particularly useful when DNS TXT record size is a concern: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; import { readFileSync } from "node:fs"; const transport = new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, auth: { user: "user@example.com", pass: "password", }, dkim: { signatures: [{ signingDomain: "example.com", selector: "ed25519", privateKey: readFileSync("./dkim-ed25519.pem", "utf8"), algorithm: "ed25519-sha256", }], }, }); ``` ### Using `CryptoKey` If you already have a [`CryptoKey`] object (from Web Crypto API), you can pass it directly instead of a PEM string: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; // Import a private key using Web Crypto API const privateKey = await crypto.subtle.importKey( "pkcs8", new Uint8Array([/* ... key bytes ... */]), { name: "Ed25519" }, false, ["sign"], ); const transport = new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, dkim: { signatures: [{ signingDomain: "example.com", selector: "mykey", privateKey: privateKey, // CryptoKey object algorithm: "ed25519-sha256", }], }, }); ``` [`CryptoKey`]: https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey ### Body processing *Since Upyo 0.6.0.* `dkim.bodyMode` selects how attachment bytes are read for signing: | Mode | Source reads per send | Additional attachment memory | | ---------------------- | --------------------- | ------------------------------------------- | | No DKIM signatures | One | Fixed buffers plus the largest source chunk | | `"buffered"` (default) | One | Complete MIME body | | `"streaming"` | Two | Fixed buffers plus the largest source chunk | ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; import { readFileSync } from "node:fs"; const transport = new SmtpTransport({ host: "smtp.example.com", port: 465, secure: true, dkim: { bodyMode: "streaming", signatures: [{ signingDomain: "example.com", selector: "mail", privateKey: readFileSync("./dkim-private.pem", "utf8"), }], }, }); ``` Streaming hashes the body before `MAIL FROM`, then reopens the attachment sources for DATA. All signatures share these two reads. The extra I/O and hashing delay delivery and hold an authenticated connection during the first pass; the receiving server's idle limit still applies. The memory bound excludes caller-owned data, text/HTML, headers, and runtime/socket buffers. An empty `signatures` array behaves like unsigned sending. Attachment factories must reopen identical bytes on every invocation. A changed second pass fails before the DATA terminator with the non-retryable `smtp.attachment-replay-mismatch` receipt code. Source errors, cancellation, size-limit failures, and replay mismatches never trigger `send-unsigned` fallback. If a later signature fails cryptographically, earlier successful signatures remain on the message. Unsigned factory sources have no known size, so SMTP omits the optional `MAIL FROM SIZE` parameter and enforces the server's advertised limit while writing DATA. Known byte-array and `Blob` sizes are checked before `MAIL FROM`. DKIM's first pass establishes the final size before submission. A failure during DATA closes the connection without accepting a truncated message. `socketTimeout` measures inactivity while reading or writing attachments, not total transfer duration. Nonempty source progress and completed writes reset the timer; an endless stream of empty chunks does not. Cancellation is passed to the source, and failed DATA connections are not returned to the pool. See [attachment factories](../messages/attachments.md#working-with-binary-content) for file-backed sources and the custom-transport migration guide. ### Multiple DKIM signatures You can add multiple DKIM signatures to a single email, which is useful when sending on behalf of multiple domains or when rotating keys: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; import { readFileSync } from "node:fs"; const transport = new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, auth: { user: "user@example.com", pass: "password", }, dkim: { signatures: [ { signingDomain: "example.com", selector: "mail2024", privateKey: readFileSync("./dkim-2024.pem", "utf8"), }, { signingDomain: "example.com", selector: "mail2025", privateKey: readFileSync("./dkim-2025.pem", "utf8"), }, ], }, }); ``` ### Error handling By default, if DKIM signing fails (e.g., due to an invalid private key), the transport throws an error. You can change this behavior using the `onSigningFailure` option: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; const transport = new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, auth: { user: "user@example.com", pass: "password", }, dkim: { signatures: [{ signingDomain: "example.com", selector: "mail", privateKey: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", }], onSigningFailure: "send-unsigned", // or "throw" (default) }, }); ``` With `onSigningFailure: "send-unsigned"`, the email will be sent without a DKIM signature if signing fails, rather than failing the entire send operation. ## Bulk email sending For sending multiple emails efficiently, the SMTP transport provides a `~SmtpTransport.sendMany()` method that reuses connections and handles errors gracefully. This approach is much more efficient than calling `~SmtpTransport.send()` multiple times: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; import { createMessage } from "@upyo/core"; await using transport = new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, auth: { user: "bulk@example.com", pass: "password", }, poolSize: 5, }); const messages = [ createMessage({ from: "newsletter@example.com", to: "subscriber1@example.com", subject: "Weekly Newsletter #1", content: { text: "Welcome to our newsletter!" }, }), createMessage({ from: "newsletter@example.com", to: "subscriber2@example.com", subject: "Weekly Newsletter #2", content: { text: "Thank you for subscribing!" }, }), ]; for await (const receipt of transport.sendMany(messages)) { if (receipt.successful) { console.log(`Message ${receipt.messageId} sent successfully`); } else { console.error(`Failed to send message: ${receipt.errorMessages.join(", ")}`); } } ``` The `~SmtpTransport.sendMany()` method processes messages sequentially, providing individual receipts for each message. Connection pooling ensures efficient resource usage, and failed messages don't prevent subsequent messages from being sent. The whole iteration runs on a single connection drawn from the same `~SmtpConfig.poolSize` budget as `~SmtpTransport.send()`. ## Development and testing For local development and testing, you can use development SMTP servers or configure the transport for testing environments. The package supports various testing scenarios including mock servers: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; // Local development with Mailpit (popular SMTP testing tool) const devTransport = new SmtpTransport({ host: "localhost", port: 1025, secure: false, // No authentication needed for local testing }); // Testing configuration with relaxed security const testTransport = new SmtpTransport({ host: "test-smtp.example.com", port: 587, secure: false, auth: { user: "test@example.com", pass: "test-password", }, tls: { rejectUnauthorized: false, // For self-signed certificates in test environments }, connectionTimeout: 5000, // Shorter timeouts for faster test feedback }); ``` > \[!TIP] > [Mailpit] is an excellent development SMTP server that provides a modern > web interface for testing email functionality. It acts as an SMTP server > that accepts all emails but doesn't deliver them, instead storing them > locally for inspection. Mailpit offers features like HTML and plain text > email viewing, attachment downloads, search functionality, and even webhook > testing for email events. > > You can install Mailpit as a standalone binary, run it via Docker, > or use package managers like Homebrew. The default configuration listens on > port 1025 for SMTP and provides a web UI on port 8025, making it perfect for > local development workflows where you need to verify email content and > formatting without sending real emails. [Mailpit]: https://mailpit.axllent.org/ ## Sending raw MIME *This feature is introduced in Upyo 0.6.0.* Use [MIME composition](../messages/mime.md) to create raw bytes from an Upyo message without opening a transport connection. `SmtpTransport` implements `RawTransport` for already serialized messages, including signed or encrypted MIME. Provide delivery addresses separately: ```typescript twoslash import { SmtpTransport } from "@upyo/smtp"; const transport = new SmtpTransport({ host: "localhost", port: 1025 }); try { await transport.sendRaw({ envelope: { from: "sender@example.com", to: ["recipient@example.net"] }, content: new TextEncoder().encode("Subject: Hello\r\n\r\nHello!\r\n"), encoding: "7bit", }); } finally { await transport.closeAllConnections(); } ``` Raw sources accept bytes, promised bytes, Blob, or replayable attachment-style factories. With `encoding` specified, the source is read once; otherwise it is analyzed and then read again. Every reader must produce identical bytes. Automatic analysis requires SMTPUTF8 for any non-ASCII byte. Specify `8bit` when all MIME headers are ASCII and only the body needs 8BITMIME. Upyo checks only top-level headers; the caller must ensure nested MIME headers are ASCII. Use `utf8` or omit `encoding` if unsure. Both `utf8` and internationalized envelope addresses require SMTPUTF8 and 8BITMIME. Unsupported capabilities produce a failed receipt before MAIL FROM. The content must already have CRLF line endings including the final CRLF, nonempty headers, no NUL, and no line longer than 998 bytes excluding CRLF. SMTP delivery adds only dot-stuffing and protocol framing. It does not compose headers, remove Bcc, add Date or Message-ID, or run configured DKIM signing. The caller is responsible for the MIME structure and any existing signatures. `sendRaw()` accepts `dsn` and `signal` options. Its envelope cannot be overridden through options; use `envelope.from: null` for a null reverse-path. Receipts include partial recipient rejections just like `send()`. The returned message ID identifies the SMTP transaction, not necessarily the MIME Message-ID. Known sizes exclude dot-stuffing and protocol framing. A factory with an explicit encoding starts reading after the server accepts DATA and does not need a preliminary size pass. Inactivity limits and cancellation cover reading and writing; a failure during DATA closes that connection without completing the message. Raw delivery is not retried automatically. ## Verifying the configuration *This feature is introduced in Upyo 0.6.0.* Call `~SmtpTransport.verify()` to check your SMTP settings without sending an email. It opens a fresh connection, checks the server greeting and EHLO/HELO negotiation, applies the same TLS policy as sending, and performs any configured authentication, including OAuth 2.0. Relay configurations without authentication are supported. ```typescript twoslash import { SmtpAuthError, SmtpResponseError, SmtpTransport } from "@upyo/smtp"; await using transport = new SmtpTransport({ host: "smtp.example.com", port: 587, requireTls: true, }); try { await transport.verify({ signal: AbortSignal.timeout(10_000) }); } catch (error) { if (error instanceof SmtpAuthError) { console.error("Check the SMTP credentials."); } else if (error instanceof SmtpResponseError) { console.error(error.command, error.code, error.response); } else { throw error; } } ``` Unlike sending, verification rejects on failure and does not return a receipt. `~SmtpAuthResponseError` extends `~SmtpAuthError` with the SMTP reply's `code`, `command`, and `response`. Network, TLS, and timeout failures may use native error types. Cancellation preserves the caller's abort reason. Verification shares the transport's `poolSize` limit and shutdown barrier. It waits when all slots are busy; when necessary, it replaces one idle connection to make room for a fresh handshake. Its connection is closed afterward, including on failure or cancellation, and is never pooled. The existing connection and socket timeouts apply; use an abort signal to bound the whole call, including waiting for capacity. Success confirms setup at verification time. It does not guarantee that a particular sender, recipient, or message will be accepted, or that delivery will succeed. No envelope or message data is sent. Verification is an [optional transport capability](./custom.md#verifying-transport-configuration); wrappers do not automatically expose it. --- --- url: /transports/jmap.md description: >- JMAP transport guide for sending emails via JMAP protocol (RFC 8620/8621), including session discovery, identity resolution, and configuration options. --- # JMAP [JMAP] (JSON Meta Application Protocol) is a modern, efficient protocol for email access and submission, designed as a replacement for IMAP and SMTP. It provides a standardized JSON-based API for interacting with mail servers, with features like efficient synchronization, typed error responses, and stateless operations. JMAP is defined in [RFC 8620] (core) and [RFC 8621] (mail) and is increasingly adopted by modern email providers. Upyo provides a fully compliant JMAP transport through the *@upyo/jmap* package, supporting session discovery, automatic identity resolution, configurable retry logic, and comprehensive error handling. [JMAP]: https://jmap.io/ [RFC 8620]: https://www.rfc-editor.org/rfc/rfc8620 [RFC 8621]: https://www.rfc-editor.org/rfc/rfc8621 ## Installation To use the JMAP transport, you need to install the *@upyo/jmap* package: ::: code-group ```sh [npm] npm add @upyo/jmap ``` ```sh [pnpm] pnpm add @upyo/jmap ``` ```sh [Yarn] yarn add @upyo/jmap ``` ```sh [Deno] deno add jsr:@upyo/jmap ``` ```sh [Bun] bun add @upyo/jmap ``` ::: ## Getting started Before using the JMAP transport, you'll need access to a JMAP-compatible mail server and authentication credentials. The JMAP session URL is typically available at `/.well-known/jmap` on the mail server. ### Bearer token authentication ```typescript twoslash import { JmapTransport } from "@upyo/jmap"; import { createMessage } from "@upyo/core"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-bearer-token", }); ``` ### Basic authentication ```typescript twoslash import { JmapTransport } from "@upyo/jmap"; import { createMessage } from "@upyo/core"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", basicAuth: { username: "user@example.com", password: "your-password", }, }); const message = createMessage({ from: "support@example.com", to: "customer@example.com", subject: "Welcome to our service", content: { text: "Thank you for signing up!" }, }); 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(", ")); } ``` The JMAP transport handles session discovery automatically, caching the session for performance while refreshing it when needed. It automatically finds the appropriate account with mail capabilities and resolves the sender identity based on the from address. ## Session discovery and caching JMAP uses a session resource to discover server capabilities, API endpoints, and account information. The transport automatically fetches and caches this session: ```typescript twoslash import { JmapTransport } from "@upyo/jmap"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-bearer-token", sessionCacheTtl: 600000, // Cache session for 10 minutes }); ``` The session cache TTL (time-to-live) controls how long the session is cached before being refreshed. The default is 5 minutes (300000ms), which balances performance with keeping the session reasonably fresh. If you know your account ID ahead of time, you can specify it directly to skip the account discovery step: ```typescript twoslash import { JmapTransport } from "@upyo/jmap"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-bearer-token", accountId: "u1234567", // Specific account ID }); ``` ## Identity resolution JMAP requires an identity ID for email submission. The transport automatically resolves the appropriate identity by matching the sender email address with the identities available on the server: ```typescript twoslash import { JmapTransport } from "@upyo/jmap"; import { createMessage } from "@upyo/core"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-bearer-token", }); // Identity is automatically resolved from the "from" address const message = createMessage({ from: "alice@example.com", // Will match identity with this email to: "bob@example.net", subject: "Meeting tomorrow", content: { text: "See you at 10am!" }, }); await transport.send(message); ``` If you want to use a specific identity, you can provide the identity ID directly in the configuration: ```typescript twoslash import { JmapTransport } from "@upyo/jmap"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-bearer-token", identityId: "i1234567", // Specific identity ID }); ``` ## Bulk email sending For sending multiple emails, the JMAP transport provides efficient batch processing that combines all messages into a single HTTP request: ```typescript twoslash import { JmapTransport } from "@upyo/jmap"; import { createMessage } from "@upyo/core"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-bearer-token", retries: 3, timeout: 30000, }); const subscribers = [ "user1@example.com", "user2@example.com", "user3@example.com", ]; const messages = subscribers.map(email => createMessage({ from: "newsletter@example.com", to: email, subject: "Weekly Update", content: { text: "Here's what's new this week..." }, }) ); for await (const receipt of transport.sendMany(messages)) { if (receipt.successful) { console.log(`Email sent: ${receipt.messageId}`); } else { console.error(`Failed: ${receipt.errorMessages.join(", ")}`); } } ``` The `~JmapTransport.sendMany()` method batches all emails into a single JMAP request, significantly reducing HTTP round-trips. Each message gets its own receipt, and partial failures are handled gracefully—if some emails fail, others in the batch can still succeed. ## Error handling The JMAP transport provides detailed error information through the `JmapApiError` class and helper functions: ```typescript twoslash import { JmapTransport, JmapApiError, isCapabilityError } from "@upyo/jmap"; import { createMessage } from "@upyo/core"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-bearer-token", }); const message = createMessage({ from: "sender@example.com", to: "recipient@example.net", subject: "Test", content: { text: "Hello!" }, }); try { const receipt = await transport.send(message); if (!receipt.successful) { console.error("Send failed:", receipt.errorMessages); } } catch (error) { if (error instanceof JmapApiError) { console.error("JMAP API error:", error.statusCode, error.responseBody); if (error.jmapErrorType) { console.error("Error type:", error.jmapErrorType); } } if (isCapabilityError(error)) { console.error("Server missing required JMAP capabilities"); } } ``` ## Advanced configuration The JMAP transport includes comprehensive configuration options for timeout handling, retry behavior, and custom headers: ```typescript twoslash import { JmapTransport } from "@upyo/jmap"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-bearer-token", timeout: 15000, // 15 second timeout retries: 5, // Retry failed requests 5 times sessionCacheTtl: 60000, // Cache session for 1 minute headers: { "X-Custom-Header": "MyApp-v1.0", "X-Environment": "production", }, }); ``` The transport uses exponential backoff for retries, with delays of 1s, 2s, 4s, etc. between attempts. Client errors (4xx responses) are not retried, as they typically indicate a problem with the request itself. ### URL rewriting Some JMAP servers may return internal hostnames in session URLs that are not accessible from the client. The `baseUrl` option allows you to rewrite these URLs: ```typescript twoslash import { JmapTransport } from "@upyo/jmap"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-bearer-token", // Rewrite URLs returned by the server to use this base URL baseUrl: "https://mail.example.com", }); ``` This is useful when connecting to containerized servers or when the server returns hostnames that differ from the external access URL. ## Request cancellation The JMAP transport supports request cancellation using the standard `AbortSignal` API: ```typescript twoslash import { JmapTransport } from "@upyo/jmap"; import { createMessage } from "@upyo/core"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-bearer-token", }); const message = createMessage({ from: "sender@example.com", to: "recipient@example.net", subject: "Test", content: { text: "Hello!" }, }); const controller = new AbortController(); // Cancel after 5 seconds setTimeout(() => controller.abort(), 5000); const receipt = await transport.send(message, { signal: controller.signal, }); if (!receipt.successful) { console.log("Send was cancelled or failed:", receipt.errorMessages); } ``` ## Attachments The JMAP transport supports file attachments via blob upload. Attachments are uploaded to the server before being referenced in the email: ```typescript twoslash // @noErrors: 2322 import { JmapTransport } from "@upyo/jmap"; import { createMessage } from "@upyo/core"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-bearer-token", }); const message = createMessage({ from: "sender@example.com", to: "recipient@example.net", subject: "Document attached", content: { text: "Please find the document attached." }, attachments: [ new File( [await fetch("https://example.com/doc.pdf").then(r => r.arrayBuffer())], "document.pdf", { type: "application/pdf" } ), ], }); await transport.send(message); ``` ### Inline attachments For inline images in HTML emails, use the `inline` property and reference the attachment via `cid:` URL in the HTML content: ```typescript twoslash // @noErrors: 2322 import { JmapTransport } from "@upyo/jmap"; import { createMessage } from "@upyo/core"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-bearer-token", }); const message = createMessage({ from: "sender@example.com", to: "recipient@example.net", subject: "Email with inline image", content: { html: '

Here is an image:

', }, attachments: [ { filename: "logo.png", content: await fetch("https://example.com/logo.png").then(r => r.arrayBuffer()), contentType: "image/png", contentId: "logo", inline: true, }, ], }); await transport.send(message); ``` ## Compatible servers The JMAP transport works with any JMAP-compliant mail server. Some notable JMAP implementations include: [Stalwart Mail Server] : Open source JMAP server [Cyrus IMAP] : Supports JMAP alongside IMAP [Fastmail] : Commercial email provider with full JMAP support [Apache James] : Modular mail server with JMAP support [Stalwart Mail Server]: https://stalw.art/ [Cyrus IMAP]: https://www.cyrusimap.org/ [Fastmail]: https://www.fastmail.com/ [Apache James]: https://james.apache.org/ ## Sending raw MIME *This feature is introduced in Upyo 0.6.0.* Use [MIME composition](../messages/mime.md) to create raw bytes from an Upyo message without opening a transport connection. `JmapTransport` implements the optional `RawTransport` interface. It uploads serialized MIME, imports the uploaded blob into Drafts, and submits that Email with an explicit delivery envelope: ```typescript twoslash import { JmapTransport } from "@upyo/jmap"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-token", }); const receipt = await transport.sendRaw({ envelope: { from: "sender@example.com", to: ["recipient@example.net"] }, content: new TextEncoder().encode( "From: sender@example.com\r\nSubject: Hello\r\n\r\nHello!\r\n" ), encoding: "7bit", }); ``` Content accepts bytes, promised bytes, Blob, or replayable attachment-style factories. Declaring `encoding` reads the source once per successful send; omitting it adds an analysis pass before upload. Every reader must reproduce the same bytes. `7bit` requires ASCII, `8bit` asserts ASCII MIME headers with an 8-bit body, and `utf8` permits internationalized headers. Automatic analysis conservatively selects `utf8` for any non-ASCII byte. With `8bit`, the caller must ensure nested MIME headers are ASCII; Upyo checks only top-level headers. Use `utf8` or omit `encoding` if unsure. No transcoding occurs. Sources must have CRLF line endings including the final CRLF, nonempty headers, no NUL, and no line longer than 998 bytes excluding CRLF. Uploads stream without collecting the message in memory. Progress refreshes the inactivity timeout; cancellation stops source reads and requests source cleanup. Response parsing also remains subject to timeout. Pass an `AbortSignal` through `signal` in the second argument to `sendRaw()`. The envelope is independent of MIME headers. A configured `identityId` takes precedence; otherwise Upyo selects the identity matching the envelope sender, or falls back to the first available identity. A null sender also uses that fallback. The server may reject the chosen identity, envelope, or null sender. Upyo does not compose or repair the uploaded bytes. JMAP servers may repair imported MIME and modify messages during submission; RFC 8621 requires removal of Bcc during submission. Raw JMAP delivery therefore does not guarantee that an existing signature or byte-for-byte representation reaches the recipient. Import and submission are each attempted once. `jmap.raw_import_failed` with `retryable: true` means no submission was issued, although an imported Email may remain after a lost response. A definite server rejection is non-retryable. `jmap.raw_submission_unknown` means submission may have succeeded: inspect the server state before attempting another send. It is marked non-retryable to avoid duplicate delivery. Cancellation cannot recall a submitted message. An `alreadyExists` import result is reused only when the existing Email refers to the exact uploaded blob; matching Message-ID alone is insufficient. Upyo leaves imported Emails in Drafts and does not delete them after success or failure. The server expires unreferenced uploaded blobs according to its policy. See [RFC 8620] for uploads and request errors, and [RFC 8621] for import and submission behavior. ## Verifying the configuration *This feature is introduced in Upyo 0.6.0.* `~JmapTransport.verify()` checks live JMAP settings without creating or sending an email. It fetches a fresh Session, checks the required capabilities and a writable account, and reads the drafts mailbox and available identities. A configured `identityId` is checked against the server as well. ```typescript twoslash import { JmapApiError, JmapTransport } from "@upyo/jmap"; const transport = new JmapTransport({ sessionUrl: "https://mail.example.com/.well-known/jmap", bearerToken: "your-bearer-token", }); try { await transport.verify({ signal: AbortSignal.timeout(10_000) }); } catch (error) { if (error instanceof JmapApiError) { console.error(error.message, error.statusCode, error.jmapErrorType); } else { throw error; } } ``` Verification rejects with `~JmapApiError` on failure, including malformed responses and timeouts. Cancellation preserves the caller's abort reason. It does not return a delivery receipt. Existing HTTP error details remain available on the error. With no `accountId`, verification uses the first mail-capable account, as `send()` does. It fails if that account lacks submission capability or is read-only. Set `accountId` explicitly in a multi-account session. The default account selection for `sendRaw()` can differ. Each Session fetch, `Mailbox/get`, and `Identity/get` operation has `timeout` as its total budget, including reading the response body and any retries. This can stop an operation before all configured retries have run. The three operations run sequentially; an abort signal can bound the entire verification. Verification neither reads nor updates the transport's Session cache. Only Session discovery and read-only JMAP methods are used. No blobs, drafts, or submissions are created. Success does not guarantee a mailbox's write permissions, acceptance of a particular sender or message, or eventual delivery. See the [optional verification capability](./custom.md#verifying-transport-configuration) for use with a generic transport. --- --- url: /transports/lettermint.md description: >- Learn how to send emails with Lettermint transport, including batch sending, idempotency, routes, metadata, tracking settings, and inline attachments. --- # Lettermint *This transport is introduced in Upyo 0.5.0.* [Lettermint] is a transactional email provider with a straightforward HTTP API for sending emails. It supports common email fields such as recipients, HTML and text content, reply-to addresses, custom headers, attachments, and inline images, plus provider-specific features such as routes, tags, metadata, tracking settings, and idempotency keys. Upyo provides the Lettermint transport through the *@upyo/lettermint* package. It supports single sends, batch sends of up to 500 messages per request, attachments, idempotency, retry logic, and `AbortSignal` cancellation. [Lettermint]: https://lettermint.co/ ## Installation To use the Lettermint transport, install the *@upyo/lettermint* package: ::: code-group ```sh [npm] npm add @upyo/lettermint ``` ```sh [pnpm] pnpm add @upyo/lettermint ``` ```sh [Yarn] yarn add @upyo/lettermint ``` ```sh [Deno] deno add jsr:@upyo/lettermint ``` ```sh [Bun] bun add @upyo/lettermint ``` ::: ## Getting started Before using the Lettermint transport, you'll need a Lettermint project and a sending API token. The token is sent to Lettermint as the `x-lettermint-token` request header. ```typescript twoslash import { createMessage } from "@upyo/core"; import { LettermintTransport } from "@upyo/lettermint"; const transport = new LettermintTransport({ apiToken: "lm_project_1234567890abcdef", }); const message = createMessage({ from: "support@example.com", to: "customer@example.com", subject: "Welcome to our service", content: { text: "Thank you for signing up!" }, }); 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(", ")); } ``` The transport converts Upyo messages to Lettermint's JSON format and sends them through the `/v1/send` endpoint. HTML and text alternatives, CC, BCC, reply-to, custom headers, priority, attachments, and inline Content-ID attachments are handled automatically. ## Routes, tags, metadata, and tracking Lettermint supports provider-specific fields for categorizing and routing messages. Configure defaults on the transport when every message sent through that transport should share the same route, metadata, or tracking settings: ```typescript twoslash import { createMessage } from "@upyo/core"; import { LettermintTransport } from "@upyo/lettermint"; const transport = new LettermintTransport({ apiToken: "lm_project_1234567890abcdef", route: "transactional", tag: "welcome", metadata: { environment: "production", service: "accounts", }, settings: { trackOpens: false, trackClicks: true, }, }); const message = createMessage({ from: "onboarding@example.com", to: "newuser@example.com", subject: "Welcome to our platform", content: { html: "

Welcome!

Thank you for joining us.

", text: "Welcome! Thank you for joining us.", }, }); await transport.send(message); ``` Lettermint accepts one tag per message. If a message has exactly one `Message.tags` value, that tag overrides the transport's default `tag`. If it has more than one tag, the transport returns a failed receipt instead of sending the message. ## Batch sending The `sendMany()` method uses Lettermint's batch endpoint and automatically splits large inputs into chunks of 500 messages: ```typescript twoslash import { createMessage } from "@upyo/core"; import { LettermintTransport } from "@upyo/lettermint"; const transport = new LettermintTransport({ apiToken: "lm_project_1234567890abcdef", }); const recipients = [ "user1@example.com", "user2@example.com", "user3@example.com", ]; const messages = recipients.map(email => createMessage({ from: "updates@example.com", to: email, subject: "Monthly update", content: { html: "

This month's updates

Here's what's new.

", text: "This month's updates\n\nHere's what's new.", }, }) ); for await (const receipt of transport.sendMany(messages)) { if (receipt.successful) { console.log(`Email sent with ID: ${receipt.messageId}`); } else { console.error(`Failed to send: ${receipt.errorMessages.join(", ")}`); } } ``` For batch sends, messages without `idempotencyKey` values are grouped into Lettermint batch requests with generated request idempotency keys. If any message has an `idempotencyKey`, `sendMany()` sends that chunk through the single-message API instead so each message's key is preserved. ## Idempotency and reliability Lettermint supports the `Idempotency-Key` HTTP header to prevent duplicate sends during retries. Upyo maps `Message.idempotencyKey` to that header: ```typescript twoslash import { createMessage } from "@upyo/core"; import { LettermintTransport } from "@upyo/lettermint"; const transport = new LettermintTransport({ apiToken: "lm_project_1234567890abcdef", retries: 3, timeout: 30000, }); const message = createMessage({ from: "alerts@example.com", to: "admin@example.com", subject: "System alert", content: { text: "CPU usage has exceeded 90%." }, priority: "high", idempotencyKey: "alert-cpu-2026-05-17T10:00Z", }); await transport.send(message); ``` The transport retries temporary failures with exponential backoff. Client errors from Lettermint are returned as failed receipts without retrying, and `AbortSignal` cancellation is supported through Upyo's standard transport options. ## Attachments and inline images Attachments are encoded as base64 before being sent to Lettermint. Inline attachments include their `content_id` so HTML content can reference them with `cid:` URLs: ```typescript twoslash import { createMessage } from "@upyo/core"; import { LettermintTransport } from "@upyo/lettermint"; const transport = new LettermintTransport({ apiToken: "lm_project_1234567890abcdef", }); const logo = new TextEncoder().encode("fake image bytes"); const message = createMessage({ from: "brand@example.com", to: "customer@example.com", subject: "Welcome", content: { html: '

Welcome

Logo', text: "Welcome", }, attachments: [{ filename: "logo.png", content: logo, contentType: "image/png", inline: true, contentId: "logo", }], }); await transport.send(message); ``` --- --- url: /transports/maileroo.md description: >- Learn how to send emails with Maileroo transport, including attachments, custom headers, tags, tracking settings, and retry configuration. --- # Maileroo *This transport is introduced in Upyo 0.6.0.* [Maileroo] is an email delivery provider with a JSON-based Email API for sending basic, templated, and bulk email. Upyo provides the Maileroo transport through the *@upyo/maileroo* package. It supports single sends, sequential `sendMany()`, attachments, custom headers, tags, tracking settings, retry logic, structured failure receipts, and `AbortSignal` cancellation. [Maileroo]: https://maileroo.com/ ## Installation To use the Maileroo transport, install the *@upyo/maileroo* package: ::: code-group ```sh [npm] npm add @upyo/maileroo ``` ```sh [pnpm] pnpm add @upyo/maileroo ``` ```sh [Yarn] yarn add @upyo/maileroo ``` ```sh [Deno] deno add jsr:@upyo/maileroo ``` ```sh [Bun] bun add @upyo/maileroo ``` ::: ## Getting started Before using the Maileroo transport, you need a verified Maileroo sending domain and a sending key. The key is sent as an `X-API-Key` header. ```typescript twoslash import { createMessage } from "@upyo/core"; import { MailerooTransport } from "@upyo/maileroo"; const transport = new MailerooTransport({ apiKey: "your-maileroo-sending-key", }); const message = createMessage({ from: "support@example.com", to: "customer@example.com", subject: "Welcome to our service", content: { text: "Thank you for signing up!" }, }); 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(", ")); } ``` The transport converts Upyo messages to Maileroo's JSON format and sends them through the `/emails` endpoint. HTML and text alternatives, CC, BCC, reply-to, custom headers, priority, attachments, and inline attachments are handled automatically. ## Tags, headers, and tracking Maileroo accepts custom tags and headers as maps. Configure default tags and tracking on the transport when every message sent through it should share the same settings: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MailerooTransport } from "@upyo/maileroo"; const transport = new MailerooTransport({ apiKey: "your-maileroo-sending-key", tracking: true, tags: { environment: "production", service: "accounts", }, }); const headers = new Headers(); headers.set("X-Campaign-ID", "welcome"); const message = createMessage({ from: "onboarding@example.com", to: "newuser@example.com", subject: "Welcome to our platform", content: { html: "

Welcome!

Thank you for joining us.

", text: "Welcome! Thank you for joining us.", }, headers, tags: ["welcome"], }); await transport.send(message); ``` `Message.tags` values are added as generated Maileroo tag keys such as `tag1` and `tag2`, alongside any default tags configured on the transport. ## Sending multiple emails The `sendMany()` method sends messages sequentially through the single-message Maileroo endpoint: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MailerooTransport } from "@upyo/maileroo"; const transport = new MailerooTransport({ apiKey: "your-maileroo-sending-key", }); const recipients = [ "user1@example.com", "user2@example.com", "user3@example.com", ]; const messages = recipients.map(email => createMessage({ from: "updates@example.com", to: email, subject: "Monthly update", content: { html: "

This month's updates

Here's what's new.

", text: "This month's updates\n\nHere's what's new.", }, }) ); for await (const receipt of transport.sendMany(messages)) { if (receipt.successful) { console.log(`Email sent with ID: ${receipt.messageId}`); } else { console.error(`Failed to send: ${receipt.errorMessages.join(", ")}`); } } ``` Maileroo also has a bulk endpoint, but its subject, body, headers, attachments, tags, and tracking settings are shared across the whole request. Upyo uses sequential sends so each `Message` keeps its own subject, content, attachments, and metadata. ## Attachments and inline images Attachments are encoded as base64 before being sent to Maileroo. Inline attachments set Maileroo's `inline` flag so HTML content can reference them with `cid:` URLs. For inline attachments, Upyo sends the attachment's `contentId` as Maileroo's attachment name so the `cid:` reference and the embedded image use the same identifier: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MailerooTransport } from "@upyo/maileroo"; const transport = new MailerooTransport({ apiKey: "your-maileroo-sending-key", }); const logo = new TextEncoder().encode("fake image bytes"); const message = createMessage({ from: "brand@example.com", to: "customer@example.com", subject: "Welcome", content: { html: '

Welcome

Logo', text: "Welcome", }, attachments: [{ filename: "logo.png", content: logo, contentType: "image/png", inline: true, contentId: "logo", }], }); await transport.send(message); ``` ## Advanced configuration and reliability The Maileroo transport supports custom API endpoints, request timeouts, retries, and additional request headers: ```typescript twoslash import { MailerooTransport } from "@upyo/maileroo"; const transport = new MailerooTransport({ apiKey: "your-maileroo-sending-key", baseUrl: "https://smtp.maileroo.com/api/v2", timeout: 15000, retries: 5, headers: { "X-Environment": "production", }, }); ``` Timeout settings control how long to wait for Maileroo's API responses. Temporary failures such as rate limits, request timeouts, and server errors are retried with exponential backoff. Permanent client errors are returned as failed receipts with provider details so application code can decide whether to report, retry later, or discard the message. `AbortSignal` cancellation is supported through the standard Upyo transport options: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MailerooTransport } from "@upyo/maileroo"; const transport = new MailerooTransport({ apiKey: "your-maileroo-sending-key", }); const controller = new AbortController(); const message = createMessage({ from: "alerts@example.com", to: "admin@example.com", subject: "System alert", content: { text: "CPU usage has exceeded 90%." }, }); const receipt = await transport.send(message, { signal: controller.signal, }); ``` --- --- url: /transports/mailtrap.md description: >- Learn how to send emails with Mailtrap transport, including Email API and Email Sandbox modes, batch sending, categories, metadata, and attachments. --- # Mailtrap *This transport is introduced in Upyo 0.6.0.* [Mailtrap] provides both a production Email API and an Email Sandbox for capturing test messages in a virtual inbox. The HTTP API supports HTML and text content, attachments, custom headers, categories, and custom variables. Upyo provides the Mailtrap transport through the *@upyo/mailtrap* package. It supports single sends, batch sends of up to 500 messages per request, attachments, retry logic, and `AbortSignal` cancellation. [Mailtrap]: https://mailtrap.io/ ## Installation To use the Mailtrap transport, install the *@upyo/mailtrap* package: ::: code-group ```sh [npm] npm add @upyo/mailtrap ``` ```sh [pnpm] pnpm add @upyo/mailtrap ``` ```sh [Yarn] yarn add @upyo/mailtrap ``` ```sh [Deno] deno add jsr:@upyo/mailtrap ``` ```sh [Bun] bun add @upyo/mailtrap ``` ::: ## Getting started Before using the Mailtrap transport, you'll need a Mailtrap API token. The token is sent to Mailtrap as the `Api-Token` request header. ```typescript twoslash import { createMessage } from "@upyo/core"; import { MailtrapTransport } from "@upyo/mailtrap"; const transport = new MailtrapTransport({ apiToken: "your-mailtrap-api-token", sandbox: true, inboxId: 12345, }); const message = createMessage({ from: "support@example.com", to: "customer@example.com", subject: "Welcome to our service", content: { text: "Thank you for signing up!" }, }); 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(", ")); } ``` The transport converts Upyo messages to Mailtrap's JSON format and sends them through the `/api/send` endpoint (or `/api/send/{inboxId}` in sandbox mode). HTML and text alternatives, CC, BCC, reply-to, custom headers, priority, attachments, and inline Content-ID attachments are handled automatically. ## Email API and Email Sandbox Mailtrap uses one API token for both environments. Set `sandbox: true` and provide an `inboxId` to capture messages in a test inbox instead of sending through the production Email API: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MailtrapTransport } from "@upyo/mailtrap"; const sandboxTransport = new MailtrapTransport({ apiToken: "your-mailtrap-api-token", sandbox: true, inboxId: 12345, }); const productionTransport = new MailtrapTransport({ apiToken: "your-mailtrap-api-token", }); const message = createMessage({ from: "onboarding@example.com", to: "newuser@example.com", subject: "Welcome", content: { text: "Welcome to our platform." }, }); ``` `sandbox` : When `true`, messages are sent to the Email Sandbox API at `sandbox.api.mailtrap.io` and captured in the inbox identified by `inboxId`. `inboxId` : Sandbox inbox ID from `mailtrap.io/sandboxes/{id}`. Required when `sandbox` is `true`. ## Categories, tags, and metadata Mailtrap uses a `category` field for message classification. Upyo maps the first tag in `Message.tags` to `category`, or falls back to `defaultCategory` (default: `transactional`). Additional tags become custom variables prefixed with `tag_`. Configure transport-level metadata when every message should carry the same tracking fields: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MailtrapTransport } from "@upyo/mailtrap"; const transport = new MailtrapTransport({ apiToken: "your-mailtrap-api-token", defaultCategory: "transactional", metadata: { environment: "production", service: "accounts", }, }); const message = createMessage({ from: "billing@example.com", to: "customer@example.com", subject: "Your invoice", content: { text: "Your invoice is ready." }, tags: ["billing", "invoice"], }); ``` ## Batch sending Use `sendMany()` to send multiple messages. The transport batches up to 500 messages per API call: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MailtrapTransport } from "@upyo/mailtrap"; const transport = new MailtrapTransport({ apiToken: "your-mailtrap-api-token", sandbox: true, inboxId: 12345, }); const messages = [ createMessage({ from: "sender@example.com", to: "user1@example.com", subject: "Hello 1", content: { text: "Message 1" }, }), createMessage({ from: "sender@example.com", to: "user2@example.com", subject: "Hello 2", content: { text: "Message 2" }, }), ]; for await (const receipt of transport.sendMany(messages)) { if (receipt.successful) { console.log("Sent:", receipt.messageId); } else { console.error("Failed:", receipt.errorMessages.join(", ")); } } ``` ## Configuration options `apiToken` : Your Mailtrap API token. `sandbox` : Use Email Sandbox instead of Email API. Default: `false`. `inboxId` : Sandbox inbox ID. Required when `sandbox` is `true`. `sendBaseUrl` : Email API base URL. Default: `https://send.api.mailtrap.io`. `sandboxBaseUrl` : Sandbox API base URL. Default: `https://sandbox.api.mailtrap.io`. `defaultCategory` : Default category when a message has no tags. Default: `transactional`. `metadata` : Metadata merged into Mailtrap `custom_variables` for every message. `userAgent` : User-Agent header sent with requests. Default: `@upyo/mailtrap`. `timeout` : Request timeout in milliseconds. Default: `30000`. `retries` : Number of retry attempts for transient failures. Default: `3`. `headers` : Additional HTTP headers to include with every request. --- --- url: /transports/mailgun.md description: >- Learn how to send emails with Mailgun transport, including regional configuration, tracking and analytics, message tagging, and bulk email sending with retry logic. --- # Mailgun [Mailgun] is a powerful email service provider that specializes in reliable email delivery for developers and businesses. It offers robust APIs for sending, receiving, and tracking emails, making it particularly well-suited for transactional emails such as password resets, order confirmations, notifications, and marketing campaigns. Mailgun provides advanced features like email analytics, A/B testing, bounce handling, and comprehensive tracking capabilities. Upyo provides a feature-rich Mailgun transport through the *@upyo/mailgun* package, supporting all major Mailgun features including tracking, tagging, regional endpoints, and bulk sending with retry logic. [Mailgun]: https://www.mailgun.com/ ## Installation To use the Mailgun transport, you need to install the *@upyo/mailgun* package: ::: code-group ```sh [npm] npm add @upyo/mailgun ``` ```sh [pnpm] pnpm add @upyo/mailgun ``` ```sh [Yarn] yarn add @upyo/mailgun ``` ```sh [Deno] deno add jsr:@upyo/mailgun ``` ```sh [Bun] bun add @upyo/mailgun ``` ::: ## Getting started Before using the Mailgun transport, you'll need a Mailgun account and a verified domain. Mailgun provides API keys and domain settings through their control panel, which you'll use to configure the transport. ```typescript twoslash import { MailgunTransport } from "@upyo/mailgun"; import { createMessage } from "@upyo/core"; const transport = new MailgunTransport({ apiKey: "1234567890abcdef1234567890abcdef", domain: "mg.example.com", region: "us", }); const message = createMessage({ from: "support@example.com", to: "customer@example.com", subject: "Welcome to our service", content: { text: "Thank you for signing up!" }, }); 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(", ")); } ``` The Mailgun transport handles authentication automatically using your API key and sends emails through Mailgun's reliable infrastructure. The service manages bounces, spam filtering, and delivery optimization automatically. ## Regional configuration Mailgun operates in multiple regions, and you can choose which region to use based on your location and compliance requirements. The transport supports both US and EU regions with automatic endpoint selection: ```typescript twoslash import { MailgunTransport } from "@upyo/mailgun"; // US region (default) const usTransport = new MailgunTransport({ apiKey: "1234567890abcdef1234567890abcdef", domain: "mg.example.com", region: "us", }); // EU region for GDPR compliance const euTransport = new MailgunTransport({ apiKey: "1234567890abcdef1234567890abcdef", domain: "mg.eu.example.com", region: "eu", }); // Custom endpoint for special configurations const customTransport = new MailgunTransport({ apiKey: "1234567890abcdef1234567890abcdef", domain: "mg.example.com", baseUrl: "https://api.mailgun.net/v3", }); ``` The region setting determines which Mailgun servers your emails are sent through and where your data is stored. EU region is particularly important for organizations that need to comply with GDPR data residency requirements. ## Email tracking and analytics One of Mailgun's key features is comprehensive email tracking and analytics. The transport enables tracking by default, but you can customize tracking behavior based on your needs: ```typescript twoslash import { MailgunTransport } from "@upyo/mailgun"; const transport = new MailgunTransport({ apiKey: "1234567890abcdef1234567890abcdef", domain: "mg.example.com", tracking: true, clickTracking: true, openTracking: true, }); ``` With tracking enabled, Mailgun provides detailed analytics about email delivery, opens, clicks, and bounces through their dashboard and webhooks. This information is invaluable for improving email campaigns and monitoring delivery success. ## Message tagging and organization Mailgun allows you to tag messages for better organization and analytics. Tags help you categorize emails and track performance across different types of messages: ```typescript twoslash import { MailgunTransport } from "@upyo/mailgun"; import { createMessage } from "@upyo/core"; const transport = new MailgunTransport({ apiKey: "1234567890abcdef1234567890abcdef", domain: "mg.example.com", }); const welcomeMessage = createMessage({ from: "onboarding@example.com", to: "newuser@example.com", subject: "Welcome to our platform", content: { html: "

Welcome!

Thank you for joining us.

", text: "Welcome! Thank you for joining us.", }, tags: ["onboarding", "welcome", "transactional"], }); const receipt = await transport.send(welcomeMessage); ``` Tags appear in Mailgun's analytics dashboard, allowing you to track open rates, click rates, and delivery statistics for specific message categories. This helps you understand which types of emails perform best with your audience. ## Bulk email sending For sending newsletters, notifications, or other bulk emails, the Mailgun transport provides efficient batch processing with automatic retry logic and error handling: ```typescript twoslash import { MailgunTransport } from "@upyo/mailgun"; import { createMessage } from "@upyo/core"; const transport = new MailgunTransport({ apiKey: "1234567890abcdef1234567890abcdef", domain: "mg.example.com", retries: 3, timeout: 30000, }); const subscribers = [ "user1@example.com", "user2@example.com", "user3@example.com", ]; const messages = subscribers.map(email => createMessage({ from: "newsletter@example.com", to: email, subject: "Monthly Newsletter - December 2024", content: { html: "

This Month's Updates

Here's what's new...

", text: "This Month's Updates\n\nHere's what's new...", }, tags: ["newsletter", "monthly"], }) ); for await (const receipt of transport.sendMany(messages)) { if (receipt.successful) { console.log(`Newsletter sent to ${receipt.messageId}`); } else { console.error(`Failed to send: ${receipt.errorMessages.join(", ")}`); } } ``` The `~MailgunTransport.sendMany()` method processes emails sequentially with built-in retry logic. Failed emails don't prevent subsequent emails from being sent, and detailed error information helps you identify and resolve delivery issues. ## Advanced configuration and reliability The Mailgun transport includes comprehensive configuration options for timeout handling, retry behavior, and SSL validation to ensure reliable email delivery: ```typescript twoslash import { MailgunTransport } from "@upyo/mailgun"; const transport = new MailgunTransport({ apiKey: "1234567890abcdef1234567890abcdef", domain: "mg.example.com", region: "us", timeout: 15000, retries: 5, validateSsl: true, headers: { "X-Custom-Header": "MyApp-v1.0", "X-Environment": "production", }, tracking: true, clickTracking: false, openTracking: true, }); ``` Timeout settings control how long to wait for Mailgun's API responses, while retry configuration determines how many times to retry failed requests. The transport uses exponential backoff for retries, reducing load on Mailgun's servers during temporary outages. ## Development and testing For development and testing, you can use Mailgun's [sandbox domain] or configure the transport for testing environments. Mailgun provides sandbox domains that accept emails but don't deliver them, perfect for development: ```typescript twoslash import { MailgunTransport } from "@upyo/mailgun"; // Development configuration with sandbox domain const devTransport = new MailgunTransport({ apiKey: "test1234567890abcdef1234567890ab", domain: "sandbox-abc123.mailgun.org", // Mailgun sandbox domain region: "us", timeout: 5000, // Shorter timeout for development }); // Testing configuration with reduced retries const testTransport = new MailgunTransport({ apiKey: process.env.MAILGUN_TEST_API_KEY ?? "test123", domain: process.env.MAILGUN_TEST_DOMAIN ?? "test.example.com", retries: 1, // Fewer retries for faster test execution timeout: 10000, tracking: false, // Disable tracking in tests }); ``` Sandbox domains allow you to test email functionality without sending real emails, while still receiving confirmation that your integration works correctly. This is essential for automated testing and development workflows. > \[!CAUTION] > Mailgun's sandbox domains have strict rate limits (typically 50 emails per > day) and are intended for basic testing only. For extensive testing, > performance testing, or CI/CD pipelines that send many emails, consider > using a verified domain with appropriate rate limits for your testing needs. [sandbox domain]: https://documentation.mailgun.com/docs/mailgun/user-manual/domains/#sandbox-domain --- --- url: /transports/plunk.md description: >- Complete Plunk transport guide covering email sending, self-hosted support, message organization, batch processing, and advanced configuration for reliable delivery. --- # Plunk *This transport is introduced in Upyo 0.3.0.* [Plunk] is a modern, developer-friendly email service that offers both cloud-hosted and self-hosted solutions for transactional email delivery. Built with simplicity and reliability in mind, Plunk provides straightforward APIs for sending emails with features like tracking, templates, and excellent deliverability rates. It's particularly well-suited for developers who want a clean, no-nonsense email service without the complexity of larger platforms, making it ideal for transactional emails such as welcome messages, password resets, notifications, and system alerts. Upyo provides a feature-rich Plunk transport through the *@upyo/plunk* package, supporting all major Plunk features including self-hosted instances, batch sending, automatic retry logic, and comprehensive error handling. [Plunk]: https://www.useplunk.com/ ## Installation To use the Plunk transport, you need to install the *@upyo/plunk* package: ::: code-group ```sh [npm] npm add @upyo/plunk ``` ```sh [pnpm] pnpm add @upyo/plunk ``` ```sh [Yarn] yarn add @upyo/plunk ``` ```sh [Deno] deno add jsr:@upyo/plunk ``` ```sh [Bun] bun add @upyo/plunk ``` ::: ## Getting started Before using the Plunk transport, you'll need a Plunk account and an API key. For cloud-hosted Plunk, you can get API keys from the Plunk dashboard at [useplunk.com]. For self-hosted instances, API keys are managed through your own Plunk installation. ```typescript twoslash import { PlunkTransport } from "@upyo/plunk"; import { createMessage } from "@upyo/core"; const transport = new PlunkTransport({ apiKey: "sk_1234567890abcdef1234567890abcdef1234567890abcdef", }); const message = createMessage({ from: "support@example.com", to: "customer@example.com", subject: "Welcome to our service", content: { text: "Thank you for signing up!" }, }); 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(", ")); } ``` The Plunk transport handles authentication automatically using your API key and sends emails through Plunk's reliable infrastructure. The service manages bounces, spam filtering, and delivery optimization automatically, providing excellent deliverability rates with minimal configuration. [useplunk.com]: https://www.useplunk.com/ ## Self-hosted instances One of Plunk's key advantages is its support for self-hosted deployments using Docker. This gives you complete control over your email infrastructure while maintaining the simplicity of Plunk's API: ```typescript twoslash import { PlunkTransport } from "@upyo/plunk"; import { createMessage } from "@upyo/core"; // Self-hosted Plunk instance const transport = new PlunkTransport({ apiKey: "your-self-hosted-api-key", baseUrl: "https://mail.yourcompany.com/api", validateSsl: true, }); const message = createMessage({ from: "noreply@yourcompany.com", to: "employee@yourcompany.com", subject: "Internal notification", content: { html: "

System Update

The maintenance is complete.

", text: "System Update\n\nThe maintenance is complete.", }, }); const receipt = await transport.send(message); ``` Self-hosted Plunk instances are deployed using the [driaug/plunk] Docker image, giving you full control over your email infrastructure, data privacy, and compliance requirements. This is particularly valuable for organizations with strict data residency requirements or those who prefer to keep email infrastructure in-house. [driaug/plunk]: https://hub.docker.com/r/driaug/plunk ## Message organization and batch sending Plunk supports message tagging for better organization and analytics. The transport also provides efficient batch processing for sending multiple emails with automatic retry logic: ```typescript twoslash import { PlunkTransport } from "@upyo/plunk"; import { createMessage } from "@upyo/core"; const transport = new PlunkTransport({ apiKey: "sk_1234567890abcdef1234567890abcdef1234567890abcdef", retries: 3, timeout: 30000, }); const subscribers = [ "user1@example.com", "user2@example.com", "user3@example.com", ]; const messages = subscribers.map(email => createMessage({ from: "newsletter@example.com", to: email, subject: "Monthly Newsletter - December 2024", content: { html: "

This Month's Updates

Here's what's new...

", text: "This Month's Updates\n\nHere's what's new...", }, tags: ["newsletter", "monthly", "transactional"], }) ); for await (const receipt of transport.sendMany(messages)) { if (receipt.successful) { console.log(`Newsletter sent with ID: ${receipt.messageId}`); } else { console.error(`Failed to send: ${receipt.errorMessages.join(", ")}`); } } ``` Tags help you categorize emails and track performance through Plunk's analytics dashboard. The `sendMany()` method processes emails efficiently with built-in retry logic, ensuring reliable delivery even when some individual messages encounter temporary issues. ## Advanced features and reliability The Plunk transport includes comprehensive configuration options for timeout handling, retry behavior, SSL validation, and custom headers to ensure reliable email delivery in production environments: ```typescript twoslash import { PlunkTransport } from "@upyo/plunk"; import { createMessage } from "@upyo/core"; import { readFile } from "node:fs/promises"; const transport = new PlunkTransport({ apiKey: "sk_1234567890abcdef1234567890abcdef1234567890abcdef", baseUrl: "https://next-api.useplunk.com", timeout: 15000, retries: 5, validateSsl: true, headers: { "X-Custom-Header": "MyApp-v1.0", "X-Environment": "production", }, }); // Sending with attachments (limited to 5 per Plunk API) const fileContent = await readFile("./reports/report.pdf"); const message = createMessage({ from: "documents@example.com", to: "client@example.com", subject: "Your requested documents", content: { text: "Please find the requested documents attached.", html: "

Please find the requested documents attached.

", }, attachments: new File([fileContent], "report.pdf", { type: "application/pdf" }), priority: "high", // Sets appropriate email headers tags: ["documents", "client"], }); const receipt = await transport.send(message); ``` The transport automatically handles attachment conversion to base64 format as required by Plunk's API, with a limit of 5 attachments per message. Priority levels are converted to appropriate email headers for better inbox placement of urgent messages. ## Cancellation and error handling The Plunk transport supports request cancellation using AbortSignal and provides comprehensive error handling for various failure scenarios: ```typescript twoslash import { PlunkTransport } from "@upyo/plunk"; import { createMessage } from "@upyo/core"; const transport = new PlunkTransport({ apiKey: "sk_1234567890abcdef1234567890abcdef1234567890abcdef", timeout: 30000, retries: 3, }); const controller = new AbortController(); // Cancel after 10 seconds setTimeout(() => controller.abort(), 10000); const message = createMessage({ from: "alerts@example.com", to: "admin@example.com", subject: "🚨 System Alert: High CPU Usage", content: { text: "Server CPU usage has exceeded 90% for the past 5 minutes.", }, priority: "high", tags: ["alert", "system"], }); const receipt = await transport.send(message, { signal: controller.signal }); if (receipt.successful) { console.log("Alert sent with ID:", receipt.messageId); } else { console.error("Alert failed:", receipt.errorMessages.join(", ")); } ``` The transport includes comprehensive retry logic with exponential backoff, automatically handling transient network errors, rate limiting, and temporary service interruptions. Failed requests provide detailed error information to help diagnose and resolve delivery issues. ## Development and testing For development and testing, you can configure the Plunk transport for testing environments. When using self-hosted instances, you can set up dedicated testing environments with isolated email delivery: ```typescript twoslash import { PlunkTransport } from "@upyo/plunk"; import { createMessage } from "@upyo/core"; // Development configuration with shorter timeouts const devTransport = new PlunkTransport({ apiKey: "sk_test1234567890abcdef1234567890abcdef1234567890ab", timeout: 5000, // Shorter timeout for development retries: 1, // Fewer retries for faster feedback }); // Testing configuration with environment variables const testTransport = new PlunkTransport({ apiKey: process.env.PLUNK_TEST_API_KEY ?? "sk_test123", baseUrl: process.env.PLUNK_TEST_URL ?? "https://next-api.useplunk.com", timeout: 10000, retries: 1, // Fewer retries for faster test execution }); // Example test message const testMessage = createMessage({ from: "test@yourdomain.com", to: "testuser@yourdomain.com", // Use verified test addresses subject: "Test Email", content: { text: "This is a test email from development." }, tags: ["test", "development"], }); const receipt = await testTransport.send(testMessage); ``` For local development, use verified sender addresses and test recipient addresses to avoid sending emails to real users. Self-hosted Plunk instances provide excellent isolation for testing, allowing you to monitor email delivery without affecting production systems. > \[!TIP] > Plunk provides a clean, straightforward dashboard for monitoring email > delivery, viewing sent emails, and debugging issues. This is particularly > valuable for self-hosted instances where you have full access to logs > and delivery details. > \[!CAUTION] > Always use test API keys and verified domains during development to avoid > sending test emails to real users. For self-hosted instances, consider > setting up separate testing environments with isolated email delivery > to prevent accidental sends to production email addresses. --- --- url: /transports/resend.md description: >- Complete Resend transport guide covering email sending, batch optimization, message tagging, idempotency handling, and advanced configuration for reliable delivery. --- # Resend *This transport is introduced in Upyo 0.3.0.* [Resend] is a modern email service provider that offers a developer-friendly API for sending transactional emails. Built with simplicity and reliability in mind, Resend provides excellent deliverability, comprehensive tracking, and intuitive tools for managing email infrastructure. It's particularly well-suited for transactional emails such as welcome messages, password resets, notifications, and system alerts, with features designed specifically for developers and modern web applications. Upyo provides a feature-rich Resend transport through the *@upyo/resend* package, supporting all major Resend features including batch sending, automatic idempotency, smart optimization, and comprehensive error handling. [Resend]: https://resend.com/ ## Installation To use the Resend transport, you need to install the *@upyo/resend* package: ::: code-group ```sh [npm] npm add @upyo/resend ``` ```sh [pnpm] pnpm add @upyo/resend ``` ```sh [Yarn] yarn add @upyo/resend ``` ```sh [Deno] deno add jsr:@upyo/resend ``` ```sh [Bun] bun add @upyo/resend ``` ::: ## Getting started Before using the Resend transport, you'll need a Resend account and an API key. Resend provides API keys through their dashboard at [resend.com/api-keys], where you can create keys with specific permissions for your application's needs. ```typescript twoslash import { ResendTransport } from "@upyo/resend"; import { createMessage } from "@upyo/core"; const transport = new ResendTransport({ apiKey: "re_1234567890abcdef_1234567890abcdef1234567890", }); const message = createMessage({ from: "support@example.com", to: "customer@example.com", subject: "Welcome to our service", content: { text: "Thank you for signing up!" }, }); 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(", ")); } ``` The Resend transport handles authentication automatically using your API key and sends emails through Resend's reliable infrastructure. The service manages bounces, spam filtering, and delivery optimization automatically, providing excellent deliverability rates with minimal configuration. [resend.com/api-keys]: https://resend.com/api-keys ## Batch sending and optimization One of Resend's key features is intelligent batch optimization. The transport automatically chooses the most efficient sending method based on your message characteristics, using Resend's batch API when possible for optimal performance: ```typescript twoslash import { ResendTransport } from "@upyo/resend"; import { createMessage } from "@upyo/core"; const transport = new ResendTransport({ apiKey: "re_1234567890abcdef_1234567890abcdef1234567890", }); const subscribers = [ "user1@example.com", "user2@example.com", "user3@example.com", ]; const messages = subscribers.map(email => createMessage({ from: "newsletter@example.com", to: email, subject: "Monthly Newsletter - December 2024", content: { html: "

This Month's Updates

Here's what's new...

", text: "This Month's Updates\n\nHere's what's new...", }, }) ); for await (const receipt of transport.sendMany(messages)) { if (receipt.successful) { console.log(`Newsletter sent with ID: ${receipt.messageId}`); } else { console.error(`Failed to send: ${receipt.errorMessages.join(", ")}`); } } ``` The transport automatically determines the optimal sending strategy: * **≤100 messages without attachments/tags**: Uses Resend's batch API for fastest delivery * **>100 messages**: Automatically chunks into multiple batch requests * **Messages with attachments or tags**: Falls back to individual requests * **Mixed scenarios**: Intelligently separates batch-compatible from individual messages This optimization happens transparently, ensuring maximum performance while maintaining full feature compatibility. ## Message tagging and organization Resend supports message tagging for better organization and analytics. Tags help you categorize emails and track performance across different types of messages and campaigns: ```typescript twoslash import { ResendTransport } from "@upyo/resend"; import { createMessage } from "@upyo/core"; const transport = new ResendTransport({ apiKey: "re_1234567890abcdef_1234567890abcdef1234567890", }); const welcomeMessage = createMessage({ from: "onboarding@example.com", to: "newuser@example.com", subject: "Welcome to our platform", content: { html: "

Welcome!

Thank you for joining us.

", text: "Welcome! Thank you for joining us.", }, tags: ["onboarding", "welcome", "transactional"], }); const receipt = await transport.send(welcomeMessage); ``` Tags appear in Resend's analytics dashboard, allowing you to track delivery statistics, open rates, and engagement metrics for specific message categories. This helps you understand which types of emails perform best with your audience and optimize your email strategies accordingly. ## Idempotency and reliability Resend provides built-in idempotency support to prevent duplicate email sends during network issues or application retries. The transport automatically generates idempotency keys, but you can provide custom keys for specific use cases: ```typescript twoslash import { ResendTransport } from "@upyo/resend"; import { createMessage } from "@upyo/core"; const transport = new ResendTransport({ apiKey: "re_1234567890abcdef_1234567890abcdef1234567890", retries: 3, timeout: 30000, }); const message = createMessage({ from: "alerts@example.com", to: "admin@example.com", subject: "🚨 System Alert: High CPU Usage", content: { text: "Server CPU usage has exceeded 90% for the past 5 minutes.", }, priority: "high", // Sets X-Priority header tags: ["alert", "system"], }); // Send the alert (idempotency key is automatically generated) const receipt = await transport.send(message); ``` The transport includes comprehensive retry logic with exponential backoff, ensuring reliable delivery even during temporary service interruptions. Priority levels are automatically converted to appropriate email headers for better inbox placement of urgent messages. ## Advanced configuration and reliability The Resend transport includes comprehensive configuration options for timeout handling, retry behavior, SSL validation, and custom headers to ensure reliable email delivery in production environments: ```typescript twoslash import { ResendTransport } from "@upyo/resend"; const transport = new ResendTransport({ apiKey: "re_1234567890abcdef_1234567890abcdef1234567890", baseUrl: "https://api.resend.com", timeout: 15000, retries: 5, validateSsl: true, headers: { "X-Custom-Header": "MyApp-v1.0", "X-Environment": "production", }, }); ``` Timeout settings control how long to wait for Resend's API responses, while retry configuration determines how many times to retry failed requests. The transport uses exponential backoff for retries, reducing load on Resend's servers during temporary outages and improving overall reliability. ## Development and testing For development and testing, you can configure the Resend transport for testing environments. Resend provides test API keys and domains that allow you to test email functionality without sending real emails to users: ```typescript twoslash import { ResendTransport } from "@upyo/resend"; import { createMessage } from "@upyo/core"; // Development configuration with test domain const devTransport = new ResendTransport({ apiKey: "re_test1234567890ab_cdef1234567890abcdef1234567890ab", timeout: 5000, // Shorter timeout for development retries: 1, // Fewer retries for faster feedback }); // Testing configuration with environment variables const testTransport = new ResendTransport({ apiKey: process.env.RESEND_TEST_API_KEY ?? "re_test123", timeout: 10000, retries: 1, // Fewer retries for faster test execution }); // Example test message using onboarding@resend.dev const testMessage = createMessage({ from: "onboarding@resend.dev", // Resend's test domain to: "delivered@resend.dev", // Test recipient address subject: "Test Email", content: { text: "This is a test email." }, }); const receipt = await testTransport.send(testMessage); ``` For local development, you can use Resend's test domains (`onboarding@resend.dev`) which accept emails but don't deliver them to real recipients. This allows you to test the complete email sending workflow without affecting real users. > \[!TIP] > Resend provides excellent debugging tools through their dashboard where you > can view all sent emails, delivery status, and detailed logs. This is > invaluable for testing email flows and debugging delivery issues in > development environments. > \[!CAUTION] > Always use test API keys and verified domains during development to avoid > sending test emails to real users. Resend's test domains like > `onboarding@resend.dev` are perfect for development but have rate limits > suitable for testing rather than production use. --- --- url: /transports/sendgrid.md description: >- Complete SendGrid transport guide covering email tracking, analytics, message tagging, bulk sending, and advanced configuration for reliable delivery. --- # SendGrid [SendGrid] is a comprehensive cloud-based email delivery platform that provides reliable email infrastructure for businesses of all sizes. It offers powerful APIs for sending transactional and marketing emails, with advanced features including email templates, A/B testing, real-time analytics, and sophisticated deliverability tools. SendGrid is particularly well-suited for transactional emails such as account verification, password resets, order confirmations, and automated notifications. Upyo provides a feature-rich SendGrid transport through the *@upyo/sendgrid* package, supporting all major SendGrid features including tracking, templates, personalization, and bulk sending with comprehensive error handling and retry logic. [SendGrid]: https://sendgrid.com/ ## Installation To use the SendGrid transport, you need to install the *@upyo/sendgrid* package: ::: code-group ```sh [npm] npm add @upyo/sendgrid ``` ```sh [pnpm] pnpm add @upyo/sendgrid ``` ```sh [Yarn] yarn add @upyo/sendgrid ``` ```sh [Deno] deno add jsr:@upyo/sendgrid ``` ```sh [Bun] bun add @upyo/sendgrid ``` ::: ## Getting started Before using the SendGrid transport, you'll need a SendGrid account and an API key. SendGrid provides API keys through their web interface under *Settings* → *API Keys*, where you can create keys with specific permissions for your application's needs. ```typescript twoslash import { SendGridTransport } from "@upyo/sendgrid"; import { createMessage } from "@upyo/core"; const transport = new SendGridTransport({ apiKey: "SG.1234567890abcdef.1234567890abcdef1234567890abcdef12345678", }); const message = createMessage({ from: "support@example.com", to: "customer@example.com", subject: "Welcome to our service", content: { text: "Thank you for signing up!" }, }); 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(", ")); } ``` The SendGrid transport handles authentication automatically using your API key and sends emails through SendGrid's robust infrastructure. The service manages bounces, spam filtering, and delivery optimization automatically, providing excellent deliverability rates. ## Email tracking and analytics SendGrid provides comprehensive email tracking and analytics capabilities. The transport enables tracking by default, but you can customize tracking behavior to match your privacy requirements and analytics needs: ```typescript twoslash import { SendGridTransport } from "@upyo/sendgrid"; const transport = new SendGridTransport({ apiKey: "SG.1234567890abcdef.1234567890abcdef1234567890abcdef12345678", clickTracking: true, openTracking: true, subscriptionTracking: false, googleAnalytics: false, }); ``` With tracking enabled, SendGrid provides detailed analytics about email delivery, opens, clicks, unsubscribes, and bounces through their dashboard and webhooks. This data is essential for monitoring email campaign performance and improving engagement rates. ## Message tagging and organization SendGrid allows you to tag messages for better organization and analytics. Tags help you categorize emails and track performance across different types of messages and campaigns: ```typescript twoslash import { SendGridTransport } from "@upyo/sendgrid"; import { createMessage } from "@upyo/core"; const transport = new SendGridTransport({ apiKey: "SG.1234567890abcdef.1234567890abcdef1234567890abcdef12345678", }); const welcomeMessage = createMessage({ from: "onboarding@example.com", to: "newuser@example.com", subject: "Welcome to our platform", content: { html: "

Welcome!

Thank you for joining us.

", text: "Welcome! Thank you for joining us.", }, tags: ["onboarding", "welcome", "transactional"], }); const receipt = await transport.send(welcomeMessage); ``` Tags appear in SendGrid's analytics dashboard and can be used with webhooks, allowing you to track open rates, click rates, and delivery statistics for specific message categories. This helps you understand which types of emails perform best with your audience. ## Bulk email sending For sending newsletters, notifications, or other bulk emails, the SendGrid transport provides efficient batch processing with automatic retry logic and comprehensive error handling: ```typescript twoslash import { SendGridTransport } from "@upyo/sendgrid"; import { createMessage } from "@upyo/core"; const transport = new SendGridTransport({ apiKey: "SG.1234567890abcdef.1234567890abcdef1234567890abcdef12345678", retries: 3, timeout: 30000, }); const subscribers = [ "user1@example.com", "user2@example.com", "user3@example.com", ]; const messages = subscribers.map(email => createMessage({ from: "newsletter@example.com", to: email, subject: "Monthly Newsletter - December 2024", content: { html: "

This Month's Updates

Here's what's new...

", text: "This Month's Updates\n\nHere's what's new...", }, tags: ["newsletter", "monthly"], }) ); for await (const receipt of transport.sendMany(messages)) { if (receipt.successful) { console.log(`Newsletter sent to ${receipt.messageId}`); } else { console.error(`Failed to send: ${receipt.errorMessages.join(", ")}`); } } ``` The `~SendGridTransport.sendMany()` method processes emails sequentially with built-in retry logic. Failed emails don't prevent subsequent emails from being sent, and detailed error information helps you identify and resolve delivery issues quickly. ## Advanced configuration and reliability The SendGrid transport includes comprehensive configuration options for timeout handling, retry behavior, SSL validation, and custom headers to ensure reliable email delivery in production environments: ```typescript twoslash import { SendGridTransport } from "@upyo/sendgrid"; const transport = new SendGridTransport({ apiKey: "SG.1234567890abcdef.1234567890abcdef1234567890abcdef12345678", baseUrl: "https://api.sendgrid.com/v3", timeout: 15000, retries: 5, validateSsl: true, headers: { "X-Custom-Header": "MyApp-v1.0", "X-Environment": "production", }, clickTracking: true, openTracking: true, subscriptionTracking: false, googleAnalytics: true, }); ``` Timeout settings control how long to wait for SendGrid's API responses, while retry configuration determines how many times to retry failed requests. The transport uses exponential backoff for retries, reducing load on SendGrid's servers during temporary outages and improving overall reliability. ## Development and testing For development and testing, you can configure the SendGrid transport for testing environments or use SendGrid's sandbox mode. SendGrid doesn't provide dedicated sandbox domains like some providers, but you can use test API keys and monitor emails through their Event Webhook for testing: ```typescript twoslash import { SendGridTransport } from "@upyo/sendgrid"; // Development configuration with reduced timeouts const devTransport = new SendGridTransport({ apiKey: "SG.test1234567890ab.cdef1234567890abcdef1234567890abcdef123456", timeout: 5000, // Shorter timeout for development retries: 1, // Fewer retries for faster feedback clickTracking: false, openTracking: false, }); // Testing configuration with environment variables const testTransport = new SendGridTransport({ apiKey: process.env.SENDGRID_TEST_API_KEY ?? "SG.test123", timeout: 10000, retries: 1, // Fewer retries for faster test execution clickTracking: false, // Disable tracking in tests openTracking: false, subscriptionTracking: false, }); ``` For testing, consider using restricted API keys that can only send to verified email addresses or specific domains. This prevents accidental email sends to real users during development while still allowing you to test the complete email sending workflow. > \[!TIP] > SendGrid provides excellent testing tools through their Event Webhook feature. > You can configure webhooks to receive real-time notifications about email > events (delivered, opened, clicked, etc.) which is invaluable for testing > email flows and debugging delivery issues in development environments. > \[!CAUTION] > Unlike some email providers, SendGrid doesn't have a true sandbox mode that > prevents email delivery. Always use test API keys with restricted permissions > and verified recipient addresses during development to avoid sending test > emails to real users. Consider using services like [MailHog] or [Mailpit] > for local development testing. [MailHog]: https://github.com/mailhog/MailHog [Mailpit]: https://github.com/axllent/mailpit --- --- url: /transports/ses.md description: >- Amazon SES transport guide with AWS authentication, regional configuration, IAM roles, rich message features, bulk sending, and configuration sets. --- # Amazon SES *This transport is introduced in Upyo 0.2.0.* [Amazon SES] (Simple Email Service) is AWS's reliable, scalable email sending service designed for high-volume transactional and marketing emails. Built on Amazon's proven infrastructure, SES provides excellent deliverability rates, comprehensive analytics, and seamless integration with other AWS services. The service automatically handles bounce and complaint processing, reputation monitoring, and compliance with industry standards, making it ideal for applications that need reliable email delivery at scale. Upyo provides a comprehensive Amazon SES transport through the *@upyo/ses* package, offering AWS Signature v4 authentication, zero dependencies for cross-runtime compatibility, and efficient bulk sending capabilities. [Amazon SES]: https://aws.amazon.com/ses/ ## Installation To use the Amazon SES transport, you need to install the *@upyo/ses* package: ::: code-group ```sh [npm] npm add @upyo/ses ``` ```sh [pnpm] pnpm add @upyo/ses ``` ```sh [Yarn] yarn add @upyo/ses ``` ```sh [Deno] deno add jsr:@upyo/ses ``` ```sh [Bun] bun add @upyo/ses ``` ::: ## Getting started Before using the Amazon SES transport, you'll need an AWS account with SES enabled and appropriate IAM permissions. You can use AWS access keys, session tokens, or IAM roles for authentication. SES requires verified email addresses or domains for sending, which you can configure through the AWS Console. ```typescript twoslash import { SesTransport } from "@upyo/ses"; import { createMessage } from "@upyo/core"; const transport = new SesTransport({ authentication: { type: "credentials", accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, region: "us-east-1", }); const message = createMessage({ from: "support@example.com", to: "customer@example.com", subject: "Welcome to our service", content: { text: "Thank you for signing up!" }, }); 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(", ")); } ``` The SES transport handles AWS authentication automatically using Signature v4 signing and sends emails through Amazon's reliable infrastructure. The service provides excellent deliverability and detailed bounce handling. ## Authentication methods The Amazon SES transport supports two primary authentication methods, each designed for different use cases and security requirements. Both methods use discriminated union types for type safety: ```typescript twoslash import { SesTransport } from "@upyo/ses"; // AWS access key credentials (for long-term access) const credentialsTransport = new SesTransport({ authentication: { type: "credentials", accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", }, region: "us-west-2", }); // Session tokens (for temporary credentials) const sessionTransport = new SesTransport({ authentication: { type: "session", accessKeyId: "ASIAXYZ...", secretAccessKey: "abc123...", sessionToken: "FwoGZXIvYXdzE...", }, region: "eu-west-1", }); ``` Session authentication is particularly useful when using temporary credentials from AWS STS, IAM roles, or federated authentication systems. The transport automatically handles the inclusion of session tokens in AWS API requests. ## IAM roles and external authentication For applications running on AWS infrastructure or requiring IAM role-based access, you can use external tools to assume roles and provide the resulting temporary credentials to the transport: ```typescript twoslash import { SesTransport } from "@upyo/ses"; // First, assume the role externally (e.g., using AWS CLI or SDK): // aws sts assume-role --role-arn "arn:aws:iam::123456789012:role/SesRole" --role-session-name "ses-session" const transport = new SesTransport({ authentication: { type: "session", accessKeyId: "ASIAXYZ...", // From AssumeRole response secretAccessKey: "abc123...", // From AssumeRole response sessionToken: "FwoGZXIv...", // From AssumeRole response }, region: "eu-west-1", }); ``` This approach gives you full control over role assumption logic, credential refresh, and session management while keeping the transport focused on email delivery. You can implement custom credential providers that automatically refresh temporary credentials as needed. > \[!TIP] > While the SES transport currently requires external role assumption, direct > IAM role support may be added in future versions. This would enable automatic > credential refresh and simplified configuration for applications running on > AWS infrastructure. The current external approach maintains the transport's > zero-dependency design while ensuring maximum flexibility and compatibility > across different deployment environments. ## Regional configuration Amazon SES operates in multiple AWS regions, and you should choose the region closest to your application or based on compliance requirements. The transport automatically constructs the correct SES endpoints for your chosen region: ```typescript twoslash import { SesTransport } from "@upyo/ses"; // US East (N. Virginia) - Default region const usEastTransport = new SesTransport({ authentication: { type: "credentials", accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, region: "us-east-1", }); // EU (Ireland) for GDPR compliance const euTransport = new SesTransport({ authentication: { type: "credentials", accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, region: "eu-west-1", }); // Asia Pacific (Sydney) const apacTransport = new SesTransport({ authentication: { type: "credentials", accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, region: "ap-southeast-2", }); ``` Choose your region based on latency requirements, data residency regulations, and SES feature availability. Some SES features may not be available in all regions, so consult the AWS documentation for your specific needs. ## Rich message features The SES transport supports all major email features including HTML content, attachments, tags, and priority settings. These features integrate seamlessly with SES's tracking and analytics capabilities: ```typescript twoslash // @noErrors: 2322 import { SesTransport } from "@upyo/ses"; import { createMessage } from "@upyo/core"; const transport = new SesTransport({ authentication: { type: "credentials", accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, region: "us-east-1", defaultTags: { environment: "production", application: "user-notifications", }, }); const message = createMessage({ from: { address: "support@example.com", name: "Support Team" }, to: [ { address: "customer@example.com", name: "John Customer" }, "backup@example.com", ], cc: "manager@example.com", replyTo: "noreply@example.com", subject: "Your Monthly Report", content: { html: "

Monthly Report

Please find your report attached.

", text: "Monthly Report\n\nPlease find your report attached.", }, attachments: [ new File( [await fetch("https://example.com/report.pdf").then(r => r.arrayBuffer())], "monthly-report.pdf", { type: "application/pdf" } ), ], tags: ["report", "monthly", "automated"], priority: "high", }); const receipt = await transport.send(message); ``` Tags are particularly powerful in SES as they enable detailed tracking and analytics through CloudWatch metrics. You can set default tags at the transport level and additional tags per message for comprehensive categorization. ## Bulk email sending For sending newsletters, notifications, or other bulk emails, the SES transport provides efficient batch processing that respects SES rate limits and handles errors gracefully: ```typescript twoslash import { SesTransport } from "@upyo/ses"; import { createMessage } from "@upyo/core"; const transport = new SesTransport({ authentication: { type: "credentials", accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, region: "us-east-1", batchSize: 25, // Process 25 messages concurrently retries: 3, }); const subscribers = [ "user1@example.com", "user2@example.com", "user3@example.com", // ... potentially thousands more ]; const messages = subscribers.map(email => createMessage({ from: "newsletter@example.com", to: email, subject: "Weekly Newsletter - December 2024", content: { html: "

This Week's Updates

Here's what's new...

", text: "This Week's Updates\n\nHere's what's new...", }, tags: ["newsletter", "weekly"], }) ); for await (const receipt of transport.sendMany(messages)) { if (receipt.successful) { console.log(`Newsletter sent: ${receipt.messageId}`); } else { console.error(`Failed to send: ${receipt.errorMessages.join(", ")}`); } } ``` The `~SesTransport.sendMany()` method processes messages concurrently using individual `SendEmail` API calls rather than SES's `SendBulkEmail` API. This approach provides maximum flexibility for different message content while still achieving excellent throughput through concurrent processing. ## Configuration sets and tracking Amazon SES provides configuration sets for advanced tracking, reputation monitoring, and event publishing. You can specify a configuration set to enable detailed analytics and webhook notifications: ```typescript twoslash import { SesTransport } from "@upyo/ses"; const transport = new SesTransport({ authentication: { type: "credentials", accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, region: "us-east-1", configurationSetName: "my-production-config-set", defaultTags: { source: "upyo-transport", version: "1.0", }, }); ``` Configuration sets enable features like: * Event publishing to CloudWatch, Kinesis, or SNS * Reputation tracking and automatic bounce handling * IP pool management for dedicated sending * Click and open tracking integration * Suppression list management Consult the [SES Configuration Sets documentation] for detailed setup instructions and available features. [SES Configuration Sets documentation]: https://docs.aws.amazon.com/ses/latest/dg/using-configuration-sets.html ## Advanced configuration and reliability The SES transport includes comprehensive configuration options for timeout handling, retry behavior, SSL validation, and custom headers to ensure reliable email delivery in production environments: ```typescript twoslash import { SesTransport } from "@upyo/ses"; const transport = new SesTransport({ authentication: { type: "session", accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, sessionToken: process.env.AWS_SESSION_TOKEN!, }, region: "us-east-1", timeout: 15000, retries: 5, validateSsl: true, headers: { "X-Application": "MyApp-v2.1", "X-Environment": "production", }, configurationSetName: "production-emails", defaultTags: { service: "notifications", environment: "prod", }, batchSize: 30, }); ``` Timeout settings control how long to wait for SES API responses, while retry configuration determines how many times to retry failed requests. The transport uses exponential backoff for retries, reducing load on SES during temporary issues and improving overall reliability. ## Development and testing For development and testing, you can configure the SES transport for testing environments. SES provides a sandbox mode for new accounts that restricts sending to verified email addresses, perfect for development workflows: ```typescript twoslash import { SesTransport } from "@upyo/ses"; // Development configuration with sandbox restrictions const devTransport = new SesTransport({ authentication: { type: "credentials", accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, region: "us-east-1", timeout: 5000, // Shorter timeout for development retries: 1, // Fewer retries for faster feedback batchSize: 5, // Smaller batches for testing defaultTags: { environment: "development", }, }); // Testing configuration with environment variables const testTransport = new SesTransport({ authentication: { type: "credentials", accessKeyId: process.env.SES_TEST_ACCESS_KEY_ID ?? "test-key", secretAccessKey: process.env.SES_TEST_SECRET_ACCESS_KEY ?? "test-secret", }, region: process.env.SES_TEST_REGION ?? "us-east-1", timeout: 10000, retries: 1, // Fewer retries for faster test execution validateSsl: false, // For testing with local endpoints }); ``` > \[!TIP] > When starting with Amazon SES, your account begins in sandbox mode, which > restricts sending to verified email addresses and domains. This is perfect > for development and testing as it prevents accidental email sends to real > users. To send to unverified addresses in production, you'll need to request > production access through the AWS Console. > \[!CAUTION] > SES sandbox mode has strict sending limits (typically 200 emails per 24-hour > period and 1 email per second). For extensive testing, performance testing, > or CI/CD pipelines that send many emails, consider requesting production > access or using alternative testing approaches like mock transports for > unit tests. --- --- url: /transports/pool.md description: >- Pool transport for combining multiple email providers with load balancing, failover strategies, and intelligent routing to ensure reliable email delivery. --- # Pool transport The pool transport is a specialized orchestration utility that combines multiple email transports into a single, resilient email delivery system. Rather than connecting to a specific email service, it intelligently routes messages across multiple underlying transports using configurable strategies like round-robin, weighted distribution, priority-based failover, and custom routing logic. This makes it invaluable for high-availability systems, cost optimization, and gradual migration between email providers. Upyo provides a comprehensive pool transport through the *@upyo/pool* package, offering multiple load balancing strategies, automatic failover, resource management, and full compatibility with all Upyo transport features. ## Installation To use the pool transport, you need to install the *@upyo/pool* package: ::: code-group ```sh [npm] npm add @upyo/pool ``` ```sh [pnpm] pnpm add @upyo/pool ``` ```sh [Yarn] yarn add @upyo/pool ``` ```sh [Deno] deno add jsr:@upyo/pool ``` ```sh [Bun] bun add @upyo/pool ``` ::: ## Basic pooling The pool transport implements the same `Transport` interface as all other Upyo transports, making it a drop-in replacement that can combine multiple email providers seamlessly. You can group transports from different providers and use them as a single unit: ```typescript twoslash import { PoolTransport } from "@upyo/pool"; import { createMessage } from "@upyo/core"; import { SmtpTransport } from "@upyo/smtp"; import { MailgunTransport } from "@upyo/mailgun"; import { SendGridTransport } from "@upyo/sendgrid"; // Create individual transports const smtpTransport = new SmtpTransport({ host: "smtp.example.com", port: 587, auth: { user: "user", pass: "pass" }, }); const mailgunTransport = new MailgunTransport({ apiKey: "your-mailgun-api-key", domain: "your-domain.com", }); const sendgridTransport = new SendGridTransport({ apiKey: "your-sendgrid-api-key", }); // Combine them into a pool with round-robin strategy const poolTransport = new PoolTransport({ strategy: "round-robin", transports: [ { transport: smtpTransport }, { transport: mailgunTransport }, { transport: sendgridTransport }, ], }); const message = createMessage({ from: "sender@example.com", to: "recipient@example.com", subject: "Pooled Email Delivery", content: { text: "This email was sent through a pool of providers." }, }); // Send through the pool (will use round-robin selection) const receipt = await poolTransport.send(message); console.log(receipt.successful); // true if (receipt.successful) { console.log(receipt.messageId); // ID from whichever provider was used } ``` The pool automatically handles provider selection, error aggregation, and resource management, giving you a single interface for multiple email services. ## Load balancing strategies Different applications need different approaches to distributing email traffic. The pool transport provides four built-in strategies, each optimized for specific use cases: ### Round-robin distribution Cycles through transports in order, ensuring perfectly even distribution: ```typescript twoslash import type { Transport } from "@upyo/core"; import { PoolTransport } from "@upyo/pool"; import { MockTransport } from "@upyo/mock"; const transport1: Transport = new MockTransport(); const transport2: Transport = new MockTransport(); const transport3: Transport = new MockTransport(); // ---cut-before--- const pool = new PoolTransport({ strategy: "round-robin", transports: [ { transport: transport1 }, { transport: transport2 }, { transport: transport3 }, ], }); // First message goes to transport1 // Second message goes to transport2 // Third message goes to transport3 // Fourth message goes to transport1 again // And so on... ``` Round-robin is perfect when you want equal load distribution and have providers with similar capabilities and costs. ### Weighted distribution Distributes traffic proportionally based on configured weights, allowing you to send more traffic through preferred or higher-capacity providers: ```typescript twoslash import type { Transport } from "@upyo/core"; const primaryProvider = {} as Transport; const secondaryProvider = {} as Transport; const backupProvider = {} as Transport; // ---cut-before--- import { PoolTransport } from "@upyo/pool"; const pool = new PoolTransport({ strategy: "weighted", transports: [ { transport: primaryProvider, weight: 5 }, // Gets ~71% of traffic { transport: secondaryProvider, weight: 2 }, // Gets ~29% of traffic { transport: backupProvider, weight: 0 }, // Gets no traffic (disabled) ], }); // Traffic is distributed randomly but proportionally to weights // Over many sends, primaryProvider gets 5/(5+2+0) ≈ 71% of messages // secondaryProvider gets 2/(5+2+0) ≈ 29% of messages ``` Weighted distribution is ideal for cost optimization, capacity management, or gradual migration between providers. ### Priority-based failover Always attempts the highest priority transport first, falling back to lower priorities only when higher ones fail: ```typescript twoslash import type { Transport } from "@upyo/core"; const primaryTransport = {} as Transport; const secondaryTransport = {} as Transport; const emergencyTransport = {} as Transport; // ---cut-before--- import { PoolTransport } from "@upyo/pool"; const pool = new PoolTransport({ strategy: "priority", transports: [ { transport: primaryTransport, priority: 100 }, { transport: secondaryTransport, priority: 50 }, { transport: emergencyTransport, priority: 10 }, ], maxRetries: 3, // Try up to 3 different transports }); // Always tries primaryTransport first // If it fails, tries secondaryTransport // If that fails too, tries emergencyTransport // If all fail, returns aggregated error messages ``` Priority-based routing ensures you always use your preferred provider when possible, with automatic failover to backup systems. ### Custom routing with selectors Routes messages based on custom logic, allowing content-based or rule-based email provider selection: ```typescript twoslash import type { Transport } from "@upyo/core"; const bulkEmailProvider = {} as Transport; const transactionalProvider = {} as Transport; const euProvider = {} as Transport; const defaultProvider = {} as Transport; // ---cut-before--- import { PoolTransport } from "@upyo/pool"; const pool = new PoolTransport({ strategy: "selector-based", transports: [ { transport: bulkEmailProvider, selector: (msg) => msg.tags?.includes("newsletter"), }, { transport: transactionalProvider, selector: (msg) => msg.priority === "high", }, { transport: euProvider, selector: (msg) => msg.headers.get("region") === "EU", }, { transport: defaultProvider, // No selector - catches everything else }, ], }); // Newsletter emails automatically use bulkEmailProvider // High-priority emails use transactionalProvider // EU-region emails use euProvider // Everything else uses defaultProvider ``` Selector-based routing enables sophisticated email routing based on content, metadata, recipient domains, or any custom logic. ## Failover and retry logic Real email services occasionally fail due to network issues, rate limits, or maintenance. The pool transport provides robust failover capabilities that automatically retry failed sends using different providers: ```typescript twoslash import type { Transport, Message } from "@upyo/core"; import { PoolTransport } from "@upyo/pool"; const unreliableProvider = {} as Transport; const reliableProvider = {} as Transport; const backupProvider = {} as Transport; const message = {} as Message; // ---cut-before--- const pool = new PoolTransport({ strategy: "priority", transports: [ { transport: unreliableProvider, priority: 100 }, { transport: reliableProvider, priority: 50 }, { transport: backupProvider, priority: 10 }, ], maxRetries: 3, // Try up to 3 different transports timeout: 10000, // 10-second timeout per attempt }); // If unreliableProvider fails, automatically tries reliableProvider // If that fails too, tries backupProvider // If all fail, returns aggregated error messages from all attempts const receipt = await pool.send(message); if (!receipt.successful) { // Contains error messages from all failed attempts console.error("All providers failed:", receipt.errorMessages); console.error("Failed providers:", receipt.errors?.map(e => e.provider)); // Example: [ // "Provider 1: Connection timeout", // "Provider 2: Rate limit exceeded", // "Provider 3: Invalid API key" // ] } ``` The pool aggregates error messages and structured errors from all failed attempts, giving you complete visibility into what went wrong across all providers. Structured errors use each child transport's provider `id`, so fallback failures remain tied to the transport that produced them. ## Custom strategies For advanced use cases, you can implement custom routing strategies by creating a class that implements the `Strategy` interface. This allows you to build sophisticated routing logic based on any criteria: ```typescript twoslash import { PoolTransport, type Strategy, type TransportSelection } from "@upyo/pool"; import type { Message, Transport } from "@upyo/core"; import type { ResolvedTransportEntry } from "@upyo/pool"; const cheapProvider = {} as Transport; const premiumProvider = {} as Transport; const usProvider = {} as Transport; const euProvider = {} as Transport; const globalProvider = {} as Transport; // ---cut-before--- class TimeBasedStrategy implements Strategy { select( message: Message, transports: readonly ResolvedTransportEntry[], attemptedIndices: Set ): TransportSelection | undefined { const hour = new Date().getHours(); // Use different providers based on time of day // Morning hours: use provider 0 (cheaper bulk rates) // Evening hours: use provider 1 (better deliverability) const preferredIndex = hour < 12 ? 0 : 1; if (!attemptedIndices.has(preferredIndex) && transports[preferredIndex]?.enabled) { return { entry: transports[preferredIndex], index: preferredIndex, }; } // Fallback to any available transport for (let i = 0; i < transports.length; i++) { if (!attemptedIndices.has(i) && transports[i].enabled) { return { entry: transports[i], index: i }; } } return undefined; } reset() { // Custom reset logic if needed } } class RegionBasedStrategy implements Strategy { select( message: Message, transports: readonly ResolvedTransportEntry[], attemptedIndices: Set ): TransportSelection | undefined { // Route based on recipient domain const recipient = message.recipients[0]?.address; const domain = recipient?.split('@')[1]; let preferredIndex = 0; if (domain?.endsWith('.eu') || domain?.endsWith('.de')) { preferredIndex = 1; // EU provider } else if (domain?.endsWith('.com') || domain?.endsWith('.org')) { preferredIndex = 0; // US provider } else { preferredIndex = 2; // Global provider } if (!attemptedIndices.has(preferredIndex) && transports[preferredIndex]?.enabled) { return { entry: transports[preferredIndex], index: preferredIndex, }; } // Fallback logic... for (let i = 0; i < transports.length; i++) { if (!attemptedIndices.has(i) && transports[i].enabled) { return { entry: transports[i], index: i }; } } return undefined; } reset() {} } // Use custom strategies const timeBasedPool = new PoolTransport({ strategy: new TimeBasedStrategy(), transports: [ { transport: cheapProvider }, // Used in mornings { transport: premiumProvider }, // Used in evenings ], }); const regionBasedPool = new PoolTransport({ strategy: new RegionBasedStrategy(), transports: [ { transport: usProvider }, // .com, .org domains { transport: euProvider }, // .eu, .de domains { transport: globalProvider }, // Everything else ], }); ``` Custom strategies enable unlimited flexibility in routing logic, from simple time-based rules to complex machine learning-driven provider selection. ## Bulk email distribution For applications that send newsletters, notifications, or other bulk emails, the pool transport efficiently distributes large message volumes across multiple providers while maintaining proper load balancing: ```typescript twoslash import type { Transport } from "@upyo/core"; const provider1 = {} as Transport; const provider2 = {} as Transport; const provider3 = {} as Transport; // ---cut-before--- import { PoolTransport } from "@upyo/pool"; import { createMessage } from "@upyo/core"; const pool = new PoolTransport({ strategy: "weighted", transports: [ { transport: provider1, weight: 3 }, // Gets ~50% of traffic { transport: provider2, weight: 2 }, // Gets ~33% of traffic { transport: provider3, weight: 1 }, // Gets ~17% of traffic ], }); // Bulk newsletter sending const subscribers = [ "alice@example.com", "bob@example.com", "charlie@example.com", // ... thousands more ]; const newsletterMessages = subscribers.map(email => createMessage({ from: "newsletter@example.com", to: email, subject: "Monthly Newsletter - December 2024", content: { html: "

This Month's Updates

Here's what's new...

", text: "This Month's Updates\n\nHere's what's new...", }, tags: ["newsletter", "monthly"], }) ); // Send all newsletters with automatic load balancing const receipts: any[] = []; let successCount = 0; let failureCount = 0; for await (const receipt of pool.sendMany(newsletterMessages)) { receipts.push(receipt); if (receipt.successful) { successCount++; } else { failureCount++; console.error(`Failed to send newsletter: ${receipt.errorMessages}`); } // Log progress every 100 messages if (receipts.length % 100 === 0) { console.log(`Processed ${receipts.length}/${newsletterMessages.length} newsletters`); } } console.log(`Newsletter campaign complete:`); console.log(` Successful: ${successCount}`); console.log(` Failed: ${failureCount}`); console.log(` Total: ${receipts.length}`); ``` The pool automatically distributes bulk emails according to your configured strategy, ensuring optimal load distribution and maximizing deliverability across multiple providers. ## Resource management The pool transport implements `AsyncDisposable` for automatic cleanup of all underlying transports. This is especially important when using connection- based transports like SMTP that maintain persistent connections: ```typescript twoslash import { PoolTransport } from "@upyo/pool"; import type { Transport, Message } from "@upyo/core"; const transport1 = {} as Transport; const transport2 = {} as Transport; const message = {} as Message; // ---cut-before--- // Automatic cleanup with 'using' statement await using pool = new PoolTransport({ strategy: "round-robin", transports: [ { transport: transport1 }, { transport: transport2 }, ], }); await pool.send(message); // All underlying transports are disposed automatically when pool goes out of scope // Or manual cleanup const pool2 = new PoolTransport({ strategy: "priority", transports: [ { transport: transport1 }, { transport: transport2 }, ], }); try { await pool2.send(message); } finally { // Properly dispose all underlying transports await pool2[Symbol.asyncDispose](); } ``` The pool ensures that all underlying transports are properly cleaned up, preventing connection leaks and ensuring graceful shutdown. ## Testing with pools Pool transports integrate seamlessly with testing workflows using mock transports. You can verify load balancing behavior, test failover scenarios, and ensure proper error handling: ```typescript twoslash import { PoolTransport } from "@upyo/pool"; import { MockTransport } from "@upyo/mock"; import { createMessage } from "@upyo/core"; import assert from "node:assert/strict"; // Create mock transports for testing const mockTransport1 = new MockTransport(); const mockTransport2 = new MockTransport(); const failingTransport = new MockTransport(); // Configure one transport to always fail failingTransport.setNextResponse({ successful: false, errorMessages: ["Simulated provider failure"], }); const pool = new PoolTransport({ strategy: "round-robin", transports: [ { transport: mockTransport1 }, { transport: mockTransport2 }, { transport: failingTransport }, ], }); const testMessage = createMessage({ from: "test@example.com", to: "user@example.com", subject: "Test Email", content: { text: "Testing pool behavior" }, }); // Test round-robin distribution await pool.send(testMessage); // Should use mockTransport1 await pool.send(testMessage); // Should use mockTransport2 await pool.send(testMessage); // Should use failingTransport (will fail) await pool.send(testMessage); // Should use mockTransport1 again // Verify distribution assert.equal(mockTransport1.getSentMessagesCount(), 2); assert.equal(mockTransport2.getSentMessagesCount(), 1); assert.equal(failingTransport.getSentMessagesCount(), 1); // Test failover behavior const poolWithFailover = new PoolTransport({ strategy: "priority", transports: [ { transport: failingTransport, priority: 100 }, { transport: mockTransport1, priority: 50 }, ], maxRetries: 2, }); const receipt = await poolWithFailover.send(testMessage); // Should succeed using mockTransport1 after failingTransport fails assert.ok(receipt.successful); assert.equal(mockTransport1.getSentMessagesCount(), 3); // One more message ``` Mock transports provide complete visibility into pool behavior, making it easy to verify that load balancing and failover work correctly. ## Production deployment When deploying pool transports to production, consider these best practices for optimal performance and reliability: ```typescript twoslash import type { Transport } from "@upyo/core"; const primaryProvider = {} as Transport; const secondaryProvider = {} as Transport; const emergencyProvider = {} as Transport; // ---cut-before--- import { PoolTransport } from "@upyo/pool"; // Production configuration with monitoring and fallbacks const productionPool = new PoolTransport({ strategy: "priority", transports: [ { transport: primaryProvider, priority: 100, enabled: true, // Can be toggled via configuration }, { transport: secondaryProvider, priority: 80, enabled: true, }, { transport: emergencyProvider, priority: 10, enabled: true, // Emergency backup }, ], maxRetries: 3, // Allow fallback through all providers timeout: 15000, // 15-second timeout per provider }); // Add monitoring and logging const originalSend = productionPool.send.bind(productionPool); productionPool.send = async function(message, options) { const startTime = Date.now(); try { const result = await originalSend(message, options); const duration = Date.now() - startTime; // Log successful sends console.log(`✅ Email sent successfully`, { messageId: result.successful ? result.messageId : 'failed', duration, recipient: message.recipients[0]?.address, subject: message.subject, }); return result; } catch (error) { const duration = Date.now() - startTime; // Log errors for monitoring console.error(`❌ Email send failed`, { error: String(error), duration, recipient: message.recipients[0]?.address, subject: message.subject, }); throw error; } }; // Graceful shutdown handling process.on('SIGTERM', async () => { console.log('Shutting down email pool...'); await productionPool[Symbol.asyncDispose](); console.log('Email pool shutdown complete'); }); ``` This configuration provides comprehensive error handling, monitoring, and graceful shutdown capabilities suitable for production environments. ## Use cases and patterns ### High availability with geographic distribution ```typescript twoslash import type { Transport } from "@upyo/core"; const usEastProvider = {} as Transport; const usWestProvider = {} as Transport; const euProvider = {} as Transport; // ---cut-before--- import { PoolTransport } from "@upyo/pool"; const geoDistributedPool = new PoolTransport({ strategy: "priority", transports: [ { transport: usEastProvider, priority: 100 }, // Primary { transport: usWestProvider, priority: 90 }, // Regional backup { transport: euProvider, priority: 50 }, // Cross-region backup ], maxRetries: 3, }); ``` ### Cost optimization with tiered providers ```typescript twoslash import type { Transport } from "@upyo/core"; const cheapProvider = {} as Transport; const standardProvider = {} as Transport; const premiumProvider = {} as Transport; // ---cut-before--- import { PoolTransport } from "@upyo/pool"; const costOptimizedPool = new PoolTransport({ strategy: "selector-based", transports: [ { transport: cheapProvider, selector: (msg) => msg.tags?.includes("bulk") || msg.tags?.includes("newsletter"), }, { transport: premiumProvider, selector: (msg) => msg.priority === "high" || msg.tags?.includes("transactional"), }, { transport: standardProvider, // Default for everything else }, ], }); ``` ### Gradual migration between providers ```typescript twoslash import type { Transport } from "@upyo/core"; const oldProvider = {} as Transport; const newProvider = {} as Transport; // ---cut-before--- import { PoolTransport } from "@upyo/pool"; // Start with 90% old, 10% new traffic const migrationPool = new PoolTransport({ strategy: "weighted", transports: [ { transport: oldProvider, weight: 90 }, { transport: newProvider, weight: 10 }, ], }); // Gradually adjust weights over time: // Week 1: 90/10 // Week 2: 70/30 // Week 3: 50/50 // Week 4: 20/80 // Week 5: 0/100 (migration complete) ``` The pool transport provides the flexibility to implement sophisticated email delivery strategies that adapt to your application's specific requirements for reliability, cost, and performance. --- --- url: /transports/retry.md description: >- Retry transport for wrapping Upyo transports with backoff, jitter, Retry-After handling, and sendMany throttling. --- # Retry transport *This transport is introduced in Upyo 0.5.0.* The retry transport is a decorator that wraps another Upyo transport and retries transient delivery failures before returning a final receipt. It is useful when a single provider occasionally returns rate limits, temporary server errors, or network failures that should be retried with backoff. Retrying depends on structured failure metadata from *@upyo/core*. Transports that return failed receipts with `retryable` or structured `errors` fields can be retried without custom logic. Cancellation via `AbortSignal` is never retried and still rejects the send operation. > \[!NOTE] > Retry transport is not a cross-provider failover system. To fail over across > several providers, use [pool transport](./pool.md), and place retry transport > inside or outside the pool depending on whether you want per-provider retries > or retries of the whole pooled operation. ## Installation To use retry transport, install the *@upyo/retry* package: ::: code-group ```sh [npm] npm add @upyo/retry ``` ```sh [pnpm] pnpm add @upyo/retry ``` ```sh [Yarn] yarn add @upyo/retry ``` ```sh [Deno] deno add jsr:@upyo/retry ``` ```sh [Bun] bun add @upyo/retry ``` ::: ## Basic usage Create your regular transport first, then wrap it with `RetryTransport`. The wrapper implements the same `Transport` interface and keeps the wrapped transport provider id in receipts: ```typescript twoslash import { createFailedReceipt, createMessage } from "@upyo/core"; import { MockTransport } from "@upyo/mock"; import { RetryTransport } from "@upyo/retry"; const baseTransport = new MockTransport({ defaultResponse: createFailedReceipt("Temporarily unavailable.", { provider: "mock", statusCode: 503, }), }); const transport = new RetryTransport(baseTransport, { maxAttempts: 3, backoff: { baseDelayMilliseconds: 1000, maxDelayMilliseconds: 30000, factor: 2, }, }); const message = createMessage({ from: "sender@example.com", to: "recipient@example.com", subject: "Hello", content: { text: "Hello from Upyo." }, }); const receipt = await transport.send(message); if (!receipt.successful) { console.error(receipt.errorMessages.join(", ")); console.error("Attempts:", receipt.attempts); } ``` By default, retry transport makes up to three total attempts, waits with exponential backoff, caps computed delays at 30 seconds, and applies full jitter to computed backoff delays. ## Retry classification Retry transport uses the final failed receipt from each attempt to decide whether another attempt should be made. It retries when the receipt or one of its structured errors is marked retryable. This includes common transient HTTP statuses such as `429`, `408`, and `5xx` when transports expose them as structured receipt errors. If a wrapped transport throws a transient error instead of returning a failed receipt, retry transport retries it using the same classifier used by *@upyo/core*. After all attempts are exhausted, thrown delivery failures are converted into a failed receipt. Caller cancellation errors are rethrown. You can override classification with `shouldRetry` when a provider needs application-specific logic: ```typescript twoslash import { MockTransport } from "@upyo/mock"; import { RetryTransport } from "@upyo/retry"; const baseTransport = new MockTransport(); const transport = new RetryTransport(baseTransport, { shouldRetry(failure) { if (failure.kind === "receipt") { return failure.receipt.errorMessages.some((message) => message.includes("temporary") ); } return failure.error instanceof TypeError; }, }); ``` ## Backoff and `Retry-After` Computed retry delays use exponential backoff: `baseDelayMilliseconds * factor ^ (attempt - 1)` : The delay before the next attempt, capped by `maxDelayMilliseconds`. `jitter` : `"full"` by default. Set it to `false` or `"none"` for deterministic computed delays. `Retry-After` : When a structured receipt error includes `retryAfterMilliseconds`, retry transport uses that provider-supplied delay before computed backoff. The delay is still capped by `maxDelayMilliseconds`. Tests or host environments can replace waiting by passing a custom `wait` function: ```typescript twoslash import { MockTransport } from "@upyo/mock"; import { RetryTransport } from "@upyo/retry"; const delays: number[] = []; const baseTransport = new MockTransport(); const transport = new RetryTransport(baseTransport, { jitter: false, wait(context, signal) { signal?.throwIfAborted(); delays.push(context.delayMilliseconds); return Promise.resolve(); }, }); ``` ## `sendMany()` throttling `sendMany()` retries each message independently by calling the wrapped transport's `send()` method for each input message. This means provider-native batch APIs are not used through retry transport. Use the provider transport directly when its batch API semantics matter more than per-message retry. For bulk sends, configure `maxConcurrent` and `intervalMilliseconds` to limit how aggressively messages are launched: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MockTransport } from "@upyo/mock"; import { RetryTransport } from "@upyo/retry"; const baseTransport = new MockTransport(); const transport = new RetryTransport(baseTransport, { maxAttempts: 3, sendMany: { maxConcurrent: 4, intervalMilliseconds: 250, }, }); const messages = [ createMessage({ from: "sender@example.com", to: "one@example.com", subject: "One", content: { text: "First message." }, }), createMessage({ from: "sender@example.com", to: "two@example.com", subject: "Two", content: { text: "Second message." }, }), ]; for await (const receipt of transport.sendMany(messages)) { console.log(receipt.successful); } ``` Receipts are yielded in the same order as input messages, even when later messages finish first. ## Composition Retry transport composes with other decorators because it preserves the standard Upyo transport interface. Put it closest to the provider when you want provider-level retries before another decorator observes or aggregates the result: ```typescript twoslash import { MockTransport } from "@upyo/mock"; import { createRetryTransport } from "@upyo/retry"; const providerTransport = new MockTransport(); const transport = createRetryTransport(providerTransport, { maxAttempts: 4, }); ``` When wrapping disposable transports, `RetryTransport` forwards async disposal to the wrapped transport. If the wrapped transport only supports synchronous disposal, that is used as a fallback. --- --- url: /transports/logtape.md description: >- Structured LogTape logging for Upyo email delivery, with log-only and decorator modes, configurable categories and levels, and privacy controls. --- # LogTape transport *This transport is introduced in Upyo 0.6.0.* The LogTape transport records email delivery lifecycle events as structured [LogTape] logs. It can be used by itself during local development, where no message is actually delivered, or as a decorator around another transport. In decorator mode, the wrapped transport still performs delivery and its receipts and errors pass through unchanged. Upyo does not configure LogTape on behalf of the application. This follows LogTape's library-first design and leaves sinks, filters, and category levels under application control. [LogTape]: https://logtape.org/ ## Installation Install *@upyo/logtape* together with LogTape: ::: code-group ```bash [npm] npm add @upyo/logtape @logtape/logtape ``` ```bash [pnpm] pnpm add @upyo/logtape @logtape/logtape ``` ```bash [Yarn] yarn add @upyo/logtape @logtape/logtape ``` ```bash [Deno] deno add jsr:@upyo/logtape jsr:@logtape/logtape ``` ```bash [Bun] bun add @upyo/logtape @logtape/logtape ``` ::: ## Log-only usage With no wrapped transport, `LogTapeTransport` records the send and returns a synthetic successful receipt. This is useful for exercising email workflows without sending real messages: ```typescript twoslash import { configure, getConsoleSink } from "@logtape/logtape"; import { createMessage } from "@upyo/core"; import { LogTapeTransport } from "@upyo/logtape"; await configure({ sinks: { console: getConsoleSink() }, loggers: [ { category: ["upyo"], lowestLevel: "debug", sinks: ["console"] }, ], }); const transport = new LogTapeTransport(); const message = createMessage({ from: "sender@example.com", to: "recipient@example.net", subject: "Welcome", content: { text: "Welcome to our service." }, }); const receipt = await transport.send(message); if (receipt.successful) { console.log(receipt.messageId); // "logtape-..." } ``` The default category is `["upyo"]`. A log-only receipt uses the provider id `"logtape"` and a generated message id, but it does not imply that an email was delivered outside the application. ## Decorating another transport Pass another Upyo transport through the `transport` option to add logs around real delivery: ```typescript twoslash import { LogTapeTransport } from "@upyo/logtape"; import { SmtpTransport } from "@upyo/smtp"; const smtp = new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, auth: { user: "smtp-user@example.com", pass: "smtp-password", }, }); const transport = new LogTapeTransport({ transport: smtp, category: ["application", "email"], }); ``` The decorator preserves the wrapped provider id, forwards `AbortSignal`, uses the wrapped `sendMany()` implementation, and passes successful and failed receipts through unchanged. Exceptions are logged with the original error object and then rethrown. Explicit disposal is also forwarded to disposable wrapped transports. Completion logs are emitted as callers consume receipts. If a caller stops reading a batch early, the wrapped iterator is closed without being drained so the logging layer does not start additional delivery work. LogTape transport can be nested with retry, pool, and OpenTelemetry transports. The outer decorator observes the complete operation performed by the inner transport, so choose the order according to which retries or failovers should appear as one logged operation. ## Categories and levels The lifecycle levels can be changed independently: ```typescript twoslash import { LogTapeTransport } from "@upyo/logtape"; const transport = new LogTapeTransport({ category: ["my-app", "outbound-email"], levels: { sending: "trace", sent: "info", failed: "fatal", }, }); ``` `sending` : Level for `email.sending` events. Defaults to `"debug"`. `sent` : Level for `email.sent` events. Defaults to `"info"`. `failed` : Level for failed receipts and thrown errors. Defaults to `"error"`. Every event includes the operation, transport id, recipient counts, attachment count, and priority. Completion events additionally include duration and receipt or error details. ## Recording message content Messages are excluded from logs by default. The `recordMessage` option has two modes: `"properties"` : Adds the complete `Message` object to the structured properties of every lifecycle event. The log message itself remains on one line. `"inline"` : Adds the same `message` property and renders the subject and body beneath the lifecycle message. This format is convenient when reading local development logs. Use `"properties"` when a sink will process the message as structured data: ```typescript twoslash import { LogTapeTransport } from "@upyo/logtape"; const transport = new LogTapeTransport({ recordMessage: "properties", }); ``` Use `"inline"` to include the subject and body in the rendered log output: ```typescript twoslash import { LogTapeTransport } from "@upyo/logtape"; const transport = new LogTapeTransport({ recordMessage: "inline", }); ``` Inline mode uses plain text whenever the `text` property is defined, even when it is an empty string. If no plain-text body is defined, it uses the HTML body. The subject and body remain LogTape placeholders instead of being combined into a string before logging, so sinks retain control over value rendering and redaction. Lifecycle errors without an associated message stay on one line. > \[!CAUTION] > Both modes expose complete messages. These can contain personal addresses, > subjects, email bodies, custom headers, and large attachment data. Enable > either mode only for sinks with suitable access controls and [redaction]. [redaction]: https://logtape.org/manual/redaction --- --- url: /transports/opentelemetry.md description: >- Complete guide to OpenTelemetry observability for Upyo transports, including distributed tracing, metrics collection, error classification, and performance monitoring. --- # OpenTelemetry *This transport is introduced in Upyo 0.2.0.* [OpenTelemetry] is the leading open source observability framework for cloud-native software, providing comprehensive standards for collecting, processing, and exporting telemetry data including traces, metrics, and logs. OpenTelemetry enables you to instrument your applications with vendor-neutral observability, allowing you to monitor performance, track distributed requests, and analyze system behavior across different environments and observability backends. Upyo provides seamless OpenTelemetry integration through the *@upyo/opentelemetry* package, which acts as a decorator around any existing email transport to add automatic tracing and metrics collection without requiring code changes. This zero-configuration observability makes it easy to monitor email delivery performance, track failures, and analyze usage patterns across your application. > \[!TIP] > The OpenTelemetry transport is a decorator that wraps existing transports, > meaning you can add observability to any Upyo transport (SMTP, Mailgun, > SendGrid, etc.) by simply wrapping it with the OpenTelemetry transport. > This approach preserves all existing functionality while adding comprehensive > monitoring capabilities. [OpenTelemetry]: https://opentelemetry.io/ ## Installation To use OpenTelemetry observability with Upyo, you need to install the *@upyo/opentelemetry* package along with the OpenTelemetry API: ::: code-group ```sh [npm] npm add @upyo/opentelemetry @opentelemetry/api ``` ```sh [pnpm] pnpm add @upyo/opentelemetry @opentelemetry/api ``` ```sh [Yarn] yarn add @upyo/opentelemetry @opentelemetry/api ``` ```sh [Deno] deno add jsr:@upyo/opentelemetry ``` ```sh [Bun] bun add @upyo/opentelemetry @opentelemetry/api ``` ::: ## Basic usage The OpenTelemetry transport wraps any existing Upyo transport to add automatic observability. You'll typically create your base transport first, then wrap it with the OpenTelemetry transport to enable monitoring: ```typescript twoslash import { trace, metrics } from "@opentelemetry/api"; import { createMessage } from "@upyo/core"; import { MailgunTransport } from "@upyo/mailgun"; import { OpenTelemetryTransport } from "@upyo/opentelemetry"; // Create your base transport const baseTransport = new MailgunTransport({ apiKey: "your-mailgun-api-key", domain: "mg.example.com", region: "us", }); // Wrap with OpenTelemetry observability const transport = new OpenTelemetryTransport(baseTransport, { tracerProvider: trace.getTracerProvider(), meterProvider: metrics.getMeterProvider(), metrics: { enabled: true }, tracing: { enabled: true }, }); const message = createMessage({ from: "system@example.com", to: "user@example.com", subject: "Account Created", content: { text: "Welcome to our platform!" }, }); 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(", ")); } // Clean up resources when done await transport[Symbol.asyncDispose](); ``` The wrapped transport behaves identically to the base transport while automatically generating traces and metrics for every email operation. These telemetry data points are sent to your configured OpenTelemetry backend for analysis and monitoring. ## Simplified setup For easier configuration, use the factory function which automatically configures providers and applies sensible defaults: ```typescript twoslash import { createOpenTelemetryTransport } from "@upyo/opentelemetry"; import { SmtpTransport } from "@upyo/smtp"; const baseTransport = new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, auth: { user: "smtp-user@example.com", pass: "smtp-password", }, }); const transport = createOpenTelemetryTransport(baseTransport, { serviceName: "email-service", serviceVersion: "1.2.0", metrics: { enabled: true, prefix: "myapp", }, tracing: { enabled: true, recordSensitiveData: false, }, }); ``` The factory function uses global OpenTelemetry providers by default, which works well with most OpenTelemetry SDK configurations. You can also provide custom providers if you need specific configurations for your observability setup. ## Using existing providers If your application already has OpenTelemetry configured with custom providers, you can pass them directly to the transport configuration. This is the most common pattern in production applications where observability is set up at the application level: ```typescript twoslash import { Resource } from "@opentelemetry/resources"; import { MeterProvider } from "@opentelemetry/sdk-metrics"; import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION, } from "@opentelemetry/semantic-conventions"; import { SmtpTransport } from "@upyo/smtp"; import { createOpenTelemetryTransport, OpenTelemetryTransport, } from "@upyo/opentelemetry"; // Your application's existing OpenTelemetry setup const tracerProvider = new BasicTracerProvider({ resource: new Resource({ [SEMRESATTRS_SERVICE_NAME]: "my-application", [SEMRESATTRS_SERVICE_VERSION]: "1.0.0", }), }); const meterProvider = new MeterProvider({ resource: new Resource({ [SEMRESATTRS_SERVICE_NAME]: "my-application", [SEMRESATTRS_SERVICE_VERSION]: "1.0.0", }), }); // Use your existing providers with the email transport const transport = createOpenTelemetryTransport( new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, auth: { user: "user", pass: "password" }, }), { tracerProvider, // Use your existing tracer provider meterProvider, // Use your existing meter provider serviceName: "my-application", serviceVersion: "1.0.0", tracing: { enabled: true, recordSensitiveData: false, }, metrics: { enabled: true, prefix: "myapp", // Custom prefix to match your naming convention }, } ); // Alternatively, use the class constructor directly const directTransport = new OpenTelemetryTransport( new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, auth: { user: "user", pass: "password" }, }), { tracerProvider, // Your existing providers meterProvider, tracing: { enabled: true, recordSensitiveData: false }, metrics: { enabled: true, prefix: "myapp" }, } ); ``` This approach ensures that email telemetry data appears alongside other application telemetry in your existing observability infrastructure, maintaining consistent resource attributes and following your established naming conventions. ## Automatic resource management The OpenTelemetry transport supports automatic resource cleanup using the `await using` statement, which ensures proper disposal of both the observability components and the wrapped transport: ```typescript twoslash import { createOpenTelemetryTransport } from "@upyo/opentelemetry"; import { SmtpTransport } from "@upyo/smtp"; import { createMessage } from "@upyo/core"; await using transport = createOpenTelemetryTransport( new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, auth: { user: "user", pass: "password" }, }), { serviceName: "notification-service", tracing: { enabled: true }, metrics: { enabled: true }, } ); const message = createMessage({ from: "notifications@example.com", to: "customer@example.com", subject: "Order Confirmation", content: { text: "Your order has been confirmed." }, }); await transport.send(message); // Both OpenTelemetry components and SMTP transport are automatically disposed ``` This approach ensures that connection pools, observability exporters, and other resources are properly cleaned up even if errors occur during email sending operations. ## Distributed tracing One of OpenTelemetry's most powerful features is distributed tracing, which tracks requests across multiple services and components. The email transport automatically participates in distributed traces by creating child spans that inherit the current trace context: ```typescript twoslash import { trace } from "@opentelemetry/api"; import { createMessage } from "@upyo/core"; import { MailgunTransport } from "@upyo/mailgun"; import { createOpenTelemetryTransport } from "@upyo/opentelemetry"; const transport = createOpenTelemetryTransport( new MailgunTransport({ apiKey: "your-api-key", domain: "mg.example.com", }), { serviceName: "user-service", tracing: { enabled: true, recordSensitiveData: false, }, } ); // Example of a distributed trace across multiple operations const tracer = trace.getTracer("user-registration"); await tracer.startActiveSpan("user-registration", async (span) => { try { // Simulate user creation logic span.setAttributes({ "user.id": "12345", "user.email": "newuser@example.com", }); // Send welcome email - this automatically becomes a child span const message = createMessage({ from: "welcome@example.com", to: "newuser@example.com", subject: "Welcome to our platform", content: { text: "Thank you for joining us!" }, }); await transport.send(message); span.setStatus({ code: 1 }); // OK } catch (error) { if (error instanceof Error) { span.recordException(error); } span.setStatus({ code: 2, message: String(error) }); // ERROR throw error; } finally { span.end(); } }); ``` The email sending operation appears as a child span in your distributed trace, showing its relationship to the broader user registration flow. This makes it easy to understand how email delivery affects overall request performance and to identify bottlenecks in your system. ## Metrics and monitoring The OpenTelemetry transport automatically collects comprehensive metrics about email operations, including delivery rates, latency, message sizes, and error categorization. These metrics are essential for monitoring email system health and performance: ```typescript twoslash import { createOpenTelemetryTransport } from "@upyo/opentelemetry"; import { SendGridTransport } from "@upyo/sendgrid"; const transport = createOpenTelemetryTransport( new SendGridTransport({ apiKey: "your-sendgrid-api-key", }), { serviceName: "marketing-service", metrics: { enabled: true, prefix: "marketing", samplingRate: 1.0, durationBuckets: [0.1, 0.5, 1.0, 2.0, 5.0, 10.0], }, tracing: { enabled: true, samplingRate: 0.1, // Sample 10% of traces }, } ); ``` The transport collects the following key metrics: Email delivery counters : Track successful and failed send attempts Duration histograms : Measure how long email operations take Message size histograms : Monitor email size distribution Active operation gauges : Track concurrent email sending operations Error classification : Categorize failures by type (auth, network, etc.) These metrics are exported to your configured observability backend (Prometheus, DataDog, etc.) where you can create dashboards and alerts to monitor your email system's health. ## Error classification and analysis The OpenTelemetry transport includes intelligent error classification that automatically categorizes email failures into meaningful groups. This helps you quickly identify and respond to different types of issues: ```typescript twoslash import { createOpenTelemetryTransport, createErrorClassifier } from "@upyo/opentelemetry"; import { MailgunTransport } from "@upyo/mailgun"; // Custom error classifier for your specific needs const customClassifier = createErrorClassifier({ patterns: { "spam_filter": /blocked.*spam|spam.*detected|reputation/i, "bounce": /bounce|undeliverable|invalid.*recipient/i, "quota_exceeded": /quota.*exceeded|mailbox.*full/i, "temporary_failure": /temporary.*failure|try.*again.*later/i, }, fallback: "email_error", }); const transport = createOpenTelemetryTransport( new MailgunTransport({ apiKey: "your-api-key", domain: "mg.example.com", }), { serviceName: "notification-service", errorClassifier: customClassifier, metrics: { enabled: true }, tracing: { enabled: true }, } ); ``` Error classifications appear in both metrics and trace data, allowing you to: * Monitor error rates by category in dashboards * Set up targeted alerts for specific error types * Analyze error patterns across different email types * Identify systematic issues vs. temporary problems The default classifier recognizes common email error patterns including authentication failures, rate limiting, network issues, validation errors, and server problems. ## Custom attributes and context You can enhance telemetry data with custom attributes that provide additional context about your email operations. This is particularly useful for tracking business metrics alongside technical metrics: ```typescript twoslash import { createOpenTelemetryTransport, createEmailAttributeExtractor } from "@upyo/opentelemetry"; import { SmtpTransport } from "@upyo/smtp"; const customExtractor = createEmailAttributeExtractor("smtp", { recordSensitiveData: false, transportVersion: "1.0.0", customAttributes: (operation, transportName, messageCount, totalSize) => ({ "app.version": "2.1.0", "app.environment": process.env.NODE_ENV || "development", "deployment.id": process.env.DEPLOYMENT_ID || "unknown", "email.campaign.type": "transactional", // Custom business context "email.priority": "high", }), }); const transport = createOpenTelemetryTransport( new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, auth: { user: "smtp-user", pass: "smtp-password" }, }), { serviceName: "transactional-email", attributeExtractor: customExtractor, tracing: { enabled: true }, metrics: { enabled: true }, } ); ``` Custom attributes appear in both traces and metrics, enabling you to: * Filter and group telemetry data by business context * Correlate email performance with deployment versions * Track different email campaigns or types separately * Add environment-specific context for debugging ## Bulk email monitoring For applications that send large volumes of emails, the OpenTelemetry transport provides specialized monitoring for batch operations with detailed performance tracking and error analysis: ```typescript twoslash /** * A hypothetical type representing a newsletter subscriber. */ interface Subscriber { /** * The email address of the subscriber. */ email: string; /** * The segment grouping of the subscriber, such as "premium", "free", or "trial". */ segment: "premium" | "free" | "trial"; } /** * A hypothetical function to retrieve newsletter subscribers. */ function getNewsletterSubscribers(): Promise { return Promise.resolve([]); } /** * A hypothetical function to generate HTML content for a newsletter. */ function generateNewsletterHtml(subscriber: Subscriber): string { return "" } /** * A hypothetical function to generate plain text content for a newsletter. */ function generateNewsletterText(subscriber: Subscriber): string { return "" } // ---cut-before--- import { createOpenTelemetryTransport } from "@upyo/opentelemetry"; import { MailgunTransport } from "@upyo/mailgun"; import { createMessage } from "@upyo/core"; const transport = createOpenTelemetryTransport( new MailgunTransport({ apiKey: "your-api-key", domain: "mg.example.com", retries: 3, }), { serviceName: "newsletter-service", metrics: { enabled: true, samplingRate: 1.0, // Monitor all bulk operations }, tracing: { enabled: true, samplingRate: 0.01, // Sample 1% of individual messages }, } ); // Generate newsletter messages for subscribers const subscribers = await getNewsletterSubscribers(); const messages = subscribers.map(subscriber => createMessage({ from: "newsletter@example.com", to: subscriber.email, subject: "Weekly Update - December 2024", content: { html: generateNewsletterHtml(subscriber), text: generateNewsletterText(subscriber), }, tags: ["newsletter", "weekly", subscriber.segment], }) ); // Send with comprehensive monitoring let successCount = 0; let failureCount = 0; for await (const receipt of transport.sendMany(messages)) { if (receipt.successful) { successCount++; } else { failureCount++; console.error(`Failed to send to ${receipt.errorMessages.join(", ")}`); } } console.log(`Newsletter sent: ${successCount} successful, ${failureCount} failed`); ``` Batch operations generate additional metrics including: Batch size tracking : Monitor the distribution of batch sizes Success/failure ratios : Track delivery rates across batches Processing duration : Measure how long large batches take Partial failure analysis : Understand patterns in batch failures This information helps you optimize batch processing, identify optimal batch sizes, and detect issues that affect bulk email delivery. ## Performance optimization The OpenTelemetry transport includes several features to minimize performance impact while providing comprehensive observability. You can tune sampling rates and feature toggles based on your monitoring needs: ```typescript twoslash import { createOpenTelemetryTransport } from "@upyo/opentelemetry"; import { SmtpTransport } from "@upyo/smtp"; // Production configuration with optimized performance const transport = createOpenTelemetryTransport( new SmtpTransport({ host: "smtp.example.com", port: 587, secure: false, auth: { user: "user", pass: "password" }, pool: true, poolSize: 10, }), { serviceName: "production-email", metrics: { enabled: true, samplingRate: 1.0, // Always collect metrics prefix: "prod", }, tracing: { enabled: true, samplingRate: 0.05, // Sample 5% of traces recordSensitiveData: false, // Optimize for privacy and performance }, } ); // High-throughput configuration for bulk operations const bulkTransport = createOpenTelemetryTransport( new SmtpTransport({ host: "bulk-smtp.example.com", port: 587, secure: false, auth: { user: "bulk-user", pass: "bulk-password" }, pool: true, poolSize: 20, }), { serviceName: "bulk-email", metrics: { enabled: true }, tracing: { enabled: false }, // Disable tracing for maximum performance } ); ``` Performance considerations for different scenarios: High-frequency transactional emails : Enable metrics, sample traces at 1–5% Bulk campaigns : Focus on metrics, disable or heavily sample tracing Development environments : Enable full observability for debugging Testing : Disable observability or use mock backends ## Development and testing For development and testing environments, you can configure the OpenTelemetry transport to provide comprehensive observability without affecting external monitoring systems: ```typescript twoslash import { createOpenTelemetryTransport } from "@upyo/opentelemetry"; import { MockTransport } from "@upyo/mock"; // Development configuration with full observability const devTransport = createOpenTelemetryTransport( new MockTransport({ failureRate: 0, delay: 100, }), { serviceName: "email-service-dev", serviceVersion: "dev", metrics: { enabled: true, samplingRate: 1.0, }, tracing: { enabled: true, samplingRate: 1.0, recordSensitiveData: true, // OK for development }, } ); // Testing configuration with observability validation const testTransport = createOpenTelemetryTransport( new MockTransport({ failureRate: 0 }), { serviceName: "email-service-test", metrics: { enabled: true }, tracing: { enabled: true }, errorClassifier: (error) => { if (error instanceof Error) { // Custom test error classification if (error.message.includes("test-auth-failure")) return "auth"; if (error.message.includes("test-rate-limit")) return "rate-limit"; } return "test_error"; }, } ); ``` > \[!TIP] > When testing OpenTelemetry integration, consider using in-memory exporters > or console exporters to validate that telemetry data is being generated > correctly without requiring external observability infrastructure. > The OpenTelemetry SDK provides excellent testing utilities for validating > trace and metric data in your test suites. The observability data from development and testing helps you: * Validate that instrumentation is working correctly * Test error handling and classification logic * Verify that custom attributes are being applied properly * Optimize observability configuration before production deployment --- --- url: /transports/mock.md description: >- Testing utility transport that simulates email sending without delivery, featuring message inspection, failure simulation, and async testing patterns. --- # Mock transport The mock transport is a specialized testing utility that simulates email sending without actually delivering messages. Instead of connecting to email servers or APIs, it stores all “sent” messages in memory where they can be inspected, verified, and manipulated during testing. This makes it invaluable for unit testing, integration testing, and development workflows where you need to verify email functionality without sending real emails. Upyo provides a comprehensive mock transport through the *@upyo/mock* package, offering configurable behavior simulation, message querying capabilities, async testing utilities, and full compatibility with all Upyo transport features. ## Installation To use the mock transport, you need to install the *@upyo/mock* package: ::: code-group ```sh [npm] npm add @upyo/mock ``` ```sh [pnpm] pnpm add @upyo/mock ``` ```sh [Yarn] yarn add @upyo/mock ``` ```sh [Deno] deno add jsr:@upyo/mock ``` ```sh [Bun] bun add @upyo/mock ``` ::: ## Basic testing The mock transport implements the same `Transport` interface as all other Upyo transports, making it a drop-in replacement for testing purposes. You can swap out real transports with the mock transport in your tests without changing any other code: ```typescript twoslash import { MockTransport } from "@upyo/mock"; import { createMessage } from "@upyo/core"; // Create a basic mock transport const transport = new MockTransport(); const message = createMessage({ from: "test-sender@example.com", to: "test-recipient@example.com", subject: "Test Email", content: { text: "This is a test email for verification." }, }); // "Send" the email (stored in memory) const receipt = await transport.send(message); // Verify the operation succeeded console.log(receipt.successful); // true if (receipt.successful) { console.log(receipt.messageId); // "mock-message-1" } // Inspect what was "sent" const sentMessages = transport.getSentMessages(); console.log(sentMessages.length); // 1 console.log(sentMessages[0].subject); // "Test Email" console.log(sentMessages[0].recipients[0].address); // "test-recipient@example.com" ``` The mock transport generates unique message IDs automatically and tracks all sent messages, making it easy to verify that your email logic works correctly without any external dependencies. ## Simulating realistic behavior Real email services have network delays, rate limits, and occasional failures. The mock transport can simulate these conditions to make your tests more realistic and help you build robust error handling: ```typescript twoslash import type { Message } from "@upyo/core"; const message = {} as unknown as Message; // ---cut-before--- import { MockTransport } from "@upyo/mock"; // Configure realistic behavior simulation const transport = new MockTransport({ // Simulate network delay (100ms fixed) delay: 100, // Or use random delays for more realistic testing randomDelayRange: { min: 50, max: 200 }, // Simulate random failures (10% failure rate) failureRate: 0.1, // Custom message ID generation generateUniqueMessageIds: true, }); // Test your error handling try { const receipt = await transport.send(message); if (receipt.successful) { console.log("Email sent successfully"); } else { console.log("Email failed:", receipt.errorMessages); } } catch (error) { console.log("Network error:", String(error)); } ``` Delay simulation helps test timeout handling and ensures your application can handle slower network conditions. Failure simulation verifies that your error handling code works correctly when email services are unavailable. ## Testing specific failure scenarios For testing specific error conditions, you can configure the mock transport to fail in controlled ways. This is essential for verifying that your application handles various email service errors gracefully: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MockTransport } from "@upyo/mock"; const transport = new MockTransport(); // Test authentication failure transport.setNextResponse({ successful: false, errorMessages: ["Authentication failed: Invalid API key"] }); const authFailMessage = createMessage({ from: "test@example.com", to: "user@example.com", subject: "Auth Test", content: { text: "Testing auth failure" }, }); const authResult = await transport.send(authFailMessage); console.log(authResult.successful); // false if (!authResult.successful) { console.log(authResult.errorMessages); // ["Authentication failed: Invalid API key"] } // Test rate limiting transport.setNextResponse({ successful: false, errorMessages: ["Rate limit exceeded: Too many requests"] }); const rateLimitResult = await transport.send(authFailMessage); if (!rateLimitResult.successful) { console.log(rateLimitResult.errorMessages); // ["Rate limit exceeded: Too many requests"] } // Next send will use default (successful) behavior const normalResult = await transport.send(authFailMessage); console.log(normalResult.successful); // true ``` The `setNextResponse()` method affects only the next send operation, making it perfect for testing specific failure scenarios while keeping the rest of your test using normal behavior. ## Message verification and querying A key feature of the mock transport is its ability to inspect and verify sent messages. This goes beyond just counting messages—you can search by recipient, subject, content, and custom criteria: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MockTransport } from "@upyo/mock"; const transport = new MockTransport(); // Send different types of messages await transport.send(createMessage({ from: "support@example.com", to: "user1@example.com", subject: "Welcome to our service", content: { text: "Welcome! Thanks for signing up." }, tags: ["onboarding", "welcome"], })); await transport.send(createMessage({ from: "billing@example.com", to: "user1@example.com", subject: "Invoice #1234", content: { text: "Your monthly invoice is ready." }, tags: ["billing", "invoice"], })); await transport.send(createMessage({ from: "support@example.com", to: "user2@example.com", subject: "Welcome to our service", content: { text: "Welcome! Thanks for signing up." }, tags: ["onboarding", "welcome"], })); // Query messages by recipient const user1Messages = transport.getMessagesTo("user1@example.com"); console.log(user1Messages.length); // 2 // Query by subject const welcomeMessages = transport.getMessagesBySubject("Welcome to our service"); console.log(welcomeMessages.length); // 2 // Custom filtering with predicates const billingMessages = transport.findMessagesBy(msg => msg.tags.includes("billing") ); console.log(billingMessages.length); // 1 const supportMessages = transport.findMessagesBy(msg => msg.sender.address === "support@example.com" ); console.log(supportMessages.length); // 2 // Find specific message const invoice = transport.findMessageBy(msg => msg.subject.includes("Invoice") && msg.recipients.some(r => r.address === "user1@example.com") ); console.log(invoice?.subject); // "Invoice #1234" ``` These querying capabilities make it easy to write comprehensive tests that verify not just that emails were sent, but that the right emails were sent to the right recipients with the correct content. ## Async testing patterns Many email workflows are asynchronous, such as sending emails after user registration or periodic notifications. The mock transport provides utilities for testing these async patterns effectively: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MockTransport } from "@upyo/mock"; const transport = new MockTransport(); // Simulate an async user registration flow async function registerUser(email: string, name: string) { // ... registration logic ... // Send welcome email asynchronously setTimeout(async () => { await transport.send(createMessage({ from: "welcome@example.com", to: email, subject: `Welcome ${name}!`, content: { text: `Hi ${name}, welcome to our platform!` }, })); }, 100); // Send verification email asynchronously setTimeout(async () => { await transport.send(createMessage({ from: "verify@example.com", to: email, subject: "Please verify your email", content: { text: "Click here to verify your email address." }, })); }, 200); } // Test the async workflow await registerUser("newuser@example.com", "John"); // Wait for both emails to be sent await transport.waitForMessageCount(2, 5000); // 5 second timeout // Verify the emails were sent correctly const welcomeEmail = await transport.waitForMessage( msg => msg.subject.includes("Welcome") && msg.recipients.some(r => r.address === "newuser@example.com"), 3000 // 3 second timeout ); console.log(welcomeEmail.subject); // "Welcome John!" const verificationEmail = await transport.waitForMessage( msg => msg.subject.includes("verify"), 3000 ); console.log(verificationEmail.subject); // "Please verify your email" ``` The `waitForMessageCount()` and `waitForMessage()` methods are essential for testing async email workflows. They prevent race conditions in tests and ensure reliable verification of async behavior. ## Bulk email testing For applications that send newsletters, notifications, or other bulk emails, the mock transport efficiently handles large message volumes while providing detailed verification capabilities: ```typescript twoslash import { MockTransport } from "@upyo/mock"; import { createMessage } from "@upyo/core"; const transport = new MockTransport(); // Simulate bulk newsletter sending const subscribers = [ "alice@example.com", "bob@example.com", "charlie@example.com", "diana@example.com", ]; const newsletterMessages = subscribers.map(email => createMessage({ from: "newsletter@example.com", to: email, subject: "Monthly Newsletter - December 2024", content: { html: "

This Month's Updates

Here's what's new...

", text: "This Month's Updates\n\nHere's what's new...", }, tags: ["newsletter", "monthly", "december-2024"], }) ); // Send all newsletters const receipts: any[] = []; for await (const receipt of transport.sendMany(newsletterMessages)) { receipts.push(receipt); if (!receipt.successful) { console.error(`Failed to send to recipient: ${receipt.errorMessages}`); } } // Verify bulk sending results console.log(`Sent ${receipts.length} newsletters`); console.log(`Successfully sent: ${receipts.filter(r => r.successful).length}`); console.log(`Failed: ${receipts.filter(r => !r.successful).length}`); // Verify all subscribers received the newsletter for (const email of subscribers) { const userNewsletters = transport.getMessagesTo(email); console.log(`${email}: ${userNewsletters.length} newsletters`); } // Check newsletter content and tagging const allNewsletters = transport.findMessagesBy(msg => msg.tags.includes("newsletter") ); console.log(`Total newsletters in system: ${allNewsletters.length}`); ``` The mock transport handles bulk sending efficiently and provides detailed verification of each message, making it perfect for testing newsletter systems, notification broadcasts, and other high-volume email features. ## Test cleanup and isolation When running multiple tests, it's important to ensure that each test starts with a clean state. The mock transport provides several methods for managing test isolation: ```typescript twoslash import { MockTransport } from "@upyo/mock"; import { afterEach, beforeEach, describe, test } from "node:test"; // Example test setup describe("Email functionality", () => { let transport: MockTransport; beforeEach(() => { // Create fresh transport for each test transport = new MockTransport(); }); afterEach(() => { // Clean up between tests transport.reset(); // Clears messages and resets configuration }); test("user registration sends welcome email", async () => { // ... test implementation ... // Verify clean starting state console.log(transport.getSentMessagesCount()); // 0 // ... send emails ... // Verify test results const messages = transport.getSentMessages(); // ... assertions ... }); test("password reset sends notification", async () => { // This test starts with empty message history console.log(transport.getSentMessagesCount()); // 0 // ... test implementation ... }); }); // Alternative: selective cleanup function cleanupTransport(transport: MockTransport) { // Clear just the messages, keep configuration transport.clearSentMessages(); // Or reset everything to defaults transport.reset(); } ``` The `reset()` method clears all messages and returns the transport to its initial configuration, while `clearSentMessages()` removes only the message history while preserving any custom configuration like delays or failure rates. ## Integration with testing frameworks The mock transport integrates seamlessly with popular testing frameworks like Jest, Mocha, Vitest, and Deno's built-in test runner. Here's how to set it up for comprehensive email testing: ```typescript twoslash import { createMessage } from "@upyo/core"; import { MockTransport } from "@upyo/mock"; import assert from "node:assert/strict"; import { beforeEach, afterEach, test } from "node:test"; // Test utility functions function createTestTransport() { return new MockTransport(); } function createTestMessage(overrides: any = {}) { return createMessage({ from: "test@example.com", to: "user@example.com", subject: "Test Email", content: { text: "Test content" }, ...overrides, }); } // Example test suite let transport: MockTransport; beforeEach(() => { transport = createTestTransport(); }); afterEach(() => { transport.reset(); }); test("should send welcome email after user registration", async () => { // Arrange const userEmail = "newuser@example.com"; const welcomeMessage = createTestMessage({ to: userEmail, subject: "Welcome to our platform!", }); // Act const receipt = await transport.send(welcomeMessage); // Assert assert.ok(receipt.successful); assert.equal(transport.getSentMessagesCount(), 1); const sentMessage = transport.getLastSentMessage(); assert.equal(sentMessage?.recipients[0].address, userEmail); assert.ok(sentMessage?.subject.includes("Welcome")); }); test("should handle email sending failures gracefully", async () => { // Arrange transport.setNextResponse({ successful: false, errorMessages: ["SMTP server unavailable"], }); // Act const receipt = await transport.send(createTestMessage()); // Assert assert.equal(receipt.successful, false); assert.ok(receipt.errorMessages.includes("SMTP server unavailable")); // Message should still be tracked even when it "fails" assert.equal(transport.getSentMessagesCount(), 1); }); ``` This pattern provides a robust foundation for testing email functionality across your entire application, ensuring that emails are sent correctly and error conditions are handled appropriately. ## Development and debugging During development, the mock transport serves as an excellent debugging tool for understanding email flows and troubleshooting issues. You can inspect exactly what emails your application would send without cluttering real inboxes or hitting email service rate limits: ```typescript twoslash import type { Message } from "@upyo/core"; const message = {} as unknown as Message; // ---cut-before--- import { MockTransport } from "@upyo/mock"; // Development configuration with detailed logging const transport = new MockTransport({ delay: 0, // No delays for faster development failureRate: 0, // No random failures during development generateUniqueMessageIds: true, }); // Add development logging const originalSend = transport.send.bind(transport); transport.send = async function(message, options) { console.log("📧 Sending email:", { from: message.sender.address, to: message.recipients.map(r => r.address), subject: message.subject, tags: message.tags, }); const result = await originalSend(message, options); console.log("✉️ Email result:", { successful: result.successful, messageId: result.successful ? result.messageId : "failed", errors: result.successful ? [] : result.errorMessages, }); return result; }; // Use throughout your development workflow // All email sending will be logged and stored for inspection ``` > \[!TIP] > The mock transport is perfect for development environments where you want > to test email functionality without sending real emails. You can inspect > the `transport.getSentMessages()` output in your browser's developer console > or server logs to see exactly what emails your application generates. This approach gives you complete visibility into your application's email behavior during development, making it much easier to debug complex email workflows and ensure they work correctly before deploying to production. --- --- url: /transports/custom.md description: >- Guide to creating custom email transports for Upyo, covering the Transport interface, HTTP patterns, resource management, and best practices. --- # Custom transport A custom transport allows you to integrate any email service with Upyo by implementing the simple `Transport` interface. Whether you're connecting to a proprietary email API, adding specialized logging, or building testing utilities, custom transports provide the flexibility you need. Upyo's transport abstraction ensures that custom implementations work seamlessly alongside built-in transports like [SMTP](./smtp.md), [Mailgun](./mailgun.md), and [SendGrid](./sendgrid.md). Your application code remains unchanged when switching between different email providers. This guide walks you through the implementation patterns and best practices for creating robust, production-ready custom transports. ## When to create a custom transport Consider creating a custom transport when: * You need to integrate with an email service not supported by Upyo * Your organization has internal email systems with custom APIs * You require specialized behavior (logging, metrics, preprocessing) * You want to create transport for testing specific scenarios ## Understanding the `Transport` interface The `Transport` interface is the foundation of Upyo's email abstraction. It defines a simple contract that all email services must implement, ensuring consistent behavior across different providers. ```typescript twoslash import type { Message, Receipt, TransportOptions } from "@upyo/core"; export interface Transport { readonly id: TProviderId; send( message: Message, options?: TransportOptions, ): Promise>; sendMany( messages: Iterable | AsyncIterable, options?: TransportOptions, ): AsyncIterable>; } ``` This interface is intentionally minimal, with just two methods that handle the core email sending operations, plus a stable provider `id` used in structured receipt metadata. The design philosophy prioritizes simplicity and reliability over feature complexity. ### Core principles When implementing a custom transport, these principles ensure compatibility with the Upyo ecosystem: *Return receipts for delivery failures.* The sending methods report delivery failures through `Receipt` objects. Caller cancellation rejects instead. The optional `verify()` method also rejects on failure because it does not perform a delivery or produce a receipt. **Support cancellation through [`AbortSignal`].** Modern applications need the ability to cancel long-running operations. Check `options?.signal?.throwIfAborted()` at strategic points in your implementation, especially before expensive network operations. **Return descriptive receipts.** Success receipts should include a meaningful `messageId` that can be used for tracking and debugging. Failure receipts should provide specific `errorMessages` that help developers understand what went wrong. Use a string literal provider id, such as `Transport<"myservice">`, when you want `Receipt.provider` and `ReceiptError.provider` to be type-safe. [`AbortSignal`]: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal ## Basic HTTP transport example Most modern email services provide HTTP APIs for sending emails. This makes HTTP-based transports the most common type of custom implementation. Let's build a complete transport for a fictional service called “MyService” to demonstrate the key patterns. The example below shows all the essential components: configuration management, HTTP communication, proper error handling, and cancellation support. ```typescript twoslash import { createFailedReceipt, type Message, type Receipt, type Transport, type TransportOptions, } from "@upyo/core"; export interface MyServiceConfig { readonly apiKey: string; readonly baseUrl?: string; } interface MyServiceResponse { readonly messageId: string; } export class MyServiceTransport implements Transport<"myservice"> { readonly id = "myservice"; private config: Required; constructor(config: MyServiceConfig) { this.config = { apiKey: config.apiKey, baseUrl: config.baseUrl ?? "https://api.myservice.com/v1", }; } async send( message: Message, options?: TransportOptions, ): Promise> { // Check for cancellation options?.signal?.throwIfAborted(); try { // Convert message to API format const payload = { from: message.sender.address, to: message.recipients.map(r => r.address), subject: message.subject, text: message.content.text, html: "html" in message.content ? message.content.html : undefined, }; options?.signal?.throwIfAborted(); // Send via API const response = await fetch(`${this.config.baseUrl}/send`, { method: "POST", headers: { "Authorization": `Bearer ${this.config.apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify(payload), signal: options?.signal, }); if (!response.ok) { const error = await response.text(); return createFailedReceipt(`HTTP ${response.status}: ${error}`, { provider: this.id, statusCode: response.status, }); } const result = await response.json() as MyServiceResponse; return { successful: true, messageId: result.messageId, provider: this.id, }; } catch (error) { return createFailedReceipt( error instanceof Error ? error.message : String(error), { provider: this.id }, ); } } async *sendMany( messages: Iterable | AsyncIterable, options?: TransportOptions, ): AsyncIterable> { for await (const message of messages) { options?.signal?.throwIfAborted(); yield await this.send(message, options); } } } ``` Let's break down the key implementation details: **Configuration with defaults:** The constructor uses a simple pattern to provide sensible defaults while requiring only essential configuration. This makes the transport easy to use while remaining flexible. **Cancellation checking:** Notice how we check `options?.signal?.throwIfAborted()` at two critical points: before starting the operation and before making the network request. This ensures operations can be cancelled promptly. **Error conversion:** All errors are caught and converted to failed `Receipt` objects. This prevents exceptions from propagating and provides a consistent error handling experience. **HTTP error handling:** The code distinguishes between HTTP errors (4xx/5xx status codes) and network errors, providing specific error messages for each case. The `~Transport.sendMany()` implementation uses a simple pattern that delegates to the `~Transport.send()` method for each message. This approach is straightforward and works well for most HTTP APIs that don't support batch operations. ## Advanced patterns ### Resource cleanup Some transports need to manage persistent resources like connection pools, file handles, or background timers. Implementing the `AsyncDisposable` interface ensures proper cleanup and integrates with modern JavaScript resource management patterns. ```typescript {25-33} twoslash import { createFailedReceipt, type Message, type Receipt, type Transport, type TransportOptions, } from "@upyo/core"; export class MyTransport implements Transport<"myservice">, AsyncDisposable { readonly id = "myservice"; private connections: Array<{ close(): Promise }> = []; async send( message: Message, options?: TransportOptions, ): Promise> { options?.signal?.throwIfAborted(); return createFailedReceipt("No connection is available.", { provider: this.id, category: "configuration", retryable: false, }); } async *sendMany( messages: Iterable | AsyncIterable, options?: TransportOptions, ): AsyncIterable> { for await (const message of messages) { yield await this.send(message, options); } } async closeConnections(): Promise { await Promise.all(this.connections.map(conn => conn.close())); this.connections = []; } async [Symbol.asyncDispose](): Promise { await this.closeConnections(); } } // Usage with automatic cleanup await using transport = new MyTransport(); // Transport automatically cleaned up when scope ends ``` This pattern is particularly important for production deployments where resource leaks can cause memory issues or exhaust connection limits. The [`await using`] syntax automatically calls the disposal method when the transport goes out of scope, even if an exception occurs. [`await using`]: https://github.com/tc39/proposal-async-explicit-resource-management#await-using-declarations ### Retry logic Network operations can fail due to temporary issues like network congestion, server overload, or brief service outages. Implementing retry logic with exponential backoff makes your transport more resilient in production environments. ```typescript twoslash async function sendWithRetry( sendFn: () => Promise, maxRetries: number = 3 ): Promise { let lastError: Error; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const response = await sendFn(); // Don't retry client errors (4xx) if (response.status >= 400 && response.status < 500) { throw new Error(`Client error: ${response.status}`); } if (!response.ok) { throw new Error(`Server error: ${response.status}`); } return response; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt === maxRetries) { throw lastError; } // Exponential backoff: 1s, 2s, 4s... const delay = Math.pow(2, attempt) * 1000; await new Promise(resolve => setTimeout(resolve, delay)); } } throw lastError!; } ``` The retry function implements several important patterns: it avoids retrying client errors (4xx status codes) since these indicate problems with the request itself, uses exponential backoff to avoid overwhelming struggling servers, and provides a configurable maximum retry count to prevent infinite loops. > \[!CAUTION] > Never retry 4xx client errors as these indicate problems with your request > that won't be resolved by retrying. Only retry 5xx server errors and > network failures. ### Configuration validation Robust configuration validation prevents runtime errors and provides clear feedback when transports are misconfigured. This is especially important in production environments where configuration errors might not be discovered until the first email is sent. ```typescript twoslash export interface ApiConfig { readonly apiKey: string; readonly timeout?: number; } export function createApiConfig(config: ApiConfig): Required { if (!config.apiKey) { throw new Error("API key is required"); } if (config.timeout && config.timeout < 1000) { throw new Error("Timeout must be at least 1000ms"); } return { apiKey: config.apiKey, timeout: config.timeout ?? 30000, }; } ``` This validation approach uses a factory function that both validates input and applies defaults. Throwing errors during construction means configuration problems are discovered immediately, rather than when the first email is sent. ## Best practices Following these practices ensures your custom transport integrates well with the Upyo ecosystem and provides a reliable experience for users. ### Handle cancellation properly Cancellation support is essential for responsive applications. Users should be able to cancel email operations that are taking too long or are no longer needed. ```typescript // Check before expensive operations options?.signal?.throwIfAborted(); // Pass to network calls await fetch(url, { signal: options?.signal }); ``` Check for cancellation before starting expensive operations and pass the signal to any network calls. This ensures operations can be cancelled promptly and resources aren't wasted. ### Always return receipts, never throw Consistent error handling is a core principle of the `Transport` interface. Users should never have to catch exceptions from transport methods. ```typescript [✅ Good] try { const result = await sendEmail(); return { successful: true, messageId: result.id }; } catch (error) { return { successful: false, errorMessages: [error.message] }; } ``` ```typescript [❌ Bad: don't throw from send()] async send(message: Message): Promise { throw new Error("Something went wrong"); } ``` This approach provides predictable error handling and allows users to handle errors consistently across all transports. ### Use web standards for cross-runtime compatibility Upyo runs on Node.js, Deno, Bun, and edge functions. Using web standards ensures your transport works everywhere. ```typescript [✅ Good: works everywhere] globalThis.fetch() AbortController() setTimeout() ``` ```typescript [❌ Bad: Node.⁠js specific] import http from "node:http" ``` Avoid runtime-specific APIs and prefer web standards that are universally supported. ### Provide sensible configuration defaults Good defaults make your transport easy to use while still allowing customization when needed. ```typescript export function createConfig(config: Config): ResolvedConfig { return { ...config, timeout: config.timeout ?? 30000, retries: config.retries ?? 3, baseUrl: config.baseUrl ?? "https://api.example.com", }; } ``` Use factory functions to apply defaults and validate configuration. This pattern makes misconfiguration errors visible early and provides a better developer experience. With these patterns, your custom transport will integrate seamlessly with Upyo's ecosystem and provide a consistent, reliable experience for users. ## Sharing your transport If you've built a transport that others might find useful, consider sharing it with the community! There are several ways to make your custom transport available to other developers. ### Publishing as a package You can package and publish your transport as a standalone npm package or JSR module. When publishing, follow the naming convention *@yourorg/upyo-servicename* to make it easy for users to discover. Look at existing transport packages in the Upyo repository for reference on package structure, documentation, and testing patterns. Each transport package includes proper TypeScript definitions, comprehensive tests, and clear usage examples. Make sure to add *@upyo/core* as a peer dependency in your *package.json* rather than a regular dependency. This ensures users can control the core version and avoids potential version conflicts: ```json { "peerDependencies": { "@upyo/core": "^0.1.0" } } ``` ### Contributing to Upyo We welcome contributions of new transport packages to the main Upyo repository! If you've implemented a transport for a popular email service, don't hesitate to submit a pull request—we'd love to see what you've built. While we have established patterns like comprehensive test coverage, proper TypeScript types, clear documentation, and cross-runtime support, you don't need to have everything perfect before contributing. Our maintainers are happy to help you polish your implementation and bring it up to project standards. Feel free to open a draft PR early in your development process if you'd like feedback or guidance. Check the existing transport implementations for examples, but remember that we're here to help you succeed! ## Raw MIME delivery *This feature is introduced in Upyo 0.6.0.* `RawTransport` extends `Transport` with an optional `sendRaw()` capability. Use `isRawTransport()` before sending through a transport supplied by another component; decorators must explicitly expose this capability themselves. ```typescript twoslash import { isRawTransport, type Transport } from "@upyo/core"; declare const transport: Transport; if (isRawTransport(transport)) { await transport.sendRaw({ envelope: { from: "sender@example.com", to: ["recipient@example.net"] }, content: new TextEncoder().encode("Subject: Hello\r\n\r\nHello!\r\n"), encoding: "7bit", }); } ``` The envelope is required and independent of all MIME headers, including Bcc. Use `null` for a null reverse-path. Content accepts `Uint8Array`, a Promise of bytes, `Blob`, or an attachment-style factory that opens an independent reader on every call. Do not pass a one-shot stream directly. An explicit `encoding` reads the source once per successful send. Omitting it reads twice: analysis followed by transmission. Factories must reproduce identical bytes on both passes, and on concurrent sends. Upyo checks structure and known size again but does not compare a digest of the two passes. `7bit` requires ASCII bytes. `8bit` allows non-ASCII body bytes and asserts that all MIME headers, including nested part headers, are ASCII. This is a caller guarantee: Upyo checks only the top-level headers and does not parse nested MIME. Use `utf8` or omit `encoding` if you cannot guarantee ASCII headers throughout. `utf8` permits internationalized headers. Automatic analysis conservatively selects `utf8` for any non-ASCII byte, even in the body; specify `8bit` to avoid that additional transport requirement when the headers are ASCII. These values do not request transcoding and do not permit binary MIME with NUL bytes. Raw content must use CRLF throughout, end in CRLF, have a nonempty header section, and contain no NUL or line longer than 998 bytes excluding CRLF. Headers without a body are valid. Upyo validates these wire constraints, not full MIME syntax or signatures, and never repairs the content. Server-side processing can still modify the message. Transport implementers can use the raw-message source helpers to validate and read incrementally. Extra memory is limited to bounded work buffers, the largest chunk supplied by the source, and runtime buffers. Sources should honor cancellation promptly and release resources when iteration ends. ## Verifying transport configuration *This feature is introduced in Upyo 0.6.0.* `~VerifiableTransport` is an optional capability for checking a transport's connection, configured authentication, and prerequisites without sending mail. The base `~Transport` interface does not require it. Use `~isVerifiableTransport()` to discover support while retaining the provider ID type: ```typescript twoslash import { isVerifiableTransport, type Transport } from "@upyo/core"; declare const transport: Transport; if (isVerifiableTransport(transport)) { await transport.verify({ signal: AbortSignal.timeout(10_000) }); } ``` `verify()` resolves without a value on success and rejects with a transport-specific error on failure. It preserves the caller's abort reason when cancelled. A successful check describes setup at that time; it does not promise acceptance of any particular message or successful delivery. [SMTP](./smtp.md#verifying-the-configuration) and [JMAP](./jmap.md#verifying-the-configuration) implement this capability. Retry, pool, and observability wrappers do not automatically forward it. Verify the underlying transport before wrapping it, or implement the capability explicitly in a custom wrapper.