---
title: "Sending"
description: "Transactional and marketing email through the backend component's email module (Resend nested inside) — setupEmail, the send action, and the EMAIL_* env vars."
canonical_url: "https://nuxt-backend.dev/docs/email/sending"
---
# Sending

> Transactional and marketing email through the backend component's email module (Resend nested inside) — setupEmail, the send action, and the EMAIL_* env vars.

The `backend` component ships transactional email powered by [Resend](https://www.convex.dev/components/resend) — **nested inside** it, as `backend/resend` in the dashboard — so email works out of the box once the `EMAIL_*` env vars are set. Its component functions are `components.backend.email.send` / `status` / `get` / `cancel` / `cleanup` / `cleanupAbandoned` / `handleWebhook`; `setupEmail(components, options?)` from `nuxt-backend/email` is the app-side helper over them, plus the marketing surface (segments / contacts / broadcasts) via the provider SDK.

## Setup

The scaffolded `backend/email.ts`, as generated:

```ts [backend/email.ts]
import { setupEmail } from 'nuxt-backend/email'
import { v } from 'convex/values'
import { components } from './_generated/api'
import { internalAction } from './_generated/server'
import { admin } from './functions'

export const email = setupEmail(components)

// The reactive delivery-status query behind `useEmailStatus`.
export const { getEmailStatus } = email.api

// Send from your own server code — internal, so the browser never picks a recipient.
export const send = internalAction({
  args: { to: v.string(), subject: v.string(), html: v.optional(v.string()), text: v.optional(v.string()) },
  returns: v.union(v.string(), v.null()),
  handler: async (ctx, args) => email.send(ctx, args),
})

// Any address, from an admin screen: admin.action refuses everyone without the admin role.
export const sendAsAdmin = admin.action({
  args: { to: v.string(), subject: v.string(), text: v.string() },
  returns: v.union(v.string(), v.null()),
  handler: async (ctx, args) => email.send(ctx, args),
})

// Marketing — privileged, so internalActions: run from ops or your own server code.
export const createSegment = internalAction({
  args: { name: v.string() },
  handler: async (ctx, { name }) => email.segments.create({ name }),
})
export const addContact = internalAction({
  args: { segmentId: v.string(), email: v.string(), firstName: v.optional(v.string()), lastName: v.optional(v.string()) },
  handler: async (ctx, { segmentId, ...contact }) => email.contacts.add({ ...contact, segments: [{ id: segmentId }] }),
})
export const createBroadcast = internalAction({
  args: { segmentId: v.string(), from: v.string(), subject: v.string(), html: v.string() },
  handler: async (ctx, args) => email.broadcasts.create(args),
})
export const sendBroadcast = internalAction({
  args: { broadcastId: v.string() },
  handler: async (ctx, { broadcastId }) => email.broadcasts.send(broadcastId),
})
```

`send` is internal on purpose. A public action that takes its recipient from the browser lets anyone who can sign in send mail from your verified domain to any address: an open relay. Send from your own server code instead. The email's address comes from data you trust, such as the signed-in user's own record, a workspace invitation, or an order. To let people type an address, do it behind a role: `sendAsAdmin` is the example, built with the `admin.action` builder from [`functions.ts`](https://nuxt-backend.dev/docs/auth/access/roles). It sends plain text, so nothing typed into the form renders as markup.

## Sending

`email.send(ctx, options)` enqueues a **durable** email through the component and resolves to the email id (or `null` while no transport is configured). It takes an action ctx (`runQuery` / `runMutation` / `runAction`), so call it from an action or `internalAction`. From a mutation (whose ctx has no `runAction`) or a workflow step (which must route every side effect through `step.*`), reach the component directly — the same durable, tracked path:

```ts
// From an action:
const emailId = await email.send(ctx, {
  to: 'user@example.com',
  subject: 'Welcome!',
  html: '<p>Hello</p>',
})

// From a mutation — or a workflow step, as `step.runMutation(...)`:
await ctx.runMutation(components.backend.email.send, { to, subject, html })
```

`SendEmailOptions`: `to` (string or array), `subject?`, `html?`, `text?`, `from?` (defaults to `EMAIL_FROM`), `cc?`, `bcc?`, `replyTo?`, `headers?` (`[{ name, value }]`), and `template?: { id, variables? }` for a provider-hosted template.

The rest of the helper: `email.status(ctx, emailId)` reads delivery status, `email.cancel(ctx, emailId)` cancels a not-yet-sent email, and `email.webhookHandler` is what [`registerBackendRoutes`](https://nuxt-backend.dev/docs/ship/production/webhooks) mounts at `/email/events`.

Retention is yours to schedule: `email.cleanup(ctx, { olderThanMs? })` prunes finalized records (delivered, bounced, cancelled, failed — default 7 days) and `email.cleanupAbandoned(ctx, { olderThanMs? })` prunes records that never finalized (default 30 days). Both schedule the provider's batched cleanup and return at once, so call them from a cron (`crons.daily(...)` → an `internalMutation` that calls `email.cleanup`).

## Auth emails are automatic

The auth emails — one-time codes, welcome, the deletion confirmation and workspace invitations — go through the same component path **automatically**; you don't pass a transport to `setupAuth`. Restyle them in [Templates](https://nuxt-backend.dev/docs/email/templates), or take over transport entirely with `setupAuth`'s `email` option.

## Testing without a mailbox

In test mode the provider delivers only to its sandbox addresses. The component keeps a copy of what it sends there, so a demo or an end-to-end test can read the code: see [Test with the sandbox inbox](https://nuxt-backend.dev/docs/email/sandbox-inbox).

## Beyond transactional email

[Send an email sequence](https://nuxt-backend.dev/docs/email/engagement/sequences) for onboarding and drips; [Send marketing email](https://nuxt-backend.dev/docs/email/engagement/marketing) for broadcasts to a segment.

## Environment

<table>
<thead>
  <tr>
    <th>
      Variable
    </th>
    
    <th>
      Effect
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        EMAIL_API_KEY
      </code>
    </td>
    
    <td>
      The provider API key — unset, sends no-op (logged), and sign-in codes and invitations to real addresses are <a href="https://nuxt-backend.dev/docs/email/deliverability#codes-and-invitations-that-would-not-arrive">
        refused
      </a>
      
      ; <code>
        NUXT_BACKEND_LOG_OTP=1
      </code>
      
       echoes codes to the <code>
        convex dev
      </code>
      
       console instead (dev deployments get it automatically)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        EMAIL_FROM
      </code>
    </td>
    
    <td>
      Sender address, e.g. <code>
        Acme <hello@yourdomain.com>
      </code>
      
       — defaults to the provider's onboarding sender
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        EMAIL_TEST_MODE
      </code>
    </td>
    
    <td>
      <code>
        true
      </code>
      
       (the default) delivers only to the provider's test addresses (<code>
        delivered@resend.dev
      </code>
      
      , <code>
        +label
      </code>
      
       aliases); set <code>
        false
      </code>
      
       once your sending domain is verified
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        EMAIL_WEBHOOK_SECRET
      </code>
    </td>
    
    <td>
      Verifies delivery events on <code>
        /email/events
      </code>
      
       — see <a href="https://nuxt-backend.dev/docs/email/delivery-tracking">
        Delivery tracking
      </a>
    </td>
  </tr>
</tbody>
</table>

The component owns these — the scaffolded `convex.config.ts` forwards them by reference from the app env, since components can't read the app's env directly.

<warning>

With `EMAIL_TEST_MODE` on, only the provider's test addresses receive mail. Flip it to `false` after verifying a sending domain in the Resend dashboard. Test mode is also what an unset variable means, so `doctor` cannot tell the two apart: check the production value before launch with `npx convex env get --prod EMAIL_TEST_MODE` — or, with a production deploy key in the environment, plain `npx convex env get EMAIL_TEST_MODE`. Without either, the command reads your dev deployment. [Get email into inboxes](https://nuxt-backend.dev/docs/email/deliverability) walks through it, and lists the sign-in codes and invitations refused meanwhile.

</warning>

<page-keys>

- Try it live in the playground: [/playground/platform/email](https://nuxt-backend.dev/playground/platform/email)
- API reference: [/docs/reference/api/email](https://nuxt-backend.dev/docs/reference/api/email)
- Source: [https://github.com/qruto/nuxt-backend/blob/main/src/convex/integrations/email.ts](https://github.com/qruto/nuxt-backend/blob/main/src/convex/integrations/email.ts)

</page-keys>


## Sitemap

See the full [sitemap](https://nuxt-backend.dev/sitemap.md) for all pages.
