YourMail

Framework guide

Send email from Convex

Why use this

Convex splits backend code into queries, mutations and actions. Anything that talks to the outside world — including sending email — has to be an action. This guide covers where the send goes and how to trigger it from a mutation without breaking transactionality.

For example

A new row lands in your users table and the welcome email goes out — but only if that write actually committed.

1. Install and set the key

The API key belongs in the deployment's environment, not in your repository.

npm install yourmail

2. Send from a Node action

Two things make this an action rather than a mutation. Mutations run in a deterministic transaction and cannot perform network calls at all. And the "use node" directive puts the file on the Node runtime, which the SDK needs.

internalAction rather than action is deliberate: an internal function is not reachable from the browser, so a client cannot invoke your send endpoint directly and mail arbitrary recipients on your account.

// convex/email.ts
"use node";

import { v } from "convex/values";
import { internalAction } from "./_generated/server";
import { YourMail } from "yourmail";

export const sendWelcome = internalAction({
  args: { to: v.string(), name: v.string() },
  handler: async (_ctx, { to, name }) => {
    const yourmail = new YourMail(process.env.YOURMAIL_API_KEY!);

    const { id } = await yourmail.send({
      from: "Acme <hello@mail.acme.com>",
      to,
      subject: "Welcome aboard",
      html: `<p>Thanks for signing up, ${name}.</p>`,
      // The user id would be a better key than the address if you have one —
      // anything stable that identifies "this particular send".
      idempotencyKey: `welcome:${to}`,
    });

    return id;
  },
});

3. Trigger it from a mutation

Schedule the action instead of calling it. The scheduled job is written as part of the same transaction, so a mutation that fails partway takes the pending email down with it — no welcome message for a user who was never created.

// convex/users.ts
import { v } from "convex/values";
import { mutation } from "./_generated/server";
import { internal } from "./_generated/api";

export const createUser = mutation({
  args: { email: v.string(), name: v.string() },
  handler: async (ctx, { email, name }) => {
    const userId = await ctx.db.insert("users", { email, name });

    // Scheduled, not awaited. The email is queued as part of this transaction:
    // if the insert above rolls back, the send never happens either.
    await ctx.scheduler.runAfter(0, internal.email.sendWelcome, {
      to: email,
      name,
    });

    return userId;
  },
});

Why not just call it inline?

Because you cannot, and the reason is worth internalising: a Convex mutation may be retried, and a retried network call would send the email twice. Scheduling moves the side effect outside the transaction while keeping it conditional on the transaction succeeding. Convex enforces this by refusing network access in mutations at all.

Delivery events arrive separately. Point a webhook at an HTTP action to record bounces and opens against your own rows.