@razmatinyan/nuxt-email

Transactional email with multi-provider support and Vue templates

nuxt-email

npm version npm downloads CI License: MIT

Transactional email for Nuxt 4: a provider-agnostic API, Vue-powered templates, and type-safe server composables.

  • 📨 One API, many providers — Resend, SendGrid, Postmark, SMTP, and a console provider for local dev
  • 🧩 Vue email templates — author emails as .vue files, rendered on the server and CSS-inlined
  • 🔒 Server-only & type-safe — credentials stay private; useEmail() is auto-imported in your server routes
  • 📎 Attachments, CC/BCC, reply-to, headers, tags, and scheduling
  • 🔁 Automatic retries on transient failures, plus batch sending with a concurrency limit
  • 🛠 Nuxt DevTools tab — browse templates, live-preview them, send a test, and watch a send log

Requirements

  • Nuxt >=4.0.0
  • Node.js 20.19+ or 22+

Quick Setup

npm install @razmatinyan/nuxt-email
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@razmatinyan/nuxt-email'],
  email: {
    provider: 'console', // logs to the terminal during development
    from: 'App <noreply@myapp.com>',
  },
})

Send your first email from any server route:

// server/api/register.post.ts
export default defineEventHandler(async (event) => {
  const { sendEmail } = useEmail() // auto-imported on the server

  const result = await sendEmail({
    to: 'user@example.com',
    subject: 'Welcome!',
    html: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
  })

  if (!result.success) {
    console.error('Email failed:', result.error)
  }

  return result
})

Providers

All providers share the same API — switch by changing provider in nuxt.config.ts, no code changes.

ProviderTransportExtra dependency
consoleLogs to stdout—
resendREST ($fetch)—
sendgridREST ($fetch)—
postmarkREST ($fetch)—
smtpnodemailernpm install nodemailer (optional peer dependency)

The REST providers use Nitro's built-in $fetch, so no SDKs are added to your dependency tree. SMTP needs nodemailer, which is an optional peer dependency you install only if you use it.

export default defineNuxtConfig({
  email: {
    provider: 'resend',
    from: 'Acme <hello@acme.com>',
    apiKey: process.env.NUXT_EMAIL_API_KEY,
  },
})
# .env
NUXT_EMAIL_API_KEY=re_your_key_here

For SMTP:

email: {
  provider: 'smtp',
  from: 'Acme <hello@acme.com>',
  smtp: {
    host: 'smtp.example.com',
    port: 587,
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
    secure: false, // true for port 465
  },
}

Multiple providers

Give each provider its own credentials with the optional providers map. Each entry overrides the top-level apiKey / smtp / from for that provider, so you can switch providers per call without reconfiguring:

email: {
  provider: 'resend', // the default
  from: 'Acme <hello@acme.com>',
  providers: {
    resend: { apiKey: process.env.RESEND_API_KEY },
    postmark: { apiKey: process.env.POSTMARK_API_KEY, from: 'Acme <tx@acme.com>' },
    smtp: { smtp: { host: 'smtp.example.com', user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } },
  },
}
// uses the matching credentials from `providers.postmark`
await sendEmail({ to, subject, template, props }, { provider: 'postmark' })

If you only use one provider, the top-level apiKey/smtp is all you need — providers is optional.

Vue email templates

Drop a .vue file into server/emails/ (configurable via templateDir) and reference it by filename:

<!-- server/emails/welcome.vue -->
<script setup lang="ts">
defineProps<{ name: string, verifyUrl: string }>()
</script>

<template>
  <div style="font-family: sans-serif; padding: 24px;">
    <h1>Welcome, {{ name }}!</h1>
    <p>Please confirm your email address.</p>
    <a :href="verifyUrl">Verify</a>
  </div>
</template>
await sendEmail({
  to: user.email,
  subject: 'Welcome to Acme!',
  template: 'welcome',
  props: { name: user.name, verifyUrl: 'https://acme.com/verify?token=abc' },
})

The template is rendered to HTML on the server, CSS is inlined for email-client compatibility, and a plain-text fallback is generated automatically. Style with inline style attributes or <style> blocks inside the <template> — SFC <style> blocks are stripped during compilation and won't be inlined.

Templates are collected at build time, so restart the dev server after adding or renaming a .vue template.

Preview data

Export previewProps from a plain <script> block to give the dev preview and DevTools sample data:

<script lang="ts">
export const previewProps = {
  name: 'Jane Doe',
  verifyUrl: 'https://example.com/verify',
}
</script>

Sending options

await sendEmail({
  to: ['a@example.com', 'b@example.com'],
  cc: 'manager@example.com',
  bcc: 'audit@example.com',
  replyTo: 'support@example.com',
  from: 'Billing <billing@acme.com>', // overrides the default `from`
  subject: 'Your invoice',
  template: 'invoice',
  props: { total: '$99.00' },
  headers: { 'X-Entity-Ref-Id': 'inv_123' },
  tags: { category: 'billing' },          // resend, sendgrid, postmark
  scheduledAt: new Date(Date.now() + 3600_000), // resend, sendgrid
  attachments: [
    {
      filename: 'invoice.pdf',
      content: pdfBuffer,           // string or Buffer
      contentType: 'application/pdf',
    },
  ],
})

Every send returns a structured result (never throws from a provider):

interface EmailResponse {
  success: boolean
  messageId?: string
  error?: string
  provider: string
  duration: number // ms
}

tags and scheduledAt are applied by the providers that support them and ignored by the rest.

Batch sending

const { sendBatch } = useEmail()

const results = await sendBatch(
  users.map(u => ({ to: u.email, subject: 'Newsletter', template: 'newsletter', props: { name: u.name } })),
  { concurrency: 10 }, // default 5
)

const failed = results.filter(r => !r.success)

Per-call provider override

Override the configured provider for a single send (handy for testing). The override reuses the same credentials, so only providers whose credentials are configured will succeed — console always works:

await sendEmail({ to: 'me@example.com', subject: 'Test', template: 'welcome', props }, { provider: 'console' })

Reliability & validation

  • Required fields — every send needs a to, a subject, and at least one of html, text, or template. A from is required for non-console providers (set email.from or pass from per call). Missing any of these throws before the provider is called.
  • Retries — failures that look transient (HTTP 429/5xx, timeouts, connection resets) are retried up to retries times with a growing delay. Client errors (4xx) are not retried.
  • Validation — recipient addresses (to/cc/bcc/replyTo) and from are validated, "Name <email>" form is accepted, and newlines are rejected to prevent header injection. Attachments must have a filename and content.

DevTools

In dev, an Email tab appears in Nuxt DevTools. From it you can browse discovered templates, live-preview them (using their previewProps), pick a provider and send a test email, and watch an in-session send log. The panel follows the DevTools light/dark theme.

These dev-only routes back the tab (and are handy on their own):

RoutePurpose
GET /_email/preview/:templateRender a template to HTML
GET /_email/templatesList templates and their preview props
POST /_email/send-test/:templateSend a test email
GET /_email/logRecent sends from this dev session
GET /_email/configConfigured provider and available list

Set preview: false to disable them.

Dev vs production

The send API (sendEmail / sendBatch) behaves the same in both modes. The differences are automatic:

  • The DevTools tab and every /_email/* route are dev-only — registered only when Nuxt runs in dev mode (and preview is enabled), so they add nothing to your production bundle. Don't build app logic that depends on them.
  • The in-session send log is dev-only and is tree-shaken out of production builds.
  • Sends are skipped during prerendering (nuxi generate) and return a no-op success, so static generation never sends real email.

A common setup is the console provider locally and a real provider in production:

email: {
  provider: process.env.NODE_ENV === 'production' ? 'resend' : 'console',
  from: 'App <no-reply@app.com>',
  apiKey: process.env.NUXT_EMAIL_API_KEY,
}

Configuration

OptionTypeDefaultDescription
providerstring'console'console · resend · sendgrid · postmark · smtp
fromstring—Default sender ("Name <email>" or email)
apiKeystring—API key for the REST providers
smtpobject—{ host, port?, user?, pass?, secure? }
providersobject—Per-provider credentials, e.g. { resend: { apiKey }, smtp: { smtp: {...} } }
templateDirstring'server/emails'Directory scanned for .vue templates
previewbooleantrueEnable dev preview routes + DevTools tab
retriesnumber2Retry attempts on transient failures
retryDelaynumber1000Base delay between retries (ms)

Keep secrets in .env and read them via process.env in nuxt.config.ts, as shown above. Provider credentials live in private runtime config and are never exposed to the client.

Development

npm install              # install dependencies
npm run dev:prepare      # build module stubs (run once after cloning)
npm run dev              # playground at http://localhost:3000
npm run test:unit        # fast unit tests
npm run test:integration # boots Nuxt, slower
npx tsc --noEmit         # type-check

License

MIT