// the find
dahlia/upyo
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.
Upyo is a TypeScript monorepo that gives Node, Deno, Bun, and edge functions one send API across SMTP, JMAP, and a dozen HTTP email providers. It suits teams that want to switch providers without rewriting call sites, or that need the same mail path to run in more than one runtime.
- Sends resolve to a receipt with a `successful` flag and `errorMessages`, so the success branch looks the same whichever provider is behind the transport.
- Transports compose. `@upyo/retry` wraps any transport with backoff, `@upyo/pool` spreads sends across transports with round-robin, weighted, or priority strategies, and `@upyo/opentelemetry` instruments them. Decorators over one interface is the right shape for this problem.
- MIME composition and DKIM signing live in their own package, `@upyo/mime`, which has an edge entry point. Message construction doesn't depend on one runtime's mail stack, and signing happens before the message reaches a provider.
- Each transport has unit tests alongside a separate e2e suite, and `@upyo/mock` gives application code a test double that doesn't need a network.
- The shared message type is the common subset. The README examples only use from, to, subject, text, and attachments. Provider features such as tags, templates, scheduled sends, or tracking controls are either missing or need an escape hatch, so check each transport's docs before assuming parity. I didn't read the type definitions, so verify this one.
- Edge support is claimed more broadly than the README shows. It names edge functions but doesn't list which platforms are tested, and the only edge-specific file in the visible tree is the mime package's worker. Test on the platform you deploy to.
- The maintenance surface is wide for a project with 586 stars. More than a dozen packages each track a separate provider API and publish to both JSR and npm, so a provider change breaks one package at a time and the maintainer carries all of that churn.
- The front-page demo teaches the lazy pattern. It reads env vars with non-null assertions and casts `MAILGUN_REGION` to a union, so a missing variable surfaces as a provider error rather than a startup failure. The per-package config.ts files may validate properly, but the example doesn't show it.