在 Next.js 里用 Rovela API 发邮件
两种在 Next.js App Router 里发邮件的完整写法:供外部调用的 Route Handler,以及供表单提交的 Server Action,都用 fetch 直接调 Rovela REST API,带 server-only 保护和幂等键。

在 Next.js 里发邮件,关键只有一条:API 密钥只能出现在服务器端。App Router 给了两个放服务端代码的地方:Route Handler 和 Server Action。下面两种写法都用 fetch 直接调 Rovela 的 REST API,不需要安装 SDK,代码可以直接复制。
准备
- 在 Rovela 控制台添加并验证你的发信域名
- 在「API 密钥」里创建一个
sending_access权限的密钥,生产环境只需要发信权限 - 写进
.env.local:
ROVELA_API_KEY=re_your_api_key
MAIL_FROM="Acme <hello@yourdomain.com>"
变量名不要加 NEXT_PUBLIC_ 前缀。加了前缀的变量会被打包进浏览器端代码,任何人打开开发者工具都能看到你的密钥。
共用的发信函数
把调用封装到一个只在服务端运行的模块里:
// lib/mail.ts
import 'server-only'
type SendInput = {
to: string | string[]
subject: string
html: string
text?: string
idempotencyKey?: string
}
export async function sendEmail({ idempotencyKey, ...input }: SendInput) {
const res = await fetch('https://api.rovela.dev/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ROVELA_API_KEY}`,
'Content-Type': 'application/json',
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
body: JSON.stringify({ from: process.env.MAIL_FROM, ...input }),
cache: 'no-store',
})
const body = await res.json()
if (!res.ok) {
// 错误响应形如 {"name": "...", "message": "..."}
throw new Error(`Rovela ${res.status}: ${body.message ?? 'send failed'}`)
}
return body as { object: 'email'; id: string }
}
两处值得说明:
import 'server-only':一旦有客户端组件误引用这个文件,构建会直接报错,而不是悄悄把密钥打进前端包。需要先npm i server-onlyIdempotency-Key:网络超时后重试时,同一个键在 24 小时内只会发出一封,用户不会收到两封验证码
写法一:Route Handler
适合给外部调用,或者前端用 fetch 提交的场景:
// app/api/contact/route.ts
import { sendEmail } from '@/lib/mail'
export async function POST(req: Request) {
const { email, message } = await req.json()
if (typeof email !== 'string' || !email.includes('@') || typeof message !== 'string') {
return Response.json({ error: 'invalid input' }, { status: 400 })
}
try {
const { id } = await sendEmail({
to: 'support@yourdomain.com',
subject: `来自 ${email} 的留言`,
text: message,
html: `<p>${escapeHtml(message)}</p>`,
})
return Response.json({ id })
} catch (err) {
console.error(err)
return Response.json({ error: 'send failed' }, { status: 502 })
}
}
function escapeHtml(s: string) {
return s.replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!,
)
}
用户输入要先转义,再拼进 HTML。不转义的话,有人提交一段 <a href="钓鱼网站"> 就会原样出现在你的收件箱里。
写法二:Server Action
表单直接提交到服务器函数,不用另写接口:
// app/signup/actions.ts
'use server'
import { randomInt } from 'node:crypto'
import { sendEmail } from '@/lib/mail'
export async function sendCode(formData: FormData) {
const email = String(formData.get('email') ?? '')
const code = randomInt(100000, 1000000).toString()
// 这里把 code 的哈希和过期时间存进数据库,略
await sendEmail({
to: email,
subject: `你的验证码:${code}`,
html: `<p>验证码 <strong>${code}</strong>,10 分钟内有效。</p>`,
text: `验证码 ${code},10 分钟内有效。`,
idempotencyKey: `signup-code:${email}:${Math.floor(Date.now() / 60000)}`,
})
}
// app/signup/page.tsx
import { sendCode } from './actions'
export default function Page() {
return (
<form action={sendCode}>
<input name="email" type="email" required />
<button type="submit">发送验证码</button>
</form>
)
}
验证码要用 crypto.randomInt 生成,不能用 Math.random。后者不是密码学安全的随机数。幂等键里带上分钟数,用户在同一分钟内连点按钮只会收到一封,过一分钟再点就是新的一封。
别忘了限流
发信接口是滥用的重灾区:别人可以用你的表单给任意地址发邮件,消耗你的额度,还会拖累你的域名声誉。至少按 IP 和收件地址各限一次,比如同一地址每分钟 1 次、每小时 5 次。
部署到 Vercel 或自己的服务器
把 ROVELA_API_KEY 和 MAIL_FROM 配进平台的环境变量就行。HTTP API 走 443 端口,不受云厂商封锁 SMTP 25 端口的影响。
延伸阅读:发送邮件 API 文档、幂等键:防止重试发出重复邮件、邮箱验证码示例。
免费版每月 100 封,足够开发和测试。注册获取 API 密钥。