Introduction
Email functionality is a cornerstone of modern web applications. Whether you’re building a contact form, user authentication system, or notification service, understanding how to handle email server-side is essential. This guide walks through implementing email functionality in SvelteKit using Nodemailer, focusing on modern Svelte 5 patterns and best practices.
What is Nodemailer?
Nodemailer is a Node.js module that makes sending emails straightforward. It supports various transport methods (SMTP, Gmail, SendGrid, etc.) and handles the complexity of email protocols for you. In a SvelteKit context, Nodemailer runs exclusively on the server, keeping sensitive credentials secure.
What You’ll Learn
By the end of this guide, you’ll understand:
- Setting up Nodemailer with environment variables
- Creating server-side email handlers
- Building progressively enhanced forms in Svelte 5
- Implementing form validation with Valibot
- Handling multiple email destinations with named actions
- Managing form state and error handling
- Type-safe implementations with TypeScript
Prerequisites
- Basic understanding of SvelteKit routing and form actions
- Node.js installed (v18 or later recommended)
- A Gmail account or SMTP service for testing
Project Setup
First, install the required dependencies:
npm install nodemailer
npm install -D @types/nodemailer # TypeScript types
npm install valibot # For validation Environment Configuration
Email credentials should never be hardcoded. SvelteKit provides environment variable modules for server-only secrets that are never exposed to the client.
Understanding SvelteKit Environment Variables:
SvelteKit offers two types of private environment variables:
$env/dynamic/private- Read at runtime from.envfiles. Perfect for local development and when environment variables are injected at container/server startup. Values can change without rebuilding.$env/static/private- Replaced at build time like string literals. Best for serverless deployments (Vercel, Netlify) where env vars are known during build. Enables tree-shaking and better performance.
Static vs Dynamic Environment VariablesWe use
$env/dynamic/privatesince we’re focused on local development where you want.envchanges to take effect on dev server restart without rebuilding. When deploying to production, consider$env/static/privateif your platform supports build-time environment variables for better performance.
1. Create Environment Variables
Create a .env file in your project root:
# .env
GOOGLE_EMAIL="your-email@gmail.com"
GOOGLE_EMAIL_PASSWORD="your-app-specific-password" Gmail App PasswordsImportant: For Gmail, you need an App Password, not your regular password. Enable 2FA on your Google account, then generate an app-specific password.
2. Update .gitignore
Ensure your .env file is never committed:
# .gitignore
.env
.env.*
!.env.example 3. Create .env.example
Provide a template for other developers:
# .env.example
GOOGLE_EMAIL=""
GOOGLE_EMAIL_PASSWORD="" Setting Up the Email Transporter
The transporter is your connection to the email service. We’ll create a reusable server-side module.
Understanding the .server.js Convention
Files ending in .server.js are SvelteKit’s way of declaring server-only modules. They can never be imported into client-side code, providing an extra safety layer for sensitive operations.
Create the Email Setup
// src/lib/mailer/emailSetup.server.ts
import nodemailer from 'nodemailer'
import type { Transporter } from 'nodemailer'
import { env } from '$env/dynamic/private'
/**
* Creates and verifies email transporter
*/
function createTransporter(): Transporter {
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 465, // SSL port
secure: true, // Use SSL
auth: {
user: env.GOOGLE_EMAIL,
pass: env.GOOGLE_EMAIL_PASSWORD
}
})
// Verify connection on startup
transporter.verify((error, success) => {
if (error) {
console.error('Email transporter error:', error)
} else {
console.log('✓ Email server ready to send messages')
}
})
return transporter
}
// Create singleton instance
export const transporter = createTransporter() Alternative: Using Other Email Services
Nodemailer supports various services. Here are common alternatives:
SendGrid
SendGrid is a popular cloud-based email delivery service:
// src/lib/mailer/emailSetup.server.ts
import type { Transporter } from 'nodemailer'
const transporter: Transporter = nodemailer.createTransport({
host: 'smtp.sendgrid.net',
port: 587,
auth: {
user: 'apikey',
pass: SENDGRID_API_KEY
}
}) Mailgun
Mailgun offers powerful email APIs and SMTP services:
// src/lib/mailer/emailSetup.server.ts
import type { Transporter } from 'nodemailer'
const transporter: Transporter = nodemailer.createTransport({
host: 'smtp.mailgun.org',
port: 587,
auth: {
user: MAILGUN_SMTP_LOGIN,
pass: MAILGUN_SMTP_PASSWORD
}
}) AWS SES
Amazon SES provides scalable, cost-effective email infrastructure:
// src/lib/mailer/emailSetup.server.ts
import { SES } from '@aws-sdk/client-ses'
import type { Transporter } from 'nodemailer'
const ses = new SES({
apiVersion: '2010-12-01',
region: 'us-east-1'
})
const transporter: Transporter = nodemailer.createTransport({
SES: { ses, aws }
}) SendPigeon
SendPigeon (GitHub) is a modern transactional email service designed for developers:
// src/lib/mailer/emailSetup.server.ts
// Using SendPigeon SDK (recommended)
import { SendPigeon } from 'sendpigeon'
import { SENDPIGEON_API_KEY } from '$env/dynamic/private'
const client = new SendPigeon(SENDPIGEON_API_KEY)
// Send email
await client.emails.send({
from: 'hello@yourdomain.com',
to: recipientEmail,
subject: 'Your subject',
html: '<h1>Your HTML content</h1>'
}) Features:
- Excellent SvelteKit integration
- TypeScript support
- 99.9% deliverability
- Free local dev server for testing
- Free drag-and-drop email builder
Opinionated Take: Why Start with Nodemailer in Svelte 5
While API-based services like SendGrid, Mailgun, and SendPigeon offer convenience and polish, Nodemailer remains the best baseline for learning email in SvelteKit projects.
Why Nodemailer First:
Understanding Over Abstraction: Nodemailer forces you to understand how email actually works—SMTP connections, message structure, headers, authentication. API-based services abstract these away, which is convenient but leaves gaps in your mental model when things go wrong.
Local Testing Integration: Nodemailer integrates seamlessly with local SMTP servers (Mailpit, MailCrab, smtp4dev) and Ethereal Email. You can test entire email workflows offline without API keys, rate limits, or external dependencies. This makes development faster and more reliable.
Architecture Consistency: With Nodemailer, your email architecture doesn’t change between development and production—you simply swap SMTP credentials. API-based services often require different code patterns for testing vs. production, adding complexity.
Zero Lock-In: Nodemailer works with any SMTP provider. Once you understand it, switching between Gmail, SendGrid, AWS SES, or a custom mail server is just configuration. API-based solutions lock you into their ecosystem.
Production Scalability: Nodemailer scales from single contact forms to enterprise-level transactional email without architectural changes. Queue systems (BullMQ) and connection pooling work the same whether you’re sending 10 or 10,000 emails per day.
When to Consider API-Based Alternatives:
- After mastering the basics: Once you understand SMTP, message structure, and deliverability, API-based services become valuable productivity tools rather than black boxes.
- For specific features: Email analytics, A/B testing, advanced template systems, and dedicated deliverability optimization justify the trade-offs of vendor lock-in.
- At significant scale: When you’re sending millions of emails per month, managed services offer infrastructure you don’t want to maintain yourself.
The Learning Path:
- Start here: Master Nodemailer + local testing tools
- Add production concerns: Queue systems, monitoring, rate limiting
- Then optimize: Consider API-based services when convenience outweighs the cost of abstraction
API-based alternatives are excellent—but they’re intentional trade-offs, not replacements. Learn the fundamentals with Nodemailer first, then choose the right tool for your specific needs.
Building the Contact Form Component
Let’s create a progressively enhanced contact form using Svelte 5’s modern patterns.
We will use the enhance action from $app/forms to handle form submissions without JavaScript, while still providing a seamless experience when JavaScript is available.
Create the Form Component
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms'
/**
* @type {import('./$types').PageProps}
* PageProps includes both data (from load) and form (from actions)
*/
let { data, form } = $props()
// Track form submission state
let isSubmitting = $state(false)
// Type-safe helper for field errors
const fieldErrors = (
errors: Record<string, string[]> | { _form: string[] } | undefined,
field: string
) => (errors && field in errors ? (errors as Record<string, string[]>)[field] : undefined)
</script>
<div class="container">
<h1>Contact Us</h1>
{#if form?.success}
<div class="success-message" role="alert">
<p>✓ Thank you! Your message has been sent successfully.</p>
</div>
{/if}
<form
method="POST"
use:enhance={() => {
// Before submission
isSubmitting = true
return async ({ result, update }) => {
// After submission
isSubmitting = false
// Apply default behavior (updates form prop, resets on success)
await update()
}
}}
>
<div class="form-field">
<label for="name">
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">
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>
<div class="form-field">
<fieldset class="checkbox-fieldset">
<legend>
Services Interested In
{#if fieldErrors(form?.errors, 'service')}
<span class="error">{fieldErrors(form?.errors, 'service')?.[0]}</span>
{/if}
</legend>
<div class="checkbox-group">
{#each ['Web Development', 'Mobile Apps', 'Consulting', 'Design'] as service}
<label class="checkbox-label">
<input
type="checkbox"
name="service"
value={service}
checked={form?.data?.service?.includes(service) ?? false}
/>
{service}
</label>
{/each}
</div>
</fieldset>
</div>
<div class="form-field">
<label for="budget">
Budget Range
{#if fieldErrors(form?.errors, 'budget')}
<span class="error">{fieldErrors(form?.errors, 'budget')?.[0]}</span>
{/if}
</label>
<select id="budget" name="budget" value={form?.data?.budget ?? ''} required>
<option value="">Select a range</option>
<option value="< $5,000">Less than $5,000</option>
<option value="$5,000 - $10,000">$5,000 - $10,000</option>
<option value="$10,000 - $25,000">$10,000 - $25,000</option>
<option value="$25,000+">$25,000+</option>
</select>
</div>
<div class="form-field">
<label for="message">
Message
{#if fieldErrors(form?.errors, 'message')}
<span class="error">{fieldErrors(form?.errors, 'message')?.[0]}</span>
{/if}
</label>
<textarea
id="message"
name="message"
rows="5"
value={form?.data?.message ?? ''}
aria-invalid={fieldErrors(form?.errors, 'message') ? 'true' : undefined}
required
></textarea>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
</div>
<style>
.container {
max-width: 600px;
margin: 2rem auto;
padding: 0 1rem;
}
.success-message {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1.5rem;
}
.form-field {
margin-bottom: 1.5rem;
}
.checkbox-fieldset {
border: none;
padding: 0;
margin: 0;
}
.checkbox-fieldset legend {
font-weight: 600;
margin-bottom: 0.5rem;
padding: 0;
}
label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
}
.error {
color: #dc3545;
font-size: 0.875rem;
font-weight: normal;
margin-left: 0.5rem;
}
input[type='text'],
input[type='email'],
select,
textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ced4da;
border-radius: 0.25rem;
font-size: 1rem;
}
input[aria-invalid='true'],
textarea[aria-invalid='true'] {
border-color: #dc3545;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: normal;
}
button {
background: #007bff;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.25rem;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
button:hover:not(:disabled) {
background: #0056b3;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style> Now form is fully functional with progressive enhancement. It will work without JavaScript, as the form still works through native HTML form submission but with JavaScript, you get:
- No full-page reload
- Loading states
- Optimistic UI updates
- Error handling
Validation
Before sending emails, we should validate user input. While there are other validation libraries as eg. Zod, we use Valibot which provides runtime type safety with excellent tree-shaking and is officially supported by Svelte.
Why Valibot?
- Lightweight: ~1-2KB minified (Zod is ~14KB)
- Tree-Shakeable: Only bundle what you use
- Type Safety: Full TypeScript integration
- Composable: Pipe multiple validations together
- Official Support: Recommended by Svelte team
- Modern API: Clean, functional approach
Create Validation Schema
Let’s create a validation schema for our contact form data. This will ensure that we only process valid data and provide meaningful error messages to users.
// src/lib/types.ts
import * as v from 'valibot'
/**
* Contact form validation schema
* Provides both type safety and runtime validation
*/
export const contactEmailSchema = 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 must be less than 100 characters'),
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'), // RFC 5321
v.toLowerCase(),
v.trim()
),
service: v.pipe(
v.array(v.string()),
v.minLength(1, 'Please select at least one service'),
v.maxLength(4, 'Please select no more than 4 services')
),
budget: v.pipe(
v.string('Please select a budget range'),
v.nonEmpty('Please select a budget range')
),
message: v.pipe(
v.string('Message is required'),
v.nonEmpty('Message is required'),
v.minLength(10, 'Message must be at least 10 characters'),
v.maxLength(1000, 'Message must be less than 1000 characters'),
v.trim()
)
})
// Type inference for use in TypeScript files
export type ContactEmailData = v.InferOutput<typeof contactEmailSchema> Server-Side Actions
Form actions are SvelteKit’s preferred way to handle form submissions. They run on the server, keeping sensitive operations secure.
Understanding Form Actions
Form actions are server-side functions that:
- Receive POST requests from forms
- Process FormData
- Can validate, transform, and store data
- Return data back to the client
- Work without JavaScript (progressive enhancement)
Create the Default Action
SvelteKit allows you to define a default action that runs when a form is submitted without an explicit action parameter. This is ideal for our contact form.
// src/routes/contact/+page.server.ts
import { env } from '$env/dynamic/private'
import { transporter } from '$lib/mailer/emailSetup.server'
import { contactEmailSchema, type ContactEmailData } from '$lib/types'
import { fail, type Actions, type RequestEvent } from '@sveltejs/kit'
import * as v from 'valibot'
export const actions: Actions = {
/**
* Default action - handles form submission
* Runs when form is submitted without an explicit action parameter
*/
default: async ({ request }: RequestEvent) => {
// Extract form data
const formData = await request.formData()
const contactEmailData = {
name: formData.get('name') as string,
email: formData.get('email') as string,
service: formData.getAll('service') as string[],
budget: formData.get('budget') as string,
message: formData.get('message') as string
}
try {
// Validate data with Valibot
const validatedData = v.parse(contactEmailSchema, contactEmailData)
// Send email
await sendContactEmail(validatedData)
// Return success response
return {
success: true
}
} catch (err) {
// Handle validation errors
if (v.isValiError(err)) {
const fieldErrors: Record<string, string[]> = {}
// Convert Valibot issues to field-specific errors
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 errors and form data for repopulation
return fail(400, {
errors: fieldErrors,
data: contactEmailData
})
}
// Handle email sending errors
console.error('Email error:', err)
return fail(500, {
errors: {
_form: ['Failed to send message. Please try again later.']
},
data: contactEmailData
})
}
}
}
/**
* Sends contact form email
*/
async function sendContactEmail(data: ContactEmailData): Promise<void> {
// Build HTML email template
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
}
.email-container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.email-header {
background: #f8f9fa;
padding: 20px;
border-radius: 8px 8px 0 0;
border-bottom: 3px solid #007bff;
}
.email-body {
background: white;
padding: 20px;
border: 1px solid #e9ecef;
border-top: none;
}
.info-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.info-table th {
background: #f8f9fa;
padding: 12px;
text-align: left;
font-weight: 600;
border: 1px solid #dee2e6;
}
.info-table td {
padding: 12px;
border: 1px solid #dee2e6;
}
.message-section {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-left: 4px solid #007bff;
}
.services-list {
list-style: none;
padding: 0;
}
.services-list li:before {
content: "✓ ";
color: #28a745;
font-weight: bold;
margin-right: 8px;
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
<h2 style="margin: 0; color: #007bff;">New Contact Form Submission</h2>
</div>
<div class="email-body">
<table class="info-table">
<tr>
<th>Name</th>
<td>${escapeHtml(data.name)}</td>
</tr>
<tr>
<th>Email</th>
<td><a href="mailto:${data.email}">${escapeHtml(data.email)}</a></td>
</tr>
<tr>
<th>Budget Range</th>
<td>${escapeHtml(data.budget)}</td>
</tr>
<tr>
<th>Services</th>
<td>
<ul class="services-list">
${data.service.map((s) => `<li>${escapeHtml(s)}</li>`).join('')}
</ul>
</td>
</tr>
</table>
<div class="message-section">
<h3 style="margin-top: 0;">Message</h3>
<p style="white-space: pre-wrap;">${escapeHtml(data.message)}</p>
</div>
</div>
</div>
</body>
</html>
`
// Configure email message
const message = {
from: `"${data.name}" <${data.email}>`,
replyTo: data.email, // Allows direct reply to sender
to: env.GOOGLE_EMAIL,
subject: `New Contact Form: ${data.name} - ${data.service[0]}`,
html,
// Plain text fallback
text: `
Name: ${data.name}
Email: ${data.email}
Budget: ${data.budget}
Services: ${data.service.join(', ')}
Message:
${data.message}
`
}
// Send email (Promise-based, works for dev + prod)
const info = await transporter.sendMail(message)
if (!info.messageId) {
console.error('Failed to send email:', info)
} else {
console.log('✓ Email sent:', info.messageId)
}
}
/**
* Escapes HTML to prevent XSS attacks
* Essential when including user input in HTML emails
*/
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
} Key Concepts Explained
1. The fail Function
SvelteKit’s fail function returns errors without triggering error boundaries:
import { fail } from '@sveltejs/kit'
// Returns 400 status with error data
return fail(400, {
errors: { email: ['Invalid email'] },
data: { name: 'John' }
}) Without fail, returning errors would trigger the error page.
2. FormData Methods
formData.get('name') as string // Single value
formData.getAll('service') as string[] // Multiple values (checkboxes) 3. Valibot Error Handling
catch (err) {
if (v.isValiError(err)) {
// Validation errors - convert to field errors
const fieldErrors: Record<string, string[]> = {};
for (const issue of err.issues) {
const path = issue.path?.[0]?.key as string;
if (path) {
fieldErrors[path] = fieldErrors[path] || [];
fieldErrors[path].push(issue.message);
}
}
return fail(400, { errors: fieldErrors });
}
// Other errors (network, email service, etc.)
return fail(500, { errors: { _form: ['General error'] } });
} 4. HTML Email Security
Always escape user input in HTML emails to prevent XSS attacks:
${escapeHtml(data.message)} // Safe
${data.message} // Dangerous! That’s all you need …kinda
If your page contains a single form and you’ve completed all the required steps—including correctly setting up a Google App Password—this setup is all you need. Form submissions should send emails successfully to your designated address.
At this point, you’re done. Seriously. If that’s your use case, you can stop here and ship it.
That said, real projects rarely stay simple forever. If you need to handle more advanced scenarios—multiple forms, different email destinations, or more structured workflows—keep reading.
Before we dive into the advanced patterns, make sure the fundamentals are solid. Test your formusing one of the recommended email testing providers, verify that emails are actually being sent, and confirm validation behaves exactly as expected. Debugging “advanced” logic on top of a broken baseline is a waste of time.
Once the basics are locked in and you’re confident everything works end-to-end, you’re ready to go deeper.
Let’s push it further.
Understanding Form Actions: From Simple to Advanced
When you’re learning email functionality with Nodemailer, one question often comes up: “Should my form have one submit button or multiple?”
The answer depends on who’s making the decision and what decision they’re making. Let’s build up your understanding step by step.
Start Here: The Single Action Pattern
Most applications—including yours—should start with the simplest pattern: one form, one action, one button.
Here’s what a production-ready contact form looks like:
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms'
let { form } = $props()
let isSubmitting = $state(false)
</script>
<form
method="POST"
use:enhance={() => {
isSubmitting = true
return async ({ update }) => {
isSubmitting = false
await update()
}
}}
>
<label>
Name
<input name="name" required />
</label>
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Message
<textarea name="message" required></textarea>
</label>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form> The server action handles all email logic in one place:
// src/routes/contact/+page.server.ts
import { fail } from '@sveltejs/kit'
import type { Actions } from './$types'
import { transporter } from '$lib/mailer/emailSetup.server'
import { env } from '$env/dynamic/private'
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData()
const data = {
name: formData.get('name') as string,
email: formData.get('email') as string,
message: formData.get('message') as string
}
try {
// Email 1: Notify admin about new message
await transporter.sendMail({
to: env.ADMIN_EMAIL,
from: env.COMPANY_EMAIL,
replyTo: data.email,
subject: `Contact Form: ${data.name}`,
text: `From: ${data.name} (${data.email})\n\n${data.message}`
})
// Email 2: Send confirmation to user
await transporter.sendMail({
to: data.email,
from: env.COMPANY_EMAIL,
subject: 'We received your message',
text: `Hi ${data.name},\n\nThanks for contacting us. We've received your message and will respond within 24 hours.\n\nBest regards,\nThe Team`
})
return { success: true }
} catch (error) {
console.error('Email error:', error)
return fail(500, { error: 'Failed to send message' })
}
}
} Notice what’s happening here:
The user sees one button → The server sends two emails → Both succeed or both fail together.
This is the right pattern for 95% of applications.
Why This Works
Think about the user’s mental model. When someone fills out a contact form, they’re thinking:
“I want to send a message”
They’re not thinking:
- “Should I notify the admin or the sales team?”
- “Do I want a confirmation email?”
- “Should this go to the priority queue?”
Those are your concerns as the developer, not the user’s concerns. Handle them server-side where they belong.
Benefits of single-action forms:
- ✅ Users can’t make mistakes (no wrong button to click)
- ✅ You control the email flow centrally
- ✅ Easy to add rate limiting, validation, logging
- ✅ Simple to test and maintain
- ✅ Works without JavaScript (progressive enhancement)
If this pattern solves your problem, you’re done. Ship it. The patterns below are for more advanced scenarios.
When You Need Multiple Actions
Sometimes users genuinely need to choose what happens with their data. This is where named actions become useful.
Recognizing the Pattern
Ask yourself: “Is the user making a conscious choice about the outcome?”
Examples where users make choices:
| Scenario | User’s Decision | Email Outcome |
|---|---|---|
| Send receipt | “Just send to customer” vs “Send + keep a copy” | Different BCC behavior |
| Contact form | “Send now” vs “Save draft” | Email vs no email |
| Approval workflow | “Approve” vs “Request changes” | Different email templates |
Examples where users DON’T make choices:
| Scenario | What Really Happens | Right Pattern |
|---|---|---|
| Contact form | Always notifies admin + confirms to user | Single action, multiple emails |
| Newsletter signup | Always confirms + notifies team | Single action, multiple emails |
| Order receipt | Always emails customer + accounting | Single action, multiple emails |
The key distinction: Is the user deciding the outcome, or is the system following rules?
Building Understanding: The Receipt Example
Let’s explore a real scenario where multiple actions make sense.
The situation: You run an e-commerce store. After an order, you need to email the receipt to the customer. Sometimes you want a copy for your records, sometimes you don’t.
The wrong approach:
<!-- ❌ Don't do this -->
<form method="POST">
<label>
<input type="checkbox" name="sendCopy" />
Send me a copy
</label>
<button type="submit">Send Receipt</button>
</form> Why is this wrong? You’re using one action but trying to handle two different behaviors. The server needs to check if (sendCopy) { ... } which creates branching logic.
The right approach with named actions:
<!-- ✅ Clear user intent -->
<script lang="ts">
import { enhance } from '$app/forms'
let { form } = $props()
let isSubmitting = $state(false)
let wantsCopy = $state(false)
</script>
<form
method="POST"
use:enhance={() => {
isSubmitting = true
return async ({ update }) => {
isSubmitting = false
await update()
}
}}
>
<input name="orderNumber" type="text" placeholder="Order #" required />
<input name="customerEmail" type="email" placeholder="Customer email" required />
<label>
<input type="checkbox" bind:checked={wantsCopy} />
Send me a copy
</label>
{#if wantsCopy}
<input name="yourEmail" type="email" placeholder="Your email" required />
{/if}
{#if wantsCopy}
<button type="submit" formaction="?/sendWithCopy" disabled={isSubmitting}>
Send Receipt + Copy
</button>
{:else}
<button type="submit" formaction="?/sendReceipt" disabled={isSubmitting}> Send Receipt </button>
{/if}
</form> Notice the formaction attribute? It tells the browser which server action to call. No JavaScript routing needed.
The Server Side
Each action is focused and explicit:
// src/routes/orders/receipt/+page.server.ts
import { fail } from '@sveltejs/kit'
import type { Actions } from './$types'
import { transporter } from '$lib/mailer/emailSetup.server'
import { env } from '$env/dynamic/private'
export const actions: Actions = {
/**
* Send receipt to customer only
*/
sendReceipt: async ({ request }) => {
const data = await request.formData()
await transporter.sendMail({
to: data.get('customerEmail'),
from: env.COMPANY_EMAIL,
subject: `Receipt for Order #${data.get('orderNumber')}`,
html: createReceiptHTML(data)
})
return { success: true }
},
/**
* Send receipt to customer + BCC yourself
*/
sendWithCopy: async ({ request }) => {
const data = await request.formData()
await transporter.sendMail({
to: data.get('customerEmail'),
from: env.COMPANY_EMAIL,
bcc: data.get('yourEmail'), // Hidden copy
subject: `Receipt for Order #${data.get('orderNumber')}`,
html: createReceiptHTML(data)
})
return { success: true }
}
}
function createReceiptHTML(data: FormData): string {
return `
<h1>Order Receipt</h1>
<p>Order Number: ${data.get('orderNumber')}</p>
<p>Thank you for your purchase!</p>
`
} What makes this good?
- Clear separation: Each action does one thing
- No conditionals: No
if (wantsCopy)server-side logic - Explicit intent: The action name documents what happens
- Easy to test: Test each action independently
Understanding formaction
The formaction attribute is built into HTML. It overrides the form’s default action attribute for that specific button.
<form method="POST" action="/default">
<button type="submit">Uses /default</button>
<button type="submit" formaction="/other">Uses /other instead</button>
</form> In SvelteKit, you use the ?/actionName syntax:
<form method="POST">
<button type="submit" formaction="?/sendReceipt">Send Receipt</button>
<button type="submit" formaction="?/sendWithCopy">Send + Copy</button>
</form> This maps to your server actions:
export const actions = {
sendReceipt: async () => {
/* ... */
},
sendWithCopy: async () => {
/* ... */
}
} Why use formaction instead of JavaScript?
Progressive enhancement. The form works even if JavaScript fails to load. The browser natively handles routing to different endpoints.
Common Misconceptions
Let’s address some misunderstandings about when to use multiple actions:
1: Multiple departments need multiple actions
Example of what NOT to do:
<!-- ❌ Bad: User shouldn't know your org chart -->
<form method="POST">
<label>
<input type="radio" name="dept" value="sales" />
Contact Sales
</label>
<label>
<input type="radio" name="dept" value="support" />
Contact Support
</label>
<button type="submit">Send</button>
</form> Why this is bad:
- Users forget to select a department
- Users pick the wrong department
- Your org structure isn’t the user’s problem
Better approach: One action, smart routing:
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData()
const message = data.get('message') as string
// Smart routing based on message content
const recipient = message.toLowerCase().includes('billing')
? env.BILLING_EMAIL
: env.SUPPORT_EMAIL
await transporter.sendMail({
to: recipient,
subject: 'Contact Form Submission',
text: message
})
}
} 2: Test vs Production = Multiple actions
Example of what NOT to do:
<!-- ❌ Bad: Don't expose test mode to production users -->
<form method="POST">
<button type="submit" formaction="?/sendTest">Test Mode</button>
<button type="submit" formaction="?/sendReal">Send Real</button>
</form> Better approach: Use environment detection:
import { dev } from '$app/environment'
export const actions: Actions = {
default: async ({ request }) => {
const recipient = dev
? 'test@ethereal.email' // Dev environment
: env.CUSTOMER_EMAIL // Production
await transporter.sendMail({
to: recipient
// ...
})
}
} Decision Framework
Use this flowchart to decide which pattern to use:
Does the user need to consciously choose what happens?
│
├─ NO → Use single action (default pattern)
│ Example: Contact form, Newsletter signup
│
└─ YES → Does the choice change the email behavior?
│
├─ YES → Use named actions + formaction
│ Example: Send receipt vs Send receipt + copy
│
└─ NO → Use single action with parameters
Example: Priority level (handle server-side) Questions to ask yourself:
“If I removed this choice, would users be confused?”
- If NO → Single action
- If YES → Multiple actions
“Is this choice about email delivery or application logic?”
- Email delivery → Named actions
- Application logic → Handle server-side
“Could a user accidentally pick the wrong option?”
- If YES → Wrong abstraction, simplify
Testing Your Understanding
Before moving on, try answering these:
Scenario 1: You have a “Report Bug” form. Should users choose between “High Priority” and “Low Priority” using two submit buttons?
Click to reveal answer
No. This is severity assessment, not email routing. Use a single submit button and let the server (or admin) determine priority based on the bug description. If you must capture priority, use a dropdown in the form, not multiple actions.
export const actions: Actions = {
default: async ({ request }) => {
const priority = formData.get('priority'); // from dropdown
const recipient = priority === 'high'
? env.URGENT_EMAIL
: env.BUGS_EMAIL;
await sendEmail({ to: recipient, ... });
}
};Scenario 2: You have a “Send Invoice” form. Should you have separate buttons for “Email to Client” and “Email to Client + Accounting”?
Click to reveal answer
Maybe. This depends on your business process:
- If accounting ALWAYS needs a copy → Single action, always send both
- If accounting ONLY needs it sometimes → Named actions makes sense
- If you’re not sure → Start with single action (always send both), can refactor later
The key question: “Does the accountant need EVERY invoice, or just some?”
Scenario 3: Your subscription form sends confirmation to the user and notification to your marketing team. How many actions?
Click to reveal answer
One action. The user isn’t choosing between outcomes - they’re just subscribing. The fact that your team gets notified is an internal implementation detail, not a user choice.
export const actions: Actions = {
default: async ({ request }) => {
// Email 1: To subscriber
await sendConfirmation(email)
// Email 2: To team (user doesn't choose this)
await notifyTeam(email)
}
}Summary: The Right Pattern for Your Situation
Use Single Action (Default) When:
✅ User wants to “submit” or “send” something
✅ Multiple emails are YOUR business logic, not user choice
✅ You’re unsure which pattern to use (start simple!)
Example: Contact forms, newsletter signups, order confirmations
Use Named Actions When:
✅ User consciously chooses between different outcomes
✅ The choice changes email behavior (recipients, timing, content)
✅ Each action has a clear, distinct purpose
Example: “Send receipt” vs “Send receipt + copy to me”
Avoid Multiple Actions When:
❌ User is choosing between YOUR departments
❌ You’re exposing test/debug modes
❌ The “choice” is really just a parameter
Solution: Handle routing server-side with intelligent logic
If you are interested in learning more about advanced patterns and techniques, you can read Advanced Email Templates article where we explore how to create professional email template system.
Testing Email Functionality
When developing email functionality, avoid using real email addresses in development environments. Instead, rely on modern email testing solutions designed for safe and effective workflows. In this section, we’ll explore the most reliable tools for testing Nodemailer within a SvelteKit application.
1. Ethereal Email (Recommended for Quick Testing)
Ethereal Email is a free mock SMTP service built for testing Nodemailer. Emails are intercepted and viewable in a web inbox, never actually delivered:
// src/lib/mailer/emailSetup.server.ts
import nodemailer from 'nodemailer'
import type { Transporter } from 'nodemailer'
import { dev } from '$app/environment'
async function createTransporter(): Promise<Transporter> {
// Use Ethereal in development
if (dev) {
const testAccount = await nodemailer.createTestAccount()
const transporter = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass
}
})
// Log preview URL for each email
const originalSendMail = transporter.sendMail.bind(transporter)
transporter.sendMail = async (mailOptions) => {
const info = await originalSendMail(mailOptions)
console.log('Preview URL:', nodemailer.getTestMessageUrl(info))
return info
}
return transporter
}
// Use real SMTP in production
return nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 465,
secure: true,
auth: {
user: process.env.GOOGLE_EMAIL,
pass: process.env.GOOGLE_EMAIL_PASSWORD
}
})
}
export const transporter = await createTransporter() How it works
The benefit of Ethereal is that you do not need to sign up or configure anything. Ethereal auto-generates a temporary account for you each time you run your app in development mode. When you send an email, Ethereal logs a preview URL in the console. Click the URL to view the email in Ethereal’s web interface.
- Run your SvelteKit app in development mode
- Fill out the contact form and submit
- Check the terminal for a log like:
Preview URL: https://ethereal.email/message/... - Click the URL to view the email in Ethereal’s web interface
Each time you send an email, a new preview URL is logged, allowing you to view each email separately. Accounts expire after 24 hours, ensuring a clean testing environment without clutter.
Testing workflow:
npm run dev # Start your app
# Fill out the contact form and submit
# Check terminal for: "Preview URL: https://ethereal.email/message/..."
# Click the URL to view the email in new browser tab This workflow is ideal for quick testing and development without the risk of sending real emails. For more advanced testing scenarios, you can explore other tools like Mailpit or MailCrab, which offer additional features such as message storage, search, and API access.
Does this match your experience using Ethereal? Or do you think readers need more/less detail here?
Also, I notice the other testing tools (Mailpit, MailCrab, etc.) have the same issue - they show installation but not the “now what?” steps. Should we add similar workflow explanations for those too?
2. Mailpit (Modern Local SMTP Server)
Mailpit (GitHub) is a modern, actively maintained local SMTP server with a beautiful web interface:
# Install via Homebrew (macOS)
brew install mailpit
# Or download binary from GitHub
# https://github.com/axllent/mailpit/releases
# Run Mailpit
mailpit Configure transporter:
// src/lib/mailer/emailSetup.server.ts
import type { Transporter } from 'nodemailer'
const transporter: Transporter = nodemailer.createTransport({
host: 'localhost',
port: 1025,
secure: false
}) View emails at http://localhost:8025
Features:
- Modern, responsive UI
- Search and filtering
- HTML/Text preview
- Attachment viewing
- Message tagging
- API for automation
- Cross-platform (Windows, macOS, Linux)
- Active development (unlike Mailhog)
3. MailCrab (Lightweight Rust Alternative)
MailCrab (GitHub) is a super lightweight email testing tool written in Rust:
# Install via Cargo
cargo install mailcrab
# Or download binary from GitHub
# https://github.com/tweedegolf/mailcrab/releases
# Run MailCrab
mailcrab Configure transporter:
// src/lib/mailer/emailSetup.server.ts
import type { Transporter } from 'nodemailer'
const transporter: Transporter = nodemailer.createTransport({
host: 'localhost',
port: 1025,
secure: false
}) View emails at http://localhost:1080
Features:
- Very fast and lightweight (~5MB binary)
- Clean, simple UI
- Real-time updates via WebSockets
- Mobile-friendly
- No dependencies
4. smtp4dev (Cross-Platform with Docker)
smtp4dev is a modern, actively maintained email testing server with excellent Docker support. It runs as a .NET application with a web UI and is particularly popular with .NET developers, though it works great for any Node.js project.
# Run with Docker
docker run -p 3000:80 -p 2525:25 rnwood/smtp4dev
# Or install globally via .NET
dotnet tool install -g Rnwood.Smtp4dev
smtp4dev Configure transporter:
// src/lib/mailer/emailSetup.server.js
const transporter = nodemailer.createTransport({
host: 'localhost',
port: 2525,
secure: false
}) View emails at http://localhost:3000
Features:
- Beautiful modern UI
- Edit and resend emails
- SMTP relay support
- Docker support
- Cross-platform (.NET)
- API access
5. SendPigeon Local Dev Server
SendPigeon provides a free local email testing server specifically designed for SvelteKit applications:
# Install SendPigeon CLI
npm install -g sendpigeon
# Start local dev server
sendpigeon dev Configure for testing:
// src/lib/mailer/emailSetup.server.ts
import { SendPigeon } from 'sendpigeon'
import { dev } from '$app/environment'
import { SENDPIGEON_API_KEY } from '$env/dynamic/private'
const client = new SendPigeon(dev ? 'test_key' : SENDPIGEON_API_KEY, {
baseUrl: dev ? 'http://localhost:3005' : undefined
})
interface SendEmailOptions {
to: string
from: string
subject: string
html: string
}
export async function sendEmail({ to, from, subject, html }: SendEmailOptions) {
return await client.emails.send({
from,
to,
subject,
html
})
} View emails at http://localhost:3005
Features:
- Built specifically for SvelteKit
- Automatic email capture
- Visual email preview
- No SMTP configuration needed
- TypeScript support
- Webhook testing
- Free and open source
Comparison Table
| Tool | SMTP Port | Web Port | Install | Best For | Links |
|---|---|---|---|---|---|
| Ethereal | N/A | N/A | Auto | CI/CD, quick tests | Website |
| Mailpit | 1025 | 8025 | Brew/Binary | Daily development | Website | GitHub |
| MailCrab | 1025 | 1080 | Cargo/Binary | Lightweight setup | GitHub |
| smtp4dev | 2525 | 3000 | Docker/.NET | Docker workflows | GitHub |
| SendPigeon | N/A | 3005 | npm global | SvelteKit projects | Website |
Recommendations
- SvelteKit Projects: Use SendPigeon Local Dev Server for seamless integration
- Development: Use Mailpit for feature-rich local testing
- CI/CD: Use Ethereal for automated tests
- Minimal Setup: Use MailCrab if you want something lightweight
- Docker Projects: Use smtp4dev for containerized workflows
To wrap it up, all of these tools solve the same core problem—safe email testing—but they shine in different scenarios, and that distinction matters when working with Nodemailer in Svelte 5.
Use Mailpit, MailCrab, or smtp4dev when you want a local, deterministic development environment. These tools behave like real SMTP servers, give you full visibility into headers, bodies, and attachments, and integrate cleanly with Nodemailer by simply changing host and port. They are ideal for day-to-day development, debugging templates, and validating edge cases without external dependencies. Among them, Mailpit stands out as the most modern and performant option today.
Choose Ethereal when you want a zero-setup, cloud-based solution for quick experiments, demos, or documentation examples. It’s excellent for learning and prototyping, but less suitable for long-running local development or CI pipelines due to its ephemeral nature.
Reach for SendPigeon if you value a unified dev-to-production workflow. It reduces configuration switching by allowing the same client setup to work locally and in real environments, which can simplify projects where email delivery is tightly coupled to business logic.
The key takeaway:
- Local SMTP catchers are best for control, realism, and repeatability.
- Ethereal is best for fast, disposable testing.
- SendPigeon is best when minimizing environment drift is a priority.
In a Svelte 5 + Nodemailer setup, pick the tool that matches your development philosophy - not just what sends emails, but what keeps your feedback loop fast, safe, and predictable.
6. Comprehensive Testing Strategy
A robust testing strategy ensures your email functionality works correctly across different scenarios and environments. This section covers unit tests, integration tests, mocking strategies, and CI/CD considerations.
6.1 Unit Testing Form Actions
Start by testing your form actions in isolation. Focus on validation, error handling, and data transformation:
// src/routes/contact/contact.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { actions } from './+page.server.js'
import * as v from 'valibot'
describe('Contact form action', () => {
let sendEmailMock
beforeEach(() => {
// Mock the email sending function
sendEmailMock = vi.fn().mockResolvedValue({ messageId: 'test-123' })
})
afterEach(() => {
vi.clearAllMocks()
})
describe('Validation', () => {
it('rejects empty form submission', async () => {
const request = new Request('http://localhost', {
method: 'POST',
body: new FormData()
})
const result = await actions.default({ request })
expect(result.status).toBe(400)
expect(result.data.errors).toBeDefined()
expect(result.data.errors.name).toBeDefined()
expect(result.data.errors.email).toBeDefined()
})
it('rejects invalid email format', async () => {
const formData = new FormData()
formData.append('name', 'John Doe')
formData.append('email', 'invalid-email')
formData.append('service', 'Web Development')
formData.append('budget', '$5,000 - $10,000')
formData.append('message', 'Test message')
const request = new Request('http://localhost', {
method: 'POST',
body: formData
})
const result = await actions.default({ request })
expect(result.status).toBe(400)
expect(result.data.errors.email).toContain('Please enter a valid email address')
})
it('rejects message that is too short', async () => {
const formData = new FormData()
formData.append('name', 'John Doe')
formData.append('email', 'john@example.com')
formData.append('service', 'Web Development')
formData.append('budget', '$5,000 - $10,000')
formData.append('message', 'Short') // Less than 10 characters
const request = new Request('http://localhost', {
method: 'POST',
body: formData
})
const result = await actions.default({ request })
expect(result.status).toBe(400)
expect(result.data.errors.message).toBeDefined()
})
})
describe('Success cases', () => {
it('accepts valid data', async () => {
const formData = new FormData()
formData.append('name', 'John Doe')
formData.append('email', 'john@example.com')
formData.append('service', 'Web Development')
formData.append('budget', '$5,000 - $10,000')
formData.append('message', 'Test message that is long enough')
const request = new Request('http://localhost', {
method: 'POST',
body: formData
})
const result = await actions.default({ request })
expect(result.success).toBe(true)
})
it('handles multiple service selections', async () => {
const formData = new FormData()
formData.append('name', 'John Doe')
formData.append('email', 'john@example.com')
formData.append('service', 'Web Development')
formData.append('service', 'Mobile Apps')
formData.append('budget', '$10,000 - $25,000')
formData.append('message', 'Need both web and mobile development')
const request = new Request('http://localhost', {
method: 'POST',
body: formData
})
const result = await actions.default({ request })
expect(result.success).toBe(true)
})
})
describe('Data transformation', () => {
it('trims whitespace from inputs', async () => {
const formData = new FormData()
formData.append('name', ' John Doe ')
formData.append('email', ' john@example.com ')
formData.append('service', 'Web Development')
formData.append('budget', '$5,000 - $10,000')
formData.append('message', ' Test message with spaces ')
const request = new Request('http://localhost', {
method: 'POST',
body: formData
})
const result = await actions.default({ request })
expect(result.success).toBe(true)
// Verify trimming happened in validation
})
it('converts email to lowercase', async () => {
const formData = new FormData()
formData.append('name', 'John Doe')
formData.append('email', 'JOHN@EXAMPLE.COM')
formData.append('service', 'Web Development')
formData.append('budget', '$5,000 - $10,000')
formData.append('message', 'Test message')
const request = new Request('http://localhost', {
method: 'POST',
body: formData
})
const result = await actions.default({ request })
expect(result.success).toBe(true)
})
})
}) 6.2 Integration Testing with Email Services
Test the actual email sending functionality using your test SMTP server:
// tests/integration/email.test.js
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import nodemailer from 'nodemailer'
import { sendEmail } from '$lib/mailer/sendEmail.server.js'
let testAccount
let transporter
beforeAll(async () => {
// Create test account for Ethereal Email
testAccount = await nodemailer.createTestAccount()
transporter = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass
}
})
})
describe('Email integration tests', () => {
it('sends email successfully', async () => {
const emailData = {
to: 'test@example.com',
from: 'noreply@yourapp.com',
subject: 'Test Email',
html: '<h1>Test</h1><p>This is a test email</p>'
}
const info = await transporter.sendMail(emailData)
expect(info.messageId).toBeDefined()
expect(info.accepted).toContain('test@example.com')
// Get preview URL
const previewUrl = nodemailer.getTestMessageUrl(info)
console.log('Preview URL:', previewUrl)
})
it('handles email sending errors gracefully', async () => {
const invalidTransporter = nodemailer.createTransport({
host: 'invalid-host.example.com',
port: 587,
secure: false
})
const emailData = {
to: 'test@example.com',
from: 'noreply@yourapp.com',
subject: 'Test Email',
html: '<p>Test</p>'
}
await expect(invalidTransporter.sendMail(emailData)).rejects.toThrow()
})
}) 6.3 Testing Email Templates
Ensure your email templates render correctly and handle edge cases:
// tests/unit/emailTemplates.test.js
import { describe, it, expect } from 'vitest';
import { contactTemplate } from '$lib/mailer/templates/contact.js';
describe('Email templates', () => {
describe('contactTemplate', () => {
it('renders with all required fields', () => {
const data: ContactEmailData = {
name: 'John Doe',
email: 'john@example.com',
budget: '$5,000 - $10,000',
service: ['Web Development', 'Mobile Apps'],
message: 'Test message'
};
const html = contactTemplate(data);
expect(html).toContain('John Doe');
expect(html).toContain('john@example.com');
expect(html).toContain('$5,000 - $10,000');
expect(html).toContain('Web Development');
expect(html).toContain('Mobile Apps');
expect(html).toContain('Test message');
});
it('escapes HTML in user input', () => {
const data: ContactEmailData = {
name: '<script>alert("xss")</script>',
email: 'test@example.com',
budget: '$5,000',
service: ['Web Development'],
message: '<img src=x onerror=alert(1)>'
};
const html = contactTemplate(data);
// Verify dangerous HTML is escaped
expect(html).not.toContain('<script>');
expect(html).toContain('<script>');
expect(html).not.toContain('<img src=x');
});
it('handles special characters correctly', () => {
const data: ContactEmailData = {
name: 'José García',
email: 'jose@example.com',
budget: '$5,000 - $10,000',
service: ['Design & Development'],
message: 'Need help with R&D'
};
const html = contactTemplate(data);
expect(html).toContain('José García');
expect(html).toContain('&');
});
});
}); 6.4 Mocking Email Services
Use mocks to avoid actually sending emails during tests:
// tests/mocks/emailMock.js
import { vi } from 'vitest'
export const createMockTransporter = () => ({
sendMail: vi.fn().mockImplementation((mailOptions, callback) => {
const info = {
messageId: 'mock-message-id',
accepted: [mailOptions.to],
rejected: [],
response: '250 Message accepted'
}
if (callback) {
callback(null, info)
} else {
return Promise.resolve(info)
}
}),
verify: vi.fn().mockResolvedValue(true)
}) Use the mock in your tests:
// tests/unit/contactAction.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockTransporter } from '../mocks/emailMock.js'
vi.mock('$lib/mailer/emailSetup.server.js', () => ({
transporter: createMockTransporter()
}))
describe('Contact action with mocked email', () => {
it('calls sendMail with correct parameters', async () => {
// Test implementation
})
}) 6.5 End-to-End Testing with Playwright
Test the complete user flow including form submission:
// tests/e2e/contact.spec.js
import { test, expect } from '@playwright/test'
test.describe('Contact form', () => {
test('submits successfully with valid data', async ({ page }) => {
await page.goto('/contact')
// Fill form
await page.fill('[name="name"]', 'John Doe')
await page.fill('[name="email"]', 'john@example.com')
await page.check('[name="service"][value="Web Development"]')
await page.selectOption('[name="budget"]', '$5,000 - $10,000')
await page.fill('[name="message"]', 'This is a test message for e2e testing')
// Submit
await page.click('button[type="submit"]')
// Verify success message
await expect(page.locator('.success-message')).toBeVisible()
await expect(page.locator('.success-message')).toContainText('Thank you')
})
test('shows validation errors for invalid data', async ({ page }) => {
await page.goto('/contact')
// Submit empty form
await page.click('button[type="submit"]')
// Verify error messages
await expect(page.locator('.error')).toHaveCount(5) // All fields required
})
test('shows loading state during submission', async ({ page }) => {
await page.goto('/contact')
// Fill valid data
await page.fill('[name="name"]', 'John Doe')
await page.fill('[name="email"]', 'john@example.com')
await page.check('[name="service"][value="Web Development"]')
await page.selectOption('[name="budget"]', '$5,000 - $10,000')
await page.fill('[name="message"]', 'Test message')
const submitButton = page.locator('button[type="submit"]')
// Start submission
await submitButton.click()
// Verify loading state (button disabled and text changed)
await expect(submitButton).toBeDisabled()
await expect(submitButton).toContainText('Sending...')
})
}) 6.6 CI/CD Integration
Configure testing in your CI/CD pipeline:
# .github/workflows/test.yml
name: Test Email Functionality
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
env:
# Use Ethereal for CI testing
NODE_ENV: test
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: test-results/ 6.7 Testing Best Practices
Do’s:
- ✅ Test validation logic thoroughly
- ✅ Mock external email services in unit tests
- ✅ Use real SMTP servers (Ethereal, Mailpit) for integration tests
- ✅ Test HTML escaping to prevent XSS
- ✅ Verify email templates render correctly
- ✅ Test error handling and retry logic
- ✅ Include E2E tests for critical user flows
Don’ts:
- ❌ Send real emails in automated tests
- ❌ Hard-code test credentials
- ❌ Skip testing email template edge cases
- ❌ Forget to test rate limiting
- ❌ Ignore error scenarios
Test Coverage Goals:
- Form validation: 100%
- Email sending logic: 90%+
- Template rendering: 90%+
- E2E critical paths: 100%
Error Handling and Logging
When sending emails, robust error handling is crucial to ensure that issues are detected and addressed promptly. This section covers best practices for error handling, logging, and implementing retry logic to improve reliability.
Create a comprehensive sendEmail function with enhanced error handling:
// src/lib/mailer/sendEmail.server.ts
import { transporter } from './emailSetup.server'
import { dev } from '$app/environment'
import type { SentMessageInfo } from 'nodemailer'
interface EmailOptions {
to: string
from: string
replyTo?: string
subject: string
html: string
text?: string
}
/**
* Error types for better handling
*/
class EmailError extends Error {
originalError?: Error
constructor(message: string, originalError?: Error) {
super(message)
this.name = 'EmailError'
this.originalError = originalError
}
}
/**
* Sends email with comprehensive error handling
*/
export async function sendEmail({
to,
from,
replyTo,
subject,
html,
text
}: EmailOptions): Promise<SentMessageInfo> {
// Validate inputs
if (!to || !from || !subject || !html) {
throw new EmailError('Missing required email fields')
}
try {
const info = await new Promise<SentMessageInfo>((resolve, reject) => {
const message = {
from,
to,
replyTo,
subject,
html,
text: text || stripHtml(html)
}
transporter.sendMail(message, (err, info) => {
if (err) {
reject(err)
} else {
resolve(info)
}
})
})
// Log success
if (dev) {
console.log('✓ Email sent:', {
messageId: info.messageId,
to,
subject
})
}
return info
} catch (err) {
// Enhanced error logging
console.error('Email send failed:', {
to,
subject,
error: err.message,
code: err.code,
command: err.command
})
// Wrap error with context
throw new EmailError(`Failed to send email to ${to}: ${err.message}`, err)
}
}
/**
* 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()
} Implement Retry Logic
The retry logic is essential for handling transient failures such as network instability, temporary SMTP outages, or provider rate limiting. Implementing retries with exponential backoff increases the likelihood of successful delivery while preventing unnecessary load on your application or email service. This approach is particularly important in production environments, where reliability and graceful failure handling are critical.
/**
* Sends email with retry logic
*/
export async function sendEmailWithRetry(
options: EmailOptions,
maxRetries = 3,
delay = 1000
): Promise<SentMessageInfo> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await sendEmail(options);
} catch (err) {
lastError = err;
console.warn(`Email send attempt ${attempt} failed:`, err.message);
if (attempt < maxRetries) {
// Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, delay * attempt));
}
}
}
throw new EmailError(
`Failed to send email after ${maxRetries} attempts`,
lastError
);
} Production Considerations
Moving from development to production requires careful consideration of security, scalability, and user experience. Email systems are particularly vulnerable to abuse and can quickly become bottlenecks if not properly architected. This section covers essential production patterns that protect your application, maintain deliverability reputation, and ensure reliable email delivery at scale.
1. Rate Limiting
Why Rate Limiting Matters:
Without rate limiting, your contact form becomes an easy target for:
- Spam bots that submit hundreds of forms per minute
- Malicious actors using your email service to send spam
- Accidental abuse from users repeatedly clicking submit
- Resource exhaustion overwhelming your SMTP server
Rate limiting protects your email deliverability reputation. Email providers like Gmail track sending patterns, and sudden spikes in volume can flag your domain as a spam source, causing legitimate emails to be blocked.
When to Implement:
- Immediately, on all public-facing forms
- Before launching any user-submitted content features
- Essential for contact forms, signup flows, and password resets
Implementation Strategy:
Prevent abuse with rate limiting:
// src/lib/rateLimit.server.ts
const submissionMap = new Map<string, number[]>()
const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute
const MAX_SUBMISSIONS = 3 // Max 3 submissions per minute
/**
* Checks if IP has exceeded rate limit
*/
export function checkRateLimit(ip: string): boolean {
const now = Date.now()
const submissions = submissionMap.get(ip) || []
// Remove old submissions outside the window
const recentSubmissions = submissions.filter((time) => now - time < RATE_LIMIT_WINDOW)
if (recentSubmissions.length >= MAX_SUBMISSIONS) {
return false
}
recentSubmissions.push(now)
submissionMap.set(ip, recentSubmissions)
return true
} Use in actions:
// src/routes/contact/+page.server.ts
import type { Actions } from './$types'
import { fail } from '@sveltejs/kit'
import { checkRateLimit } from '$lib/rateLimit.server'
export const actions = {
default: async ({ request, getClientAddress }) => {
const ip = getClientAddress()
if (!checkRateLimit(ip)) {
return fail(429, {
errors: {
_form: ['Too many requests. Please try again later.']
}
})
}
// Continue with form processing...
}
} 2. Queue System for High Volume
Why Queues Matter:
Sending emails synchronously during HTTP requests creates several critical problems:
Performance Issues:
- Users wait 2-5 seconds for SMTP connections and email delivery
- Each failed email delivery blocks the user’s request
- Server resources are tied up during slow SMTP operations
- Poor user experience with long page loads
Reliability Problems:
- Network timeouts cause form submissions to fail
- Users see errors even though their data is valid
- No automatic retry on temporary failures
- Email provider rate limits cause cascading failures
Scalability Constraints:
- Can’t send bulk emails without blocking application
- Difficult to implement priority queues (urgent vs. marketing)
- No way to pause/resume email sending
- Hard to scale email sending independently from web servers
When to Implement:
- Immediate need (100+ emails/day): When users notice slow form submissions
- Plan ahead (500+ emails/day): Before email volume impacts user experience
- Essential (1000+ emails/day): When reliability and deliverability become critical
- Critical (10,000+ emails/day): When you need dedicated email infrastructure
Real-World Scenarios:
- Welcome email sequences with multiple touchpoints
- Daily digest emails to thousands of subscribers
- Password reset emails during traffic spikes
- Transactional receipts after purchase
- Marketing campaigns with precise send times
Queue Benefits:
- Instant user feedback: Form submits return immediately
- Automatic retries: Failed emails retry with exponential backoff
- Rate limiting: Control sending speed to stay within provider limits
- Priority handling: Send urgent emails before marketing content
- Monitoring: Track delivery rates, failures, and processing times
- Scalability: Add workers to process more emails in parallel
Implementation with BullMQ and Redis:
For applications sending many emails, use a queue:
npm install bullmq ioredis // src/lib/queue/emailQueue.server.ts
import { Queue, Worker } from 'bullmq'
import Redis from 'ioredis'
import { sendEmail } from '$lib/mailer/sendEmail.server'
interface EmailJob {
to: string
from: string
replyTo: string
subject: string
html: string
}
const connection = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
maxRetriesPerRequest: null
})
// Create email queue
export const emailQueue = new Queue('emails', { connection })
// Create worker to process emails
const worker = new Worker(
'emails',
async (job) => {
const { to, from, replyTo, subject, html } = job.data
await sendEmail({ to, from, replyTo, subject, html })
},
{
connection,
concurrency: 5, // Process 5 emails concurrently
limiter: {
max: 10, // Max 10 emails
duration: 1000 // Per second
}
}
)
worker.on('completed', (job) => {
console.log(`✓ Email ${job.id} sent successfully`)
})
worker.on('failed', (job, err) => {
console.error(`✗ Email ${job.id} failed:`, err)
}) Use queue in actions:
// src/routes/contact/+page.server.ts
import type { Actions } from './$types';
import { emailQueue } from '$lib/queue/emailQueue.server';
export const actions = {
default: async ({ request }) => {
// ... validation ...
// Add to queue instead of sending directly
await emailQueue.add('contact-form', {
to: GOOGLE_EMAIL,
from: `"${data.name}" <${data.email}>`,
replyTo: data.email,
subject: `Contact: ${data.name}`,
html: contactTemplate(data)
});
return { success: true };
}
}; 3. Email Delivery Monitoring
Why Monitoring Matters:
Email delivery happens asynchronously across multiple systems, and failures can occur silently without proper monitoring:
Silent Failure Scenarios:
- SMTP server accepts email but never delivers it (soft bounce)
- Email lands in spam folder (delivered but not seen)
- Recipient server rejects email after initial acceptance
- Domain blacklisting prevents delivery to entire providers
- Rate limiting causes temporary failures that become permanent
Business Impact:
- Lost revenue: Confirmation emails never arrive, users abandon carts
- Support burden: Users don’t receive password resets, create duplicate tickets
- Reputation damage: Important transactional emails go to spam
- Compliance risk: Cannot prove email notifications were sent
- Debugging nightmare: No way to investigate “I never got the email” reports
What to Monitor:
Success Metrics:
- Total emails sent per hour/day
- Average delivery time
- Delivery success rate by recipient domain
- Peak sending times and volumes
Failure Tracking:
- Hard bounces (permanent delivery failures)
- Soft bounces (temporary failures, retry candidates)
- Spam complaints and unsubscribes
- Provider-specific rejection rates
System Health:
- SMTP connection failures
- Queue depth and processing rate
- Retry attempts and outcomes
- Worker errors and crashes
When to Implement:
- Day one: Basic logging (sent/failed with messageId)
- Before launch: Database logging for support queries
- Post-launch: Dashboard with delivery rates and alerts
- At scale: Real-time monitoring with alerting on anomalies
Implementation Strategy:
Track email delivery status:
// src/lib/db/emailLog.server.ts
import { db } from './database.server'
interface EmailLogData {
to: string
subject: string
status: 'sent' | 'failed' | 'queued'
messageId?: string
error?: string
}
/**
* Logs email send attempts
*/
export async function logEmail({
to,
subject,
status,
messageId,
error
}: EmailLogData): Promise<void> {
await db.emailLog.create({
data: {
to,
subject,
status, // 'sent', 'failed', 'queued'
messageId,
error,
sentAt: new Date()
}
})
} 4. Unsubscribe Management
Why Unsubscribe Management Matters:
Unsubscribe functionality isn’t just good practice—it’s legally required in many jurisdictions and critical for maintaining email deliverability:
Legal Requirements:
- CAN-SPAM Act (US): Requires visible unsubscribe link in all commercial emails
- GDPR (EU): Users have right to withdraw consent for marketing communications
- CASL (Canada): Requires clear unsubscribe mechanism
- Penalties: Fines up to $43,792 per violation (CAN-SPAM) or 4% of revenue (GDPR)
Deliverability Impact:
- Users who can’t unsubscribe mark emails as spam instead
- High spam complaint rates (>0.1%) trigger provider filtering
- Gmail, Outlook automatically filter domains with poor unsubscribe experience
- Blacklisting can block ALL emails from your domain
User Experience:
- Respectful unsubscribe = positive brand perception
- Forced subscriptions = negative reviews and social media complaints
- One-click unsubscribe = lower spam complaint rates
- Confirmation emails show professionalism
Technical Considerations:
What Constitutes Marketing vs. Transactional:
Marketing (requires unsubscribe):
- Newsletter and blog updates
- Product announcements and promotions
- Event invitations and webinars
- Feature highlights and tips
Transactional (unsubscribe not required):
- Order confirmations and receipts
- Password reset emails
- Account security alerts
- Service status notifications
When unclear: Err on the side of including unsubscribe
Implementation Requirements:
- Unsubscribe link in every marketing email footer
- One-click unsubscribe (no login required)
- Process unsubscribes within 10 business days (legal requirement)
- Honor unsubscribe immediately (best practice)
- Confirmation message after unsubscribe
- Re-subscribe option for mistakes
Database Design:
- Store unsubscribe timestamp (audit trail)
- Separate table from users (privacy/GDPR)
- Index on email for fast lookups
- Include unsubscribe reason (optional, valuable feedback)
Best Practices:
- Show unsubscribe link prominently in footer
- Use clear language: “Unsubscribe” not “Manage preferences”
- Don’t hide behind multiple clicks
- Respect granular preferences (weekly digest vs. daily)
- Send confirmation email (proves they unsubscribed)
Implementation Strategy:
For marketing emails, implement unsubscribe:
// src/routes/unsubscribe/+page.server.ts
import type { Actions } from './$types'
import { db } from '$lib/db'
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData()
const email = formData.get('email')
// Add to unsubscribe list
await db.unsubscribed.create({
data: {
email: email.toLowerCase(),
unsubscribedAt: new Date()
}
})
return { success: true }
}
} Check before sending:
// src/lib/mailer/sendEmail.server.ts
interface MarketingEmailContent {
subject: string;
html: string;
from: string;
}
async function sendMarketingEmail(email: string, content: MarketingEmailContent): Promise<void> {
// Check unsubscribe list
const unsubscribed = await db.unsubscribed.findUnique({
where: { email: email.toLowerCase() }
});
if (unsubscribed) {
console.log(`Skipping unsubscribed email: ${email}`);
return;
}
await sendEmail({ to: email, ...content });
} Additional Monitoring:
Track unsubscribe metrics to improve email strategy:
- Unsubscribe rate by campaign
- Time to unsubscribe after signup
- Reason for unsubscribe (if collected)
- Re-subscribe rate
High unsubscribe rates (>2%) indicate content or frequency issues that need addressing.
Production Considerations Summary:
These four pillars—rate limiting, queuing, monitoring, and unsubscribe management—form the foundation of a production-ready email system. Implement rate limiting and basic monitoring from day one. Add queuing before you need it (at ~100 emails/day). Build unsubscribe functionality before sending any marketing emails. Together, these patterns ensure your email system scales reliably while maintaining deliverability and legal compliance.
Security Best Practices
1. Never Expose Email Credentials
// src/lib/mailer/emailSetup.server.js
// ❌ NEVER do this
export const GMAIL_PASSWORD = 'my-password'
// ✅ Always use environment variables
import { GOOGLE_EMAIL_PASSWORD } from '$env/dynamic/private' 2. Validate and Sanitize Input
// src/lib/types.js
// Use Valibot for comprehensive validation
const emailSchema = v.object({
email: v.pipe(v.string(), v.email(), v.maxLength(254)),
message: v.pipe(v.string(), v.nonEmpty(), v.maxLength(1000), v.trim())
})
// src/lib/mailer/templates/contact.js
// Escape HTML in email templates
function escapeHtml(text) {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
} 3. Implement CAPTCHA
Prevent bot submissions:
npm install @sveltejs/kit-turnstile <!-- src/routes/contact/+page.svelte -->
<script>
import { Turnstile } from '@sveltejs/kit-turnstile'
let token = $state('')
</script>
<form method="POST">
<!-- Form fields -->
<Turnstile bind:token />
<button type="submit" disabled={!token}> Send Message </button>
</form> Verify on server:
// src/routes/contact/+page.server.js
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData()
const token = formData.get('cf-turnstile-response')
// Verify CAPTCHA
const verification = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY,
response: token
})
})
const outcome = await verification.json()
if (!outcome.success) {
return fail(400, {
errors: { _form: ['CAPTCHA verification failed'] }
})
}
// Process form...
}
} Debugging Common Issues
1: “Invalid login: 535 Authentication failed”
Cause: Wrong credentials or app password not enabled
Solution:
- Enable 2FA on Google account
- Generate app-specific password
- Use app password in
.envfile
2: Emails going to spam
Solutions:
- Use verified sender domain
- Add SPF, DKIM, DMARC records
- Avoid spam trigger words
- Include unsubscribe link
- Use reputable SMTP service
3: “ECONNREFUSED” error
Cause: Cannot connect to SMTP server
Solution:
// src/lib/mailer/emailSetup.server.js
// Add connection verification
transporter.verify((error, success) => {
if (error) {
console.error('SMTP connection failed:', error)
console.log('Check:')
console.log('- SMTP host and port')
console.log('- Firewall settings')
console.log('- Network connectivity')
} else {
console.log('✓ SMTP connection successful')
}
}) 4: Form not submitting
Check:
method="POST"on form- Action URL correct
use:enhancefrom$app/forms- Browser console for errors
Complete Working Example
Here’s everything together in a production-ready implementation:
Project Structure
src/
├── lib/
│ ├── mailer/
│ │ ├── emailSetup.server.js
│ │ ├── sendEmail.server.js
│ │ └── templates/
│ │ ├── layout.js
│ │ └── contact.js
│ ├── types.js
│ └── rateLimit.server.js
├── routes/
│ └── contact/
│ ├── +page.svelte
│ └── +page.server.js
└── app.html Environment Setup
# .env
GOOGLE_EMAIL="your-email@gmail.com"
GOOGLE_EMAIL_PASSWORD="your-app-password" Complete Implementation
All code provided above works together. To summarize the flow:
- User fills form in
+page.svelte - Form submits to action in
+page.server.js - Action validates with Valibot
- Email sends via Nodemailer transporter
- Response returns to component
- Component shows success/error messages
Conclusion
You now have a complete understanding of implementing email functionality in SvelteKit with Nodemailer. Key takeaways:
- Server-Side Security: Email logic runs server-side only
- Progressive Enhancement: Forms work without JavaScript
- Modern Patterns: Svelte 5 runes and $props()
- Type Safety: TypeScript and Valibot validation
- Lightweight: Valibot’s small bundle size
- Scalability: Queue systems for high volume
- Security: Rate limiting, CAPTCHA, sanitization
Next Steps
- Implement email confirmation flows
- Add file attachment support
- Create email notification system
- Build newsletter subscription
- Implement transactional emails
Additional Resources
For more in-depth guides and tutorials
Documentation:
Email Services:
- SendPigeon - Modern transactional email for developers
- SendGrid - Cloud-based email delivery
- Mailgun - Email API service
- Amazon SES - AWS email infrastructure
Testing Tools:
- Ethereal Email - Free fake SMTP service
- Mailpit - Modern local SMTP server
- MailCrab - Lightweight Rust email testing
- smtp4dev - .NET email testing server
- SendPigeon Local Server - SvelteKit email testing
Email Testing:
- Email on Acid - Email rendering testing
This guide focuses on practical, production-ready implementations using modern Svelte 5 patterns and Valibot validation. For more tutorials, visit The Hackpile Chronicles.