Skip to main content

Email and Notification Systems

Overview

Notification systems are critical infrastructure for user engagement, operational alerts, and communication workflows. This guide covers email delivery, push notifications, SMS, and in-app notifications, focusing on reliability, deliverability, user preferences, and compliance.

Modern notification systems must handle high throughput, ensure delivery, respect user preferences, comply with regulations (GDPR, CAN-SPAM), and provide analytics for optimization. The architecture typically involves message queuing, template rendering, delivery tracking, and retry logic.

The notification pipeline starts with an event trigger, checks user preferences, routes to appropriate channels, handles delivery, and tracks results for analytics and debugging.

Email Delivery

Email remains the most reliable notification channel for transactional messages, reports, and asynchronous communications. Modern email delivery requires understanding SMTP, authentication protocols, and deliverability best practices.

Email Service Providers

Choose providers based on deliverability, features, pricing, and compliance requirements:

AWS Simple Email Service (SES):

  • Cost-effective for high volume ($0.10 per 1000 emails)
  • Requires managing IP reputation and warm-up
  • Deep AWS integration for Lambda triggers, SNS notifications
  • Supports configuration sets for tracking
  • Requires production access approval to leave sandbox

SendGrid:

  • Managed deliverability and IP reputation
  • Rich template engine with dynamic content
  • Advanced analytics and A/B testing
  • Web hooks for events (open, click, bounce, spam)
  • Marketing campaign features

Mailgun:

  • Developer-friendly API design
  • Powerful routing and filtering rules
  • Email validation API for signup forms
  • Parse API for inbound email processing
  • Detailed logs and analytics

Implementation example with resilient error handling:

// Email service with retry logic and circuit breaker
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
import CircuitBreaker from 'opossum';

interface EmailMessage {
to: string[];
cc?: string[];
bcc?: string[];
subject: string;
html: string;
text?: string;
replyTo?: string;
attachments?: Attachment[];
}

class EmailService {
private sesClient: SESClient;
private circuitBreaker: CircuitBreaker;

constructor() {
this.sesClient = new SESClient({ region: 'us-east-1' });

// Circuit breaker prevents cascading failures
this.circuitBreaker = new CircuitBreaker(
this.sendEmailInternal.bind(this),
{
timeout: 5000,
errorThresholdPercentage: 50,
resetTimeout: 30000,
}
);
}

async sendEmail(message: EmailMessage): Promise<void> {
try {
await this.circuitBreaker.fire(message);
} catch (error) {
// Log failure for monitoring
logger.error('Email send failed', {
to: message.to,
subject: message.subject,
error: error.message,
});

// Queue for retry if circuit is open
if (this.circuitBreaker.opened) {
await this.queueForRetry(message);
}

throw error;
}
}

private async sendEmailInternal(message: EmailMessage): Promise<void> {
const command = new SendEmailCommand({
Source: '[email protected]',
Destination: {
ToAddresses: message.to,
CcAddresses: message.cc,
BccAddresses: message.bcc,
},
Message: {
Subject: { Data: message.subject, Charset: 'UTF-8' },
Body: {
Html: { Data: message.html, Charset: 'UTF-8' },
Text: { Data: message.text || this.htmlToText(message.html), Charset: 'UTF-8' },
},
},
ReplyToAddresses: message.replyTo ? [message.replyTo] : undefined,
ConfigurationSetName: 'tracking-config',
});

await this.sesClient.send(command);
}
}

The circuit breaker pattern prevents overwhelming a failing email service. When the error rate exceeds the threshold (50% in this example), the circuit opens and requests fail fast. After the reset timeout (30 seconds), the circuit enters a half-open state to test if the service has recovered. This protects both the application and the email provider from cascading failures.

Email Authentication and Deliverability

Email authentication protocols prove sender legitimacy and prevent spoofing. Without proper authentication, emails are flagged as spam or rejected entirely.

SPF (Sender Policy Framework): DNS record listing authorized sending servers for your domain. Receiving servers check if the sending IP is in this list.

# SPF record in DNS
example.com TXT "v=spf1 include:_spf.google.com include:sendgrid.net -all"

The include: directives authorize third-party services. The -all means reject emails from unauthorized servers (strict). Use ~all for soft fail during testing (mark as spam but accept).

DKIM (DomainKeys Identified Mail): Cryptographic signature attached to emails. The sending server signs emails with a private key, and receiving servers verify using a public key in DNS.

# DKIM public key in DNS
default._domainkey.example.com TXT "v=DKIM1; k=rsa; p=MIGfMA0GCS..."

Email providers generate the private key and provide the public key to add to DNS. This proves the email wasn't modified in transit and confirms the sender domain.

DMARC (Domain-based Message Authentication): Policy framework that uses SPF and DKIM results to decide email fate. Defines what to do with emails that fail authentication and where to send reports.

# DMARC policy in DNS
_dmarc.example.com TXT "v=DMARC1; p=reject; rua=mailto:[email protected]; ruf=mailto:[email protected]; pct=100"
  • p=reject: Reject emails that fail SPF and DKIM (use quarantine or none initially)
  • rua: Aggregate reports sent daily with statistics
  • ruf: Forensic reports for individual failures
  • pct=100: Apply policy to 100% of emails

Start with p=none to monitor without affecting delivery. Once confident in authentication setup, move to p=quarantine, then p=reject.

Email Template Management

Templates separate content from code, enable non-technical updates, support localization, and maintain brand consistency. Templates must handle dynamic data, responsive design, and email client compatibility.

Modern template engines provide:

  • Variable substitution with fallbacks
  • Conditional sections based on data
  • Loops for dynamic lists
  • Partials for reusable components
  • Filters for formatting (dates, currencies)
// Template service with Handlebars
import Handlebars from 'handlebars';
import { promises as fs } from 'fs';
import path from 'path';

interface TemplateData {
[key: string]: any;
}

class TemplateService {
private templateCache = new Map<string, HandlebarsTemplateDelegate>();

async renderTemplate(
templateName: string,
data: TemplateData,
locale: string = 'en'
): Promise<string> {
const cacheKey = `${templateName}-${locale}`;

if (!this.templateCache.has(cacheKey)) {
await this.loadTemplate(templateName, locale);
}

const template = this.templateCache.get(cacheKey);
return template(data);
}

private async loadTemplate(
templateName: string,
locale: string
): Promise<void> {
const templatePath = path.join(
__dirname,
'templates',
locale,
`${templateName}.hbs`
);

const templateSource = await fs.readFile(templatePath, 'utf-8');
const template = Handlebars.compile(templateSource);

this.templateCache.set(`${templateName}-${locale}`, template);
}

// Helper for common formatting
registerHelpers(): void {
Handlebars.registerHelper('formatDate', (date: Date, format: string) => {
return new Intl.DateTimeFormat('en-US', {
dateStyle: format === 'short' ? 'short' : 'long',
}).format(date);
});

Handlebars.registerHelper('formatCurrency', (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
});
}
}

Template caching avoids repeated file system reads. Helpers provide consistent formatting across templates. Supporting multiple locales requires organizing templates by language and using locale-aware helpers.

Example template structure:

{{!-- templates/en/payment-confirmation.hbs --}}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
/* Inline CSS for email client compatibility */
body { font-family: Arial, sans-serif; }
.container { max-width: 600px; margin: 0 auto; }
.header { background: #007bff; color: white; padding: 20px; }
.content { padding: 20px; }
.footer { background: #f8f9fa; padding: 20px; text-align: center; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Payment Confirmed</h1>
</div>
<div class="content">
<p>Hi {{customerName}},</p>
<p>Your payment of {{formatCurrency amount currency}} was processed successfully.</p>

{{#if referenceNumber}}
<p>Reference number: <strong>{{referenceNumber}}</strong></p>
{{/if}}

<p>Transaction date: {{formatDate transactionDate 'long'}}</p>

{{#if items}}
<h3>Items:</h3>
<ul>
{{#each items}}
<li>{{this.description}} - {{formatCurrency this.amount ../currency}}</li>
{{/each}}
</ul>
{{/if}}
</div>
<div class="footer">
<p>Need help? <a href="{{supportUrl}}">Contact Support</a></p>
</div>
</div>
</body>
</html>

Email HTML requires inline CSS because email clients ignore external stylesheets and <style> tags inconsistently. Use tables for layout in older clients. Test across clients using services like Litmus or Email on Acid.

For responsive design principles in email templates, see Frontend Styling Guidelines.

Push Notifications

Push notifications deliver real-time alerts to mobile devices and web browsers. They require device registration, token management, and platform-specific implementations.

Platform Integration

Firebase Cloud Messaging (FCM) for Android and web:

  • Unified API for Android, iOS, and web
  • Message priority (normal vs high priority affects battery)
  • Topics for broadcast to subscriber groups
  • Data messages for silent background updates
  • Notification messages for visible alerts

Apple Push Notification Service (APNs) for iOS:

  • Certificate-based or token-based authentication (prefer tokens)
  • Silent notifications for background updates
  • Critical alerts bypass Do Not Disturb (requires entitlement)
  • Badge counts and custom sounds
  • Notification grouping and categories
// Push notification service supporting FCM and APNs
import admin from 'firebase-admin';
import apn from 'apn';

interface PushNotification {
title: string;
body: string;
data?: Record<string, string>;
imageUrl?: string;
badge?: number;
sound?: string;
}

class PushNotificationService {
private fcmApp: admin.app.App;
private apnProvider: apn.Provider;

constructor() {
this.fcmApp = admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});

this.apnProvider = new apn.Provider({
token: {
key: apnKeyPath,
keyId: apnKeyId,
teamId: apnTeamId,
},
production: process.env.NODE_ENV === 'production',
});
}

async sendToDevice(
deviceToken: string,
platform: 'ios' | 'android',
notification: PushNotification
): Promise<void> {
if (platform === 'android') {
await this.sendFCM(deviceToken, notification);
} else {
await this.sendAPNs(deviceToken, notification);
}
}

private async sendFCM(
token: string,
notification: PushNotification
): Promise<void> {
const message: admin.messaging.Message = {
token,
notification: {
title: notification.title,
body: notification.body,
imageUrl: notification.imageUrl,
},
data: notification.data,
android: {
priority: 'high',
notification: {
sound: notification.sound || 'default',
channelId: 'default',
},
},
};

try {
await admin.messaging().send(message);
} catch (error) {
if (error.code === 'messaging/registration-token-not-registered') {
// Token is invalid, remove from database
await this.removeDeviceToken(token);
}
throw error;
}
}

private async sendAPNs(
token: string,
notification: PushNotification
): Promise<void> {
const apnNotification = new apn.Notification({
alert: {
title: notification.title,
body: notification.body,
},
badge: notification.badge,
sound: notification.sound || 'default',
payload: notification.data || {},
topic: 'com.example.app',
});

const result = await this.apnProvider.send(apnNotification, token);

if (result.failed.length > 0) {
const failure = result.failed[0];
if (failure.status === '410') {
// Token is no longer valid
await this.removeDeviceToken(token);
}
}
}

// Send to all devices for a user
async sendToUser(
userId: string,
notification: PushNotification
): Promise<void> {
const devices = await this.getUserDevices(userId);

await Promise.all(
devices.map(device =>
this.sendToDevice(device.token, device.platform, notification)
)
);
}
}

Device tokens expire or become invalid when users uninstall apps. The service must handle these errors by removing invalid tokens from the database to avoid wasting resources on failed sends.

Token Management

Device tokens must be stored securely, associated with users, and kept current. A user may have multiple devices (phone, tablet, desktop browser).

interface DeviceToken {
userId: string;
token: string;
platform: 'ios' | 'android' | 'web';
appVersion: string;
createdAt: Date;
lastUsedAt: Date;
}

// Register device token
async function registerDevice(
userId: string,
token: string,
platform: 'ios' | 'android' | 'web',
appVersion: string
): Promise<void> {
await db.deviceTokens.upsert({
where: { userId_token: { userId, token } },
create: {
userId,
token,
platform,
appVersion,
createdAt: new Date(),
lastUsedAt: new Date(),
},
update: {
lastUsedAt: new Date(),
appVersion,
},
});
}

// Clean up stale tokens (not used in 60 days)
async function cleanupStaleTokens(): Promise<void> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 60);

await db.deviceTokens.deleteMany({
where: {
lastUsedAt: { lt: cutoffDate },
},
});
}

Update lastUsedAt when sending notifications successfully to track active tokens. Schedule periodic cleanup to remove stale tokens and reduce database size.

Notification Channels (Android)

Android 8.0+ requires notification channels to give users granular control over notification types. Each channel has its own settings for sound, vibration, and importance.

// Create notification channels on app startup
fun createNotificationChannels(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channels = listOf(
NotificationChannel(
"messages",
"Messages",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Direct messages from other users"
enableLights(true)
enableVibration(true)
},
NotificationChannel(
"transactions",
"Transactions",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Payment and transaction alerts"
enableLights(true)
setSound(Uri.parse("android.resource://${context.packageName}/raw/transaction"), null)
},
NotificationChannel(
"updates",
"App Updates",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "New features and updates"
}
)

val notificationManager = context.getSystemService(NotificationManager::class.java)
channels.forEach { notificationManager.createNotificationChannel(it) }
}
}

Channel importance determines how intrusively notifications appear (heads-up, status bar only, silent). Users can override these settings. Once created, channel settings cannot be changed programmatically - users must change them in system settings.

SMS and Text Messaging

SMS provides critical notifications when push notifications may not be delivered (user logged out, app uninstalled). Use SMS sparingly due to cost and potential user annoyance.

SMS Providers

Twilio:

  • Most comprehensive feature set
  • Programmable messaging with webhooks
  • Short codes and long codes
  • International support with local numbers
  • Message status tracking

AWS SNS:

  • Cost-effective for basic SMS ($0.00645 per message in US)
  • Integrates with AWS services
  • Transactional and promotional message types
  • Limited features compared to Twilio
// SMS service with Twilio
import twilio from 'twilio';

interface SmsMessage {
to: string;
body: string;
from?: string;
}

class SmsService {
private client: twilio.Twilio;
private defaultFrom: string;

constructor() {
this.client = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
this.defaultFrom = process.env.TWILIO_PHONE_NUMBER;
}

async sendSms(message: SmsMessage): Promise<string> {
// Validate phone number format
const phoneNumber = this.normalizePhoneNumber(message.to);

const result = await this.client.messages.create({
to: phoneNumber,
from: message.from || this.defaultFrom,
body: message.body,
statusCallback: `${process.env.API_URL}/webhooks/sms-status`,
});

// Store message ID for tracking
await this.storeSmsDelivery({
messageId: result.sid,
to: phoneNumber,
body: message.body,
status: result.status,
});

return result.sid;
}

// Normalize phone numbers to E.164 format
private normalizePhoneNumber(phone: string): string {
// Remove non-numeric characters
const cleaned = phone.replace(/\D/g, '');

// Add country code if missing (assuming US)
if (cleaned.length === 10) {
return `+1${cleaned}`;
}

return `+${cleaned}`;
}

// Handle delivery status webhooks
async handleStatusWebhook(data: any): Promise<void> {
await db.smsDeliveries.update({
where: { messageId: data.MessageSid },
data: {
status: data.MessageStatus,
errorCode: data.ErrorCode,
errorMessage: data.ErrorMessage,
updatedAt: new Date(),
},
});

// Alert on delivery failures
if (data.MessageStatus === 'failed' || data.MessageStatus === 'undelivered') {
logger.error('SMS delivery failed', {
messageId: data.MessageSid,
errorCode: data.ErrorCode,
errorMessage: data.ErrorMessage,
});
}
}
}

Phone numbers must be in E.164 format (+[country code][number]). Status webhooks provide real-time delivery updates (queued, sent, delivered, failed). Monitor delivery failures to identify carrier issues or invalid numbers.

SMS Best Practices

Cost Management:

  • SMS costs 100-1000x more than push notifications
  • Use SMS only for critical notifications (OTP, security alerts)
  • Implement rate limiting per user
  • Consider batch sending during off-peak hours for lower rates

Message Length:

  • Standard SMS is 160 characters
  • Longer messages split into multiple parts (higher cost)
  • Unicode (emojis, non-Latin scripts) reduces limit to 70 characters
  • Include sender name and opt-out instructions

Compliance:

  • Obtain explicit consent before sending (TCPA in US)
  • Provide opt-out mechanism (reply STOP)
  • Honor opt-outs immediately
  • Include clear sender identification
// SMS with opt-out handling
async function sendSmsWithOptOutCheck(
userId: string,
phoneNumber: string,
message: string
): Promise<void> {
// Check opt-out status
const optOut = await db.smsOptOuts.findUnique({
where: { phoneNumber },
});

if (optOut) {
logger.info('User opted out of SMS', { userId, phoneNumber });
return;
}

// Add opt-out instruction to message
const messageWithOptOut = `${message}\n\nReply STOP to unsubscribe`;

await smsService.sendSms({
to: phoneNumber,
body: messageWithOptOut,
});
}

// Handle incoming SMS for opt-out
async function handleIncomingSms(
from: string,
body: string
): Promise<void> {
const normalizedBody = body.trim().toUpperCase();

if (normalizedBody === 'STOP' || normalizedBody === 'UNSUBSCRIBE') {
await db.smsOptOuts.create({
data: {
phoneNumber: from,
optedOutAt: new Date(),
},
});

// Send confirmation
await smsService.sendSms({
to: from,
body: 'You have been unsubscribed from SMS notifications.',
});
}
}

In-App Notifications

In-app notifications display alerts within the application interface. They provide immediate feedback without requiring external delivery infrastructure and support rich content and interactivity.

Notification Center

A persistent notification center allows users to view, manage, and act on notifications:

// Notification data model
interface Notification {
id: string;
userId: string;
type: 'info' | 'success' | 'warning' | 'error';
title: string;
message: string;
actionUrl?: string;
actionLabel?: string;
imageUrl?: string;
metadata?: Record<string, any>;
read: boolean;
createdAt: Date;
expiresAt?: Date;
}

// Notification service
class InAppNotificationService {
async createNotification(
userId: string,
notification: Omit<Notification, 'id' | 'userId' | 'read' | 'createdAt'>
): Promise<Notification> {
const created = await db.notifications.create({
data: {
userId,
...notification,
read: false,
createdAt: new Date(),
},
});

// Emit real-time event via WebSocket
await this.emitNotification(userId, created);

return created;
}

async getNotifications(
userId: string,
options: { unreadOnly?: boolean; limit?: number }
): Promise<Notification[]> {
return db.notifications.findMany({
where: {
userId,
read: options.unreadOnly ? false : undefined,
expiresAt: {
gt: new Date(),
},
},
orderBy: {
createdAt: 'desc',
},
take: options.limit || 50,
});
}

async markAsRead(notificationId: string): Promise<void> {
await db.notifications.update({
where: { id: notificationId },
data: { read: true },
});
}

async markAllAsRead(userId: string): Promise<void> {
await db.notifications.updateMany({
where: { userId, read: false },
data: { read: true },
});
}

async getUnreadCount(userId: string): Promise<number> {
return db.notifications.count({
where: {
userId,
read: false,
expiresAt: {
gt: new Date(),
},
},
});
}

// Real-time notification via WebSocket
private async emitNotification(
userId: string,
notification: Notification
): Promise<void> {
const socket = getSocketForUser(userId);
if (socket) {
socket.emit('notification', notification);
}
}
}

Expiring notifications prevent stale content from cluttering the notification center. WebSocket integration provides real-time delivery when users are active. For WebSocket implementation details, see Real-Time Communication.

Toast Notifications

Toast notifications provide temporary, non-intrusive alerts:

// React toast notification component
import { toast } from 'react-hot-toast';

interface ToastOptions {
duration?: number;
position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';
icon?: string;
}

class ToastService {
success(message: string, options?: ToastOptions): void {
toast.success(message, {
duration: options?.duration || 4000,
position: options?.position || 'top-right',
GOOD: icon: options?.icon || '',
});
}

error(message: string, options?: ToastOptions): void {
toast.error(message, {
duration: options?.duration || 6000,
position: options?.position || 'top-right',
BAD: icon: options?.icon || '',
});
}

warning(message: string, options?: ToastOptions): void {
toast(message, {
duration: options?.duration || 5000,
position: options?.position || 'top-right',
icon: options?.icon || '',
style: {
background: '#fff3cd',
color: '#856404',
},
});
}

promise<T>(
promise: Promise<T>,
messages: {
loading: string;
success: string;
error: string;
}
): Promise<T> {
return toast.promise(promise, messages);
}
}

// Usage example
async function saveSettings(data: Settings): Promise<void> {
await toastService.promise(
api.updateSettings(data),
{
loading: 'Saving settings...',
success: 'Settings saved successfully',
error: 'Failed to save settings',
}
);
}

Toast notifications should be brief, actionable, and automatically dismiss. Error toasts may persist longer to ensure users see them. For accessibility considerations with toast notifications, see Accessibility Guidelines.

User Preferences and Opt-Out Management

Respecting user preferences is both a legal requirement (GDPR, CAN-SPAM) and a user experience best practice. Provide granular control over notification types and channels.

Preference Data Model

interface NotificationPreferences {
userId: string;
channels: {
email: boolean;
push: boolean;
sms: boolean;
inApp: boolean;
};
categories: {
transactional: boolean; // Cannot be disabled
security: boolean; // Cannot be disabled
marketing: boolean;
updates: boolean;
reminders: boolean;
};
frequency: {
digest: 'immediate' | 'hourly' | 'daily' | 'weekly';
quietHours: {
enabled: boolean;
start: string; // "22:00"
end: string; // "08:00"
timezone: string;
};
};
}

class NotificationPreferenceService {
async getPreferences(userId: string): Promise<NotificationPreferences> {
return db.notificationPreferences.findUnique({
where: { userId },
}) || this.getDefaultPreferences(userId);
}

async updatePreferences(
userId: string,
updates: Partial<NotificationPreferences>
): Promise<NotificationPreferences> {
return db.notificationPreferences.upsert({
where: { userId },
create: {
userId,
...this.getDefaultPreferences(userId),
...updates,
},
update: updates,
});
}

async shouldSendNotification(
userId: string,
category: string,
channel: string
): Promise<boolean> {
const prefs = await this.getPreferences(userId);

// Always send transactional and security notifications
if (category === 'transactional' || category === 'security') {
return prefs.channels[channel];
}

// Check if category is enabled
if (!prefs.categories[category]) {
return false;
}

// Check if channel is enabled
if (!prefs.channels[channel]) {
return false;
}

// Check quiet hours for non-critical notifications
if (channel === 'push' || channel === 'sms') {
if (prefs.frequency.quietHours.enabled) {
const now = new Date();
const userTime = new Date(now.toLocaleString('en-US', {
timeZone: prefs.frequency.quietHours.timezone,
}));

const currentHour = userTime.getHours();
const startHour = parseInt(prefs.frequency.quietHours.start.split(':')[0]);
const endHour = parseInt(prefs.frequency.quietHours.end.split(':')[0]);

if (currentHour >= startHour || currentHour < endHour) {
return false;
}
}
}

return true;
}

private getDefaultPreferences(userId: string): NotificationPreferences {
return {
userId,
channels: {
email: true,
push: true,
sms: false,
inApp: true,
},
categories: {
transactional: true,
security: true,
marketing: false,
updates: true,
reminders: true,
},
frequency: {
digest: 'immediate',
quietHours: {
enabled: false,
start: '22:00',
end: '08:00',
timezone: 'America/New_York',
},
},
};
}
}

Transactional and security notifications cannot be disabled - they're required for application functionality and user protection. Marketing notifications default to disabled. Quiet hours prevent disturbing users during sleep hours.

Unsubscribe Implementation

Email unsubscribe links must work reliably and immediately:

// Generate unsubscribe token
function generateUnsubscribeToken(
userId: string,
category: string
): string {
const payload = { userId, category, type: 'unsubscribe' };
return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '90d' });
}

// Unsubscribe endpoint
app.get('/unsubscribe/:token', async (req, res) => {
try {
const decoded = jwt.verify(req.params.token, process.env.JWT_SECRET);

await notificationPreferenceService.updatePreferences(decoded.userId, {
channels: { email: false },
});

res.send(`
<html>
<body>
<h1>Unsubscribed</h1>
<p>You have been unsubscribed from email notifications.</p>
<p><a href="/settings/notifications">Manage preferences</a></p>
</body>
</html>
`);
} catch (error) {
res.status(400).send('Invalid or expired unsubscribe link');
}
});

// Include unsubscribe link in emails
function addUnsubscribeLink(
html: string,
userId: string,
category: string
): string {
const token = generateUnsubscribeToken(userId, category);
const unsubscribeUrl = `https://example.com/unsubscribe/${token}`;

// Add unsubscribe link to footer
return html.replace(
'</body>',
`<p style="text-align: center; color: #666;">
<a href="${unsubscribeUrl}">Unsubscribe</a> from these emails
</p></body>`
);
}

The unsubscribe link must work without requiring login. Use JWT tokens with long expiration (90 days) to handle delayed unsubscribe clicks. CAN-SPAM requires unsubscribe processing within 10 business days, but process immediately for better user experience.

Delivery Tracking and Analytics

Track notification delivery, opens, clicks, and failures to optimize performance and debug issues.

Event Tracking

interface NotificationEvent {
notificationId: string;
userId: string;
channel: 'email' | 'push' | 'sms' | 'in-app';
category: string;
event: 'sent' | 'delivered' | 'opened' | 'clicked' | 'failed' | 'bounced';
metadata?: Record<string, any>;
timestamp: Date;
}

class NotificationAnalytics {
async trackEvent(event: NotificationEvent): Promise<void> {
await db.notificationEvents.create({
data: event,
});

// Send to analytics platform
await this.sendToAnalytics(event);
}

async getDeliveryStats(
channel: string,
startDate: Date,
endDate: Date
): Promise<DeliveryStats> {
const events = await db.notificationEvents.findMany({
where: {
channel,
timestamp: { gte: startDate, lte: endDate },
},
});

const stats = {
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
failed: 0,
bounced: 0,
};

events.forEach(event => {
stats[event.event]++;
});

return {
...stats,
deliveryRate: (stats.delivered / stats.sent) * 100,
openRate: (stats.opened / stats.delivered) * 100,
clickRate: (stats.clicked / stats.opened) * 100,
failureRate: (stats.failed / stats.sent) * 100,
};
}

// Identify users with delivery issues
async getUsersWithDeliveryIssues(): Promise<string[]> {
const recentFailures = await db.notificationEvents.groupBy({
by: ['userId'],
where: {
event: { in: ['failed', 'bounced'] },
timestamp: {
gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7 days
},
},
having: {
userId: { _count: { gte: 3 } },
},
});

return recentFailures.map(group => group.userId);
}
}

Track events at each stage of delivery to calculate funnel metrics. High failure rates indicate deliverability issues. Low open rates suggest poor subject lines or timing. Low click rates indicate weak content or unclear calls to action.

Email Tracking Implementation

// Add tracking pixel for email opens
function addTrackingPixel(html: string, emailId: string): string {
const pixelUrl = `https://example.com/track/open/${emailId}`;
const trackingPixel = `<img src="${pixelUrl}" width="1" height="1" alt="" />`;

return html.replace('</body>', `${trackingPixel}</body>`);
}

// Track email opens
app.get('/track/open/:emailId', async (req, res) => {
await notificationAnalytics.trackEvent({
notificationId: req.params.emailId,
event: 'opened',
timestamp: new Date(),
});

// Return 1x1 transparent pixel
const pixel = Buffer.from(
'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
'base64'
);

res.type('image/gif').send(pixel);
});

// Track link clicks
function addClickTracking(
html: string,
emailId: string
): string {
return html.replace(
/href="([^"]+)"/g,
(match, url) => {
const trackingUrl = `https://example.com/track/click/${emailId}?url=${encodeURIComponent(url)}`;
return `href="${trackingUrl}"`;
}
);
}

app.get('/track/click/:emailId', async (req, res) => {
const originalUrl = req.query.url as string;

await notificationAnalytics.trackEvent({
notificationId: req.params.emailId,
event: 'clicked',
metadata: { url: originalUrl },
timestamp: new Date(),
});

res.redirect(originalUrl);
});

Tracking pixels detect email opens but are not 100% accurate. Many email clients block images by default. Click tracking is more reliable. For privacy-sensitive applications, make tracking opt-in and clearly disclose it.