Email remains one of the most effective communication channels for businesses. A robust email system handles transactional emails (password resets, order confirmations), marketing campaigns, and ensures messages actually reach inboxes. This guide covers building a production-ready email infrastructure using Node.js.
Understanding Email Infrastructure
A modern email system consists of several components:
SMTP Server: Sends emails to recipients Email Service Provider: Manages delivery, reputation, and scaling Template Engine: Generates dynamic email content Queue System: Handles high-volume sending asynchronously Analytics: Tracks opens, clicks, and deliverability Bounce Handling: Processes failed deliveries
Choosing an Email Service Provider
Popular ESPs
SendGrid (Twilio)
- Excellent deliverability
- 100 free emails/day
- Good API and documentation
- Real-time analytics
Amazon SES
- Cost-effective for high volume
- Pay-as-you-go pricing
- AWS integration
- Requires more setup
Mailgun
- Developer-friendly API
- 5,000 free emails/month
- Good documentation
- Advanced features
Postmark
- Transactional email focused
- Excellent deliverability
- Fast processing
- No marketing features
For this guide, we'll use SendGrid for its balance of features and ease of use.
Setup and Installation
npm install @sendgrid/mail nodemailer
npm install -D @types/nodemailerBasic Email Sending
Configuration
// lib/email.ts
import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
interface EmailOptions {
to: string | string[];
from: string;
subject: string;
html?: string;
text?: string;
templateId?: string;
dynamicTemplateData?: Record<string, any>;
}
export async function sendEmail(options: EmailOptions) {
try {
const msg: sgMail.MailDataRequired = {
to: Array.isArray(options.to) ? options.to : [options.to],
from: options.from,
subject: options.subject,
};
if (options.templateId) {
msg.templateId = options.templateId;
msg.dynamicTemplateData = options.dynamicTemplateData;
} else {
if (options.html) msg.html = options.html;
if (options.text) msg.text = options.text;
}
await sgMail.send(msg);
return { success: true };
} catch (error) {
console.error('Email send failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}Sending Plain Text Emails
// api/send-email.ts
export async function POST(req: Request) {
const { to, subject, message } = await req.json();
const result = await sendEmail({
to,
from: 'noreply@yourapp.com',
subject,
text: message,
});
if (result.success) {
return Response.json({ success: true });
}
return Response.json({ error: result.error }, { status: 500 });
}Email Templates
Inline HTML Templates
// lib/templates/welcome-email.ts
export function generateWelcomeEmail(userName: string) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Our App</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #2563eb;">Welcome, ${userName}!</h1>
<p>Thank you for signing up. We're excited to have you on board.</p>
<div style="background: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h2 style="margin-top: 0;">Getting Started</h2>
<ul>
<li>Complete your profile</li>
<li>Explore our features</li>
<li>Connect with the community</li>
</ul>
</div>
<p>
<a href="${process.env.NEXT_PUBLIC_BASE_URL}/dashboard"
style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Get Started
</a>
</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">
<p style="font-size: 12px; color: #6b7280;">
You received this email because you signed up for Our App.
<br>
<a href="${process.env.NEXT_PUBLIC_BASE_URL}/unsubscribe">Unsubscribe</a>
</p>
</div>
</body>
</html>
`;
}Using Handlebars for Dynamic Templates
npm install handlebars// lib/template-engine.ts
import Handlebars from 'handlebars';
export function compileTemplate(template: string, data: Record<string, any>) {
const compiledTemplate = Handlebars.compile(template);
return compiledTemplate(data);
}
// Register helpers
Handlebars.registerHelper('eq', (a: any, b: any) => a === b);
Handlebars.registerHelper('formatDate', (date: Date) => {
return new Date(date).toLocaleDateString();
});
Handlebars.registerHelper('json', (obj: any) => JSON.stringify(obj));Transactional Emails
Welcome Email
// api/emails/welcome.ts
import { sendEmail } from '@/lib/email';
import { generateWelcomeEmail } from '@/lib/templates/welcome-email';
export async function sendWelcomeEmail(userEmail: string, userName: string) {
return await sendEmail({
to: userEmail,
from: 'welcome@yourapp.com',
subject: 'Welcome to Our App!',
html: generateWelcomeEmail(userName),
});
}Password Reset Email
// api/emails/password-reset.ts
export function generatePasswordResetEmail(resetLink: string) {
return `
<!DOCTYPE html>
<html>
<body>
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1>Reset Your Password</h1>
<p>You requested to reset your password. Click the link below to create a new password:</p>
<a href="${resetLink}"
style="background: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">
Reset Password
</a>
<p style="margin-top: 30px;">
<strong>This link expires in 1 hour.</strong><br>
If you didn't request this, please ignore this email.
</p>
</div>
</body>
</html>
`;
}
export async function sendPasswordResetEmail(userEmail: string, resetToken: string) {
const resetLink = `${process.env.NEXT_PUBLIC_BASE_URL}/reset-password?token=${resetToken}`;
return await sendEmail({
to: userEmail,
from: 'security@yourapp.com',
subject: 'Reset Your Password',
html: generatePasswordResetEmail(resetLink),
});
}Order Confirmation Email
// api/emails/order-confirmation.ts
interface OrderItem {
name: string;
quantity: number;
price: number;
}
interface OrderDetails {
orderId: string;
items: OrderItem[];
total: number;
shippingAddress: Address;
}
export function generateOrderConfirmationEmail(order: OrderDetails) {
const itemsHtml = order.items
.map(
item => `
<tr>
<td style="padding: 12px 0; border-bottom: 1px solid #e5e7eb;">
${item.name}
</td>
<td style="padding: 12px 0; border-bottom: 1px solid #e5e7eb; text-align: center;">
${item.quantity}
</td>
<td style="padding: 12px 0; border-bottom: 1px solid #e5e7eb; text-align: right;">
$${(item.price * item.quantity).toFixed(2)}
</td>
</tr>
`
)
.join('');
return `
<!DOCTYPE html>
<html>
<body>
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1>Order Confirmed!</h1>
<p>Thank you for your order. Here are the details:</p>
<h2>Order #${order.orderId}</h2>
<table style="width: 100%; margin: 20px 0;">
<thead>
<tr style="border-bottom: 2px solid #374151;">
<th style="text-align: left; padding: 12px 0;">Item</th>
<th style="text-align: center; padding: 12px 0;">Quantity</th>
<th style="text-align: right; padding: 12px 0;">Price</th>
</tr>
</thead>
<tbody>
${itemsHtml}
</tbody>
<tfoot>
<tr style="border-top: 2px solid #374151;">
<td colspan="2" style="padding: 12px 0; text-align: right; font-weight: bold;">
Total:
</td>
<td style="padding: 12px 0; text-align: right; font-weight: bold;">
$${order.total.toFixed(2)}
</td>
</tr>
</tfoot>
</table>
<h3>Shipping Address</h3>
<p>
${order.shippingAddress.firstName} ${order.shippingAddress.lastName}<br>
${order.shippingAddress.addressLine1}<br>
${order.shippingAddress.city}, ${order.shippingAddress.state} ${order.shippingAddress.postalCode}
</p>
<p style="margin-top: 30px;">
We'll send you another email when your order ships.
</p>
</div>
</body>
</html>
`;
}Queue System for High Volume
For sending emails at scale, use a queue system:
// lib/email-queue.ts
import { Queue } from 'bull';
import { sendEmail } from './email';
const emailQueue = new Queue('email', process.env.REDIS_URL!);
emailQueue.process(async (job) => {
const { to, from, subject, html, text, templateId, dynamicTemplateData } = job.data;
return await sendEmail({
to,
from,
subject,
html,
text,
templateId,
dynamicTemplateData,
});
});
export async function queueEmail(options: EmailOptions, delay = 0) {
return await emailQueue.add(options, {
delay,
attempts: 3,
backoff: {
type: 'exponential',
delay: 60000, // 1 minute
},
});
}Handling Bounces and Complaints
Webhook Handler
// api/webhooks/sendgrid.ts
export async function POST(req: Request) {
const events = await req.json();
for (const event of events) {
switch (event[0]) {
case 'bounce':
await handleBounce(event[1]);
break;
case 'spamreport':
await handleSpamReport(event[1]);
break;
case 'delivered':
await handleDelivered(event[1]);
break;
case 'open':
await handleOpen(event[1]);
break;
case 'click':
await handleClick(event[1]);
break;
}
}
return Response.json({ received: true });
}
async function handleBounce(event: any) {
const { email, reason, status } = event;
// Log bounce
await db.emailBounces.create({
data: {
email,
reason,
status,
timestamp: new Date(),
},
});
// Update user status if too many bounces
const bounceCount = await db.emailBounces.count({
where: { email },
});
if (bounceCount >= 3) {
await db.users.update({
where: { email },
data: { emailStatus: 'bounced' },
});
}
}
async function handleSpamReport(event: any) {
const { email } = event;
// Mark user as spam reporter
await db.users.update({
where: { email },
data: { emailStatus: 'spam_reported' },
});
// Stop sending to this user
await db.unsubscribeUsers.create({
data: { email, reason: 'spam_report' },
});
}Email Analytics
Tracking Email Engagement
// lib/email-analytics.ts
export async function trackEmailOpen(messageId: string) {
await db.emailAnalytics.create({
data: {
messageId,
event: 'open',
timestamp: new Date(),
},
});
}
export async function trackEmailClick(messageId: string, url: string) {
await db.emailAnalytics.create({
data: {
messageId,
event: 'click',
url,
timestamp: new Date(),
},
});
}
export async function getEmailStats(userId: string) {
const emails = await db.email.findMany({
where: { userId },
include: {
analytics: true,
},
});
return {
sent: emails.length,
opened: emails.filter(e => e.analytics.some(a => a.event === 'open')).length,
clicked: emails.filter(e => e.analytics.some(a => a.event === 'click')).length,
};
}Deliverability Best Practices
1. Authenticate Your Emails
Set up SPF, DKIM, and DMARC:
``txt ; SPF Record yourdomain.com. IN TXT "v=spf1 include:sendgrid.net -all"
; DKIM Record sendgrid._domainkey.yourdomain.com. IN TXT "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..."
; DMARC Record _dmarc.yourdomain.com. IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"
### 2. Monitor Sender Reputation
// lib/sender-reputation.ts export async function checkSenderReputation() { const response = await fetch('https://api.sendgrid.com/v3/sender_reputation', { headers: { 'Authorization': Bearer ${process.env.SENDGRID_API_KEY}, }, });
const data = await response.json();
return { score: data.reputation_score, recommendations: data.recommendations, }; }
### 3. Warm Up New IPs
Gradually increase sending volume:
// lib/ip-warmup.ts const warmupSchedule = [ { day: 1, emailsPerDay: 50 }, { day: 2, emailsPerDay: 100 }, { day: 3, emailsPerDay: 200 }, { day: 4, emailsPerDay: 500 }, { day: 5, emailsPerDay: 1000 }, { day: 6, emailsPerDay: 2000 }, { day: 7, emailsPerDay: 5000 }, ];
export async function warmupIP() { for (const schedule of warmupSchedule) { console.log(Day ${schedule.day}: Sending ${schedule.emailsPerDay} emails); // Send emails gradually throughout the day await new Promise(resolve => setTimeout(resolve, 24 * 60 * 60 * 1000)); } }
## Testing
### Email Testing in Development
// lib/email-test.ts import { createTestAccount, createTransport, getTestMessageUrl } from 'nodemailer';
export async function setupTestEmail() { const testAccount = await createTestAccount();
const transporter = createTransport({ host: 'smtp.ethereal.email', port: 587, secure: false, auth: { user: testAccount.user, pass: testAccount.pass, }, });
return { transporter, testAccount }; }
// Usage in development if (process.env.NODE_ENV === 'development') { const { transporter, testAccount } = await setupTestEmail();
const info = await transporter.sendMail({ from: '"Test" <test@example.com>', to: 'recipient@example.com', subject: 'Test Email', html: '<b>Hello world?</b>', });
console.log('Preview URL: %s', getTestMessageUrl(info)); }
## Conclusion
Building a scalable email system requires careful planning and attention to deliverability. Key takeaways:
- **Choose the right ESP** for your needs
- **Use templates** for consistent branding
- **Implement queueing** for high volume
- **Handle bounces** to maintain reputation
- **Track analytics** to optimize performance
- **Authenticate emails** with SPF/DKIM/DMARC
- **Test thoroughly** before sending to customers
Remember: Email is a critical communication channel. Invest in building a robust system that scales with your business and keeps your customers engaged.