# ADR-042: Email and Notification Architecture **Date**: 2026-01-09 **Status**: Accepted **Implemented**: 2026-01-09 ## Context The application sends emails for multiple purposes: 1. **Transactional Emails**: Password reset, welcome emails, account verification. 2. **Deal Notifications**: Alerting users when watched items go on sale. 3. **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: 1. **Nodemailer**: For SMTP transport and email composition. 2. **BullMQ**: For job queuing, retry logic, and rate limiting. 3. **Dedicated Worker**: Background process for email delivery. 4. **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`: ```typescript 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 ```typescript // src/types/job-data.ts export interface EmailJobData { to: string; subject: string; text: string; html: string; } ``` ### Core Send Function ```typescript 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 ```typescript export const processEmailJob = async (job: Job) => { // 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 ```typescript export const sendPasswordResetEmail = async (to: string, token: string, logger: Logger) => { const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${token}`; const html = `

Password Reset Request

Click the link below to set a new password. This link expires in 1 hour.

Reset Your Password

If you did not request this, please ignore this email.

`; await sendEmail({ to, subject: 'Your Password Reset Request', text: '...', html }, logger); }; ``` #### Welcome Email ```typescript export const sendWelcomeEmail = async (to: string, name: string | null, logger: Logger) => { const recipientName = name || 'there'; const html = `

Welcome!

Hello ${recipientName},

Thank you for joining Flyer Crawler.

Start by uploading your first flyer to see how much you can save!

`; await sendEmail({ to, subject: 'Welcome to Flyer Crawler!', text: '...', html }, logger); }; ``` #### Deal Notifications ```typescript export const sendDealNotificationEmail = async ( to: string, name: string | null, deals: WatchedItemDeal[], logger: Logger, ) => { const dealsListHtml = deals .map( (deal) => `
  • ${deal.item_name} is on sale for $${(deal.best_price_in_cents / 100).toFixed(2)} at ${deal.store_name}!
  • `, ) .join(''); const html = `

    Hi ${name || 'there'},

    We found great deals on items you're watching:

    Check them out on the deals page!

    `; await sendEmail({ to, subject: 'New Deals Found!', text: '...', html }, logger); }; ``` ### Queue Configuration Located in `src/services/queueService.server.ts`: ```typescript import { Queue, Worker, Job } from 'bullmq'; import { processEmailJob } from './emailService.server'; export const emailQueue = new Queue('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 ```typescript // 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`: ```typescript export class BackgroundJobService { constructor( private personalizationRepo: PersonalizationRepository, private notificationRepo: NotificationRepository, private emailQueue: Queue, private logger: Logger, ) {} async runDailyDealCheck(): Promise { 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 ```bash # 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 ```typescript // 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 sending - `src/services/queueService.server.ts` - Queue configuration and workers - `src/services/backgroundJobService.ts` - Scheduled deal notifications - `src/types/job-data.ts` - Email job data types ## Related ADRs - [ADR-006](./0006-background-job-processing-and-task-queues.md) - Background Job Processing - [ADR-004](./0004-standardized-application-wide-structured-logging.md) - Structured Logging - [ADR-039](./0039-dependency-injection-pattern.md) - Dependency Injection