All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m0s
330 lines
8.7 KiB
Markdown
330 lines
8.7 KiB
Markdown
# 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
|