tRPC sendEmail Procedure with Rovela (2026): Server Mutation + Idempotency
Add a typed tRPC sendEmail mutation that calls Rovela POST /emails with Idempotency-Key. Keep sending_access in server env; optional welcome and OTP procedures. Distinct from Next.js Route Handlers and Express OTP guides.
tRPC owns the procedure — Rovela only delivers the message
tRPC lets you define end-to-end typed procedures that run on your Node server. A mutation or procedure is the right place to send transactional mail: the handler already has access to env secrets, your database, and request context. There is no first-party tRPC “email adapter” that talks to a mail API for you.
This guide adds a sendEmail mutation (and optional welcome / OTP helpers) that call Rovela POST https://api.rovela.dev/emails with an Idempotency-Key, keep ROVELA_API_KEY in server env only, and never ship sending_access to the browser (Send Email API, Quick Start, Authentication).
Rovela does not provide a tRPC plugin. It does not define routers, Zod inputs, or session context. Your tRPC stack owns procedure auth, validation, and business logic. Rovela queues, DKIM-signs, and delivers the message.
Distinct from:
- Sending Email from Next.js with Rovela — Route Handlers / Server Actions with
fetch, not a typed tRPC procedure - Send OTP Emails in Express.js — plain Express routes without tRPC routers or end-to-end types
- Auth.js / NextAuth + Rovela — auth-framework email hooks, not a general
sendEmailprocedure
What tRPC does vs what Rovela sends
| Concern | Owner |
|---|---|
| Procedure input (Zod), context, auth middleware | Your tRPC server |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key choice per logical send | Your procedure |
| Minting OTP codes / welcome copy | Your app |
| Browser / React Query client | Calls tRPC only — never Rovela |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Prerequisites
- Verified sending domain in the Rovela dashboard. Unverified
fromdomains are refused with400(Quick Start). Prefer a dedicated subdomain such asmail.example.comfor auth and product mail. - An API key shaped like
re_…. Prefersending_accessfor production senders (Authentication). - A tRPC v11 (or v10) server — typically Next.js App Router, Express, Fastify, or a standalone Node adapter — with procedures that run only on the server.
- Keep
ROVELA_API_KEYandROVELA_FROMin server env.sending_accessnever belongs in the client bundle, a publicNEXT_PUBLIC_*var, or a React Query hook.
Environment
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <hello@mail.example.com>'
# optional, for OTP helpers:
export CODE_PEPPER='long-random-server-secret'
Rotate Rovela keys by creating a new one, deploying, then revoking the old key (Authentication).
Shared Rovela sender (server-only)
Centralize fetch so every procedure shares headers, error handling, and idempotency. Truncate keys to 256 characters — that is the API limit (Send Email 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 };
}
Always send both html and text. Include an Idempotency-Key for any send that may retry (queue workers, double-clicks, network timeouts). Within 24 hours, the same key returns the original response instead of sending again (Send Email API).
tRPC context and router skeleton
Wire env-backed helpers into context if you prefer injection; reading process.env inside the helper is fine as long as procedures never run in the browser.
// 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) {
// Attach session / user from cookies or Authorization as you already do.
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;
/** Example: require a signed-in user before sending mail on their behalf. */
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
A general-purpose procedure for product mail (receipts, notifications, support replies). Validate inputs with Zod; map Rovela errors to tRPC codes so clients see stable failures without ever seeing your 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),
/** Stable per logical send; omit to mint a UUID (still retry-safe within one call stack). */
idempotencyKey: z.string().min(8).max(256).optional(),
}),
)
.mutation(async ({ input, ctx }) => {
// Optional: enforce that `to` is the signed-in user, or that the caller
// is an admin. Never accept an arbitrary from-address from the client.
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) {
// Misconfigured server key — do not leak details to the client.
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" });
}
}),
});
Merge into the app 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;
The client calls trpc.email.sendEmail.mutate({ ... }). The browser never sees Authorization: Bearer re_….
Optional: welcome email after signup
Keep welcome copy and the idempotency key on the server. Derive the key from a stable business id (userId) so a retried signup webhook cannot double-send.
// inside 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 };
}),
Prefer triggering welcome mail from the same server path that creates the user (or a job queue), not from an unauthenticated public procedure.
Optional: OTP send as a procedure
Rovela does not mint codes. Your procedure generates digits with crypto.randomInt, stores an HMAC, and only puts the plaintext in html/text (Email verification codes).
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 or rate-limited procedure — same answer whether the email exists
sendOtp: publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input }) => {
const email = input.email.trim().toLowerCase();
// enforce one send / 60s in your DB, then:
const code = mintCode();
// upsert verification_codes row with hashCode(email, code), 10 min expiry
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 };
}),
Use a verify procedure that increments attempts in SQL and compares with crypto.timingSafeEqual — same pattern as the docs example. Do not return whether the address exists.
Calling from the client (no secrets)
// app example — React Query + tRPC client
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}`,
});
If you use Next.js, keep the tRPC HTTP handler in a Route Handler / server adapter. Never import src/server/rovela.ts from a Client Component.
Idempotency and retries
- Pass a stable key for a logical event:
welcome-${userId},receipt-${orderId},otp-${email}-${minuteBucket}. - On network failure inside one request, retry with the same key (up to ~3 attempts; honour
Retry-Afteron429). - Within 24 hours, duplicate keys return the cached
{ id }instead of a second delivery (Send Email API).
Errors you will actually hit
| Status | Typical cause | Procedure response |
|---|---|---|
400 | Unverified from, missing body, suppressed recipient | BAD_REQUEST with Rovela message |
401 / 403 | Bad or wrong-scoped API key | INTERNAL_SERVER_ERROR (fix server) |
429 | Rate / quota | TOO_MANY_REQUESTS |
Error bodies look like { "statusCode": 400, "name": "Bad Request", "message": "..." } (Quick Start). Surface message in server logs; do not expose stack traces or the bearer token.
Security checklist
sending_accessonly on the server; neverNEXT_PUBLIC_ROVELA_API_KEY.- Do not let clients choose the
fromaddress or arbitrary custom headers that could spoof your domain. - Protect
sendEmailwith auth + rate limits; a public mutation is an open relay. - Prefer subdomain senders for auth OTP vs marketing (OTP and magic-link spam).
- Confirm delivery with webhooks or
GET /emails/:id(needsfull_accessor dashboard), not by assuming a tRPC success means the inbox got it.
Where next
- Send Email API — fields, batch, scheduling, idempotency
- Email verification codes — OTP crypto and storage
- Authentication —
sending_accessvsfull_access - Quick Start — domain verify → first send