Email, decoupled from where and how: A cross-runtime, cross-provider email library for JavaScript and TypeScript
There's already an answer to how you send email from Node.js. Node.js has Nodemailer, and it's been a solid choice for a long time. But the code people write today doesn't only run on Node.js anymore. It's common to see it running on Deno, Bun, or an edge runtime like Cloudflare Workers, and it's common to switch providers between development and production: send nothing locally, then use SES or Resend once deployed.
So I wanted to change the question a little. Not how do you send email from Node.js, but how does an application deliver email regardless of runtime or provider. That's the question Upyo started from.
Nodemailer is great; the landscape just got bigger
If you're sending email over SMTP from Node.js, Nodemailer is enough. Its SMTP implementation is mature, it supports OAuth 2.0, and it already handles DKIM signing and calendar invitations.
The catch is that Nodemailer runs on top of Node.js built-in modules like node:net, node:tls, and node:stream. That makes it hard to use on Cloudflare Workers, Vercel Edge Runtime, or Supabase Edge Functions. Its provider support centers on SMTP too, so using an HTTP API service like Resend or SendGrid means installing that service's own SDK or relying on a third-party transport. Switching providers usually means touching application code too.
The principle of universality
Upyo is built on web standard APIs like fetch(), Web Streams, and Web Crypto. Since it doesn't depend on Node.js specific modules, the same code runs unchanged on Node.js, Deno, Bun, and edge functions.
Every email service is handled through the same Transport interface: SMTP and JMAP, Resend, SendGrid, Mailgun, Amazon SES, Plunk, Lettermint. Swap out how the transport is constructed and the application code that sends mail stays the same, whether that's a local SMTP server or a mock transport in development, or SES in production.
Keeping dependencies minimal follows from the same goal. @upyo/ses implements AWS Signature v4 itself instead of pulling in the AWS SDK. The AWS SDK assumes Node.js, and using it as is would make the transport unusable on Deno, Bun, or an edge runtime.
Separating retry logic from application logic
The Transport interface itself is small. send() sends one message, sendMany() sends several, and cancellation goes through a standard AbortSignal.
How sendMany() is implemented differs by transport. The transport for a provider like Resend or SendGrid that exposes a batch endpoint calls that endpoint directly. SMTP, where reusing one connection for several messages is the natural approach, does that instead. A transport without either optimization might still run several send() calls concurrently instead of one after another, to cut down total time.
Because the interface is this small, the answer to where retry policy should live changes. Wrap a transport in RetryTransport and application code never needs its own retry logic.
That's possible because application code only knows the Transport interface type, not a concrete implementation. The function that sends mail takes a transport: Transport parameter, and the caller decides which transport gets injected.
Providers fail differently; what if that didn't matter?
import { MockTransport } from "@upyo/mock";
import { RetryTransport } from "@upyo/retry";
const provider = new MockTransport();
const transport = new RetryTransport(provider, {
maxAttempts: 3,
backoff: {
baseDelayMilliseconds: 1000,
maxDelayMilliseconds: 30000,
factor: 2,
},
});
RetryTransport implements the same Transport interface, so application code doesn't need to know whether it's talking to SMTP or SES, or whether retries are even happening. Wrap it in PoolTransport and a failing provider can fail over to the next one by priority, or traffic can be spread across several with round robin.
import { PoolTransport } from "@upyo/pool";
const pool = new PoolTransport({
strategy: "priority",
transports: [
{ transport: primaryProvider, priority: 100 },
{ transport: backupProvider, priority: 10 },
],
maxRetries: 3,
});
The two can be stacked. Whether you retry per provider before failing over to the next, or retry the whole pool as a single operation, comes down to which one sits on the outside.
This composition works because every transport returns failures in the same shape. Upyo's Receipt is a discriminated union of success and failure, and a failure carries structured fields like retryable, category, and retryAfterMilliseconds. Once each transport translates a provider's error into these fields, RetryTransport can decide whether to retry a 429 from Resend or a transient SMTP error the same way.
Still waiting on magic links, even in development
The annoying part of building magic-link login was never the login itself. It was opening an inbox every time and waiting for the link to show up, when the link never needed to be delivered anywhere during development.
The function that sends mail was written to take a Transport from the start.
import { createMessage } from "@upyo/core";
import type { Transport } from "@upyo/core";
async function sendMagicLink(transport: Transport, email: string, link: string) {
await transport.send(createMessage({
from: "auth@example.com",
to: email,
subject: "Your login link",
content: { text: `Click to log in: ${link}` },
}));
}
@upyo/logtape wraps another transport and logs instead of sending. In development, that's what gets injected.
import { LogTapeTransport } from "@upyo/logtape";
import { SmtpTransport } from "@upyo/smtp";
const smtp = new SmtpTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "smtp-user", pass: "smtp-password" },
});
const transport = process.env.NODE_ENV === "development"
? new LogTapeTransport({ category: ["app", "email"] })
: new LogTapeTransport({ transport: smtp, category: ["app", "email"] });
Copy the link straight out of the server log and paste it into the browser. No refreshing an inbox.
Automated tests use the same injection point. This time, sendMagicLink() gets @upyo/mock's MockTransport instead.
import { MockTransport } from "@upyo/mock";
import assert from "node:assert/strict";
const transport = new MockTransport();
await sendMagicLink(transport, "user@example.com", "https://example.com/verify?token=...");
const sent = await transport.waitForMessage(
(msg) => msg.subject.includes("login link"),
1000,
);
assert.equal(sent.recipients[0].address, "user@example.com");
sendMagicLink() itself never changed. Only the Transport handed to it did.
A small API that still holds the complexity
A small Transport interface doesn't mean the messy parts of email got skipped. The SMTP transport handles internationalized addresses through SMTPUTF8 and requests delivery status notifications through DSN. Attachments stream, so even large files don't sit in memory. The Message.calendar field turns a message into a meeting invitation or cancellation, and messageId, inReplyTo, and references let outgoing mail be part of a conversation rather than a one-off notice.
Getting started
To send over SMTP, install these packages.
npm add @upyo/core @upyo/smtp
import { createMessage } from "@upyo/core";
import { SmtpTransport } from "@upyo/smtp";
const transport = new SmtpTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "smtp-user", pass: "smtp-password" },
});
const message = createMessage({
from: "sender@example.com",
to: "recipient@example.com",
subject: "Hello from Upyo",
content: { text: "This is a test email." },
});
await transport.send(message);
Layer RetryTransport, PoolTransport, or LogTapeTransport on top as needed. Configuration for each transport is in the docs, the source is on GitHub, and packages are on npm and JSR.
That's the answer so far to the question I started with: how an application delivers email regardless of runtime or provider. There's still plenty to refine, so if something's broken or missing, an issue or a discussion is welcome.
日本語:「Node.jsからメールを送るにはどうすればいいか」という問いには、すでに答えがある。Node.jsにはNodemailerがあり、長年頼りにされてきた。ただ、最近書くコードはNode.jsだけで動くとは限らない。DenoやBun、Cloudflare WorkersのようなエッジランタイムでNode.jsと同じコードを動かすことも珍しくないし、開発中は実際にメールを送らず、本番ではSESやResendを使うといった具合に、プロバイダーを環境ごとに使い分けることも多い。
そこで、問いそのものを少し変えてみたかった。「Node.jsからメールをどう送るか」ではなく、「ランタイムやプロバイダーに関係なく、アプリケーションはメールをどう届けるか」に。Upyoはこの問いから始まったライブラリだ。
Nodemailerは優秀だ。ただ、土俵が広がった
Node.jsからSMTPでメールを送るだけなら、Nodemailerで十分だ。SMTPの実装は成熟していて、OAuth 2.0にも対応しているし、DKIM署名や会議への招待にも対応している。
ただしNodemailerは、node:netやnode:tls、node:streamといったNode.js組み込みモジュールの上で動く。そのためCloudflare WorkersやVercel Edge Runtime、Supabase Edge Functionsのような環境では使いにくい。プロバイダー対応もSMTP中心なので、ResendやSendGridのようなHTTP APIを提供するメールサービスを使うには、各サービス公式のSDKを個別にインストールするか、サードパーティのトランスポートに頼ることになる。プロバイダーを切り替えるとなると、アプリケーションコード側にも手を入れることが多い。
普遍性という原則
Upyoはfetch()やWeb Streams、Web CryptoといったWeb標準APIを使って実装している。Node.js固有のモジュールに依存していないため、Node.js、Deno、Bun、エッジ関数で同じコードがそのまま動く。
メールサービスごとの実装も、同じTransportインターフェースで扱う。SMTPやJMAPはもちろん、ResendやSendGrid、Mailgun、Amazon SES、Plunk、Lettermintも同様だ。トランスポートを組み立てる部分さえ差し替えれば、メールを送るアプリケーションコードはそのまま使える。たとえば開発環境ではローカルのSMTPやモックのトランスポートを使い、本番ではSESに切り替える、という具合に。
依存関係を最小限にとどめているのも同じ理由からだ。@upyo/sesはAWS SDKを使う代わりに、SESへのリクエストに必要なAWS Signature v4署名を自前で実装している。AWS SDKはNode.jsを前提に作られているので、そのまま使うとDenoやBun、エッジランタイムでは動かせなくなる。
リトライ戦略をアプリケーションロジックから切り離す
Transportインターフェース自体は小さい。send()でメッセージを1通送り、sendMany()で複数通送る。キャンセルは標準のAbortSignalで処理する。
sendMany()の実装はトランスポートごとに異なる。ResendやSendGridのようにバッチ送信用のHTTPエンドポイントを持つプロバイダーなら、そのトランスポートはそのエンドポイントを直接呼び出す。SMTPでは、同じ接続を再利用して複数のメッセージを順に送る。バッチ送信に対応していない場合でも、複数のsend()を順番にではなく並行して実行し、全体の処理時間を縮める実装もある。
インターフェースがこれだけ小さいからこそ、RetryTransportでトランスポートをラップするだけで、メールを送る処理にリトライの実装を加えずに済む。
これが可能なのは、アプリケーションコードが具体的なトランスポートの実装ではなく、Transportというインターフェースの型だけを知っていればいいからだ。メールを送る関数がtransport: Transportを引数に取るようにしておけば、実際にどのトランスポートを注入(dependency injection、DI)するかは呼び出す側が決められる。
プロバイダーごとに失敗の仕方は違う。それを共通化できないか
import { MockTransport } from "@upyo/mock";
import { RetryTransport } from "@upyo/retry";
const provider = new MockTransport();
const transport = new RetryTransport(provider, {
maxAttempts: 3,
backoff: {
baseDelayMilliseconds: 1000,
maxDelayMilliseconds: 30000,
factor: 2,
},
});
RetryTransportも同じTransportインターフェースを実装しているので、アプリケーションコードは相手がSMTPなのかSESなのか、リトライが挟まっているのかどうかを知らなくていい。PoolTransportでラップすれば、優先度の高いプロバイダーが失敗したときに次のプロバイダーへフェイルオーバーさせたり、複数のプロバイダーにラウンドロビンで分散させたりできる。
import { PoolTransport } from "@upyo/pool";
const pool = new PoolTransport({
strategy: "priority",
transports: [
{ transport: primaryProvider, priority: 100 },
{ transport: backupProvider, priority: 10 },
],
maxRetries: 3,
});
2つを重ねて使うこともできる。プロバイダーごとに何回かリトライしてからそれでも失敗したら別のプロバイダーに切り替えるのか、それともプール全体をまとめてリトライするのかは、どちらを外側に置くかで決まる。
この組み合わせが成立するのは、失敗結果の形がプロバイダーごとにバラバラではないからだ。UpyoのReceiptは成功と失敗を区別するタグ付きユニオン(discriminated union)で、失敗を表す値にはretryableやcategory、retryAfterMillisecondsといった構造化されたフィールドがある。各トランスポートがプロバイダーのエラーをこれらのフィールドに変換しておけば、RetryTransportはResendの429であれSMTPの一時的なエラーであれ、区別なくリトライすべきかどうかを判断できる。
開発中でもマジックリンクを待たされる煩わしさ
マジックリンクによるログインを作っていて一番面倒だったのは、機能そのものではなく、毎回受信箱を開いてリンクが届くのを待たなければならないことだった。開発中はそのリンクがどこかに実際に届く必要はないのに。
メールを送る部分は、最初からTransport型だけを受け取るように書いていた。
import { createMessage } from "@upyo/core";
import type { Transport } from "@upyo/core";
async function sendMagicLink(transport: Transport, email: string, link: string) {
await transport.send(createMessage({
from: "auth@example.com",
to: email,
subject: "ログインリンク",
content: { text: `次のリンクをクリックしてログインしてください: ${link}` },
}));
}
@upyo/logtapeは別のトランスポートをラップして、送信の代わりにログを残す。開発環境ではこれを注入すればいい。
import { LogTapeTransport } from "@upyo/logtape";
import { SmtpTransport } from "@upyo/smtp";
const smtp = new SmtpTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "smtp-user", pass: "smtp-password" },
});
const transport = process.env.NODE_ENV === "development"
? new LogTapeTransport({ category: ["app", "email"] })
: new LogTapeTransport({ transport: smtp, category: ["app", "email"] });
サーバーのログに出力されたマジックリンクをそのままコピーして、ブラウザに貼り付ければいい。受信箱をリロードしながら待つ必要はない。
自動テストでも同じDIの仕組みを使う。今度はsendMagicLink()に@upyo/mockのMockTransportを渡すだけだ。
import { MockTransport } from "@upyo/mock";
import assert from "node:assert/strict";
const transport = new MockTransport();
await sendMagicLink(transport, "user@example.com", "https://example.com/verify?token=...");
const sent = await transport.waitForMessage(
(msg) => msg.subject.includes("ログインリンク"),
1000,
);
assert.equal(sent.recipients[0].address, "user@example.com");
sendMagicLink()自体は一切変わっていない。変わったのは、どのTransportを渡したかだけだ。
小さいのに、複雑さもちゃんと抱えられるAPI
Transportインターフェースが小さいからといって、実装の細部まで省いているわけではない。SMTPトランスポートはSMTPUTF8で国際化されたアドレスを処理し、DSNで配送状況の通知を要求する。添付ファイルはストリーミングで処理されるので、大きなファイルでもメモリをほとんど使わずに送れる。Message.calendarフィールドを使えば会議の招待やキャンセルのメッセージを作れるし、messageIdやinReplyTo、referencesフィールドは、送信するメールを単発の通知ではなく会話の一部として扱えるようにしてくれる。
使ってみる
SMTPで送るだけなら、次のパッケージをインストールする。
npm add @upyo/core @upyo/smtp
import { createMessage } from "@upyo/core";
import { SmtpTransport } from "@upyo/smtp";
const transport = new SmtpTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "smtp-user", pass: "smtp-password" },
});
const message = createMessage({
from: "sender@example.com",
to: "recipient@example.com",
subject: "Hello from Upyo",
content: { text: "This is a test email." },
});
await transport.send(message);
必要に応じて、ここまでに出てきたRetryTransportやPoolTransport、LogTapeTransportを上に重ねればいい。各トランスポートの設定やオプションはドキュメントにまとまっているし、ソースはGitHubにある。パッケージはnpmとJSRの両方に上がっている。
ランタイムやプロバイダーに関係なくアプリケーションがメールをどう届けるか、最初に立てたその問いに対する今のところの答えはこれくらいだ。まだ手を入れたい部分も残っているので、使ってみて気になった点があれば、IssueやDiscussionで教えてもらえるとうれしい。
한국어:“Node.js에서 메일을 어떻게 보내나”라는 질문에는 답이 있다. Node.js에는 Nodemailer가 있고, 오래전부터 잘 쓰이고 있다. 그런데 요즘 짜는 코드는 Node.js에서만 돌지 않는다. Deno나 Bun, Cloudflare Workers 같은 엣지 런타임에서 돌아가는 경우도 흔하고, 개발 중에는 실제로 메일을 보내지 않다가 운영 환경에서는 SES나 Resend를 쓰는 식으로 프로바이더를 나눠 쓰기도 한다.
그래서 질문을 조금 바꿔보고 싶었다. “Node.js에서 메일을 어떻게 보내나”가 아니라, “런타임과 프로바이더에 상관없이 애플리케이션이 메일을 어떻게 전달하나”로. Upyo는 이 질문에서 시작했다.
Nodemailer는 훌륭하지만, 이제 판이 커졌다
Node.js에서 SMTP로 메일을 보낼 때는 Nodemailer로 충분하다. SMTP 구현이 성숙하고, OAuth 2.0 인증도 지원하고, DKIM 서명이나 캘린더 초대장까지 갖추고 있다.
다만 Nodemailer는 node:net, node:tls, node:stream 같은 Node.js 내장 모듈 위에서 동작한다. 그래서 Cloudflare Workers나 Vercel Edge Runtime, Supabase Edge Functions 같은 환경에서는 쓰기 어렵다. 프로바이더 쪽도 SMTP 중심이라, Resend나 SendGrid 같은 HTTP API 서비스를 쓰려면 각 서비스의 SDK를 따로 설치하거나 서드파티 트랜스포트에 기대야 한다. 프로바이더를 바꾸면 그만큼 애플리케이션 코드도 손봐야 한다.
보편성이라는 원칙
Upyo는 fetch(), Web Streams, Web Crypto 같은 웹 표준 API를 쓴다. Node.js 전용 모듈에 의존하지 않기 때문에, Node.js, Deno, Bun, 엣지 함수에서 같은 코드가 그대로 돌아간다.
메일 서비스별 구현도 같은 Transport 인터페이스로 다룬다. SMTP, JMAP부터 Resend, SendGrid, Mailgun, Amazon SES, Plunk, Lettermint까지 전부. 트랜스포트를 만드는 부분만 바꾸면, 메일을 보내는 애플리케이션 코드는 그대로 쓸 수 있다. 개발 환경에서는 로컬 SMTP나 목(mock) 트랜스포트를, 프로덕션에서는 SES를 쓰는 식으로.
의존성을 최소화하려 한 것도 같은 이유에서다. @upyo/ses는 AWS SDK를 쓰는 대신, SES 요청에 필요한 AWS Signature v4 서명을 직접 구현했다. AWS SDK는 Node.js를 전제로 만들어져 있어서, 그대로 가져다 쓰면 Deno나 Bun, 엣지 런타임에서는 쓸 수 없게 된다.
재시도 전략을 애플리케이션 로직에서 분리하기
Transport 인터페이스 자체는 작다. send()로 메시지 하나를 보내고, sendMany()로 여러 개를 보낸다. 취소는 표준 AbortSignal로 처리한다.
sendMany()의 구현은 트랜스포트마다 다르다. Resend나 SendGrid처럼 배치 발송용 HTTP 엔드포인트를 제공하는 프로바이더라면 그 엔드포인트를 직접 호출하고, SMTP처럼 연결 하나로 여러 메시지를 순차 전송하는 편이 자연스러운 경우엔 커넥션을 재사용한다. 그런 최적화가 없는 트랜스포트라도, 여러 send() 호출을 순차가 아니라 동시에 실행해서 전체 처리 시간을 줄이는 경우도 있다.
인터페이스가 이 정도로 작다 보니, 재시도 정책을 어디에 둘지에 대한 답도 달라진다. RetryTransport로 트랜스포트를 감싸면, 애플리케이션 코드에 재시도 로직을 따로 넣지 않아도 된다.
이게 가능한 건 애플리케이션 코드가 구체적인 트랜스포트 구현체가 아니라 Transport 인터페이스 타입만 알기 때문이다. 메일을 보내는 함수를 transport: Transport 형태로 매개변수를 받게 만들어두면, 실제로 어떤 트랜스포트가 주입(dependency injection)되는지는 그 함수를 호출하는 쪽이 정한다.
프로바이더들은 저마다 다른 방식으로 실패하지만, 이를 공통화하면 어떨까?
import { MockTransport } from "@upyo/mock";
import { RetryTransport } from "@upyo/retry";
const provider = new MockTransport();
const transport = new RetryTransport(provider, {
maxAttempts: 3,
backoff: {
baseDelayMilliseconds: 1000,
maxDelayMilliseconds: 30000,
factor: 2,
},
});
RetryTransport도 Transport 인터페이스를 그대로 구현하기 때문에, 애플리케이션 코드는 이게 SMTP인지 SES인지, 재시도가 붙어 있는지 몰라도 된다. PoolTransport로 감싸면 우선순위가 높은 프로바이더가 실패했을 때 다음 프로바이더로 넘어가게 하거나, 여러 프로바이더에 라운드로빈으로 분산시킬 수도 있다.
import { PoolTransport } from "@upyo/pool";
const pool = new PoolTransport({
strategy: "priority",
transports: [
{ transport: primaryProvider, priority: 100 },
{ transport: backupProvider, priority: 10 },
],
maxRetries: 3,
});
두 트랜스포트를 겹쳐 쌓을 수도 있다. 프로바이더별로 몇 번 재시도한 다음 그래도 실패하면 다른 프로바이더로 넘어가게 할지, 풀 전체를 통째로 재시도할지는 어느 쪽을 바깥에 두느냐로 정해진다.
이 조합이 가능한 건 실패 결과의 형식이 프로바이더마다 다르지 않기 때문이다. Upyo의 Receipt는 성공과 실패를 구분하는 태그된 공용체(discriminated union)이고, 실패에는 retryable, category, retryAfterMilliseconds 같은 필드가 실린다. 각 트랜스포트가 프로바이더의 오류를 이 필드로 변환해두면, RetryTransport는 Resend의 429든 SMTP의 일시적 오류든 구분 없이 재시도 여부를 판단할 수 있다.
한창 개발 중일 때도 매직 링크를 기다려야 하는 성가심
매직 링크 로그인을 개발할 때는 매번 메일함을 열어 링크가 도착하기를 기다리는 게 번거로웠다. 개발 중에는 그 링크가 실제로 어딘가에 배달될 필요가 없는데도.
메일을 보내는 부분은 처음부터 Transport 타입만 받도록 짰다.
import { createMessage } from "@upyo/core";
import type { Transport } from "@upyo/core";
async function sendMagicLink(transport: Transport, email: string, link: string) {
await transport.send(createMessage({
from: "auth@example.com",
to: email,
subject: "로그인 링크",
content: { text: `다음 링크를 눌러 로그인하세요: ${link}` },
}));
}
@upyo/logtape는 다른 트랜스포트를 감싸서 전송 대신 로그를 남긴다. 개발 환경에서는 이걸 주입하면 된다.
import { LogTapeTransport } from "@upyo/logtape";
import { SmtpTransport } from "@upyo/smtp";
const smtp = new SmtpTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "smtp-user", pass: "smtp-password" },
});
const transport = process.env.NODE_ENV === "development"
? new LogTapeTransport({ category: ["app", "email"] })
: new LogTapeTransport({ transport: smtp, category: ["app", "email"] });
서버 로그에 찍힌 매직 링크를 그대로 복사해서 브라우저에 붙여넣으면 된다. 메일함을 새로고침하며 기다릴 필요가 없다.
자동화된 테스트에서도 같은 DI 지점을 쓴다. sendMagicLink()가 받는 transport: Transport 자리에, 이번엔 @upyo/mock의 MockTransport를 넣으면 된다.
import { MockTransport } from "@upyo/mock";
import assert from "node:assert/strict";
const transport = new MockTransport();
await sendMagicLink(transport, "user@example.com", "https://example.com/verify?token=...");
const sent = await transport.waitForMessage(
(msg) => msg.subject.includes("로그인 링크"),
1000,
);
assert.equal(sent.recipients[0].address, "user@example.com");
sendMagicLink() 자체는 한 줄도 바뀌지 않았다. 바뀐 건 어떤 Transport를 넘겼느냐뿐이다.
작지만 복잡함을 품는 API
Transport 인터페이스가 작다고 해서 이메일이라는 프로토콜의 세부 사항까지 생략한 건 아니다. SMTP 트랜스포트는 SMTPUTF8로 국제화된 주소를 처리하고, DSN으로 배달 상태 알림을 요청한다. 첨부 파일은 스트리밍 방식으로 처리해서 큰 파일도 메모리를 거의 쓰지 않고 보낼 수 있다. Message.calendar 필드를 쓰면 회의 초대나 취소 메시지를 만들 수 있고, messageId와 inReplyTo, references 필드는 발신 메일을 대화의 일부로 다룰 수 있게 해준다.
시작하기
SMTP로 보내려면 다음 패키지를 설치한다.
npm add @upyo/core @upyo/smtp
import { createMessage } from "@upyo/core";
import { SmtpTransport } from "@upyo/smtp";
const transport = new SmtpTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "smtp-user", pass: "smtp-password" },
});
const message = createMessage({
from: "sender@example.com",
to: "recipient@example.com",
subject: "Hello from Upyo",
content: { text: "This is a test email." },
});
await transport.send(message);
필요하면 위에서 본 RetryTransport나 PoolTransport, LogTapeTransport를 그 위에 겹쳐 쌓으면 된다. 각 트랜스포트의 설정과 옵션은 문서에 정리해뒀고, 소스는 GitHub에 있다. 패키지는 npm과 JSR에 모두 올라간다.
처음 던졌던 질문, 런타임과 프로바이더에 상관없이 애플리케이션이 메일을 어떻게 전달하느냐는 질문에 대한 지금까지의 답은 이 정도다. 아직 다듬어야 할 부분도 많으니, 써보고 느낀 점이나 고쳐야 할 부분이 있으면 이슈나 토론으로 남겨주면 좋겠다.
中文(中国):关于如何从 Node.js 发送电子邮件,其实早已有了答案。Node.js 拥有 Nodemailer,长期以来它都是一个可靠的选择。但如今人们编写的代码已不再局限于 Node.js 运行环境。代码运行在 Deno、Bun,或是像 Cloudflare Workers 这样的边缘运行时上,已是司空见惯;而在开发环境与生产环境之间切换邮件服务商,也是常见做法:本地开发时不发送邮件,部署后再切换到 SES 或 Resend。
因此,我想稍微改变一下问题的角度。问题不再是如何从 Node.js 发送电子邮件,而是应用程序如何在不受运行时或服务商限制的情况下发送电子邮件。这正是 Upyo 项目的出发点。
Nodemailer 很出色,只是版图变大了
如果你是在 Node.js 中通过 SMTP 发送电子邮件,Nodemailer 已经够用了。它的 SMTP 实现十分成熟,支持 OAuth 2.0,并且已经能够处理 DKIM 签名与日历邀请。
问题在于,Nodemailer 运行依赖于 Node.js 内置模块,例如 node:net、node:tls 与 node:stream。这使得它难以在 Cloudflare Workers、Vercel Edge Runtime 或 Supabase Edge Functions 上使用。它对服务商的支持也主要围绕 SMTP 展开,因此若要使用像 Resend 或 SendGrid 这样的 HTTP API 服务,就需要安装该服务自身的 SDK,或依赖第三方传输层。而切换服务商,通常也意味着要改动应用程序代码。
普适性原则
Upyo 构建于 fetch()、Web Streams 与 Web Crypto 等 Web 标准 API 之上。由于它不依赖 Node.js 特有的模块,同一份代码可以在 Node.js、Deno、Bun 以及边缘函数上原封不动地运行。
每个电子邮件服务都通过同一个 Transport 接口来处理:SMTP 以及 JMAP、Resend、SendGrid、Mailgun、Amazon SES、Plunk、Lettermint。只需替换传输层的构造方式,发送邮件的应用程序代码便始终保持不变——无论用的是本地 SMTP 服务器、开发环境中的模拟传输层,还是生产环境中的 SES。
保持依赖精简也是出于同样的目标。@upyo/ses 自行实现了 AWS Signature v4,而不是引入 AWS SDK。AWS SDK 是以 Node.js 为前提设计的,若直接使用,会导致该传输层在 Deno、Bun 或边缘运行时上无法使用。
将重试逻辑与应用逻辑分离
Transport 接口本身十分精简。send() 用于发送单条消息,sendMany() 用于发送多条消息,而取消操作则通过标准的 AbortSignal 来实现。
sendMany() 的具体实现方式因传输层而异。对于像 Resend 或 SendGrid 这类提供批量发送端点的服务商,其传输层会直接调用该端点。而对于 SMTP 而言,复用同一个连接来发送多条消息才是自然的做法,因此它采取的正是这种方式。若某个传输层两种优化手段都不具备,它仍可以选择并发运行多个 send() 调用,而非逐一顺序执行,以缩短总耗时。
正因为这个接口如此精简,"重试策略应放在何处"这一问题的答案也随之改变。只需用 RetryTransport 包裹某个传输层,应用程序代码便完全无需自行实现重试逻辑。
之所以能做到这一点,是因为应用程序代码只关心 Transport 这一接口类型,而不关心具体实现。负责发送邮件的函数接受一个 transport: Transport 参数,而具体注入哪种传输层,则由调用方决定。
各服务商的失败方式各不相同;如果这一点不再重要呢?
import { MockTransport } from "@upyo/mock";
import { RetryTransport } from "@upyo/retry";
const provider = new MockTransport();
const transport = new RetryTransport(provider, {
maxAttempts: 3,
backoff: {
baseDelayMilliseconds: 1000,
maxDelayMilliseconds: 30000,
factor: 2,
},
});
RetryTransport 实现了同一个 Transport 接口,因此应用程序代码无需知道自己面对的是 SMTP 还是 SES,也无需知道重试是否正在发生。再用 PoolTransport 将其包裹起来,某个失败的服务商便可按优先级故障转移至下一个,或者也可以采用轮询方式将流量分散到多个服务商上。
import { PoolTransport } from "@upyo/pool";
const pool = new PoolTransport({
strategy: "priority",
transports: [
{ transport: primaryProvider, priority: 100 },
{ transport: backupProvider, priority: 10 },
],
maxRetries: 3,
});
这两者可以叠加使用。究竟是在故障转移至下一个服务商之前对单个服务商进行重试,还是将整个连接池视为一次操作来重试,取决于哪一个位于外层。
这种组合之所以可行,是因为每个传输层返回的失败信息都具有相同的结构。Upyo 的 Receipt 是成功与失败的可辨识联合类型,而失败结果携带着诸如 retryable、category 与 retryAfterMilliseconds 之类的结构化字段。一旦每个传输层都将服务商的错误转换为这些字段,RetryTransport 便能以同样的方式,决定是否应该对来自 Resend 的 429 错误,或是某个临时性的 SMTP 错误进行重试。
仍需等待魔法链接,即使在开发环境中也不例外
构建魔法链接登录功能时,令人烦恼的部分从来都不是登录本身,而是每次都要打开收件箱、等待链接出现——尽管在开发过程中,这条链接原本就不需要真正送达任何地方。
从一开始,负责发送邮件的函数就被设计为接受一个 Transport。
import { createMessage } from "@upyo/core";
import type { Transport } from "@upyo/core";
async function sendMagicLink(transport: Transport, email: string, link: string) {
await transport.send(createMessage({
from: "auth@example.com",
to: email,
subject: "Your login link",
content: { text: `Click to log in: ${link}` },
}));
}
@upyo/logtape 会包裹另一个传输层,并以记录日志的方式代替实际发送。在开发环境中,注入的正是它。
import { LogTapeTransport } from "@upyo/logtape";
import { SmtpTransport } from "@upyo/smtp";
const smtp = new SmtpTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "smtp-user", pass: "smtp-password" },
});
const transport = process.env.NODE_ENV === "development"
? new LogTapeTransport({ category: ["app", "email"] })
: new LogTapeTransport({ transport: smtp, category: ["app", "email"] });
直接从服务器日志中把链接复制出来,粘贴到浏览器里即可。无需反复刷新收件箱。
自动化测试也使用同一个注入点。这一次,sendMagicLink() 接收到的是 @upyo/mock 提供的 MockTransport。
import { MockTransport } from "@upyo/mock";
import assert from "node:assert/strict";
const transport = new MockTransport();
await sendMagicLink(transport, "user@example.com", "https://example.com/verify?token=...");
const sent = await transport.waitForMessage(
(msg) => msg.subject.includes("login link"),
1000,
);
assert.equal(sent.recipients[0].address, "user@example.com");
sendMagicLink() 本身从未发生过改变,改变的只是传递给它的那个 Transport。
小巧的 API,依然承载着完整的复杂性
精简的 Transport 接口,并不意味着电子邮件中那些繁琐棘手的部分被略过了。SMTP 传输层 通过 SMTPUTF8 处理国际化地址,并通过 DSN 请求投递状态通知。附件 采用流式处理,因此即便是大文件也不会占用内存。Message.calendar 字段能将一条消息转变为会议邀请或取消通知,而 messageId、inReplyTo 与 references 则让发出的邮件成为对话的一部分,而非一次性的孤立通知。
快速上手
若要通过 SMTP 发送邮件,请安装以下软件包。
npm add @upyo/core @upyo/smtp
import { createMessage } from "@upyo/core";
import { SmtpTransport } from "@upyo/smtp";
const transport = new SmtpTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "smtp-user", pass: "smtp-password" },
});
const message = createMessage({
from: "sender@example.com",
to: "recipient@example.com",
subject: "Hello from Upyo",
content: { text: "This is a test email." },
});
await transport.send(message);
按需在其上叠加 RetryTransport、PoolTransport 或 LogTapeTransport。每个传输层的配置说明请参阅文档,源代码托管于 GitHub,软件包则发布在 npm 与 JSR 上。
这便是目前对我最初提出的那个问题的回答:应用程序如何在不受运行时或服务商限制的情况下发送电子邮件。仍有许多地方有待打磨完善,因此如果发现有任何问题或缺失,欢迎提交 issue 或参与讨论。
中文(台灣):關於如何從 Node.js 寄送電子郵件,其實早已有解答。Node.js 有 Nodemailer,長久以來都是一個穩固的選擇。但如今人們撰寫的程式碼,早已不再只運行於 Node.js 之上。如今看到程式碼運行於 Deno、Bun,或是像 Cloudflare Workers 這類邊緣執行環境(edge runtime),已是稀鬆平常之事;而在開發與正式環境之間切換供應商,也已是常見做法:在本機端不寄送任何郵件,部署後則改用 SES 或 Resend。
因此,我想稍微調整一下問題的方向。問題不在於「如何從 Node.js 寄送電子郵件」,而在於「無論執行環境或供應商為何,應用程式該如何遞送電子郵件」。而這正是 Upyo 出發的起點。
Nodemailer 很優秀,只是版圖變大了
如果你是從 Node.js 透過 SMTP 寄送電子郵件,Nodemailer 就已經夠用了。它的 SMTP 實作相當成熟,支援 OAuth 2.0,也已能處理 DKIM 簽章與行事曆邀請。
問題在於,Nodemailer 是建構於 Node.js 內建模組之上,例如 node:net、node:tls 與 node:stream。這使得它難以在 Cloudflare Workers、Vercel Edge Runtime 或 Supabase Edge Functions 上使用。而且其供應商支援也主要集中於 SMTP,因此若要使用像 Resend 或 SendGrid 這類 HTTP API 服務,就得安裝該服務自身的 SDK,或是仰賴第三方傳輸層(transport)。而切換供應商,通常也意味著必須連帶修改應用程式的程式碼。
通用性原則
Upyo 是建構於 Web 標準 API 之上,例如 fetch()、Web Streams 與 Web Crypto。由於它不依賴 Node.js 特有的模組,同一份程式碼便能不加修改地運行於 Node.js、Deno、Bun 以及各式邊緣函式(edge functions)之上。
每一項電子郵件服務,都是透過相同的 Transport 介面來處理:SMTP 與 JMAP、Resend、SendGrid、Mailgun、Amazon SES、Plunk、Lettermint。只需替換傳輸層(transport)的建構方式,寄送郵件的應用程式程式碼便維持不變——無論是本機端的 SMTP 伺服器、開發環境中的模擬傳輸層,或是正式環境中的 SES,皆是如此。
將相依套件維持在最少,也是出自同一個目標。@upyo/ses 自行實作了 AWS Signature v4,而非引入 AWS SDK。因為 AWS SDK 是以 Node.js 為前提設計的,若原封不動地使用,將導致該傳輸層無法在 Deno、Bun 或邊緣執行環境上使用。
將重試邏輯與應用程式邏輯分離
Transport 介面本身相當精簡。send() 用於寄送單一訊息,sendMany() 用於寄送多則訊息,而取消操作則透過標準的 AbortSignal 進行。
sendMany() 的實作方式,因傳輸層(transport)而異。像 Resend 或 SendGrid 這類提供批次端點的供應商,其傳輸層便會直接呼叫該端點。而 SMTP 由於重複使用同一條連線來傳送多則訊息才是自然的做法,因此便採用此方式。若某個傳輸層既無批次端點、也無連線重用的優化空間,仍可選擇同時並行執行多次 send() 呼叫,而非逐一依序執行,藉此縮短總耗時。
正因為這個介面如此精簡,「重試策略該置於何處」這個問題的答案也隨之改變。只要以 RetryTransport 包裝某個傳輸層,應用程式的程式碼便完全不需要自行實作重試邏輯。
而這一切之所以可行,是因為應用程式的程式碼只認得 Transport 這個介面型別,而不認得具體的實作類別。負責寄送郵件的函式接受一個 transport: Transport 參數,至於要注入哪一個傳輸層,則由呼叫端自行決定。
供應商各有各的失敗方式;如果這一點無關緊要呢?
import { MockTransport } from "@upyo/mock";
import { RetryTransport } from "@upyo/retry";
const provider = new MockTransport();
const transport = new RetryTransport(provider, {
maxAttempts: 3,
backoff: {
baseDelayMilliseconds: 1000,
maxDelayMilliseconds: 30000,
factor: 2,
},
});
RetryTransport 實作了相同的 Transport 介面,因此應用程式的程式碼無需知道自己正在與 SMTP 還是 SES 通訊,也不需要知道重試是否正在進行。只要以 PoolTransport 包裝它,失敗的供應商便能依優先順序容錯移轉(failover)至下一個,或是以循環(round robin)方式將流量分散至多個供應商。
import { PoolTransport } from "@upyo/pool";
const pool = new PoolTransport({
strategy: "priority",
transports: [
{ transport: primaryProvider, priority: 100 },
{ transport: backupProvider, priority: 10 },
],
maxRetries: 3,
});
這兩者可以疊加使用。究竟是在容錯移轉至下一個供應商之前,先針對每個供應商進行重試,還是將整個池(pool)視為單一操作進行重試,端看哪一者位於外層。
這種組合之所以可行,是因為每一個傳輸層(transport)都以相同的結構回傳失敗結果。Upyo 的 Receipt 是成功與失敗的可辨識聯合型別(discriminated union),而失敗結果則帶有結構化欄位,例如 retryable、category 與 retryAfterMilliseconds。一旦每個傳輸層都將供應商的錯誤轉換為這些欄位,RetryTransport 便能以相同的方式,判斷是否該重試來自 Resend 的 429 錯誤,或是暫時性的 SMTP 錯誤。
仍在等待魔法連結,即使在開發環境中也是如此
打造魔法連結(magic-link)登入功能時,惱人之處從來就不是登入本身。而是每次都得打開信箱,等待連結出現——儘管在開發階段,這個連結其實根本不需要被真正遞送到任何地方。
負責寄送郵件的函式,從一開始就設計為接受一個 Transport。
import { createMessage } from "@upyo/core";
import type { Transport } from "@upyo/core";
async function sendMagicLink(transport: Transport, email: string, link: string) {
await transport.send(createMessage({
from: "auth@example.com",
to: email,
subject: "Your login link",
content: { text: `Click to log in: ${link}` },
}));
}
@upyo/logtape 會包裝另一個傳輸層(transport),改為記錄日誌而非實際寄送。在開發環境中,注入的正是這個傳輸層。
import { LogTapeTransport } from "@upyo/logtape";
import { SmtpTransport } from "@upyo/smtp";
const smtp = new SmtpTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "smtp-user", pass: "smtp-password" },
});
const transport = process.env.NODE_ENV === "development"
? new LogTapeTransport({ category: ["app", "email"] })
: new LogTapeTransport({ transport: smtp, category: ["app", "email"] });
直接從伺服器日誌複製連結,貼到瀏覽器裡即可。無需再刷新信箱。
自動化測試也使用相同的注入點。這次,sendMagicLink() 拿到的是 @upyo/mock 的 MockTransport。
import { MockTransport } from "@upyo/mock";
import assert from "node:assert/strict";
const transport = new MockTransport();
await sendMagicLink(transport, "user@example.com", "https://example.com/verify?token=...");
const sent = await transport.waitForMessage(
(msg) => msg.subject.includes("login link"),
1000,
);
assert.equal(sent.recipients[0].address, "user@example.com");
sendMagicLink() 本身從未改變。改變的,只有傳遞給它的那個 Transport。
精簡的 API,卻依然承載著複雜性
精簡的 Transport 介面,並不代表電子郵件中那些棘手的部分就被略過了。SMTP 傳輸層透過 SMTPUTF8 處理國際化位址,並透過 DSN 請求遞送狀態通知。附件採用串流方式處理,因此即使是大型檔案,也不會佔用記憶體。Message.calendar 欄位能將一則訊息轉變為會議邀請或取消通知,而 messageId、inReplyTo 與 references 則讓外寄郵件能成為對話的一部分,而非只是一次性的通知。
快速上手
若要透過 SMTP 寄送郵件,請安裝以下套件。
npm add @upyo/core @upyo/smtp
import { createMessage } from "@upyo/core";
import { SmtpTransport } from "@upyo/smtp";
const transport = new SmtpTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "smtp-user", pass: "smtp-password" },
});
const message = createMessage({
from: "sender@example.com",
to: "recipient@example.com",
subject: "Hello from Upyo",
content: { text: "This is a test email." },
});
await transport.send(message);
依需求,可再疊加 RetryTransport、PoolTransport 或 LogTapeTransport。各個傳輸層(transport)的設定方式可參閱文件,原始碼位於 GitHub,套件則發布於 npm 與 JSR。
以上便是我一開始所提出的問題——無論執行環境或供應商為何,應用程式該如何遞送電子郵件——目前所得到的答案。仍有許多值得琢磨之處,因此若發現有任何問題或缺漏之處,歡迎提出 issue 或展開討論。