← 返回博客

在 Next.js 里用 Rovela API 发邮件

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

更新于 2026年10月15日
在 Next.js 里用 Rovela API 发邮件

在 Next.js 里发邮件,关键只有一条:API 密钥只能出现在服务器端。App Router 给了两个放服务端代码的地方:Route Handler 和 Server Action。下面两种写法都用 fetch 直接调 Rovela 的 REST API,不需要安装 SDK,代码可以直接复制。

准备

  1. 在 Rovela 控制台添加并验证你的发信域名
  2. 在「API 密钥」里创建一个 sending_access 权限的密钥,生产环境只需要发信权限
  3. 写进 .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-only
  • Idempotency-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) =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 密钥。