← Back to blog

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.

Updated November 4, 2026
tRPC sendEmail Procedure with Rovela (2026): Server Mutation + Idempotency

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:

What tRPC does vs what Rovela sends

ConcernOwner
Procedure input (Zod), context, auth middlewareYour tRPC server
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key choice per logical sendYour procedure
Minting OTP codes / welcome copyYour app
Browser / React Query clientCalls tRPC only — never Rovela

A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).

Prerequisites

  1. Verified sending domain in the Rovela dashboard. Unverified from domains are refused with 400 (Quick Start). Prefer a dedicated subdomain such as mail.example.com for auth and product mail.
  2. An API key shaped like re_…. Prefer sending_access for production senders (Authentication).
  3. 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.
  4. Keep ROVELA_API_KEY and ROVELA_FROM in server env. sending_access never belongs in the client bundle, a public NEXT_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-After on 429).
  • Within 24 hours, duplicate keys return the cached { id } instead of a second delivery (Send Email API).

Errors you will actually hit

StatusTypical causeProcedure response
400Unverified from, missing body, suppressed recipientBAD_REQUEST with Rovela message
401 / 403Bad or wrong-scoped API keyINTERNAL_SERVER_ERROR (fix server)
429Rate / quotaTOO_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_access only on the server; never NEXT_PUBLIC_ROVELA_API_KEY.
  • Do not let clients choose the from address or arbitrary custom headers that could spoof your domain.
  • Protect sendEmail with 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 (needs full_access or dashboard), not by assuming a tRPC success means the inbox got it.

Where next