All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m0s
8.7 KiB
8.7 KiB
ADR-042: Email and Notification Architecture
Date: 2026-01-09
Status: Accepted
Implemented: 2026-01-09
Context
The application sends emails for multiple purposes:
- Transactional Emails: Password reset, welcome emails, account verification.
- Deal Notifications: Alerting users when watched items go on sale.
- Bulk Communications: System announcements, marketing (future).
Email delivery has unique challenges:
- Reliability: Emails must be delivered even if the main request fails.
- Rate Limits: SMTP servers enforce sending limits.
- Retry Logic: Failed emails should be retried with backoff.
- Templating: Emails need consistent branding and formatting.
- Testing: Tests should not send real emails.
Decision
We will implement a queue-based email system using:
- Nodemailer: For SMTP transport and email composition.
- BullMQ: For job queuing, retry logic, and rate limiting.
- Dedicated Worker: Background process for email delivery.
- Structured Logging: Job-scoped logging for debugging.
Design Principles
- Asynchronous Delivery: Queue emails immediately, deliver asynchronously.
- Idempotent Jobs: Jobs can be retried safely.
- Separation of Concerns: Email composition separate from delivery.
- Environment-Aware: Disable real sending in test environments.
Implementation Details
Email Service Structure
Located in src/services/emailService.server.ts:
import nodemailer from 'nodemailer';
import type { Job } from 'bullmq';
import type { Logger } from 'pino';
// SMTP transporter configured from environment
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
Email Job Data Structure
// src/types/job-data.ts
export interface EmailJobData {
to: string;
subject: string;
text: string;
html: string;
}
Core Send Function
export const sendEmail = async (options: EmailJobData, logger: Logger) => {
const mailOptions = {
from: `"Flyer Crawler" <${process.env.SMTP_FROM_EMAIL}>`,
to: options.to,
subject: options.subject,
text: options.text,
html: options.html,
};
const info = await transporter.sendMail(mailOptions);
logger.info(
{ to: options.to, subject: options.subject, messageId: info.messageId },
'Email sent successfully.',
);
};
Job Processor
export const processEmailJob = async (job: Job<EmailJobData>) => {
// Create child logger with job context
const jobLogger = globalLogger.child({
jobId: job.id,
jobName: job.name,
recipient: job.data.to,
});
jobLogger.info('Picked up email job.');
try {
await sendEmail(job.data, jobLogger);
} catch (error) {
const wrappedError = error instanceof Error ? error : new Error(String(error));
jobLogger.error({ err: wrappedError, attemptsMade: job.attemptsMade }, 'Email job failed.');
throw wrappedError; // BullMQ will retry
}
};
Specialized Email Functions
Password Reset
export const sendPasswordResetEmail = async (to: string, token: string, logger: Logger) => {
const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${token}`;
const html = `
<div style="font-family: sans-serif; padding: 20px;">
<h2>Password Reset Request</h2>
<p>Click the link below to set a new password. This link expires in 1 hour.</p>
<a href="${resetUrl}" style="background-color: #007bff; color: white; padding: 14px 25px; ...">
Reset Your Password
</a>
<p>If you did not request this, please ignore this email.</p>
</div>
`;
await sendEmail({ to, subject: 'Your Password Reset Request', text: '...', html }, logger);
};
Welcome Email
export const sendWelcomeEmail = async (to: string, name: string | null, logger: Logger) => {
const recipientName = name || 'there';
const html = `
<div style="font-family: sans-serif; padding: 20px;">
<h2>Welcome!</h2>
<p>Hello ${recipientName},</p>
<p>Thank you for joining Flyer Crawler.</p>
<p>Start by uploading your first flyer to see how much you can save!</p>
</div>
`;
await sendEmail({ to, subject: 'Welcome to Flyer Crawler!', text: '...', html }, logger);
};
Deal Notifications
export const sendDealNotificationEmail = async (
to: string,
name: string | null,
deals: WatchedItemDeal[],
logger: Logger,
) => {
const dealsListHtml = deals
.map(
(deal) => `
<li>
<strong>${deal.item_name}</strong> is on sale for
<strong>$${(deal.best_price_in_cents / 100).toFixed(2)}</strong>
at ${deal.store_name}!
</li>
`,
)
.join('');
const html = `
<h1>Hi ${name || 'there'},</h1>
<p>We found great deals on items you're watching:</p>
<ul>${dealsListHtml}</ul>
<p>Check them out on the deals page!</p>
`;
await sendEmail({ to, subject: 'New Deals Found!', text: '...', html }, logger);
};
Queue Configuration
Located in src/services/queueService.server.ts:
import { Queue, Worker, Job } from 'bullmq';
import { processEmailJob } from './emailService.server';
export const emailQueue = new Queue<EmailJobData>('email', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
removeOnComplete: 100,
removeOnFail: 500,
},
});
// Worker to process email jobs
const emailWorker = new Worker('email', processEmailJob, {
connection: redisConnection,
concurrency: 5,
});
Enqueueing Emails
// From backgroundJobService.ts
await emailQueue.add('deal-notification', {
to: user.email,
subject: 'New Deals Found!',
text: textContent,
html: htmlContent,
});
Background Job Integration
Located in src/services/backgroundJobService.ts:
export class BackgroundJobService {
constructor(
private personalizationRepo: PersonalizationRepository,
private notificationRepo: NotificationRepository,
private emailQueue: Queue<EmailJobData>,
private logger: Logger,
) {}
async runDailyDealCheck(): Promise<void> {
this.logger.info('Starting daily deal check...');
const deals = await this.personalizationRepo.getBestSalePricesForAllUsers(this.logger);
for (const userDeals of deals) {
await this.emailQueue.add('deal-notification', {
to: userDeals.email,
subject: 'New Deals Found!',
text: '...',
html: '...',
});
}
}
}
Environment Variables
# SMTP Configuration
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=user@example.com
SMTP_PASS=secret
SMTP_FROM_EMAIL=noreply@flyer-crawler.com
# Frontend URL for email links
FRONTEND_URL=https://flyer-crawler.com
Consequences
Positive
- Reliability: Failed emails are automatically retried with exponential backoff.
- Scalability: Queue can handle burst traffic without overwhelming SMTP.
- Observability: Job-scoped logging enables easy debugging.
- Separation: Email composition is decoupled from delivery timing.
- Testability: Can mock the queue or use Ethereal for testing.
Negative
- Complexity: Adds queue infrastructure dependency (Redis).
- Delayed Delivery: Emails are not instant (queued first).
- Monitoring Required: Need to monitor queue depth and failure rates.
Mitigation
- Use Bull Board UI for queue monitoring (already implemented).
- Set up alerts for queue depth and failure rate thresholds.
- Consider Ethereal or MailHog for development/testing.
Testing Strategy
// Unit test with mocked queue
const mockEmailQueue = {
add: vi.fn().mockResolvedValue({ id: 'job-1' }),
};
const service = new BackgroundJobService(
mockPersonalizationRepo,
mockNotificationRepo,
mockEmailQueue as any,
mockLogger,
);
await service.runDailyDealCheck();
expect(mockEmailQueue.add).toHaveBeenCalledWith('deal-notification', expect.any(Object));
Key Files
src/services/emailService.server.ts- Email composition and sendingsrc/services/queueService.server.ts- Queue configuration and workerssrc/services/backgroundJobService.ts- Scheduled deal notificationssrc/types/job-data.ts- Email job data types