Email

Send transactional emails with Resend or your preferred provider

This boilerplate uses Resend by default for sending transactional emails, but is designed with an abstraction layer that makes it easy to swap providers.

Overview

The email system provides:

  • Transactional emails - Auth emails, notifications, support messages
  • Beautiful templates - HTML email templates included
  • Provider abstraction - Easy to switch email providers
  • Type-safe - Full TypeScript support
  • Server-side only - Emails sent from secure server endpoints

Configuration

Environment variables

Add your Resend API key to .env:

.env
RESEND_API_KEY="re_..."
SUPPORT_FORM_TARGET_EMAIL="support@yourdomain.com"
Get your Resend API key from the Resend dashboard.

Email configuration

Email settings are in server/utils/config.ts:

server/utils/config.ts
export const EMAIL_CONFIG = {
  FROM_EMAIL: `${config.public.siteName} <no-reply@${config.public.siteDomain}>`,
} as const

Sending emails

The email service is located in server/services/email-server-service.ts:

server/services/email-server-service.ts
export async function sendEmail({ to, subject, html }: { to: string; subject: string; html: string }) {
  return resend.emails.send({
    from: EMAIL_CONFIG.FROM_EMAIL,
    to,
    subject,
    html,
  })
}

Example usage

In your API route:

server/api/send-notification.post.ts
import { sendEmail } from '~/server/services/email-server-service'

export default defineEventHandler(async event => {
  const { email, name } = await readBody(event)

  await sendEmail({
    to: email,
    subject: 'Welcome to our app!',
    html: `
      <h1>Welcome ${name}!</h1>
      <p>Thanks for signing up.</p>
    `,
  })

  return { success: true }
})

Email templates

Pre-built templates are in server/email-templates/:

Verify email template

server/email-templates/verifyEmailTemplate.ts
export const verifyEmailTemplate = `
<!DOCTYPE html>
<html>
<head>
  <style>
    /* Email styles */
  </style>
</head>
<body>
  <h1>Verify your email</h1>
  <p>Click the button below to verify your email address:</p>
  <a href="{{action_url}}" style="...">Verify Email</a>
</body>
</html>
`

Template placeholders

Templates use double curly braces for dynamic content:

  • {{action_url}} - Action link (verify email, reset password)
  • {{site_name}} - Your site name from env
  • {{site_domain}} - Your site domain from env
  • {{logo_url}} - Your logo URL
  • {{otp}} - One-time password code
  • {{newEmail}} - New email address (for email changes)

Using templates

Replace placeholders before sending:

const html = verifyEmailTemplate
  .replaceAll('{{action_url}}', verificationUrl)
  .replaceAll('{{site_name}}', config.public.siteName)
  .replaceAll('{{logo_url}}', logoUrl)

await sendEmail({
  to: user.email,
  subject: `Verify your email for ${config.public.siteName}`,
  html,
})

Creating custom templates

To create a new email template:

  1. Create the template file:
server/email-templates/welcomeEmailTemplate.ts
export const welcomeEmailTemplate = `
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Welcome</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      line-height: 1.6;
      color: #333;
      max-width: 600px;
      margin: 0 auto;
      padding: 20px;
    }
    .button {
      display: inline-block;
      padding: 12px 24px;
      background-color: #007bff;
      color: white;
      text-decoration: none;
      border-radius: 4px;
    }
  </style>
</head>
<body>
  <img src="{{logo_url}}" alt="Logo" style="max-width: 120px;">
  <h1>Welcome to {{site_name}}, {{user_name}}!</h1>
  <p>We're excited to have you on board.</p>
  <a href="{{dashboard_url}}" class="button">Get Started</a>
  <p style="color: #666; font-size: 12px;">
    © {{site_name}}. All rights reserved.
  </p>
</body>
</html>
`
  1. Create a helper function:
server/utils/email-helpers.ts
import { welcomeEmailTemplate } from '../email-templates/welcomeEmailTemplate'

export function prepareWelcomeEmail(user: { name: string; email: string }) {
  const config = useRuntimeConfig()

  const html = welcomeEmailTemplate
    .replaceAll('{{user_name}}', user.name)
    .replaceAll('{{site_name}}', config.public.siteName)
    .replaceAll('{{logo_url}}', `${config.public.siteUrl}/logo-180px.png`)
    .replaceAll('{{dashboard_url}}', `${config.public.siteUrl}/dashboard`)

  return {
    to: user.email,
    subject: `Welcome to ${config.public.siteName}!`,
    html,
  }
}
  1. Use it in your code:
import { sendEmail } from '~/server/services/email-server-service'
import { prepareWelcomeEmail } from '~/server/utils/email-helpers'

const emailData = prepareWelcomeEmail(user)
await sendEmail(emailData)

Support form emails

Send support form submissions to your email:

server/api/support.post.ts
import { sendSupportEmail } from '~/server/services/email-server-service'

export default defineEventHandler(async event => {
  const { name, email, message } = await readBody(event)

  await sendSupportEmail({
    subject: `Support request from ${name}`,
    html: `
      <h2>New support request</h2>
      <p><strong>From:</strong> ${name} (${email})</p>
      <p><strong>Message:</strong></p>
      <p>${message}</p>
    `,
  })

  return { success: true }
})

Replacing Resend with another provider

To switch to a different email provider:

Option 1: Simple replacement

Replace the Resend client in server/services/email-server-service.ts:

server/services/email-server-service.ts
// Before: Resend
import { Resend } from 'resend'
const resend = new Resend(config.resendApiKey)

// After: SendGrid example
import sgMail from '@sendgrid/mail'
sgMail.setApiKey(config.sendgridApiKey)

export async function sendEmail({ to, subject, html }) {
  return sgMail.send({
    from: EMAIL_CONFIG.FROM_EMAIL,
    to,
    subject,
    html,
  })
}

Option 2: Create an adapter

For more flexibility, create an email adapter pattern:

  1. Create the adapter interface:
server/utils/email-adapter.ts
export interface EmailAdapter {
  send(params: { from: string; to: string; subject: string; html: string }): Promise<any>
}
  1. Implement provider adapters:
server/adapters/resend-adapter.ts
import { Resend } from 'resend'
import type { EmailAdapter } from '~/server/utils/email-adapter'

export class ResendAdapter implements EmailAdapter {
  private client: Resend

  constructor(apiKey: string) {
    this.client = new Resend(apiKey)
  }

  async send({ from, to, subject, html }) {
    return this.client.emails.send({ from, to, subject, html })
  }
}
server/adapters/sendgrid-adapter.ts
import sgMail from '@sendgrid/mail'
import type { EmailAdapter } from '~/server/utils/email-adapter'

export class SendGridAdapter implements EmailAdapter {
  constructor(apiKey: string) {
    sgMail.setApiKey(apiKey)
  }

  async send({ from, to, subject, html }) {
    return sgMail.send({ from, to, subject, html })
  }
}
  1. Update the email service:
server/services/email-server-service.ts
import { ResendAdapter } from '~/server/adapters/resend-adapter'
// import { SendGridAdapter } from '~/server/adapters/sendgrid-adapter'

const config = useRuntimeConfig()
const emailAdapter = new ResendAdapter(config.resendApiKey)
// const emailAdapter = new SendGridAdapter(config.sendgridApiKey)

export async function sendEmail({ to, subject, html }) {
  return emailAdapter.send({
    from: EMAIL_CONFIG.FROM_EMAIL,
    to,
    subject,
    html,
  })
}

Here are some popular alternatives to Resend:

Best practices

Always test emails in development

Use a service like Mailtrap to test emails without sending to real addresses:

const isDev = process.env.NODE_ENV === 'development'

const transport = isDev
  ? createMailtrapTransport() // Catches emails in dev
  : createProductionTransport() // Sends real emails in prod

Use plain text alternatives

Some email clients don't support HTML. Include plain text:

await sendEmail({
  to: user.email,
  subject: 'Welcome!',
  html: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
  text: 'Welcome! Thanks for signing up.', // Plain text version
})

Handle email failures gracefully

try {
  await sendEmail({
    /* ... */
  })
} catch (error) {
  logger.error('Failed to send email:', error)
  // Don't throw - log the error but don't block the user
  // Consider adding to a retry queue
}

Respect email limits

Most providers have rate limits. For bulk emails:

  1. Use a proper email marketing service (Mailchimp, SendGrid Marketing)
  2. Implement queuing for large batches
  3. Add delays between sends if needed

Testing emails

Manual testing

  1. Set up a test email in your .env
  2. Trigger the email flow (sign up, password reset, etc.)
  3. Check your inbox

Automated testing

Mock the email service in your tests:

// vitest example
import { vi } from 'vitest'
import * as emailService from '~/server/services/email-server-service'

vi.spyOn(emailService, 'sendEmail').mockResolvedValue({ success: true })

// Test your code that sends emails

Troubleshooting

Emails not sending

  1. Check your API key - Verify it's correct in .env
  2. Check sender domain - Some providers require domain verification
  3. Check logs - Look for error messages in your server logs
  4. Check spam folder - Emails might be filtered

Emails look broken

  1. Test HTML - Use an email testing tool like Litmus
  2. Inline CSS - Email clients have poor CSS support, inline styles work best
  3. Use tables - For layout, tables are more reliable than flexbox/grid

Reference

For production use, make sure to set up SPF, DKIM, and DMARC records for your domain to improve email deliverability.