Introduction
Most email tutorials stop at sending a single email with inline HTML. But production applications need something more sophisticated: a template system that maintains consistency across dozens of email types while remaining maintainable and type-safe.
This guide walks through building a professional email template system in SvelteKit using Nodemailer. You’ll learn to create base layouts that establish brand consistency, specialized templates for different email types, and orchestrate multi-email workflows that send coordinated message sequences.
What You’ll Build
By the end of this article, you’ll have:
- Base layout template providing consistent styling and structure
- Specialized templates for welcome emails, notifications, and contact forms
- Multi-email workflows that send coordinated sequences
- Type-safe interfaces ensuring template data correctness
- Reusable send functions abstracting email transport
- Production patterns for template composition and reuse
Prerequisites
This is an advanced article. You should already understand:
- SvelteKit routing and server-side code
- Basic Nodemailer email sending
- TypeScript fundamentals
- Form validation patterns
If you need foundational knowledge, start with a basic Nodemailer guide before tackling advanced template systems.
The Problem Space
Sending emails in production quickly becomes messy without a template system. Here’s what happens:
Problem 1: HTML Duplication
Every email contains duplicated header, footer, and styling:
// ❌ Duplicated across every email type
async function sendWelcomeEmail(email: string) {
await transporter.sendMail({
to: email,
subject: 'Welcome!',
html: `
<!DOCTYPE html>
<html>
<head>
<style>
/* 200 lines of CSS repeated in every email */
body { font-family: sans-serif; }
.header { background: #007bff; }
.footer { color: #666; }
</style>
</head>
<body>
<div class="header"><!-- Header repeated everywhere --></div>
<div class="content">Welcome content here</div>
<div class="footer"><!-- Footer repeated everywhere --></div>
</body>
</html>
`
})
}
// Same structure copy-pasted 15 times for different email types The cost: Change your footer? Update 15 files. Tweak brand colors? Find-and-replace across your codebase. Add a legal link? Hope you don’t miss any files.
Problem 2: No Type Safety
Email data is stringly typed, making typos runtime errors:
// ❌ Typos caught only when emails fail
await sendEmail({
to: user.email,
subject: 'Welcome',
html: `<p>Hello ${user.nmae}</p>` // Typo: nmae instead of name
}) Problem 3: Inconsistent Styling
Without a system, every developer invents their own patterns:
// Developer A uses inline styles
html: `<p style="color: #333; font-size: 16px;">Text</p>`
// Developer B uses classes
html: `<p class="body-text">Text</p>`
// Developer C copies from old emails
html: `<font color="#333">Text</font>` // Deprecated HTML The result: Emails look different, some break in Outlook, maintenance becomes archaeological work.
Problem 4: Multi-Email Chaos
Real workflows send multiple coordinated emails:
// ❌ Fragile, hard to test, unclear dependencies
async function handleDemoRequest(data) {
// Email 1: Sales notification
await transporter.sendMail({
/* ... */
})
// Email 2: Welcome to lead
await transporter.sendMail({
/* ... */
})
// Email 3: Confirmation
await transporter.sendMail({
/* ... */
})
} Questions this code doesn’t answer:
- What happens if email #2 fails but #1 succeeded?
- How do you test each email independently?
- How do you know which template each email uses?
- How do you share styling between them?
The SvelteKit Mental Model
SvelteKit teaches us to think about composition and reusability. We apply those same principles to email templates:
Mental Model 1: Templates as Functions
In SvelteKit, components are functions that take props and return markup. Email templates work the same way:
// A template is a pure function
function welcomeTemplate(data: WelcomeData): string {
return `<html>...</html>`
}
// Compose templates like components
function emailWithLayout(content: string, title: string): string {
return baseLayout(content, { title })
} Key insight: Treat templates like Svelte components—pure functions that accept typed props and return HTML.
Mental Model 2: Inheritance Through Composition
Just like Svelte’s <slot> pattern, email templates compose through content injection:
// Base layout accepts content (like a slot)
function baseLayout(content: string, options: LayoutOptions): string {
return `
<html>
<body>
<div class="header">...</div>
${content} <!-- Content "slot" -->
<div class="footer">...</div>
</body>
</html>
`
}
// Specific templates inject their content
function welcomeTemplate(data: WelcomeData): string {
const content = `<p>Welcome ${data.name}!</p>`
return baseLayout(content, { title: 'Welcome!' })
} Mental Model 3: Server-Only Module Boundaries
Email templates are server-only logic, just like +page.server.ts files:
src/lib/mailer/
├── emailSetup.server.ts # Transport configuration
├── sendEmail.server.ts # Send function
└── templates/ # Template library
├── layout.ts # Base layout
├── welcome.ts # Welcome emails
├── notification.ts # Notifications
└── contact.ts # Contact forms Why .ts not .server.ts?
Template files don’t need the .server.ts extension because they’re imported exclusively by server-only modules (like +page.server.ts files). The server boundary is already established by the importing file.
Template System Architecture
Let’s build a production-ready email template system from the ground up.
Directory Structure
Organize templates as a library within your mailer module:
src/lib/mailer/
├── emailSetup.server.ts # SMTP transporter configuration
├── sendEmail.server.ts # Reusable send function
└── templates/
├── layout.ts # Base layout (shared structure)
├── contact.ts # Contact form emails
├── welcome.ts # User onboarding emails
└── notification.ts # System alerts/updates Design principles:
- Single Responsibility: Each template handles one email type
- Composition: Templates inject content into shared layouts
- Type Safety: TypeScript interfaces define expected data
- Reusability: Templates are pure functions with no side effects
Building the Base Layout
The base layout provides consistent structure and styling for all emails. Think of it as your email “app shell.”
Why You Need a Base Layout
Without a base layout:
- Header HTML duplicated in 15 files
- Footer links updated manually everywhere
- Brand color changes require massive find-replace
- Email client CSS resets copy-pasted inconsistently
With a base layout:
- Change footer once, update all emails
- Add unsubscribe link in one place
- Tweak brand colors globally
- Ensure consistent email client compatibility
Create the Layout Template
// src/lib/mailer/templates/layout.ts
interface EmailLayoutOptions {
title?: string
preheader?: string
}
/**
* Base email layout providing consistent structure and styling
*
* All email templates should inject their content into this layout
* to ensure brand consistency and email client compatibility
*/
export function emailLayout(
content: string,
{ title = 'Email', preheader }: EmailLayoutOptions = {}
): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="x-apple-disable-message-reformatting">
<title>${escapeHtml(title)}</title>
${
preheader
? `
<!--[if !mso]><!-->
<div style="display: none; max-height: 0px; overflow: hidden;">
${escapeHtml(preheader)}
</div>
<!--<![endif]-->
`
: ''
}
<style>
/* Reset styles for email clients */
body, table, td, a {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
/* Base styles */
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333333;
background-color: #f4f4f4;
}
/* Container */
.email-wrapper {
width: 100%;
background-color: #f4f4f4;
padding: 20px 0;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Header */
.email-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px 20px;
text-align: center;
}
.email-header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
/* Body */
.email-body {
padding: 30px 20px;
}
/* Footer */
.email-footer {
background-color: #f8f9fa;
padding: 20px;
text-align: center;
font-size: 12px;
color: #6c757d;
border-top: 1px solid #e9ecef;
}
.email-footer p {
margin: 0 0 10px 0;
}
.email-footer a {
color: #667eea;
text-decoration: none;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
width: 100% !important;
border-radius: 0 !important;
}
.email-body {
padding: 20px 15px !important;
}
}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="email-container">
<div class="email-header">
<h1>${escapeHtml(title)}</h1>
</div>
<div class="email-body">
${content}
</div>
<div class="email-footer">
<p>© ${new Date().getFullYear()} Your Company. All rights reserved.</p>
<p>
<a href="https://yourcompany.com/unsubscribe">Unsubscribe</a> |
<a href="https://yourcompany.com/privacy">Privacy Policy</a> |
<a href="https://yourcompany.com/contact">Contact Us</a>
</p>
<p>123 Business St, City, State 12345</p>
</div>
</div>
</div>
</body>
</html>
`
}
/**
* Escapes HTML to prevent XSS attacks
* Essential when including user input in email templates
*/
function escapeHtml(unsafe: string): string {
return String(unsafe)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
} Key features of this layout:
- Email Client Resets: Normalizes rendering across Gmail, Outlook, Apple Mail
- Responsive Design: Adapts to mobile screens with media queries
- Preheader Support: Shows preview text in email clients
- Gradient Header: Modern, branded visual element
- Footer Links: Unsubscribe, privacy, contact (required for compliance)
- XSS Protection:
escapeHtmlfunction prevents injection attacks
Preheader TextThe preheader is the preview text shown in email clients before opening. It appears after the subject line in most email apps. Use it to expand on your subject line and improve open rates.
Example: Subject: “Welcome to Our Platform” | Preheader: “Get started with our quick setup guide”
Building Specialized Templates
Now that we have a base layout, let’s create specialized templates for different email types. Each template focuses on a specific use case while leveraging the shared layout.
Template 1: Contact Form Email
The contact template displays user-submitted information to your team:
// src/lib/mailer/templates/contact.ts
import { emailLayout } from './layout'
export interface ContactEmailData {
name: string
email: string
service: string[]
budget: string
message: string
}
/**
* Contact form email template
* Displays user-submitted information in a structured, readable format
*/
export function contactTemplate(data: ContactEmailData): string {
const content = `
<div style="margin-bottom: 20px;">
<p style="font-size: 16px; color: #555;">
You have received a new contact form submission from your website.
</p>
</div>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr>
<td style="padding: 12px; background: #f8f9fa; font-weight: 600;
border: 1px solid #dee2e6; width: 30%;">
Name
</td>
<td style="padding: 12px; border: 1px solid #dee2e6;">
${escapeHtml(data.name)}
</td>
</tr>
<tr>
<td style="padding: 12px; background: #f8f9fa; font-weight: 600;
border: 1px solid #dee2e6;">
Email
</td>
<td style="padding: 12px; border: 1px solid #dee2e6;">
<a href="mailto:${escapeHtml(data.email)}"
style="color: #667eea; text-decoration: none;">
${escapeHtml(data.email)}
</a>
</td>
</tr>
<tr>
<td style="padding: 12px; background: #f8f9fa; font-weight: 600;
border: 1px solid #dee2e6;">
Budget Range
</td>
<td style="padding: 12px; border: 1px solid #dee2e6;">
${escapeHtml(data.budget)}
</td>
</tr>
<tr>
<td style="padding: 12px; background: #f8f9fa; font-weight: 600;
border: 1px solid #dee2e6;">
Services Interested In
</td>
<td style="padding: 12px; border: 1px solid #dee2e6;">
<ul style="margin: 0; padding-left: 20px;">
${data.service.map((s) => `<li>${escapeHtml(s)}</li>`).join('')}
</ul>
</td>
</tr>
</table>
<div style="margin-top: 30px; padding: 20px; background: #f8f9fa;
border-left: 4px solid #667eea; border-radius: 4px;">
<h3 style="margin: 0 0 10px 0; font-size: 18px; color: #333;">
Message
</h3>
<p style="margin: 0; white-space: pre-wrap; color: #555;">
${escapeHtml(data.message)}
</p>
</div>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e9ecef;">
<a href="mailto:${escapeHtml(data.email)}"
style="display: inline-block; padding: 12px 24px; background: #667eea;
color: white; text-decoration: none; border-radius: 6px;
font-weight: 600;">
Reply to ${escapeHtml(data.name)}
</a>
</div>
`
return emailLayout(content, {
title: 'New Contact Form Submission',
preheader: `New inquiry from ${data.name} regarding ${data.service[0]}`
})
}
function escapeHtml(unsafe: string): string {
return String(unsafe)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
} Template features:
- Structured data table: Easy-to-scan information layout
- Email-ready links: Clickable mailto links for quick replies
- Message highlighting: Distinct visual treatment for user message
- Action button: Prominent reply CTA
- Type safety: Interface ensures all required fields are present
Template 2: Welcome Email
The welcome template onboards new users with a warm, engaging message:
// src/lib/mailer/templates/welcome.ts
import { emailLayout } from './layout'
export interface WelcomeEmailData {
name: string
email: string
accountType?: 'free' | 'pro' | 'enterprise'
verificationUrl?: string
}
/**
* Welcome email template for new user onboarding
*
* Creates engaging first impression with:
* - Personalized greeting
* - Account-specific benefits
* - Optional email verification
* - Clear next steps
*/
export function welcomeTemplate(data: WelcomeEmailData): string {
const accountBenefits = getAccountBenefits(data.accountType || 'free')
const content = `
<div style="margin-bottom: 30px;">
<p style="font-size: 18px; margin: 0 0 20px 0; color: #333;">
Welcome aboard, ${escapeHtml(data.name)}! 🎉
</p>
<p style="margin: 0 0 15px 0; color: #555; font-size: 15px;">
We're thrilled to have you join our community. Your account has been
successfully created, and you're now ready to explore everything we have to offer.
</p>
</div>
${
data.verificationUrl
? `
<div style="background: #fff3cd; border-left: 4px solid #ffc107;
padding: 20px; margin: 30px 0; border-radius: 4px;">
<p style="margin: 0 0 15px 0; font-weight: 600; color: #856404; font-size: 16px;">
📧 Please verify your email address
</p>
<p style="margin: 0 0 15px 0; color: #856404; font-size: 14px;">
To ensure the security of your account and enable all features, please verify
your email address by clicking the button below.
</p>
<div style="text-align: center; margin: 20px 0;">
<a href="${data.verificationUrl}"
style="display: inline-block; padding: 14px 32px; background: #667eea;
color: white; text-decoration: none; border-radius: 6px;
font-weight: 600; font-size: 16px;">
Verify Email Address
</a>
</div>
<p style="margin: 15px 0 0 0; font-size: 13px; color: #856404;">
This link expires in 24 hours. If you didn't create this account,
you can safely ignore this email.
</p>
</div>
`
: ''
}
<div style="margin: 30px 0;">
<h2 style="font-size: 20px; margin: 0 0 20px 0; color: #333;">
🚀 Get Started with Your ${accountBenefits.title}
</h2>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px;
border: 1px solid #e9ecef;">
${accountBenefits.features
.map(
(feature) => `
<div style="display: flex; margin-bottom: 15px; align-items: start;">
<span style="color: #28a745; font-size: 20px; margin-right: 12px;
line-height: 1;">✓</span>
<div>
<p style="margin: 0 0 5px 0; font-weight: 600; color: #333; font-size: 15px;">
${feature.title}
</p>
<p style="margin: 0; color: #666; font-size: 14px;">
${feature.description}
</p>
</div>
</div>
`
)
.join('')}
</div>
</div>
<div style="margin: 30px 0; text-align: center; padding: 30px 20px;
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
border-radius: 8px;">
<h3 style="margin: 0 0 15px 0; color: #333; font-size: 18px;">
Need help getting started?
</h3>
<p style="margin: 0 0 20px 0; color: #666; font-size: 14px;">
Check out our comprehensive guides, video tutorials, and active community forum.
</p>
<div style="display: inline-flex; gap: 15px; flex-wrap: wrap;
justify-content: center;">
<a href="https://yourcompany.com/docs"
style="display: inline-block; padding: 10px 20px; background: white;
color: #667eea; text-decoration: none; border-radius: 6px;
font-weight: 600; border: 2px solid #667eea; font-size: 14px;">
📚 Documentation
</a>
<a href="https://yourcompany.com/tutorials"
style="display: inline-block; padding: 10px 20px; background: white;
color: #667eea; text-decoration: none; border-radius: 6px;
font-weight: 600; border: 2px solid #667eea; font-size: 14px;">
🎥 Video Tutorials
</a>
<a href="https://yourcompany.com/community"
style="display: inline-block; padding: 10px 20px; background: white;
color: #667eea; text-decoration: none; border-radius: 6px;
font-weight: 600; border: 2px solid #667eea; font-size: 14px;">
💬 Community
</a>
</div>
</div>
<div style="margin: 30px 0; padding: 20px; background: white;
border: 1px solid #e9ecef; border-radius: 8px;">
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">
<strong>Your account details:</strong>
</p>
<p style="margin: 0; color: #666; font-size: 14px; line-height: 1.8;">
Email: ${escapeHtml(data.email)}<br>
Plan: ${accountBenefits.title}<br>
Joined: ${new Date().toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})}
</p>
</div>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e9ecef;">
<p style="margin: 0; color: #666; font-size: 14px; line-height: 1.6;">
If you have any questions or need assistance, our support team is here to help.
Simply reply to this email or visit our
<a href="https://yourcompany.com/support" style="color: #667eea; text-decoration: none;">
support center
</a>.
</p>
</div>
`
return emailLayout(content, {
title: 'Welcome to Your Company!',
preheader: `Get started with your ${accountBenefits.title.toLowerCase()} and explore our platform`
})
}
/**
* Returns account-specific benefits and features
* Customizes welcome message based on subscription tier
*/
function getAccountBenefits(accountType: 'free' | 'pro' | 'enterprise') {
const benefits = {
free: {
title: 'Free Account',
features: [
{
title: 'Explore Core Features',
description:
'Get familiar with our intuitive dashboard and essential tools to start building right away.'
},
{
title: 'Join the Community',
description:
'Connect with thousands of users in our forums, share ideas, and learn from others.'
},
{
title: 'Access Learning Resources',
description: 'Watch tutorial videos, read documentation, and follow step-by-step guides.'
}
]
},
pro: {
title: 'Pro Account',
features: [
{
title: 'Unlock Advanced Tools',
description:
'Access premium features including automation, advanced analytics, and priority support.'
},
{
title: 'Increased Limits',
description: 'Enjoy 10x higher usage limits, more storage, and faster processing speeds.'
},
{
title: 'Priority Support',
description: 'Get help from our expert support team within hours, not days.'
},
{
title: 'Exclusive Content',
description: 'Access pro-only webinars, advanced tutorials, and industry insights.'
}
]
},
enterprise: {
title: 'Enterprise Account',
features: [
{
title: 'Dedicated Success Manager',
description:
'Your personal account manager will help you maximize value and ensure smooth onboarding.'
},
{
title: 'Custom Integration',
description:
'Our team will help integrate our platform with your existing systems and workflows.'
},
{
title: 'Advanced Security',
description:
'Enterprise-grade security with SSO, audit logs, and compliance certifications.'
},
{
title: '24/7 Premium Support',
description:
'Round-the-clock support with guaranteed response times and direct access to engineers.'
}
]
}
}
return benefits[accountType]
}
function escapeHtml(unsafe: string): string {
return String(unsafe)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
} Template features:
- Personalization: Uses recipient’s name for connection
- Conditional verification: Shows email verification only when needed
- Account-specific benefits: Tailors content to subscription tier
- Multiple CTAs: Documentation, tutorials, community links
- Account summary: Confirms registration details
Welcome Email TimingWelcome emails have 50-86% open rates—the highest of any email type. Send them within 1 minute of signup while user engagement is peak. Every minute of delay reduces open rates significantly.
Template 3: Notification Email
The notification template handles system alerts, updates, and transactional messages:
// src/lib/mailer/templates/notification.ts
import { emailLayout } from './layout'
export type NotificationLevel = 'info' | 'success' | 'warning' | 'error'
export interface NotificationEmailData {
title: string
message: string
level?: NotificationLevel
actionUrl?: string
actionLabel?: string
details?: Array<{ label: string; value: string }>
timestamp?: Date
userName?: string
}
/**
* Notification email template for system alerts and updates
*
* Handles various notification types:
* - Account security alerts (password changes, login attempts)
* - System announcements (maintenance, feature launches)
* - Activity updates (mentions, comments, assignments)
* - Transaction confirmations (payments, invoices)
*
* Visual styling adapts based on severity level
*/
export function notificationTemplate(data: NotificationEmailData): string {
const level = data.level || 'info'
const colors = getNotificationColors(level)
const icon = getNotificationIcon(level)
const formattedTime = data.timestamp
? data.timestamp.toLocaleString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: new Date().toLocaleString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
const content = `
${
data.userName
? `
<div style="margin-bottom: 20px;">
<p style="margin: 0; color: #666; font-size: 15px;">
Hi ${escapeHtml(data.userName)},
</p>
</div>
`
: ''
}
<div style="background: ${colors.background}; border-left: 4px solid ${colors.border};
padding: 25px; margin: 20px 0; border-radius: 6px;">
<div style="display: flex; align-items: start; gap: 15px;">
<div style="font-size: 32px; line-height: 1; margin-top: -2px;">
${icon}
</div>
<div style="flex: 1;">
<h2 style="margin: 0 0 12px 0; font-size: 20px; color: ${colors.text};">
${escapeHtml(data.title)}
</h2>
<p style="margin: 0; color: ${colors.text}; font-size: 15px; line-height: 1.6;">
${escapeHtml(data.message)}
</p>
</div>
</div>
</div>
${
data.details && data.details.length > 0
? `
<div style="margin: 30px 0;">
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #333; font-weight: 600;">
Details
</h3>
<table style="width: 100%; border-collapse: collapse; background: #f8f9fa;
border-radius: 6px; overflow: hidden;">
${data.details
.map(
(detail) => `
<tr>
<td style="padding: 14px 20px; border-bottom: 1px solid #e9ecef;
font-weight: 600; color: #555; width: 35%;">
${escapeHtml(detail.label)}
</td>
<td style="padding: 14px 20px; border-bottom: 1px solid #e9ecef; color: #333;">
${escapeHtml(detail.value)}
</td>
</tr>
`
)
.join('')}
</table>
</div>
`
: ''
}
${
data.actionUrl && data.actionLabel
? `
<div style="margin: 35px 0; text-align: center;">
<a href="${data.actionUrl}"
style="display: inline-block; padding: 14px 32px; background: ${colors.button};
color: white; text-decoration: none; border-radius: 6px;
font-weight: 600; font-size: 16px;">
${escapeHtml(data.actionLabel)}
</a>
</div>
`
: ''
}
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e9ecef;">
<p style="margin: 0 0 8px 0; color: #999; font-size: 13px;">
<strong>Time:</strong> ${formattedTime}
</p>
<p style="margin: 0; color: #666; font-size: 14px; line-height: 1.6;">
${
level === 'error' || level === 'warning'
? `If you didn't expect this notification or believe it was sent in error,
please contact our support team immediately.`
: `You're receiving this because of activity on your account.
You can manage your notification preferences in your account settings.`
}
</p>
</div>
${
level === 'error' || level === 'warning'
? `
<div style="margin-top: 20px; padding: 15px; background: #f8f9fa; border-radius: 6px;">
<p style="margin: 0; color: #666; font-size: 13px;">
<strong>Security Tip:</strong> Never share your password or verification codes
with anyone, including our support team. We will never ask for this information via email.
</p>
</div>
`
: ''
}
`
return emailLayout(content, {
title: data.title,
preheader: data.message.slice(0, 100)
})
}
/**
* Returns color scheme based on notification severity
*/
function getNotificationColors(level: NotificationLevel) {
const colorSchemes = {
info: {
background: '#e7f3ff',
border: '#2196f3',
text: '#0d47a1',
button: '#2196f3'
},
success: {
background: '#e8f5e9',
border: '#4caf50',
text: '#1b5e20',
button: '#4caf50'
},
warning: {
background: '#fff3e0',
border: '#ff9800',
text: '#e65100',
button: '#ff9800'
},
error: {
background: '#ffebee',
border: '#f44336',
text: '#b71c1c',
button: '#f44336'
}
}
return colorSchemes[level]
}
/**
* Returns emoji icon for notification level
*/
function getNotificationIcon(level: NotificationLevel): string {
const icons = {
info: 'ℹ️',
success: '✅',
warning: '⚠️',
error: '🚨'
}
return icons[level]
}
function escapeHtml(unsafe: string): string {
return String(unsafe)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
} Template features:
- Severity levels: Visual styling changes based on importance (info/success/warning/error)
- Flexible structure: Supports optional details table, action buttons, personalization
- Contextual messaging: Footer text adapts to severity level
- Security awareness: Security tips for sensitive notifications
- Timestamp tracking: Shows when event occurred
Reusable Send Function
Create a central send function that all templates use:
// src/lib/mailer/sendEmail.server.ts
import { transporter } from './emailSetup.server'
import type { SentMessageInfo } from 'nodemailer'
export interface EmailOptions {
to: string
from: string
replyTo?: string
subject: string
html: string
text?: string
}
/**
* Sends email using Nodemailer with error handling
*
* Automatically generates plain text version from HTML
* Provides structured error messages for debugging
*/
export async function sendEmail({
to,
from,
replyTo,
subject,
html,
text
}: EmailOptions): Promise<SentMessageInfo> {
// Validate required fields
if (!to || !from || !subject || !html) {
throw new Error('Missing required email fields')
}
try {
const info = await transporter.sendMail({
from,
to,
replyTo,
subject,
html,
text: text || stripHtml(html) // Auto-generate text version
})
console.log('✓ Email sent:', {
messageId: info.messageId,
to,
subject
})
return info
} catch (err) {
console.error('Email send failed:', {
to,
subject,
error: err.message
})
throw new Error(`Failed to send email to ${to}: ${err.message}`)
}
}
/**
* Strips HTML tags for plain text version
*/
function stripHtml(html: string): string {
return html
.replace(/<style[^>]*>.*?<\/style>/gi, '')
.replace(/<script[^>]*>.*?<\/script>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
} Multi-Email Workflows
Real applications often need to send multiple coordinated emails for a single user action. Let’s build a complete example.
The Scenario: Demo Request
When someone requests a demo, we need to:
- Notify sales team (contact template) with lead details
- Welcome the lead (welcome template) while they wait
- Confirm receipt (notification template) with submitted details
Step 1: Define Validation Schema
// src/lib/types.ts
import * as v from 'valibot'
export const demoRequestSchema = v.object({
name: v.pipe(
v.string('Name is required'),
v.nonEmpty('Name is required'),
v.minLength(2, 'Name must be at least 2 characters'),
v.maxLength(100, 'Name is too long'),
v.trim()
),
email: v.pipe(
v.string('Email is required'),
v.nonEmpty('Email is required'),
v.email('Please enter a valid email address'),
v.maxLength(254, 'Email is too long'),
v.toLowerCase(),
v.trim()
),
company: v.pipe(
v.string('Company name is required'),
v.nonEmpty('Company name is required'),
v.minLength(2, 'Company name must be at least 2 characters'),
v.maxLength(100, 'Company name is too long'),
v.trim()
),
role: v.pipe(
v.string('Job title is required'),
v.nonEmpty('Job title is required'),
v.maxLength(100, 'Job title is too long'),
v.trim()
),
companySize: v.pipe(
v.string('Company size is required'),
v.nonEmpty('Please select a company size')
),
interests: v.pipe(
v.array(v.string()),
v.minLength(1, 'Please select at least one area of interest'),
v.maxLength(5, 'Please select no more than 5 interests')
)
})
export type DemoRequestData = v.InferOutput<typeof demoRequestSchema> Step 2: Create Form Component
<!-- src/routes/demo/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms'
interface FormResponse {
success?: boolean
email?: string
errors?: Record<string, string[]>
data?: any
}
let { form = $bindable() } = $props<{ form?: FormResponse }>()
let isSubmitting = $state(false)
const fieldErrors = (errors: Record<string, string[]> | undefined, field: string) =>
errors && field in errors ? errors[field] : undefined
</script>
<div class="container">
<h1>Request a Demo</h1>
<p class="intro">
See how our platform can transform your workflow. Fill out the form below and our team will
reach out to schedule a personalized demo.
</p>
{#if form?.success}
<div class="success-message" role="alert">
<h2>✓ Demo Request Submitted!</h2>
<p>
Thank you for your interest! We've sent a confirmation to
<strong>{form.email}</strong>. Our team will contact you within 24 hours to schedule your
personalized demo.
</p>
</div>
{/if}
<form
method="POST"
use:enhance={() => {
isSubmitting = true
return async ({ update }) => {
isSubmitting = false
await update()
}
}}
>
<!-- Personal Information -->
<fieldset>
<legend>Personal Information</legend>
<div class="form-field">
<label for="name">
Full Name *
{#if fieldErrors(form?.errors, 'name')}
<span class="error">{fieldErrors(form?.errors, 'name')?.[0]}</span>
{/if}
</label>
<input
id="name"
name="name"
type="text"
value={form?.data?.name ?? ''}
aria-invalid={fieldErrors(form?.errors, 'name') ? 'true' : undefined}
required
/>
</div>
<div class="form-field">
<label for="email">
Work Email *
{#if fieldErrors(form?.errors, 'email')}
<span class="error">{fieldErrors(form?.errors, 'email')?.[0]}</span>
{/if}
</label>
<input
id="email"
name="email"
type="email"
value={form?.data?.email ?? ''}
aria-invalid={fieldErrors(form?.errors, 'email') ? 'true' : undefined}
required
/>
</div>
</fieldset>
<!-- Company Details -->
<fieldset>
<legend>Company Details</legend>
<div class="form-field">
<label for="company">
Company Name *
{#if fieldErrors(form?.errors, 'company')}
<span class="error">{fieldErrors(form?.errors, 'company')?.[0]}</span>
{/if}
</label>
<input
id="company"
name="company"
type="text"
value={form?.data?.company ?? ''}
aria-invalid={fieldErrors(form?.errors, 'company') ? 'true' : undefined}
required
/>
</div>
<div class="form-field">
<label for="role">
Job Title *
{#if fieldErrors(form?.errors, 'role')}
<span class="error">{fieldErrors(form?.errors, 'role')?.[0]}</span>
{/if}
</label>
<input
id="role"
name="role"
type="text"
value={form?.data?.role ?? ''}
placeholder="e.g., Marketing Director"
aria-invalid={fieldErrors(form?.errors, 'role') ? 'true' : undefined}
required
/>
</div>
<div class="form-field">
<label for="companySize">
Company Size *
{#if fieldErrors(form?.errors, 'companySize')}
<span class="error">{fieldErrors(form?.errors, 'companySize')?.[0]}</span>
{/if}
</label>
<select id="companySize" name="companySize" value={form?.data?.companySize ?? ''} required>
<option value="">Select company size</option>
<option value="1-10">1-10 employees</option>
<option value="11-50">11-50 employees</option>
<option value="51-200">51-200 employees</option>
<option value="201-500">201-500 employees</option>
<option value="501+">501+ employees</option>
</select>
</div>
</fieldset>
<!-- Areas of Interest -->
<fieldset>
<legend>
Areas of Interest *
{#if fieldErrors(form?.errors, 'interests')}
<span class="error">{fieldErrors(form?.errors, 'interests')?.[0]}</span>
{/if}
</legend>
<div class="checkbox-group">
{#each ['Marketing Automation', 'Sales Pipeline Management', 'Customer Analytics', 'Team Collaboration', 'Workflow Automation'] as interest}
<label class="checkbox-label">
<input
type="checkbox"
name="interests"
value={interest}
checked={form?.data?.interests?.includes(interest) ?? false}
/>
{interest}
</label>
{/each}
</div>
</fieldset>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting Request...' : 'Request Demo'}
</button>
</form>
</div>
<style>
.container {
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
}
.intro {
color: #666;
font-size: 1.1rem;
line-height: 1.6;
margin-bottom: 2rem;
}
.success-message {
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
border: 2px solid #4caf50;
color: #1b5e20;
padding: 2rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.success-message h2 {
margin: 0 0 1rem 0;
color: #2e7d32;
}
.success-message p {
margin: 0;
font-size: 1rem;
line-height: 1.6;
}
fieldset {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
legend {
font-weight: 700;
font-size: 1.1rem;
color: #333;
padding: 0 0.5rem;
}
.form-field {
margin-bottom: 1.5rem;
}
label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
color: #444;
}
.error {
color: #d32f2f;
font-size: 0.875rem;
font-weight: normal;
margin-left: 0.5rem;
}
input[type='text'],
input[type='email'],
select {
width: 100%;
padding: 0.75rem;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
}
input[type='text']:focus,
input[type='email']:focus,
select:focus {
outline: none;
border-color: #667eea;
}
input[aria-invalid='true'] {
border-color: #d32f2f;
}
.checkbox-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
margin-top: 0.75rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.checkbox-label:hover {
background: #f5f5f5;
}
.checkbox-label input[type='checkbox'] {
width: auto;
cursor: pointer;
}
button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 2rem;
border: none;
border-radius: 8px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
</style> Step 3: Create Server Action
// src/routes/demo/+page.server.ts
import { env } from '$env/dynamic/private'
import { fail, type Actions } from '@sveltejs/kit'
import * as v from 'valibot'
import { demoRequestSchema, type DemoRequestData } from '$lib/types'
import { sendEmail } from '$lib/mailer/sendEmail.server'
import { contactTemplate } from '$lib/mailer/templates/contact'
import { welcomeTemplate } from '$lib/mailer/templates/welcome'
import { notificationTemplate } from '$lib/mailer/templates/notification'
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData()
const demoData = {
name: formData.get('name') as string,
email: formData.get('email') as string,
company: formData.get('company') as string,
role: formData.get('role') as string,
companySize: formData.get('companySize') as string,
interests: formData.getAll('interests') as string[]
}
try {
const validatedData = v.parse(demoRequestSchema, demoData)
// Send three coordinated emails
await sendDemoEmails(validatedData)
return {
success: true,
email: validatedData.email
}
} catch (err) {
if (v.isValiError(err)) {
const fieldErrors: Record<string, string[]> = {}
for (const issue of err.issues) {
const path = issue.path?.[0]?.key as string
if (path) {
if (!fieldErrors[path]) {
fieldErrors[path] = []
}
fieldErrors[path].push(issue.message)
}
}
return fail(400, {
errors: fieldErrors,
data: demoData
})
}
console.error('Demo request error:', err)
return fail(500, {
errors: {
_form: ['Failed to process your request. Please try again later.']
},
data: demoData
})
}
}
}
/**
* Sends three coordinated emails for demo request:
* 1. Internal notification to sales (contact template)
* 2. Welcome email to lead (welcome template)
* 3. Confirmation to lead (notification template)
*/
async function sendDemoEmails(data: DemoRequestData): Promise<void> {
/**
* Email 1: Sales team notification
* Reuses contact template by mapping fields
*/
await sendEmail({
to: env.GOOGLE_EMAIL,
from: env.GOOGLE_EMAIL,
replyTo: data.email,
subject: `New Demo Request: ${data.company} - ${data.name}`,
html: contactTemplate({
name: data.name,
email: data.email,
service: data.interests, // Map interests → services
budget: data.companySize, // Map companySize → budget
message: `Company: ${data.company}\nRole: ${data.role}\n\nRequested demo for: ${data.interests.join(', ')}`
})
})
/**
* Email 2: Welcome to lead
* Engages them while waiting for sales contact
*/
await sendEmail({
to: data.email,
from: env.GOOGLE_EMAIL,
subject: 'Welcome! Your Demo Request is Confirmed',
html: welcomeTemplate({
name: data.name,
email: data.email,
accountType: 'free'
})
})
/**
* Email 3: Confirmation to lead
* Provides receipt with submitted details
*/
await sendEmail({
to: data.email,
from: env.GOOGLE_EMAIL,
subject: 'Demo Request Confirmation',
html: notificationTemplate({
title: 'Demo Request Received',
message: `Thank you for requesting a demo, ${data.name}! We've received your request and our team will contact you within 24 hours to schedule your personalized demonstration.`,
level: 'success',
userName: data.name,
details: [
{ label: 'Company', value: data.company },
{ label: 'Your Role', value: data.role },
{ label: 'Company Size', value: data.companySize },
{ label: 'Areas of Interest', value: data.interests.join(', ') },
{ label: 'Contact Email', value: data.email }
],
actionUrl: 'https://google.com', // Placeholder URL for demo scheduling
actionLabel: 'Prepare for Your Demo',
timestamp: new Date()
})
})
} Why this order matters:
- Sales notification first: Ensures team knows about lead even if subsequent emails fail
- Welcome second: Engages lead with immediate value
- Confirmation last: Provides detailed reference
If email #3 fails, emails #1 and #2 still succeeded—your team knows about the lead and the lead received a welcome.
Template Reusability Patterns
Pattern 1: Field Mapping
Reuse templates by mapping different data to expected fields:
// src/routes/demo/+page.server.ts
// ... inside sendDemoEmails function
// Demo request uses contact template by mapping fields
await sendEmail({
html: contactTemplate({
name: data.name,
email: data.email,
service: data.interests, // interests → service
budget: data.companySize, // companySize → budget
message: constructMessage(data)
})
}) Pattern 2: Template Variants
Create template variants for related use cases:
// src/routes/demo/+page.server.ts
// ... inside sendEmail function
// Base notification
notificationTemplate({ title, message, level: 'info' })
// Others can reuse same template with different levels and content:
// Password changed (success variant)
notificationTemplate({
title: 'Password Changed',
message: 'Your password was successfully updated',
level: 'success'
})
// Security alert (error variant)
notificationTemplate({
title: 'Unusual Login Detected',
message: 'We detected a login from a new device',
level: 'error'
}) Pattern 3: Composition
Compose complex emails from simpler parts:
function orderReceiptTemplate(order: Order): string {
const itemsList = order.items
.map(
(item) => `
<tr>
<td>${item.name}</td>
<td>${item.quantity}</td>
<td>$${item.price}</td>
</tr>
`
)
.join('')
const content = `
${renderOrderSummary(order)}
<table>${itemsList}</table>
${renderPaymentInfo(order.payment)}
${renderShippingInfo(order.shipping)}
`
return emailLayout(content, { title: 'Order Receipt' })
} Testing Templates
Unit Test Templates
// tests/unit/templates.test.ts
import { describe, it, expect } from 'vitest'
import { contactTemplate } from '$lib/mailer/templates/contact'
describe('contactTemplate', () => {
it('renders all required fields', () => {
const data = {
name: 'John Doe',
email: 'john@example.com',
service: ['Web Development'],
budget: '$10,000',
message: 'Test message'
}
const html = contactTemplate(data)
expect(html).toContain('John Doe')
expect(html).toContain('john@example.com')
expect(html).toContain('Web Development')
expect(html).toContain('$10,000')
expect(html).toContain('Test message')
})
it('escapes HTML in user input', () => {
const data = {
name: '<script>alert("xss")</script>',
email: 'test@example.com',
service: ['Web Development'],
budget: '$5,000',
message: '<img src=x onerror=alert(1)>'
}
const html = contactTemplate(data)
expect(html).not.toContain('<script>')
expect(html).toContain('<script>')
expect(html).not.toContain('<img src=x')
})
}) Preview Templates Locally
Create a preview route for development:
// src/routes/email-preview/[template]/+page.server.ts
import { error } from '@sveltejs/kit'
import { contactTemplate } from '$lib/mailer/templates/contact'
import { welcomeTemplate } from '$lib/mailer/templates/welcome'
import { notificationTemplate } from '$lib/mailer/templates/notification'
export async function load({ params }) {
const mockData = {
contact: contactTemplate({
name: 'John Doe',
email: 'john@example.com',
service: ['Web Development', 'Consulting'],
budget: '$10,000 - $25,000',
message: 'Looking for help with a SvelteKit project'
}),
welcome: welcomeTemplate({
name: 'Jane Smith',
email: 'jane@example.com',
accountType: 'pro',
verificationUrl: 'https://example.com/verify'
}),
notification: notificationTemplate({
title: 'Password Changed Successfully',
message:
'Your account password was recently updated. If you did not make this change, please contact support immediately.',
level: 'success',
userName: 'Alex Johnson',
details: [
{ label: 'Changed At', value: new Date().toLocaleString() },
{ label: 'IP Address', value: '192.168.1.1' },
{ label: 'Location', value: 'San Francisco, CA' }
],
timestamp: new Date()
})
}
const html = mockData[params.template as keyof typeof mockData]
if (!html) {
error(404, 'Template not found')
}
return { html }
} <!-- src/routes/email-preview/[template]/+page.svelte -->
<script lang="ts">
let { data } = $props()
</script>
<svelte:head>
<title>Email Preview</title>
</svelte:head>
{@html data.html} Visit /email-preview/contact, /email-preview/welcome, or /email-preview/notification to see rendered templates.
Production Best Practices
1. Template Versioning
If you need to update templates in production, include version numbers for tracking and rollback by including a version comment in the template:
// src/lib/mailer/templates/welcome.ts
export const TEMPLATE_VERSION = '2.1.0'
export function welcomeTemplate(data: WelcomeEmailData): string {
const content = `
<!-- Template Version: ${TEMPLATE_VERSION} -->
${/* template content */}
`
return emailLayout(content, { title: 'Welcome!' })
} 2. Preheader Best Practices
Craft compelling preheaders (50-100 characters):
// ✅ Good preheaders
preheader: 'Get started with your new account in 3 easy steps'
preheader: 'Your order #12345 shipped and will arrive Tuesday'
preheader: 'Security alert: New login from Chrome on MacOS'
// ❌ Bad preheaders
preheader: 'View in browser'
preheader: data.name // Wasted space
preheader: '' // Missed opportunity 3. Mobile Optimization
Test templates on mobile devices:
/* Always include mobile styles */
@media only screen and (max-width: 600px) {
.email-container {
width: 100% !important;
border-radius: 0 !important;
}
.email-body {
padding: 20px 15px !important;
}
h1 {
font-size: 20px !important;
}
table {
font-size: 14px !important;
}
} 4. Accessibility
Make templates accessible:
<!-- Use semantic HTML -->
<h1>Welcome</h1>
<!-- Not <div style="font-size: 24px"> -->
<!-- Provide alt text -->
<img src="logo.png" alt="Company Logo" />
<!-- Ensure sufficient color contrast -->
<p style="color: #333; background: #fff;">
<!-- 4.5:1 ratio -->
<!-- Use proper link text -->
<a href="/verify">Verify Your Email</a>
<!-- Not "Click Here" -->
</p> 5. Email Client Testing
Test across major clients:
- Gmail (web, iOS, Android)
- Outlook (desktop, web, iOS, Android)
- Apple Mail (macOS, iOS)
- Yahoo Mail
- Outlook.com
Use services like Litmus or Email on Acid for automated testing.
Common Mistakes
Mistake 1: Inline Styles on Elements
<!-- ❌ Hard to maintain -->
<p style="color: #333; font-size: 16px; line-height: 1.6; margin-bottom: 20px;">
<!-- ✅ Use classes where supported -->
<style>
.text {
color: #333;
font-size: 16px;
line-height: 1.6;
margin-bottom: 20px;
}
</style>
</p>
<p class="text"></p> Mistake 2: Not Escaping User Input
// ❌ XSS vulnerability
html: `<p>${data.message}</p>`
// ✅ Always escape
html: `<p>${escapeHtml(data.message)}</p>` Mistake 3: Missing Plain Text Version
// ❌ HTML only
await sendEmail({ html })
// ✅ Include text fallback
await sendEmail({
html,
text: stripHtml(html)
}) Mistake 4: No Template Testing
// ❌ Test in production
await sendEmail({ to: 'customer@example.com', html })
// ✅ Preview locally first
// Visit /email-preview/template-name Conclusion
You’ve built a production-ready email template system with:
- Base layout ensuring consistency across all emails
- Specialized templates for contacts, welcome, and notifications
- Multi-email workflows coordinating message sequences
- Type safety preventing runtime errors
- Reusable patterns for template composition
Key Takeaways
- Templates as functions: Pure functions accepting typed data
- Composition over duplication: Inject content into shared layouts
- Server-only boundaries: Templates live in
.server.tsmodules - Type safety: Interfaces prevent missing/incorrect data
- Testing: Preview locally before sending to customers
Next Steps
- Add more specialized templates (password reset, invoice, etc.)
- Implement template versioning for A/B testing
- Set up automated email client testing
- Create template preview dashboard
- Build template metrics tracking
This guide focuses on production-ready patterns for building maintainable, type-safe email template systems in SvelteKit. For more tutorials, visit The Hackpile Chronicles.