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

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

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

```typescript
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`:

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

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

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