GraphQL Mutation + Rovela 发事务邮件(2026):Apollo / Yoga / Mercurius Resolver
用 Apollo / Yoga / Mercurius 风格的 GraphQL mutation sendTransactionalEmail,在服务端 resolver 调用 Rovela POST /emails 并带 Idempotency-Key;sending_access 只留在环境变量,绝不暴露给客户端。与 tRPC sendEmail 指南区分。
GraphQL 负责 Mutation,Rovela 只负责投递
GraphQL API(Apollo Server、GraphQL Yoga、Mercurius 或同类 Node schema)很适合发事务邮件:resolver 本来就跑在服务端,能读环境变量、数据库和请求上下文。Rovela 没有官方 GraphQL schema、SDL,也没有 Apollo / Yoga / Mercurius 插件替你挂好 sendEmail 字段。
本文定义 sendTransactionalEmail mutation:resolver 调用 Rovela POST https://api.rovela.dev/emails,带上 Idempotency-Key;ROVELA_API_KEY 只放在服务端环境变量;绝不把 sending_access 暴露给 GraphQL 客户端(发送邮件 API、快速开始、鉴权)。
Rovela 不提供 GraphQL 集成。 鉴权指令、入参校验、业务逻辑归你的 GraphQL 栈;排队、DKIM 签名与投递归 Rovela。
与下列文章区分:
- tRPC sendEmail Procedure + Rovela — tRPC procedure / router,不是 GraphQL
Mutation字段 - Next.js + Rovela 发信 — Route Handler / Server Action 的
fetch,不是 GraphQL resolver - Express 发 OTP — 普通 Express 路由,没有 GraphQL schema
GraphQL 做什么,Rovela 发什么
| 职责 | 归属 |
|---|---|
| SDL / schema、resolver、context、鉴权 | 你的 GraphQL 服务(Apollo、Yoga、Mercurius 等) |
POST /emails、DKIM、队列、退信 | Rovela |
每个逻辑发送的 Idempotency-Key | 你的 mutation resolver |
| 生成 OTP / 欢迎信文案 | 你的应用 |
| 浏览器 / Apollo Client / urql | 只调 GraphQL,从不直连 Rovela |
Rovela 返回 200 表示已接受并入队,不等于「已进收件箱」(快速开始)。
前置条件
- 在 Rovela 控制台验证发信域名。未验证的
from域名会以400拒绝(快速开始)。产品邮件建议用mail.example.com这类子域。 - API Key 形如
re_…。生产发信优先sending_access(鉴权)。 - GraphQL 服务端 — Apollo Server 4、GraphQL Yoga、Fastify + Mercurius 等 — resolver 只在服务端执行。
ROVELA_API_KEY与ROVELA_FROM放在服务端环境变量。不要写进浏览器包、公开的 Vite /NEXT_PUBLIC_*,也不要做成客户端 GraphQL link 能读到的配置。
环境变量
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <hello@mail.example.com>'
轮换密钥:先创建新 key、部署,再吊销旧 key(鉴权)。
共用 Rovela 发送封装(仅服务端)
集中 fetch,统一请求头、错误处理与幂等。幂等键最长 256 字符(发送邮件 API)。
// src/lib/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)。
Schema:sendTransactionalEmail
Mutation 保持克制。不要让客户端传入 from、API Key 或任意自定义头 —— 这些留在服务端。
# schema.graphql
type EmailSendResult {
id: ID!
}
input SendTransactionalEmailInput {
to: String!
subject: String!
html: String!
text: String!
"""
每个逻辑发送稳定不变(如 receipt-{orderId})。
省略则在 resolver 内生成 UUID(同一次调用内仍可安全重试)。
"""
idempotencyKey: String
}
type Mutation {
sendTransactionalEmail(input: SendTransactionalEmailInput!): EmailSendResult!
}
Resolver(Apollo / Yoga 风格)
Apollo Server 的 resolvers 映射、GraphQL Yoga 的 createSchema({ resolvers })、Mercurius 的 resolvers 选项都可用同一形状。鉴权放在 context(JWT、session cookie、网关头),不要塞进 Rovela 调用。
// src/resolvers/email.ts
import { GraphQLError } from "graphql";
import { randomUUID } from "node:crypto";
import { RovelaError, sendViaRovela } from "../lib/rovela";
type Ctx = {
userId: string | null;
};
function requireUser(ctx: Ctx) {
if (!ctx.userId) {
throw new GraphQLError("Unauthorized", {
extensions: { code: "UNAUTHORIZED" },
});
}
return ctx.userId;
}
export const emailResolvers = {
Mutation: {
sendTransactionalEmail: async (
_parent: unknown,
args: {
input: {
to: string;
subject: string;
html: string;
text: string;
idempotencyKey?: string | null;
};
},
ctx: Ctx,
) => {
const userId = requireUser(ctx);
const { to, subject, html, text } = args.input;
// 可选:限制 to 必须是当前用户,或要求管理员。
// 绝不接受客户端任意指定 from。
if (!to.includes("@") || subject.length < 1 || subject.length > 200) {
throw new GraphQLError("Invalid input", {
extensions: { code: "BAD_USER_INPUT" },
});
}
if (!html.trim() || !text.trim()) {
throw new GraphQLError("html and text are required", {
extensions: { code: "BAD_USER_INPUT" },
});
}
const idempotencyKey = (
args.input.idempotencyKey?.trim() ||
`gql-send-${userId}-${randomUUID()}`
).slice(0, 256);
try {
const { id } = await sendViaRovela({
to,
subject,
html,
text,
idempotencyKey,
});
return { id };
} catch (err) {
if (err instanceof RovelaError) {
if (err.status === 400) {
throw new GraphQLError(err.message, {
extensions: { code: "BAD_USER_INPUT", httpStatus: 400 },
});
}
if (err.status === 401 || err.status === 403) {
console.error("rovela auth error", err.status, err.message);
throw new GraphQLError("Internal error", {
extensions: { code: "INTERNAL_SERVER_ERROR" },
});
}
if (err.status === 429) {
throw new GraphQLError("email rate limited; retry shortly", {
extensions: { code: "TOO_MANY_REQUESTS" },
});
}
}
throw new GraphQLError("Internal error", {
extensions: { code: "INTERNAL_SERVER_ERROR" },
});
}
},
},
};
GraphQL Yoga 示例(context 注入登录用户):
// src/server.ts(示意)
import { createYoga, createSchema } from "graphql-yoga";
import { readFileSync } from "node:fs";
import { emailResolvers } from "./resolvers/email";
const typeDefs = readFileSync("./schema.graphql", "utf8");
const yoga = createYoga({
schema: createSchema({
typeDefs,
resolvers: emailResolvers,
}),
context: async ({ request }) => {
const userId = null as string | null;
return { userId };
},
});
Apollo Server 4 同理:new ApolloServer({ typeDefs, resolvers }),再在 expressMiddleware / startStandaloneServer 里提供 context。Mercurius 则是 Fastify 插件上的 schema + resolvers + context。
客户端调用(无密钥)
mutation SendReceipt($input: SendTransactionalEmailInput!) {
sendTransactionalEmail(input: $input) {
id
}
}
await client.mutate({
mutation: SEND_RECEIPT,
variables: {
input: {
to: "user@example.com",
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
text: "Thanks for your order.",
idempotencyKey: `receipt-${orderId}`,
},
},
});
GraphQL HTTP 端点鉴权的是用户;resolver 用环境变量里的 Authorization: Bearer re_… 调 Rovela。浏览器看不到 Rovela key。
可选:注册后欢迎信
欢迎文案与幂等键放在服务端,用稳定业务 id 派生,避免重试重复发送。
const { id } = await sendViaRovela({
to: user.email,
subject: "Welcome to Acme",
html: `<p>Hi ${escapeHtml(user.name)},</p><p>Your account is ready.</p>`,
text: `Hi ${user.name},\n\nYour account is ready.`,
idempotencyKey: `welcome-${user.id}`,
});
更稳妥的是在创建用户的同一服务端路径(或任务队列)触发欢迎信,而不是开放未鉴权的公共 mutation。
幂等与重试
- 逻辑事件用稳定键:
welcome-${userId}、receipt-${orderId}、invite-${inviteId}。 - 同一次 resolver 内网络失败时,用同一键重试(约 3 次;遇
429遵守Retry-After)。 - 24 小时内重复键返回缓存的
{ id },不会再投递一封(发送邮件 API)。
你会真实碰到的错误
| 状态码 | 常见原因 | GraphQL 响应 |
|---|---|---|
400 | from 未验证、缺正文、收件人被抑制 | BAD_USER_INPUT + Rovela message |
401 / 403 | Key 无效或权限不对 | INTERNAL_SERVER_ERROR(修服务端) |
429 | 限流 / 配额 | TOO_MANY_REQUESTS |
错误体形如 { "statusCode": 400, "name": "Bad Request", "message": "..." }(快速开始)。message 记服务端日志即可,不要把堆栈或 bearer token 写进 GraphQL error。
安全清单
sending_access只在服务端;不要公开环境变量,也不要做成能返回 key 的 GraphQL 字段。- 不要让客户端选择
from或可伪造域名的任意头。 - 给
sendTransactionalEmail加鉴权与限流;公开 mutation 等于开放中继。 - 认证 OTP 与营销信尽量分开发信子域(OTP / 魔法链接进垃圾箱)。
- 投递确认用 webhook 或
GET /emails/:id(需full_access或控制台),不要以为 GraphQL 成功就等于进箱。
接下来
- 发送邮件 API — 字段、批量、定时、幂等
- 鉴权 —
sending_access与full_access - 快速开始 — 验证域名 → 第一封信
- tRPC sendEmail + Rovela — 同一 HTTP 发送模式,栈换成 tRPC