Cross-runtime email library
Send email from anywhere your code runs.
Upyo is a small email library for JavaScript and TypeScript. You write a message once, choose a transport, and send. The same code works on Node.js, Deno, Bun, and edge functions, with whichever provider you prefer.
Runtimes
Most email libraries quietly assume Node.js. Upyo doesn't. It builds on web-standard APIs like fetch(), Uint8Array, File, and AbortSignal, so the same code runs unchanged across runtimes. You can develop against a local server and deploy to an edge function without rewriting anything.
The familiar default, with SMTP and every HTTP provider.
Install straight from JSR, with no build step required.
Runs on Bun's fast runtime without any shims.
Cloudflare Workers, Vercel, Netlify, Deno Deploy, and the like.
Transports
A transport is simply where your messages go. Upyo gives every provider the same interface, so moving from SMTP in development to a hosted API in production, or from one provider to another, is a one-line change. The code that builds your message never has to know the difference.
// In development, talk to a local SMTP server:
const transport = new SmtpTransport({
host: "localhost",
port: 1025,
});
// In production, switch to a hosted API. Just this one
// line changes; everything below stays the same:
const transport = new SendGridTransport({
apiKey: process.env.SENDGRID_KEY,
});
await transport.send(message);Providers
Packaging
Upyo is written in TypeScript and published the way you'd hope a library written today would be. The result is a package that drops cleanly into whatever toolchain you already have, and a sending result the compiler can check for you.
const receipt = await transport.send(message);
if (receipt.successful) {
receipt.messageId; // string, available only here
} else {
receipt.errorMessages; // string[], available only here
}Install from npm with the tools you already use, or from JSR for Deno. Both registries are first-class, not an afterthought.
Every package ships both module formats with type declarations, so it works whether your project uses import or require().
Types live in the source, so there are no @types packages to add. Results come back as a discriminated union you can narrow safely.
The core and transports lean on web-standard APIs instead of a dependency tree, so installs stay small and audits stay short.
Testing & telemetry
Two small things make day-to-day work easier: a mock transport for your tests, and OpenTelemetry for when you need to see what's happening in production. Both implement the same interface as every other transport, so they slot in without touching your sending code.
The mock transport keeps every message in memory so your tests can inspect exactly what would have been sent: recipients, subject, attachments, and all.
const transport = new MockTransport();
await transport.send(message);
const sent = transport.getSentMessages();
sent[0].subject; // "Hello from Upyo!"
sent[0].recipients[0].address; // "[email protected]"Wrap any transport to get traces and metrics like delivery rates, latency, and error classification, with nothing to change in the code that actually sends.
const transport = createOpenTelemetryTransport(base, {
serviceName: "email-service",
metrics: { enabled: true },
tracing: { enabled: true },
});
// your sending code stays the same
await transport.send(message);Get started
Add a package, copy one of the examples above, and you'll be sending email in a few minutes.
Upyo is free, open source, and maintained by Hong Minhee. If it saves you some time, you can support its continued development through GitHub Sponsors ♥. Thank you for considering it.
Upyo (郵票) is the Sino-Korean word for a postage stamp.