SDKs and frameworks/Background jobs/Trigger.dev

Markdown

Send email from Trigger.dev

Updated Oct 24, 2026 · 2 min read

Send transactional email from Trigger.dev tasks with the Groundmail Node SDK: welcome emails after sign-up, receipts after a payment job, or a drip sequence that waits days between emails. Trigger.dev retries failed tasks, so each send below carries an idempotency key and a retry never emails the same person twice.


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.
  • A Trigger.dev project using @trigger.dev/sdk (v4).

Add the API key

Local dev: npx trigger.dev dev reads .env, .env.development and .env.local, so add it there:

Terminal
GROUNDMAIL_API_KEY=gm_your_key_here

Deployed tasks: in the Trigger.dev dashboard, open Environment Variables, click Add new and set GROUNDMAIL_API_KEY for each environment (staging, prod). Deploys don't read .env.production, so set it in the dashboard.

Install the SDK:

Terminal
npm install groundmail

A task that sends an email

TypeScript
// trigger/welcome-email.ts
import { task } from '@trigger.dev/sdk'
import { Groundmail } from 'groundmail'

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

export const welcomeEmail = task({
  id: 'welcome-email',
  retry: { maxAttempts: 5 },
  run: async (payload: { email: string; name: string }, { ctx }) => {
    const { id } = await groundmail.emails.send({
      from: 'App <no-reply@mail.yourdomain.com>',
      to: payload.email,
      subject: `Welcome, ${payload.name}`,
      html: `<p>Hi ${payload.name}, thanks for signing up.</p>`,
      // Same run, same key: a retry returns the first send instead of sending again.
      idempotencyKey: `welcome-${ctx.run.id}`,
    })
    return { emailId: id }
  },
})

Trigger it from your backend. Import the task as a type only, so the task code stays out of your app bundle:

TypeScript
import { tasks } from '@trigger.dev/sdk'
import type { welcomeEmail } from '~/trigger/welcome-email'

await tasks.trigger<typeof welcomeEmail>('welcome-email', {
  email: user.email,
  name: user.name,
})

ctx.run.id stays the same across retries of one run, which is what makes it a safe idempotency key. Groundmail keeps keys for 24 hours and replays the original response (msg_… id and status) for a repeat.


A drip sequence: wait, then send

wait.for pauses the run. On Trigger.dev Cloud, waits longer than 5 seconds don't count toward compute, so waiting days costs nothing.

TypeScript
// trigger/onboarding.ts
import { task, wait } from '@trigger.dev/sdk'
import { Groundmail } from 'groundmail'
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 = task({
  id: 'onboarding-drip',
  run: async (payload: { userId: string; email: string }, { ctx }) => {
    await groundmail.emails.send({
      from,
      to: payload.email,
      subject: 'Welcome aboard',
      html: '<p>Here is how to get started.</p>',
      idempotencyKey: `onboarding-1-${ctx.run.id}`,
    })

    await wait.for({ days: 3 })

    // Decide at send time: skip the nudge if they already did the thing.
    if (await hasCreatedProject(payload.userId)) return { skipped: true }

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

wait.for takes seconds, minutes, hours, days, weeks, months or years. Use wait.until({ date }) for a fixed time.

To delay the whole run instead, pass delay when you trigger it: tasks.trigger<typeof welcomeEmail>('welcome-email', payload, { delay: '1h' }).


Trigger.dev delay or Groundmail scheduled_at?

Both work. Pick by where the decision lives:

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. No task needs to wait.
Check something before sending (did they upgrade, did they verify) wait.for in a task, then send.

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. A 5xx or 429 is worth retrying; a 4xx like an unverified From domain will fail every time. Stop those early with AbortTaskRunError:

TypeScript
import { AbortTaskRunError } from '@trigger.dev/sdk'
import { GroundmailError } from 'groundmail'

try {
  await groundmail.emails.send({ /* … */ })
} catch (err) {
  if (err instanceof GroundmailError && err.status >= 400 && err.status < 500 && err.status !== 429) {
    throw new AbortTaskRunError(`${err.code ?? err.status}: ${err.message}`)
  }
  throw err // let Trigger.dev retry
}

Troubleshooting

Problem Fix
GROUNDMAIL_API_KEY is undefined in a deployed run Set it under Environment Variables for that environment, then redeploy. .env files only apply to trigger.dev dev.
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 Pass an idempotencyKey built from ctx.run.id, not a random value or a timestamp.
429 from Groundmail Your account hit a sending limit. Let the task retry with backoff, and see Sending limits.

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

Was this page helpful?