← 返回博客

tRPC sendEmail Procedure + Rovela(2026):服务端 Mutation 与幂等键

在 tRPC 服务端增加类型化的 sendEmail mutation,调用 Rovela POST /emails 并带 Idempotency-Key;sending_access 只留在服务端环境变量;可选欢迎信与 OTP procedure。与 Next.js Route Handler、Express OTP 指南区分。

更新于 2026年11月4日
tRPC sendEmail Procedure + Rovela(2026):服务端 Mutation 与幂等键

tRPC 负责 procedure,Rovela 只负责投递

tRPC 让你在 Node 服务端定义端到端类型化的 procedure。mutation / procedure 是发送事务邮件的合适位置:handler 本来就能读环境变量、数据库和请求上下文。tRPC 本身没有「邮件适配器」替你对接邮件 API。

本文增加一个 sendEmail mutation(以及可选的欢迎信 / OTP 辅助 procedure):调用 Rovela POST https://api.rovela.dev/emails,带上 Idempotency-Key,把 ROVELA_API_KEY 只放在服务端环境变量里,绝不把 sending_access 打进浏览器(发送邮件 API、快速开始、鉴权)。

Rovela 不提供 tRPC 插件。 它不定义 router、Zod 输入或 session context。你的 tRPC 栈负责 procedure 鉴权、校验与业务逻辑;Rovela 负责入队、DKIM 签名与投递。

与下列文章区分:

tRPC 做什么,Rovela 发什么

关注点负责方
Procedure 输入(Zod)、context、鉴权中间件你的 tRPC 服务端
POST /emails、DKIM、队列、退信Rovela
每次逻辑发送的 Idempotency-Key你的 procedure
生成 OTP / 欢迎信文案你的应用
浏览器 / React Query 客户端只调 tRPC,绝不直接调 Rovela

Rovela 返回 200 表示 已接受并入队,不等于「已进收件箱」(快速开始)。

前置条件

  1. 在 Rovela 控制台 验证发信域名。未验证域名的 from 会以 400 拒绝(快速开始)。鉴权与产品邮件建议用独立子域,例如 mail.example.com。
  2. API key 形如 re_…。生产发信优先 sending_access(鉴权)。
  3. tRPC v11(或 v10)服务端——常见于 Next.js App Router、Express、Fastify 或独立 Node adapter——procedure 只在服务端执行。
  4. ROVELA_API_KEY 与 ROVELA_FROM 只放服务端环境变量。sending_access 绝不能进客户端包、公开的 NEXT_PUBLIC_* 或 React Query hook。

环境变量

export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <hello@mail.example.com>'
# OTP 辅助可选:
export CODE_PEPPER='long-random-server-secret'

轮换密钥:先新建、再部署、最后吊销旧 key(鉴权)。

共用 Rovela 发送封装(仅服务端)

集中 fetch,让每个 procedure 共用请求头、错误处理与幂等键。键最长 256 字符——这是 API 限制(发送邮件 API)。

// src/server/rovela.ts
const ROVELA_URL = "https://api.rovela.dev/emails";

export class RovelaError extends Error {
  constructor(
    public status: number,
    public name: string,
    message: string,
  ) {
    super(message);
  }
}

export async function sendViaRovela(opts: {
  to: string | string[];
  subject: string;
  html: string;
  text: string;
  idempotencyKey: string;
  replyTo?: string;
}) {
  const apiKey = process.env.ROVELA_API_KEY;
  const from = process.env.ROVELA_FROM;
  if (!apiKey || !from) {
    throw new Error("ROVELA_API_KEY and ROVELA_FROM must be set on the server");
  }

  const key = opts.idempotencyKey.slice(0, 256);

  const res = await fetch(ROVELA_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      Accept: "application/json",
      "Idempotency-Key": key,
    },
    body: JSON.stringify({
      from,
      to: opts.to,
      subject: opts.subject,
      html: opts.html,
      text: opts.text,
      ...(opts.replyTo ? { reply_to: opts.replyTo } : {}),
    }),
  });

  const body = await res.json().catch(() => ({}));
  if (!res.ok) {
    throw new RovelaError(
      res.status,
      String((body as { name?: string }).name ?? "Error"),
      String((body as { message?: string }).message ?? "send failed"),
    );
  }
  return body as { object: "email"; id: string };
}

尽量同时发 html 与 text。凡可能重试的发送(队列、双击、超时)都要带 Idempotency-Key。24 小时内相同键会返回原响应而不是再发一封(发送邮件 API)。

tRPC context 与 router 骨架

可以把依赖注入 context;在 helper 内读 process.env 也可以,只要 procedure 绝不在浏览器跑。

// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";

export async function createContext(_opts: FetchCreateContextFnOptions) {
  // 按你现有方式从 cookie / Authorization 挂上 session / user
  return { userId: null as string | null };
}

type Context = Awaited<ReturnType<typeof createContext>>;

const t = initTRPC.context<Context>().create({
  transformer: superjson,
});

export const router = t.router;
export const publicProcedure = t.procedure;

/** 示例:代表用户发信前要求已登录 */
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.userId) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({ ctx: { ...ctx, userId: ctx.userId } });
});

sendEmail mutation

通用产品邮件(收据、通知、客服回复)。用 Zod 校验输入;把 Rovela 错误映射成 tRPC code,客户端看到稳定失败,永远看不到 API key。

// src/server/routers/email.ts
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, protectedProcedure } from "../trpc";
import { RovelaError, sendViaRovela } from "../rovela";
import { randomUUID } from "node:crypto";

export const emailRouter = router({
  sendEmail: protectedProcedure
    .input(
      z.object({
        to: z.string().email(),
        subject: z.string().min(1).max(200),
        html: z.string().min(1),
        text: z.string().min(1),
        /** 对同一次逻辑发送保持稳定;省略则生成 UUID(单次调用栈内仍可安全重试) */
        idempotencyKey: z.string().min(8).max(256).optional(),
      }),
    )
    .mutation(async ({ input, ctx }) => {
      // 可选:限制 to 必须是当前用户,或调用方必须是管理员。
      // 绝不要接受客户端传来的任意 from。
      const idempotencyKey =
        input.idempotencyKey ?? `trpc-send-${ctx.userId}-${randomUUID()}`;

      try {
        const { id } = await sendViaRovela({
          to: input.to,
          subject: input.subject,
          html: input.html,
          text: input.text,
          idempotencyKey,
        });
        return { id };
      } catch (err) {
        if (err instanceof RovelaError) {
          if (err.status === 400) {
            throw new TRPCError({
              code: "BAD_REQUEST",
              message: err.message,
            });
          }
          if (err.status === 401 || err.status === 403) {
            // 服务端密钥配置错误 — 不要把细节泄漏给客户端
            console.error("rovela auth error", err.status, err.message);
            throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
          }
          if (err.status === 429) {
            throw new TRPCError({
              code: "TOO_MANY_REQUESTS",
              message: "email rate limited; retry shortly",
            });
          }
        }
        throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
      }
    }),
});

挂到应用 router:

// src/server/root.ts
import { router } from "./trpc";
import { emailRouter } from "./routers/email";

export const appRouter = router({
  email: emailRouter,
});

export type AppRouter = typeof appRouter;

客户端调用 trpc.email.sendEmail.mutate({ ... })。浏览器看不到 Authorization: Bearer re_…。

可选:注册后的欢迎信

欢迎文案与幂等键留在服务端。用稳定业务 id(如 userId)派生键,避免重试的注册 webhook 连发两封。

// 放在 emailRouter 内
welcome: protectedProcedure
  .input(z.object({ to: z.string().email(), name: z.string().min(1).max(80) }))
  .mutation(async ({ input, ctx }) => {
    const name = input.name.replace(/[<>&]/g, "");
    const { id } = await sendViaRovela({
      to: input.to,
      subject: "Welcome to Acme",
      html: `<p>Hi ${name},</p><p>Your account is ready.</p>`,
      text: `Hi ${name},\n\nYour account is ready.`,
      idempotencyKey: `welcome-${ctx.userId}`,
    });
    return { id };
  }),

更稳妥的是在创建用户的同一服务端路径(或任务队列)触发欢迎信,而不是开放未鉴权的 public procedure。

可选:把 OTP 发送做成 procedure

Rovela 不会生成验证码。你的 procedure 用 crypto.randomInt 生成数字、存 HMAC,只把明文放进 html/text(邮箱验证码示例)。

import crypto from "node:crypto";

function mintCode() {
  return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
}

function hashCode(email: string, code: string) {
  return crypto
    .createHmac("sha256", process.env.CODE_PEPPER!)
    .update(`${email}:${code}`)
    .digest("hex");
}

// publicProcedure 或带限流的 procedure — 无论邮箱是否存在,对外响应一致
sendOtp: publicProcedure
  .input(z.object({ email: z.string().email() }))
  .mutation(async ({ input }) => {
    const email = input.email.trim().toLowerCase();
    // 在数据库强制同一地址 60 秒内只发一次,然后:
    const code = mintCode();
    // upsert verification_codes:hashCode(email, code),10 分钟过期
    await sendViaRovela({
      to: email,
      subject: `Your Acme code: ${code}`,
      html:
        `<p>Your verification code is</p>` +
        `<p style="font-size:28px;letter-spacing:4px"><b>${code}</b></p>` +
        `<p>It expires in 10 minutes.</p>`,
      text: `Your verification code is ${code}. It expires in 10 minutes.`,
      idempotencyKey: `otp-${email}-${Date.now() - (Date.now() % 60_000)}`,
    });
    return { ok: true as const };
  }),

校验 procedure 应在 SQL 里递增 attempts,并用 crypto.timingSafeEqual 比较——与文档示例相同。不要返回「该地址是否已注册」。

客户端调用(无密钥)

// React Query + tRPC 客户端示例
const send = trpc.email.sendEmail.useMutation();

await send.mutateAsync({
  to: "user@example.com",
  subject: "Your receipt",
  html: "<p>Thanks for your order.</p>",
  text: "Thanks for your order.",
  idempotencyKey: `receipt-${orderId}`,
});

若用 Next.js,把 tRPC HTTP handler 放在 Route Handler / 服务端 adapter。永远不要从 Client Component 导入 src/server/rovela.ts。

幂等与重试

  • 对逻辑事件使用 稳定 键:welcome-${userId}、receipt-${orderId}、otp-${email}-${minuteBucket}。
  • 单次请求内网络失败时用 同一 键重试(约 3 次;遇 429 遵守 Retry-After)。
  • 24 小时内重复键返回缓存的 { id },不会二次投递(发送邮件 API)。

你会真正碰到的错误

状态码常见原因Procedure 响应
400from 未验证、缺正文、收件人被抑制BAD_REQUEST + Rovela message
401 / 403API key 无效或权限不对INTERNAL_SERVER_ERROR(修服务端)
429限流 / 配额TOO_MANY_REQUESTS

错误体形如 { "statusCode": 400, "name": "Bad Request", "message": "..." }(快速开始)。在服务端日志里保留 message;不要暴露堆栈或 bearer token。

安全清单

  • sending_access 只留在服务端;不要用 NEXT_PUBLIC_ROVELA_API_KEY。
  • 不要让客户端选择 from,或可伪装你域名的任意自定义头。
  • 用鉴权 + 限流保护 sendEmail;公开 mutation 等于开放中继。
  • 鉴权 OTP 与营销信尽量用不同子域发信(OTP / magic link 进垃圾箱)。
  • 用 webhook 或 GET /emails/:id(需 full_access 或控制台)确认投递,不要把 tRPC 成功当成「已进收件箱」。

下一步