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.
- 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:
RESEND_API_KEY="re_..."
SUPPORT_FORM_TARGET_EMAIL="support@yourdomain.com"
Email configuration
Email settings are in server/utils/config.ts:
export const EMAIL_CONFIG = {
FROM_EMAIL: `${config.public.siteName} <no-reply@${config.public.siteDomain}>`,
} as const
Although the current configuration dynamically uses the site name and domain from environment variables, you can also simply set a static email address instead:
export const EMAIL_CONFIG = {
FROM_EMAIL: 'hello@mydomain.com',
} as const
Sending emails
The email service is located in 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:
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
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:
- Create the template file:
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>
`
- Use it in your code:
import { sendEmail } from '@@/server/services/email-server-service'
import { welcomeEmailTemplate } from '@@/server/email-templates/welcomeEmailTemplate'
export default defineEventHandler(async event => {
const user = { name: 'John', email: 'john@example.com' }
const config = useRuntimeConfig()
// Replace template placeholders
const html = welcomeEmailTemplate
.replaceAll('{{user_name}}', user.name)
.replaceAll('{{site_name}}', config.public.siteName)
.replaceAll('{{logo_url}}', `${config.public.siteUrl}/logo-160px.png`)
.replaceAll('{{dashboard_url}}', `${config.public.siteUrl}/app/dashboard`)
// Send the email
await sendEmail({
to: user.email,
subject: `Welcome to ${config.public.siteName}!`,
html,
})
return { success: true }
})
server/utils/auth.ts for examples). For frequently sent emails, you can extract this into a reusable function.Support form emails
The template includes a working support form that sends emails. The pattern is:
- Frontend builds the email (
app/components/support/SupportForm.vue) - Client service calls API (
app/services/email-client-service.ts) - Server endpoint sends email (
server/api/email/send-support.ts)
The server endpoint includes rate limiting (5 requests per hour per IP):
import { sendSupportEmail } from '@@/server/services/email-server-service'
import { rateLimit } from '@@/server/utils/rate-limit'
export default defineEventHandler(async event => {
// Rate limiting for email endpoints
await rateLimit(event, {
max: 5,
window: '1h',
prefix: 'support-email',
})
const { subject, html } = await readBody(event)
// Validate required fields
if (!subject?.trim() || !html?.trim()) {
throw createError({
statusCode: 400,
statusMessage: 'Subject and message are required',
})
}
return await sendSupportEmail({ subject, html })
})
app/components/support/SupportForm.vue to see how the frontend builds the email HTML with user and app details.Replacing Resend with another provider
While Resend works great out of the box, you may need to use a different provider based on your existing infrastructure or regional requirements. The email service is designed to make switching providers straightforward.
To switch providers, update server/services/email-server-service.ts with your provider's client. Here's an example using SendGrid:
import sgMail from '@sendgrid/mail'
import { createError } from 'h3'
import { EMAIL_CONFIG } from '../utils/config'
const config = useRuntimeConfig()
sgMail.setApiKey(config.sendgridApiKey)
export async function sendEmail({ to, subject, html }: { to: string; subject: string; html: string }) {
return sgMail.send({
from: EMAIL_CONFIG.FROM_EMAIL,
to,
subject,
html,
})
}
export async function sendSupportEmail({ subject, html }: { subject: string; html: string }) {
const targetEmail = config.supportFormTargetEmail
if (!targetEmail) {
throw createError({
statusCode: 500,
statusMessage: 'Support email configuration is missing',
})
}
return sgMail.send({
from: EMAIL_CONFIG.FROM_EMAIL,
to: targetEmail,
subject,
html,
})
}
sendEmail and sendSupportEmail functions, and add your new provider's API key to .env.Popular email providers
Here are some popular alternatives to Resend:
- SendGrid - sendgrid.com
- Mailgun - mailgun.com
- Postmark - postmarkapp.com
- AWS SES - aws.amazon.com/ses
- Mailchimp Transactional - mailchimp.com/developer/transactional
Best practices
Handle failures gracefully
Don't let email failures block your users:
try {
await sendEmail({ to: user.email, subject: 'Welcome!', html: '...' })
} catch (error) {
logger.error('Failed to send email:', error)
// Log the error but don't throw - user can still continue
}
Common issues
Emails not sending:
- Check your API key in
.env - Verify your sender domain (some providers require verification)
- Check your server logs for errors
- Look in spam folder
Emails look broken:
- Use inline CSS (email clients have limited CSS support)
- Use tables for layout instead of flexbox/grid
- Test with tools like Litmus
Rate limits: For bulk emails, use a proper email marketing service (Mailchimp, SendGrid Marketing) instead of transactional email APIs