@upyo/core
v0.6.0
Published
Simple email sending library for Node.js, Deno, Bun, and edge functions
Readme
@upyo/core
Core types and interfaces for Upyo, a cross-runtime email library that provides a unified, type-safe API for sending emails across Node.js, Deno, Bun, and edge functions.
The @upyo/core package provides the foundational types and interfaces that all
Upyo transport implementations use. It defines the common Message, Address,
Transport, and Receipt types that enable seamless switching between
different email providers while maintaining consistent type safety and
error handling.
Features
- Universal types: Common interfaces for all email transports
- Type-safe messaging: Comprehensive TypeScript definitions for email messages
- Attachment support: File attachment handling
- Cross-runtime compatibility: Works on Node.js, Deno, Bun, and edge functions
- Zero dependencies: Lightweight with no external dependencies
Installation
npm add @upyo/core
pnpm add @upyo/core
yarn add @upyo/core
deno add jsr:@upyo/core
bun add @upyo/coreUsage
Creating messages
The createMessage() function provides a convenient way to create email
messages:
import { createMessage } from "@upyo/core";
const message = createMessage({
from: "[email protected]",
to: ["[email protected]", "[email protected]"],
cc: "[email protected]",
subject: "Hello from Upyo!",
content: {
text: "This is a plain text message.",
html: "<p>This is an <strong>HTML</strong> message.</p>",
},
priority: "high",
});Since Upyo 0.6.0, internationalized mailbox addresses are accepted, including UTF-8 local parts and Unicode domains:
const message = createMessage({
from: "josé@example.com",
to: "用户@例子.广告",
subject: "Hello",
content: { text: "Welcome!" },
});Support for delivering these addresses depends on the selected transport. The @upyo/smtp transport negotiates RFC 6531 SMTPUTF8 automatically.
Adding attachments
Attachments can be added using the standard File API:
import { createMessage } from "@upyo/core";
const message = createMessage({
from: "[email protected]",
to: "[email protected]",
subject: "Document attached",
content: { text: "Please find the document attached." },
attachments: [
new File(
[await fetch("document.pdf").then(r => r.arrayBuffer())],
"document.pdf",
{ type: "application/pdf" }
),
],
});Handling receipts
All transport operations return Receipt objects that use discriminated unions
for type-safe error handling:
import type { Receipt } from "@upyo/core";
function handleReceipt(receipt: Receipt) {
if (receipt.successful) {
console.log("Message sent with ID:", receipt.messageId);
} else {
console.error("Send failed:", receipt.errorMessages.join(", "));
console.error("Retryable:", receipt.retryable ?? false);
for (const error of receipt.errors ?? []) {
console.error(error.category, error.code, error.provider);
}
}
}Failed receipts keep the legacy errorMessages array and can also carry
structured errors for programmatic handling. Transports use categories such
as auth, rate-limit, network, timeout, validation, rejected,
server-error, service-unavailable, configuration, and unknown.
When implementing a transport, use createFailedReceipt() to keep these fields
consistent:
import { createFailedReceipt } from "@upyo/core";
const receipt = createFailedReceipt("HTTP 429: Too Many Requests", {
provider: "example",
statusCode: 429,
retryAfterMilliseconds: 30_000,
});Implementing custom transports
The Transport interface defines the contract for all email providers:
import type { Message, Receipt, Transport, TransportOptions } from "@upyo/core";
class MyCustomTransport implements Transport<"example"> {
readonly id = "example";
async send(
message: Message,
options?: TransportOptions,
): Promise<Receipt<"example">> {
// Implementation details...
return { successful: true, messageId: "12345", provider: this.id };
}
async *sendMany(
messages: Iterable<Message> | AsyncIterable<Message>,
options?: TransportOptions,
): AsyncIterable<Receipt<"example">> {
for await (const message of messages) {
yield await this.send(message, options);
}
}
}Related packages
The @upyo/core package is the foundation for all Upyo transport
implementations:
| Package | JSR | npm | Description | | ------------------- | ------------------------------ | ------------------------------ | ------------------------------------------------- | | @upyo/smtp | JSR | npm | SMTP transport for any mail server | | @upyo/mailgun | JSR | npm | Mailgun HTTP API transport | | @upyo/sendgrid | JSR | npm | SendGrid HTTP API transport | | @upyo/ses | JSR | npm | Amazon SES HTTP API transport | | @upyo/mock | JSR | npm | Mock transport for testing | | @upyo/opentelemetry | JSR | npm | OpenTelemetry observability for Upyo transports |
Documentation
For comprehensive documentation, examples, and guides, visit https://upyo.org/.
API reference documentation is available on JSR: https://jsr.io/@upyo/core.
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.
import { isRawTransport, type Transport } from "@upyo/core";
declare const transport: Transport;
if (isRawTransport(transport)) {
await transport.sendRaw({
envelope: { from: "[email protected]", to: ["[email protected]"] },
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.
