用 CloudBase 云函数代理 Resend 发邮件
一句话定义:在 CloudBase Web 云函数(HTTP 触发) 里调
resend.emails.send,从已验证域名的发件人地址发事务邮件;Resend 那侧负责 SPF/DKIM/DMARC,前端不暴露 API key。预计耗时:30–60 分钟(其中域名 DNS 验证可能需要 5 分钟到几小时不等,看 DNS 服务商生效速度)| 难度:进阶
适用场景
- 注册验证邮件、密码重置、订单确认、账单提醒、agent 跑完任务给用户发结果——这类一对一的事务邮件
- 不想自建 SMTP(Postfix/SendGrid 自部署),又想保持送达率和可观测性
- 用户主要在海外,对国内运营商邮箱依赖少;或对国内邮箱的送达率能接受小流量先验证
不适用:
- 收件方主要是 QQ/163/126/Foxmail 等国内运营商邮箱:海外发件 IP 偶尔被这些邮箱归类垃圾邮件,要先小流量灰度看送达率,必要时叠加国内邮件服务(腾讯云邮件推送、阿里云邮件推送)
- 只是发企微群通知、告警、不到 IM 用户:直接看 connect-wecom-webhook-cloud-function,不要走邮件
- 营销邮件、群发 newsletter:Resend 也能做,但本篇只覆盖事务邮件场景,营销发送涉及 unsubscribe/合规另说
环境要求
| 依赖 | 版本/要求 |
|---|---|
| Node.js(云函数运行时) | ≥ 18 |
resend SDK | latest |
@cloudbase/cli | latest |
| 云函数类型 | Web 云函数(HTTP 触发) —— 前端/小程序直接调 |
| 公网出口 | 云函数默认能访问公网,控制台「网络 → 公网访问」确认开启 |
| Resend 账号 | 免费档每天有限额,价格以 Resend pricing 当前展示为准 |
| 已验证发件域名 | 必须,下文第一步详述;否则 from 只能用 onboarding@resend.dev,仅供测试 |
第一步:Resend 注册并验证发件域名
发件域名验证是整个流程里耗时最长、最容易翻车的一步,建议先做。
- 登录 resend.com → 左侧 Domains → Add Domain,填你想用作发件人的域名(例如
mail.yourdomain.com,建议用子域名而非根域名,避免污染主域邮件信誉) - Resend 会给出三组 DNS 记录,具体值以 dashboard 当前给的为准,不要照抄任何文档里的"示例值"。结构上一般包括:
- 一条
MX记录(指向 Resend 的 MX server,用于退信处理) - 一条
TXT记录(SPF:声明 Resend 有权代你域名发件) - 一条
CNAME或TXT记录(DKIM:邮件签名公钥)
- 一条
- 把这三条记录加到你域名的 DNS 管理处(Cloudflare / 阿里云 DNS / DNSPod 等都可以)。注意 host 字段要按 DNS 服务商的规则填——比如 Cloudflare 直接填子域名,阿里云 DNS 要去掉根域名只留前缀
- 回到 Resend dashboard 点 Verify。状态会从
not_started→pending→verified。生效时间从几分钟到几小时不等,看 DNS 服务商的 TTL 和缓存 - 验证通过后,DKIM/SPF 都由 Resend 自动签名,你不用在云函数里管这些;想再叠 DMARC 策略可以再加一条
_dmarc的 TXT,但不是必须
临时跳过验证只为跑通流程:可以把
from填成onboarding@resend.dev,但只能发到你账号绑定的邮箱,不能发给真实用户。
第二步:在 CloudBase 云函数环境变量里配 RESEND_API_KEY
到 Resend dashboard → API Keys → Create API Key,scope 选 Sending access,复制出来的 re_xxx...。这个 key 之后看不到第二次,丢了只能重建。
之后到 CloudBase 控制台「云函数 → send-email → 环境变量」加:
RESEND_API_KEY:上面拿到的re_xxx...RESEND_FROM:例如Acme <noreply@mail.yourdomain.com>,必须是已验证域名下的地址,且必须带Name <email>格式(Resend 的 from 字段不接受裸 email)
代码里绝对不要硬编码 key——一旦提交进 git 就等同泄露,Resend 会自动 revoke 但你已经掉一次坑了。延伸看 secure-secrets-in-cloud-function。
第三步:写云函数(resend.emails.send 完整调用 + 错误处理)
初始化项目:
mkdir send-email && cd send-email
npm init -y
npm install resend express
package.json 设置 "main": "index.js" 且有 "start": "node index.js"。
index.js:
const express = require('express');
const { Resend } = require('resend');
const app = express();
app.use(express.json({ limit: '10mb' }));
const RESEND_API_KEY = process.env.RESEND_API_KEY;
const RESEND_FROM = process.env.RESEND_FROM; // 例: 'Acme <noreply@mail.yourdomain.com>'
if (!RESEND_API_KEY || !RESEND_FROM) {
console.error('Missing RESEND_API_KEY or RESEND_FROM env');
process.exit(1);
}
const resend = new Resend(RESEND_API_KEY);
app.post('/send', async (req, res) => {
const { to, subject, html, text, replyTo, cc, bcc, tags } = req.body || {};
// 入参校验:to/subject/html 三选必给(html 没有就给 text)
if (!to || !subject || (!html && !text)) {
return res.status(400).json({ error: 'missing_fields', message: 'to/subject/html 或 text 必填' });
}
const { data, error } = await resend.emails.send({
from: RESEND_FROM,
to: Array.isArray(to) ? to : [to],
subject,
html,
text, // 可选,纯文本 fallback
replyTo, // 可选,回信跳到客服邮箱
cc, // 可选,数组
bcc, // 可选,数组
tags, // 可选,[{ name: 'category', value: 'transactional' }] 用于 dashboard 分类
headers: { 'X-Entity-Ref-ID': req.body?.refId || '' }, // 可选,业务侧 ID 透传
});
if (error) {
// Resend 的 error 结构: { name: 'validation_error' | 'missing_required_field' | ..., message: '...' }
console.error('resend send failed', error);
return res.status(502).json({ error: error.name || 'resend_error', message: error.message });
}
// 成功: data = { id: 'uuid' },这个 id 是 Resend 侧的邮件 ID,可在 dashboard 查送达状态
res.json({ ok: true, id: data?.id });
});
app.get('/health', (_req, res) => res.json({ ok: true }));
const PORT = process.env.PORT || 9000;
app.listen(PORT, () => {
console.log(`send-email listening on ${PORT}`);
});
几个容易踩的点:
to在 SDK 里要的是数组,但单收件人传字符串也接受;为了少踩雷统一传数组from里没有Name <email>这个尖括号格式 Resend 会直接报validation_erroridempotencyKey没 在示例里用,但如果你的业务端可能重试(队列消费失败重发等)建议加上:相同 key 24 小时内只发一次,避免用户连收三封一样的邮件scheduledAt想做"明天 9 点提醒"这类定时邮件可以传 ISO 8601 时间戳(UTC 或带时区),Resend 会延迟到那个时间发出- 错误处理用
if (error)不要用try/catch——Resend SDK 把网络失败和业务失败都收敛进了error字段,try/catch只能兜住 SDK 自己抛的同步错(参数缺失这类)
第四步:模板选型——纯 HTML 字符串 vs React Email 组件
最简单的就是 html 字段直接拼字符串:
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 480px;">
<h2>欢迎注册 Acme</h2>
<p>请点击下面的链接验证你的邮箱:</p>
<a href="${verifyUrl}" style="display: inline-block; padding: 10px 20px; background: #000; color: #fff; text-decoration: none; border-radius: 4px;">验证邮箱</a>
</div>
`
简单粗暴,但邮件 HTML 兼容性是出了名的恶心——Outlook 不认 flex、Gmail 把 <style> 标签砍了一半、深色模式下颜色全反。模板复杂起来后建议上 React Email:
npm install @react-email/components @react-email/render
// templates/welcome.jsx
const { Html, Body, Container, Heading, Button } = require('@react-email/components');
function WelcomeEmail({ verifyUrl }) {
return (
<Html>
<Body style={{ fontFamily: 'sans-serif' }}>
<Container>
<Heading>欢迎注册 Acme</Heading>
<Button href={verifyUrl} style={{ background: '#000', color: '#fff', padding: '10px 20px' }}>
验证邮箱
</Button>
</Container>
</Body>
</Html>
);
}
module.exports = WelcomeEmail;
调用时把 react 字段塞进去(不用 html):
const WelcomeEmail = require('./templates/welcome');
const { data, error } = await resend.emails.send({
from: RESEND_FROM,
to: [userEmail],
subject: '验证你的 Acme 账号',
react: WelcomeEmail({ verifyUrl }),
});
Resend SDK 内部会调 @react-email/render 把 React 组件渲染成兼容性处理过的 HTML——它会把样式 inline 化、把不被支持的 CSS 降级、生成 plain text fallback。云函数侧记得 package.json 加上 react 和 react-dom 做依赖,否则 React Email 跑不起来。
第五步:部署
tcb login
tcb fn deploy send-email --httpFn -e your-env-id
部署完到控制台「云函数 → send-email」做:
- 确认环境变量里
RESEND_API_KEY和RESEND_FROM已经设置(第二步做过的话跳过) - 网络配置 公网访问已启用
- 触发方式 HTTP 访问服务已启用,记下 URL,类似
https://your-env.service.tcloudbase.com/send-email
运行验证
从命令行调一发到自己邮箱:
curl -X POST 'https://your-env.service.tcloudbase.com/send-email/send' \
-H 'Content-Type: application/json' \
-d '{
"to": "yourself@gmail.com",
"subject": "CloudBase × Resend 测试",
"html": "<strong>It works!</strong>"
}'
预期:
- 函数返回
{ "ok": true, "id": "xxxxxxx-uuid" } - 邮箱在 1–10 秒内收到,发件人是
RESEND_FROM配的地址 - 到 Resend dashboard → Logs,能看到这封邮件的
delivered状态(sent→delivered),如果到bounced或complained就是收件方拒收了 - Gmail 收件箱点开邮件 → 三个点 → Show original,能看到
dkim=pass和spf=pass,没过的话回去检查第一步 DNS
常见错误
| 错误信息 | 原因 | 修复 |
|---|---|---|
validation_error: The 'from' field must be a verified domain | 发件域名没在 Resend 验证通过 | 回第一步把 DNS 记录加齐,等 dashboard 状态变成 verified;测试期临时改成 onboarding@resend.dev |
validation_error: from must be in format 'Name <email>' | RESEND_FROM 写成了裸 email 例如 noreply@mail.yourdomain.com | 改成 Acme <noreply@mail.yourdomain.com>,必须有名字 + 尖括号 |
restricted_api_key: This API key is restricted to only sending emails | API key scope 不对(比如选成了 Domains access) | 到 dashboard 重建一个 Sending access 的 key |
| 邮件发出去了,QQ/163/126 进了垃圾邮件 | 海外发件 IP 在国内运营商信誉一般 + 发件域名新建立 | DMARC 策略加严(p=none → p=quarantine);先小流量预热域名几天;引导用户加白名单;高频发送考虑切到腾讯云邮件推送 |
attachment_too_large | 附件超过 Resend 单封邮件 40 MB 总大小限制 | 大附件改用对象存储发链接,或者拆多封;以 Resend 附件文档 当前限制为准 |
rate_limit_exceeded | 短时间发太多,触发限流(默认每秒 2 封,可在 dashboard 申请上调) | 业务侧加队列削 峰;批量发用 resend.batch.send 一次最多 100 封;联系 Resend support 开白 |
invalid_idempotency_key | 重试时 idempotencyKey 用了之前成功过的,但参数不一致 | 同一 key 必须配相同参数;要发不同邮件就换 key |
getaddrinfo EAI_AGAIN api.resend.com | 云函数没公网出口 | 控制台开公网访问;地域受限的话挂出口 NAT |
| 函数超时 30 秒断了 | 默认函数超时 30 秒,碰上 Resend 慢响应或重试 | 控制台 → 函数配置 → 超时时间,调到 60 秒;或者把发送丢到队列异步处理 |
错误码完整列表见 docs.cloudbase.net 错误码 和 Resend errors 官方文档。
相关文档
- alert-cloud-function-errors-to-wecom — 告警通道选型:本篇是邮件,企微 Webhook 是 IM;事务邮件给用户、运维告警走企微,组合用
- connect-wecom-webhook-cloud-function — 只发内部群通知不需要走邮件,企微 Webhook 更直接
- secure-secrets-in-cloud-function —
RESEND_API_KEY等敏感值的环境变量管理、本地开发与生产分层 - HTTP 云函数 — Web 云函数(HTTP 触发)的快速上手
- 函数环境变量 — 配置注入