Files
flyer-crawler.projectium.com/docs/adr/0042-email-and-notification-architecture.md
Torben Sorensen e14c19c112
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m0s
linting docs + some fixes go claude and gemini
2026-01-09 22:38:57 -08:00

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:

  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:

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 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