SDKs and frameworks/Background jobs/Inngest

Markdown

Send email from Inngest

Updated Oct 25, 2026 · 2 min read

Send transactional email from Inngest 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. A subdomain such as mail.yourdomain.com works well.
  • An API key (gm_…) from API keys in the dashboard. A scoped key with only emails:send is enough.
  • An app serving Inngest functions (for example at /api/inngest in Next.js).

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.

Terminal
npm install groundmail inngest

Send an email when an event arrives

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

export const inngest = new Inngest({ id: 'my-app' })
TypeScript
// 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:

TypeScript
// 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] })
TypeScript
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.


A drip sequence with step.sleep

TypeScript
// 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.


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:

TypeScript
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.

Related: Node.js SDK · Send email API · Trigger.dev · Upstash QStash

Was this page helpful?