← Back to blog

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.

Updated November 4, 2026
GraphQL Mutation to Send Transactional Email with Rovela (2026)

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:

What GraphQL does vs what Rovela sends

ConcernOwner
SDL / schema, resolvers, context, authYour GraphQL server (Apollo, Yoga, Mercurius, …)
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key choice per logical sendYour mutation resolver
Minting OTP codes / welcome copyYour app
Browser / Apollo Client / urqlCalls GraphQL 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 product mail.
  2. An API key shaped like re_…. Prefer sending_access for production senders (Authentication).
  3. A GraphQL server that runs resolvers only on the server — Apollo Server 4, GraphQL Yoga, Mercurius on Fastify, or equivalent.
  4. Keep ROVELA_API_KEY and ROVELA_FROM in server env. Never put sending_access in 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-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 causeGraphQL response
400Unverified from, missing body, suppressed recipientBAD_USER_INPUT 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 in GraphQL errors.

Security checklist

  • sending_access only on the server; never a public env var or a GraphQL field that returns the key.
  • Do not let clients choose the from address or arbitrary headers that could spoof your domain.
  • Protect sendTransactionalEmail 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 GraphQL success means the inbox got it.

Where next