# Send email from Inngest

Send transactional email from [Inngest](https://www.inngest.com) functions with the Groundmail Node SDK. An event such as `app/user.signed_up` starts a function, a `step.run` sends the email, and `step.sleep` spaces out a drip sequence over hours or days. Each send carries an idempotency key, so a retried step never emails anyone twice.

The examples use the Inngest TypeScript SDK v4, where triggers go in the function's options (`triggers: { event: … }`).

---

## Prerequisites

- A Groundmail account with a [verified sending domain](https://docs.groundmail.dev/domains). A subdomain such as `mail.yourdomain.com` works well.
- An API key (`gm_…`) from **API keys** in the dashboard. A [scoped key](https://docs.groundmail.dev/api-keys) with only `emails:send` is enough.
- An app serving Inngest functions (for example at `/api/inngest` in Next.js).

---

## 1. Add the API key

Set `GROUNDMAIL_API_KEY` wherever your app runs: `.env.local` for local dev with `npx inngest-cli@latest dev`, and your host's environment variables in production (Vercel, Netlify, Railway and so on). Inngest calls your app's endpoint, so the key lives with your app, not in Inngest.

```bash
npm install groundmail inngest
```

---

## 2. Send an email when an event arrives

```ts
// inngest/client.ts
import { Inngest } from 'inngest'

export const inngest = new Inngest({ id: 'my-app' })
```

```ts
// inngest/welcome-email.ts
import { Groundmail } from 'groundmail'
import { inngest } from './client'

const groundmail = new Groundmail(process.env.GROUNDMAIL_API_KEY!)

export const welcomeEmail = inngest.createFunction(
  { id: 'welcome-email', triggers: { event: 'app/user.signed_up' } },
  async ({ event, step, runId }) => {
    const { id } = await step.run('send-welcome', () =>
      groundmail.emails.send({
        from: 'App <no-reply@mail.yourdomain.com>',
        to: event.data.email,
        subject: `Welcome, ${event.data.name}`,
        html: `<p>Hi ${event.data.name}, thanks for signing up.</p>`,
        // Same run, same key: a retried step returns the first send.
        idempotencyKey: `welcome-${runId}`,
      }),
    )
    return { emailId: id }
  },
)
```

Register the function with your serve handler, then send the event from your app:

```ts
// app/api/inngest/route.ts (Next.js App Router)
import { serve } from 'inngest/next'
import { inngest } from '@/inngest/client'
import { welcomeEmail } from '@/inngest/welcome-email'

export const { GET, POST, PUT } = serve({ client: inngest, functions: [welcomeEmail] })
```

```ts
await inngest.send({
  name: 'app/user.signed_up',
  data: { email: user.email, name: user.name },
})
```

Each `step.run` retries on its own when it throws, and a step that already succeeded isn't run again on later attempts. Wrapping the send in a step keeps a failure elsewhere in the function from sending a second email.

---

## 3. A drip sequence with `step.sleep`

```ts
// inngest/onboarding.ts
import { Groundmail } from 'groundmail'
import { inngest } from './client'
import { hasCreatedProject } from '@/lib/users' // your own lookup

const groundmail = new Groundmail(process.env.GROUNDMAIL_API_KEY!)
const from = 'App <no-reply@mail.yourdomain.com>'

export const onboarding = inngest.createFunction(
  { id: 'onboarding-drip', triggers: { event: 'app/user.signed_up' } },
  async ({ event, step, runId }) => {
    await step.run('send-welcome', () =>
      groundmail.emails.send({
        from,
        to: event.data.email,
        subject: 'Welcome aboard',
        html: '<p>Here is how to get started.</p>',
        idempotencyKey: `onboarding-1-${runId}`,
      }),
    )

    await step.sleep('wait-3-days', '3 days')

    // Decide at send time: skip the nudge if they already did the thing.
    const active = await step.run('check-activity', () => hasCreatedProject(event.data.userId))
    if (active) return { skipped: true }

    await step.run('send-nudge', () =>
      groundmail.emails.send({
        from,
        to: event.data.email,
        subject: 'Need a hand with your first project?',
        html: '<p>Reply to this email and we will help.</p>',
        idempotencyKey: `onboarding-2-${runId}`,
      }),
    )
  },
)
```

`step.sleep` takes milliseconds or a duration string such as `'30m'`, `'3 hours'` or `'2.5d'`. While it sleeps, nothing runs on your server.

---

## Inngest sleep or Groundmail `scheduled_at`?

| You want to | Use |
|-------------|-----|
| Send one known email at a set time | `scheduledAt` on the send (`'2026-11-01T09:00:00Z'` or a `Date`). Groundmail stores it, and you can reschedule or cancel it with `groundmail.emails.update` / `groundmail.emails.cancel`. |
| Check something before sending (did they upgrade, did they verify) | `step.sleep`, then a step that checks and sends. |

Scheduled emails can't carry attachments. See [Schedule, reschedule, and cancel](https://docs.groundmail.dev/emails#schedule-reschedule-and-cancel).

---

## Don't retry errors that won't fix themselves

The SDK throws `GroundmailError` with the HTTP `status` and a stable `code`. Retry `5xx` and `429`; stop on other `4xx` errors (an unverified From domain fails every time) with `NonRetriableError`:

```ts
import { NonRetriableError } from 'inngest'
import { GroundmailError } from 'groundmail'

await step.run('send-receipt', async () => {
  try {
    return await groundmail.emails.send({ /* … */ })
  } catch (err) {
    if (err instanceof GroundmailError && err.status >= 400 && err.status < 500 && err.status !== 429) {
      throw new NonRetriableError(err.message, { cause: err })
    }
    throw err // let Inngest retry the step
  }
})
```

---

## Troubleshooting

| Problem | Fix |
|---------|-----|
| `GROUNDMAIL_API_KEY` is undefined in a function | Functions run in your app, so set the variable on your app's host and redeploy. |
| `401 Unauthorized` | The key was revoked or belongs to another team. Create a new one under **API keys**. |
| `domain is not verified` | Send from an address on a domain you verified under **Domains**. |
| A retry sent a second email | Send inside `step.run`, and build the `idempotencyKey` from `runId` or `event.id`, not a random value. |
| `429` from Groundmail | Your account hit a sending limit. Let the step retry, and see [Sending limits](https://docs.groundmail.dev/sending-limits). |

Related: [Node.js SDK](https://docs.groundmail.dev/frameworks/node) · [Send email API](https://docs.groundmail.dev/emails) · [Trigger.dev](https://docs.groundmail.dev/integrations/trigger-dev) · [Upstash QStash](https://docs.groundmail.dev/integrations/qstash)
