GraphQL Mutation to Send Transactional Email with Rovela (2026)
Define a sendTransactionalEmail GraphQL mutation whose Apollo/Yoga/Mercurius resolver calls Rovela POST /emails with Idempotency-Key. Keep sending_access in server env — never expose it to clients. Distinct from the tRPC sendEmail guide.
GraphQL owns the mutation — Rovela only delivers the message
A GraphQL API (Apollo Server, GraphQL Yoga, Mercurius, or a similar Node schema) is a natural place to send transactional mail: resolvers already run on your server, with access to env secrets, your database, and request context. There is no first-party GraphQL “email scalar” or Rovela plugin that ships a sendEmail field for you.
This guide defines a sendTransactionalEmail mutation whose resolver calls Rovela POST https://api.rovela.dev/emails with an Idempotency-Key, keeps ROVELA_API_KEY in server env only, and never exposes a sending_access key to GraphQL clients (Send Email API, Quick Start, Authentication).
Rovela does not provide a GraphQL schema, SDL, or Apollo/Yoga/Mercurius integration. Your GraphQL stack owns auth directives, input validation, and business logic. Rovela queues, DKIM-signs, and delivers the message.
Distinct from:
- tRPC sendEmail Procedure with Rovela — typed tRPC procedures / routers, not a GraphQL
Mutationfield - Sending Email from Next.js with Rovela — Route Handlers / Server Actions with
fetch, not a GraphQL resolver - Send OTP Emails in Express.js — plain Express routes without a GraphQL schema
What GraphQL does vs what Rovela sends
| Concern | Owner |
|---|---|
| SDL / schema, resolvers, context, auth | Your GraphQL server (Apollo, Yoga, Mercurius, …) |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key choice per logical send | Your mutation resolver |
| Minting OTP codes / welcome copy | Your app |
| Browser / Apollo Client / urql | Calls GraphQL 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 product mail. - An API key shaped like
re_…. Prefersending_accessfor production senders (Authentication). - A GraphQL server that runs resolvers only on the server — Apollo Server 4, GraphQL Yoga, Mercurius on Fastify, or equivalent.
- Keep
ROVELA_API_KEYandROVELA_FROMin server env. Never putsending_accessin the browser bundle, a public Vite/NEXT_PUBLIC_*var, or a client-side GraphQL link.
Environment
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <hello@mail.example.com>'
Rotate Rovela keys by creating a new one, deploying, then revoking the old key (Authentication).
Shared Rovela sender (server-only)
Centralize fetch so every resolver shares headers, error handling, and idempotency. Truncate keys to 256 characters — that is the API limit (Send Email 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 };
}
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).
Schema: sendTransactionalEmail
Keep the mutation narrow. Do not accept from, API keys, or arbitrary custom headers from the client — those stay on the server.
# schema.graphql
type EmailSendResult {
id: ID!
}
input SendTransactionalEmailInput {
to: String!
subject: String!
html: String!
text: String!
"""
Stable per logical send (e.g. receipt-{orderId}).
Omit to mint a UUID inside the resolver (still retry-safe within one call).
"""
idempotencyKey: String
}
type Mutation {
sendTransactionalEmail(input: SendTransactionalEmailInput!): EmailSendResult!
}
Resolver (Apollo / Yoga style)
The same resolver shape works with Apollo Server’s resolvers map, GraphQL Yoga’s createSchema({ resolvers }), or Mercurius’s resolvers option. Auth belongs in context (JWT, session cookie, API gateway headers) — not in the Rovela call.
// src/resolvers/email.ts
import { GraphQLError } from "graphql";
import { randomUUID } from "node:crypto";
import { RovelaError, sendViaRovela } from "../lib/rovela";
type Ctx = {
userId: string | null;
// isAdmin?: boolean
};
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;
// Optional: enforce that `to` is the signed-in user, or require admin.
// Never accept an arbitrary from-address from the GraphQL client.
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) {
// Misconfigured server key — do not leak details to clients.
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" },
});
}
},
},
};
Wire context so resolvers see the signed-in user (cookie / Authorization JWT). Example with GraphQL Yoga:
// src/server.ts (sketch)
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 }) => {
// Parse session / JWT the same way your other mutations do.
const userId = null as string | null;
return { userId };
},
});
Apollo Server 4 is the same idea: new ApolloServer({ typeDefs, resolvers }) plus a context function on expressMiddleware / startStandaloneServer. Mercurius takes schema + resolvers and a context callback on the Fastify plugin.
Client call (no secrets)
mutation SendReceipt($input: SendTransactionalEmailInput!) {
sendTransactionalEmail(input: $input) {
id
}
}
// Apollo Client / urql — browser or mobile app
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}`,
},
},
});
The GraphQL HTTP endpoint authenticates the user. The resolver authenticates to Rovela with Authorization: Bearer re_… from env. The browser never sees the Rovela key.
Optional: welcome after signup
Keep welcome copy and the idempotency key on the server. Derive the key from a stable business id so a retried signup cannot double-send.
// Alternative mutation: sendWelcomeEmail(userId) — no client-supplied HTML
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}`,
});
Prefer triggering welcome mail from the same server path that creates the user (or a job queue), not from an unauthenticated public mutation.
Idempotency and retries
- Pass a stable key for a logical event:
welcome-${userId},receipt-${orderId},invite-${inviteId}. - On network failure inside one resolver invocation, 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 | GraphQL response |
|---|---|---|
400 | Unverified from, missing body, suppressed recipient | BAD_USER_INPUT 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 in GraphQL errors.
Security checklist
sending_accessonly on the server; never a public env var or a GraphQL field that returns the key.- Do not let clients choose the
fromaddress or arbitrary headers that could spoof your domain. - Protect
sendTransactionalEmailwith 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 GraphQL success means the inbox got it.
Where next
- Send Email API — fields, batch, scheduling, idempotency
- Authentication —
sending_accessvsfull_access - Quick Start — domain verify → first send
- tRPC sendEmail Procedure with Rovela — same HTTP send pattern under tRPC instead of GraphQL