# 邮件发送:脱离环境与方式的束缚——面向 JavaScript 与 TypeScript 的跨运行时、跨服务商邮件库
来源:https://hackers.pub/@hongminhee/2026/upyo-email-decoupled-from-where-and-how
如何在 Node.js 中发送邮件的问题早已存在解决方案。Node.js 拥有 Nodemailer(https://nodemailer.com/),它长期以来一直是可靠的选择。但如今编写的代码不再仅限于在 Node.js 上运行。常见的现象包括:代码运行在 Deno、Bun 或 Cloudflare Workers 等边缘运行时上,并且开发环境与生产环境之间频繁切换服务商——本地不发送任何邮件,部署后则使用 SES 或 Resend。
因此,我想稍微改变一下问题的提法。不是如何从 Node.js 发送邮件,而是应用程序如何在无论运行时或服务商的情况下都能可靠地投递邮件。这正是 Upyo(https://upyo.org/)出发点所在。
## Nodemailer 很优秀;但生态已更加广阔
如果你在 Node.js 上通过 SMTP 发送邮件,Nodemailer 绑绑有余。它的 SMTP 实现成熟,支持 OAuth 2.0,并且已经能处理 DKIM 签名和日历邀请。
但问题在于,Nodemailer 建立在 Node.js 内置模块之上,例如 `node:net`(https://nodejs.org/api/net.html)、`node:tls`(https://nodejs.org/api/tls.html)和 `node:stream`(https://nodejs.org/api/stream.html)。这使得它很难在 Cloudflare Workers、Vercel Edge Runtime 或 Supabase Edge Functions 上使用。其服务商支持也以 SMTP 为主,因此要使用 Resend 或 SendGrid 等 HTTP API 服务,就需要安装该服务自身的 SDK 或依赖第三方传输模块。更换服务商通常也意味着需要修改应用代码。
## 通用性原则
Upyo 构建在 Web 标准 API 之上,如 `fetch()`(https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch)、Web Streams(https://developer.mozilla.org/en-US/docs/Web/API/Streams_API)和 Web Crypto(https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API)。由于它不依赖 Node.js 特定模块,相同的代码无需更改即可在 Node.js、Deno、Bun 和边缘函数上运行。
每种邮件服务都通过统一的 `Transport` 接口来处理:SMTP 和 JMAP(https://jmap.io/)、Resend(https://resend.com/)、SendGrid(https://sendgrid.com/)、Mailgun(https://www.mailgun.com/)、Amazon SES(https://aws.amazon.com/ses/)、Plunk(https://useplunk.com/)、Lettermint(https://lettermint.co/)。只需更换传输层的构造方式,发送邮件的应用代码就能保持不变,无论传输层是开发环境的本地 SMTP 服务器或模拟传输,还是生产环境的 SES。
保持依赖最小化也源于同一目标。`@upyo/ses` 自行实现了 AWS Signature v4,而不是引入 AWS SDK。AWS SDK 默认假定 Node.js 环境,如果直接使用,将导致该传输层无法在 Deno、Bun 或边缘运行时上使用。
## 将重试逻辑与应用逻辑分离
`Transport` 接口本身很精简。`send()` 发送单条消息,`sendMany()` 发送多条,取消操作则通过标准的 `AbortSignal`(https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal)实现。
`sendMany()` 的具体实现因传输层而异。对于像 Resend 或 SendGrid 这样提供批量端点的服务商,其传输层会直接调用该端点。而 SMTP 的天然做法是重用单个连接发送多条消息。对于没有上述优化的传输层,也可能会并发执行多个 `send()` 调用而非顺序执行,以缩短总耗时。
由于接口如此精简,重试策略应存放于何处的答案也随之改变。将传输层包装在 `RetryTransport` 中,应用代码就永远不需要自己实现重试逻辑。
这是可行的,因为应用代码只知晓 `Transport` 接口类型,而非具体实现。发送邮件的函数接受一个 `transport: Transport` 参数,而调用方决定注入哪个传输层。
## 服务商故障模式各异;如果这无关紧要呢?
```javascript
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` 中,一个服务商故障时可以按优先级切换到下一个,或者使用轮询策略将流量分散到多个服务商。
```javascript
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`。
```javascript
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: "
[email protected]",
to: email,
subject: "Your login link",
content: { text: `Click to log in: ${link}` },
}));
}
```
`@upyo/logtape` 会包装另一个传输层并执行日志记录而非发送。在开发环境中,注入的正是它。
```javascript
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`。
```javascript
import { MockTransport } from "@upyo/mock";
import assert from "node:assert/strict";
const transport = new MockTransport();
await sendMagicLink(transport, "
[email protected]", "https://example.com/verify?token=...");
const sent = await transport.waitForMessage(
(msg) => msg.subject.includes("login link"),
1000,
);
assert.equal(sent.recipients[0].address, "
[email protected]");
```
`sendMagicLink()` 函数本身从未改变。只有传入其中的 `Transport` 发生了变化。
## 小巧的 API 依然承载复杂功能
精简的 `Transport` 接口并不意味着邮件发送的复杂部分被忽略了。SMTP 传输层(https://upyo.org/transports/smtp)通过 SMTPUTF8 处理国际化地址,并通过 DSN 请求投递状态通知。附件(https://upyo.org/messages/attachments)支持流式处理,因此即使是大文件也不会占用内存。`Message.calendar`(https://upyo.org/messages/calendar)字段能将消息转换为会议邀请或取消通知,而 `messageId`、`inReplyTo` 和 `references`(https://upyo.org/messages/compose)使得发出的邮件能成为对话的一部分,而非孤立的通知。
## 快速开始
要通过 SMTP 发送邮件,请安装以下包。
```bash
npm add @upyo/core @upyo/smtp
```
```javascript
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: "
[email protected]",
to: "
[email protected]",
subject: "Hello from Upyo",
content: { text: "This is a test email." },
});
await transport.send(message);
```
根据需要,可以在其上层叠加 `RetryTransport`、`PoolTransport` 或 `LogTapeTransport`。每个传输层的配置详见文档(https://upyo.org/),源代码位于 GitHub(https://github.com/dahlia/upyo),包发布在 npm(https://www.npmjs.com/package/@upyo/core)和 JSR(https://jsr.io/@upyo/core)上。
至此,对于最初提出的问题——应用程序如何在无论运行时或服务商的情况下都能可靠地投递邮件——这便是目前的答案。仍有大量内容需要完善,如果遇到问题或发现缺失功能,欢迎提交 Issue 或参与讨论。