Library / SDK
dahlia/upyo avatar
dahlia/upyo

Upyo: one typed email API across Node, Deno, Bun and edge runtimes

Upyo is a simple and cross-runtime library for sending email messages using SMTP and various email providers. It works on Node.js, Deno, Bun, and edge functions.

589 stars17 forksTypeScriptMIT

At a glance

What is it?
Upyo is a TypeScript monorepo of email transports that lets you switch between SMTP and HTTP providers such as Mailgun, Resend, SendGrid, Amazon SES and others without changing application code, with mock transports and a retry decorator shipped alongside.
Who is it for?
Adopt Upyo if your TypeScript code runs in more than one runtime or you want to change email providers without rewriting the call sites, since the transport abstraction is the point. Do not adopt it if a single provider API is enough for you, because the abstraction costs an extra layer.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 4 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

What the library abstracts, and what it deliberately does not

The pitch is stated in the opening paragraph: a cross-runtime email library providing a unified, type-safe API for sending emails across Node.js, Deno, Bun and edge functions, letting you switch between SMTP and HTTP-based providers without changing your application code.

The list of providers is concrete: Lettermint, Maileroo, Mailgun, Mailtrap, Resend, SendGrid and Amazon SES, alongside SMTP. Each is a separate package rather than a configuration option, and the README is explicit that the main package, @upyo/core, provides the shared types and common interfaces, while the other packages implement specific transports.

That is the design in one sentence. You construct a message with createMessage from the core package, then hand it to a transport from whichever provider package you have installed. The receipt that comes back is a uniform result object with a successful flag, a messageId and an errorMessages array, so the calling code does not have to know which provider it is talking to.

The two other claims in that paragraph are worth separating from the provider story. Full TypeScript support means the message shape and the receipt shape are typed in the core package, and each transport implements that same interface. Consistent error handling is what makes the errorMessages array meaningful, because every transport is expected to report failures in the same shape rather than throwing provider-specific errors. Built-in testing capabilities with mock transports across all runtimes is the third, and it is the one that shows the abstraction was designed for testability rather than only for convenience.

The licence is MIT and the documentation site is https://upyo.org/.

A monorepo of seventeen packages published twice

The package table is the most useful part of the documentation, because it tells you the shape of the whole thing.

Every package is published to both JSR and npm, which is the detail that matters most for a cross-runtime library. JSR is the registry that serves Deno, and npm is the one that serves Node, Bun and most edge platforms, so publishing to both is what makes a single package tree usable from Deno and from Node without a build step or a compatibility shim.

The core package, @upyo/core, holds the shared types and interfaces for email messages. @upyo/mime does portable MIME composition and DKIM signing. @upyo/smtp is the SMTP transport, and @upyo/jmap is the JMAP transport following RFC 8620 and 8621, which is the protocol behind Fastmail.

Then there is one package per HTTP provider: @upyo/lettermint, @upyo/maileroo, @upyo/mailtrap, @upyo/mailgun, @upyo/plunk, @upyo/resend, @upyo/sendgrid and @upyo/ses for Amazon SES.

And four that are not transports at all. @upyo/retry is a retry and backoff decorator for transports. @upyo/opentelemetry is an OpenTelemetry observability layer for Upyo transports, and @upyo/logtape is a LogTape observability transport. @upyo/mock is a mock transport for testing.

The retry and observability packages being separate from the transports is a design decision worth noticing. A retry policy or a tracing span is a cross-cutting concern that applies to any provider, and putting it in a decorator means you compose it once rather than configuring it per provider.

Sending a message with a provider transport

The README's demo is the fastest way to understand the API shape, and it uses the Mailgun transport.

typescript
import { createMessage } from "@upyo/core";
import { MailgunTransport } from "@upyo/mailgun";
import fs from "node:fs/promises";
import process from "node:process";

const message = createMessage({
  from: "[email protected]",
  to: "[email protected]",
  subject: "Hello from Upyo!",
  content: { text: "This is a test email." },
  attachments: [
    new File(
      [await fs.readFile("image.jpg"), "image.jpg", { type: "image/jpeg" }]
    )
  ],
});

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(", "));
}

Four things are visible in that snippet.

The message is constructed independently of the transport, with a from, a to, a subject, a content object and attachments. The content is a structured object rather than a raw string, which is what lets the MIME layer decide the encoding.

Attachments use the web File API, with a name and a type given explicitly alongside the bytes. That is the runtime-neutral choice, since File exists in Node, Deno, Bun and edge runtimes, and it means the attachment code does not change when you move between them.

The transport takes its credentials as plain options, and the demo reads them from environment variables with non-null assertions, which is the sort of thing you would tighten in real code.

And the result is checked, not thrown. The receipt has a successful boolean, a messageId on success and errorMessages on failure, so the calling code branches rather than catching.

Testing without a provider, and the packages that support it

The mock transport exists as its own published package, @upyo/mock, which is the reason the abstraction is testable at all.

Because every provider implements the same interface and the message is a plain data structure, a test can substitute a mock transport for a real one and assert on what would have been sent. There is no network call, no API key in the test environment, and no flakiness from a third-party service being slow or rate limiting you. The README says the built-in testing capabilities with mock transports work across all runtimes, and the mock package is published to both JSR and npm like everything else.

The observability packages follow the same separation. @upyo/opentelemetry gives OpenTelemetry instrumentation for Upyo transports, and @upyo/logtape gives a LogTape observability transport. Both are optional packages, so a project that wants neither simply does not install them.

The retry package, @upyo/retry, is a retry and backoff decorator for transports. As a decorator it wraps a transport rather than being configured inside one, which means the policy applies to whichever transport you end up using. That is the same reasoning as the core abstraction itself: the parts that do not differ between providers are expressed once, in the core, and the parts that do differ are expressed once per provider package.

The package list also includes a jmap transport, which is worth noting because it is the least common of the choices here. JMAP is the protocol used by Fastmail, and supporting it directly means you can talk to a JMAP server without going through an HTTP API shim, but it is a niche path compared with the provider SDKs.

How the repository is set up, and what it says about the project

The tree of the repository is worth a look, because it is a modern TypeScript monorepo and the files tell you how the work is organised.

The root package.json is a private workspace stub named upyo-workspace with type set to module, and the devDependencies use the catalog: protocol, meaning versions are defined once in a catalog rather than repeated per package. The workspace uses pnpm, with pnpm-workspace.yaml and pnpm-lock.yaml at the root, and the individual packages live under packages/. Deno has its own deno.json and deno.lock, which is how the same source is checked under Deno's toolchain as well.

There is a lot of AI-related configuration at the root: an .agents/ directory, .cursorrules, .windsurfrules, a .zed/ directory, AGENTS.md, CLAUDE.md, AI_POLICY.md and context7.json. The presence of skills-lock.json and sacho.toml suggests the project ships agent-readable documentation as a maintained artefact rather than an afterthought.

There is also a mise.toml for toolchain pinning, .oxlintrc.json and .oxfmtrc.json for linting and formatting with the oxlint and oxfmt tools rather than the ESLint and Prettier defaults, and separate tsconfig.check.json and tsconfig.tools.json for type checking and for the scripts directory. cspell.json handles spell checking and .hongdown.toml is present for markdown formatting.

The changes.d/ directory is a changelog fragment workflow, similar to the one in the WakaQ project, with CHANGES.md as the published history. CONTRIBUTING.md, SECURITY.md and LICENSE are all at the root.

One thing to keep in mind: the repository has no GitHub releases, so there is no tag history to consult, and the last push was on 2026-10-19. The package version in your lockfile, not a release tag, is the reference point for what you are running.

Where the abstraction stops paying off

There are three honest limits.

The first is that the provider-agnostic layer only covers what the providers have in common. The core package defines the message and the receipt, and everything a transport does outside that contract, such as provider-specific templates, suppression lists, or per-recipient scheduling, has no home in the shared interface. A team that leans on those provider features ends up reaching past Upyo, and then the abstraction is in the way rather than helping.

The second is the dependency cost. Seventeen packages published to two registries is a lot of surface for a mail library, and a project that only needs one provider and one runtime is paying for a design that solves a problem it does not have. The counterargument is that you only install what you use, since each transport is a separate package, so the practical cost for a single-provider project is the core package plus one transport.

The third is the edge function case, which is the one the project advertises most strongly and the one with the most moving parts. An edge runtime has no persistent connections and a different filesystem model, so an SMTP transport is not available there at all, and every provider that is reachable from an edge runtime needs its own HTTP path. The @upyo/smtp package being separate from the HTTP transports is what makes that possible, but it also means the set of working combinations is not uniform, and the documentation does not spell out which transports run where.

For a project that only ever sends from one Node service to one provider, a provider SDK on its own is less code and fewer moving parts. Upyo earns its place when the runtime varies or the provider might change.

Editorial conclusion

Adopt Upyo if your TypeScript code runs in more than one runtime or you want to change email providers without rewriting the call sites, since the transport abstraction is the point. Do not adopt it if a single provider API is enough for you, because the abstraction costs an extra layer. Before you depend on it, check the package you need exists for your provider in the packages table, and note the repository has no GitHub releases, so the last push on 2026-10-19 is the only version reference available.

Frequently asked questions

Which email providers does Upyo support?

The transports cover Lettermint, Maileroo, Mailgun, Mailtrap, Plunk, Resend, SendGrid and Amazon SES, plus SMTP and JMAP for RFC 8620 and 8621 servers. Each provider is a separate package under the @upyo scope, published to both JSR and npm.

Which runtimes does Upyo work on?

Node.js, Deno, Bun and edge functions. Every package is published to JSR as well as npm, which is what lets the same code run under Deno and Node without a compatibility layer.

How do I test email sending without a provider?

The @upyo/mock package provides a mock transport that implements the same interface as the real ones, so a test can assert on the message that would have been sent without a network call or an API key.

Does Upyo support retries and tracing?

Yes, as separate packages. @upyo/retry is a retry and backoff decorator for transports, @upyo/opentelemetry provides OpenTelemetry observability for Upyo transports, and @upyo/logtape is a LogTape observability transport.

What do I need to send an email with Upyo?

A message built with createMessage from @upyo/core, and a transport instance from a provider package such as @upyo/mailgun, constructed with that provider's credentials. The transport send method returns a receipt with a successful flag, a messageId and an errorMessages array.

Official sources

  1. dahlia/upyo on GitHub
  2. Issues
  3. License: MIT
  4. Project website
  5. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/dahlia-upyo.svg)](https://hysenlabs.com/projects/dahlia-upyo)

Was this useful?