跳到主要内容

用 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 SDKlatest
@cloudbase/clilatest
云函数类型Web 云函数(HTTP 触发) —— 前端/小程序直接调
公网出口云函数默认能访问公网,控制台「网络 → 公网访问」确认开启
Resend 账号免费档每天有限额,价格以 Resend pricing 当前展示为准
已验证发件域名必须,下文第一步详述;否则 from 只能用 onboarding@resend.dev,仅供测试

第一步:Resend 注册并验证发件域名

发件域名验证是整个流程里耗时最长、最容易翻车的一步,建议先做。

  1. 登录 resend.com → 左侧 DomainsAdd Domain,填你想用作发件人的域名(例如 mail.yourdomain.com,建议用子域名而非根域名,避免污染主域邮件信誉)
  2. Resend 会给出三组 DNS 记录,具体值以 dashboard 当前给的为准,不要照抄任何文档里的"示例值"。结构上一般包括:
    • 一条 MX 记录(指向 Resend 的 MX server,用于退信处理)
    • 一条 TXT 记录(SPF:声明 Resend 有权代你域名发件)
    • 一条 CNAMETXT 记录(DKIM:邮件签名公钥)
  3. 把这三条记录加到你域名的 DNS 管理处(Cloudflare / 阿里云 DNS / DNSPod 等都可以)。注意 host 字段要按 DNS 服务商的规则填——比如 Cloudflare 直接填子域名,阿里云 DNS 要去掉根域名只留前缀
  4. 回到 Resend dashboard 点 Verify。状态会从 not_startedpendingverified。生效时间从几分钟到几小时不等,看 DNS 服务商的 TTL 和缓存
  5. 验证通过后,DKIM/SPF 都由 Resend 自动签名,你不用在云函数里管这些;想再叠 DMARC 策略可以再加一条 _dmarc 的 TXT,但不是必须

临时跳过验证只为跑通流程:可以把 from 填成 onboarding@resend.dev,但只能发到你账号绑定的邮箱,不能发给真实用户。

第二步:在 CloudBase 云函数环境变量里配 RESEND_API_KEY

到 Resend dashboard → API KeysCreate 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_error
  • idempotencyKey 没在示例里用,但如果你的业务端可能重试(队列消费失败重发等)建议加上:相同 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 加上 reactreact-dom 做依赖,否则 React Email 跑不起来。

第五步:部署

tcb login
tcb fn deploy send-email --httpFn -e your-env-id

部署完到控制台「云函数 → send-email」做:

  1. 确认环境变量RESEND_API_KEYRESEND_FROM 已经设置(第二步做过的话跳过)
  2. 网络配置 公网访问已启用
  3. 触发方式 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 状态(sentdelivered),如果到 bouncedcomplained 就是收件方拒收了
  • Gmail 收件箱点开邮件 → 三个点 → Show original,能看到 dkim=passspf=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 emailsAPI key scope 不对(比如选成了 Domains access到 dashboard 重建一个 Sending access 的 key
邮件发出去了,QQ/163/126 进了垃圾邮件海外发件 IP 在国内运营商信誉一般 + 发件域名新建立DMARC 策略加严(p=nonep=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 官方文档

相关文档