11 KiB
11 KiB
ADR-008: Email & Notification Strategy
Status: ✅ Accepted Date: 2025-12-01 Decision Makers: Backend Team, System Architect Related Documents: Backend Guidelines, TASK-BE-011
Context and Problem Statement
ระบบ LCBP3-DMS ต้องการส่งการแจ้งเตือนให้ผู้ใช้งานผ่านหลายช่องทาง (Email, LINE Notify, In-App) เมื่อมี Events สำคัญเกิดขึ้น เช่น Correspondence ได้รับการอนุมัติ, RFA ถูก Review, Workflow เปลี่ยนสถานะ
ปัญหาที่ต้องแก้:
- Multi-Channel: รองรับหลายช่องทางการแจ้งเตือน (Email, LINE, In-app)
- Reliability: ทำอย่างไรให้การส่ง Email ไม่ Block main request
- Retry Logic: จัดการ Email delivery failures อย่างไร
- Template Management: จัดการ Email templates อย่างไรให้ Maintainable
- User Preferences: ให้ User เลือก Channel ที่ต้องการได้อย่างไร
Decision Drivers
- ⚡ Performance: ส่ง Email ต้องไม่ทำให้ API Response ช้า
- 🔄 Reliability: Email ส่งไม่สำเร็จต้อง Retry ได้
- 🎨 Branding: Email template ต้องดูเป็นมืออาชีพ
- 🛠️ Maintainability: แก้ไข Template ได้ง่าย
- 📱 Multi-Channel: รองรับ Email, LINE, In-app notification
Considered Options
Option 1: Sync Email Sending (ส่งทันที ใน Request)
Implementation:
await this.emailService.sendEmail({ to, subject, body });
return { success: true };
Pros:
- ✅ Simple implementation
- ✅ ง่ายต่อการ Debug
Cons:
- ❌ Block API response (slow)
- ❌ หาก SMTP server down จะ Timeout
- ❌ ไม่มี Retry mechanism
Option 2: Async with Event Emitter (NestJS EventEmitter)
Implementation:
this.eventEmitter.emit('correspondence.approved', { correspondenceId });
// Return immediately
return { success: true };
// Listener
@OnEvent('correspondence.approved')
async handleApproved(payload) {
await this.emailService.sendEmail(...);
}
Pros:
- ✅ Non-blocking (async)
- ✅ Decoupled
Cons:
- ❌ ไม่มี Retry หาก Event listener fail
- ❌ Lost jobs หาก Server restart
Option 3: Message Queue (BullMQ + Redis)
Implementation:
await this.emailQueue.add('send-email', {
to,
subject,
template,
context,
});
Pros:
- ✅ Non-blocking (async)
- ✅ Persistent (Store in Redis)
- ✅ Built-in Retry mechanism
- ✅ Job monitoring & management
- ✅ Scalable (Multiple workers)
Cons:
- ❌ Requires Redis infrastructure
- ❌ More complex setup
Decision Outcome
Chosen Option: Option 3 - Message Queue (BullMQ + Redis)
Rationale
- Performance: ไม่ Block API response, ส่ง Email แบบ Async
- Reliability: Persistent jobs ใน Redis, มี Retry mechanism
- Scalability: สามารถ Scale workers แยกได้
- Monitoring: ดู Job status, Failed jobs ได้
- Infrastructure: Redis มีอยู่แล้วสำหรับ Locking และ Caching (ADR-006)
Implementation Details
1. Email Queue Setup
// File: backend/src/modules/notification/notification.module.ts
import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
BullModule.registerQueue({
name: 'email',
connection: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT),
},
}),
BullModule.registerQueue({
name: 'line-notify',
}),
],
providers: [NotificationService, EmailProcessor, LineNotifyProcessor],
})
export class NotificationModule {}
2. Queue Email Job
// File: backend/src/modules/notification/notification.service.ts
@Injectable()
export class NotificationService {
constructor(
@InjectQueue('email') private emailQueue: Queue,
@InjectQueue('line-notify') private lineQueue: Queue
) {}
async sendEmailNotification(dto: SendEmailDto): Promise<void> {
await this.emailQueue.add(
'send-email',
{
to: dto.to,
subject: dto.subject,
template: dto.template, // e.g., 'correspondence-approved'
context: dto.context, // Template variables
},
{
attempts: 3, // Retry 3 times
backoff: {
type: 'exponential',
delay: 5000, // Start with 5s delay
},
removeOnComplete: {
age: 24 * 3600, // Keep completed jobs for 24h
},
removeOnFail: false, // Keep failed jobs for debugging
}
);
}
async sendLineNotification(dto: SendLineDto): Promise<void> {
await this.lineQueue.add('send-line', {
token: dto.token,
message: dto.message,
});
}
}
3. Email Processor (Worker)
// File: backend/src/modules/notification/processors/email.processor.ts
import { Processor, Process } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import * as nodemailer from 'nodemailer';
import * as handlebars from 'handlebars';
import * as fs from 'fs/promises';
@Processor('email')
export class EmailProcessor {
private transporter: nodemailer.Transporter;
constructor() {
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT),
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
}
@Process('send-email')
async handleSendEmail(job: Job) {
const { to, subject, template, context } = job.data;
try {
// Load and compile template
const templatePath = `./templates/emails/${template}.hbs`;
const templateSource = await fs.readFile(templatePath, 'utf-8');
const compiledTemplate = handlebars.compile(templateSource);
const html = compiledTemplate(context);
// Send email
const info = await this.transporter.sendMail({
from: process.env.SMTP_FROM,
to,
subject,
html,
});
console.log('Email sent:', info.messageId);
return info;
} catch (error) {
console.error('Failed to send email:', error);
throw error; // Will trigger retry
}
}
}
4. Email Template (Handlebars)
<!-- File: backend/templates/emails/correspondence-approved.hbs -->
<html>
<head>
<style>
body { font-family: Arial, sans-serif; } .container { max-width: 600px;
margin: 0 auto; padding: 20px; } .header { background: #007bff; color:
white; padding: 20px; } .content { padding: 20px; } .button { background:
#007bff; color: white; padding: 10px 20px; text-decoration: none; }
</style>
</head>
<body>
<div class='container'>
<div class='header'>
<h1>Correspondence Approved</h1>
</div>
<div class='content'>
<p>สวัสดีคุณ {{userName}},</p>
<p>เอกสาร <strong>{{documentNumber}}</strong> ได้รับการอนุมัติแล้ว</p>
<p><strong>Subject:</strong> {{subject}}</p>
<p><strong>Approved by:</strong> {{approver}}</p>
<p><strong>Date:</strong> {{approvedDate}}</p>
<p>
<a href='{{documentUrl}}' class='button'>ดูเอกสาร</a>
</p>
</div>
</div>
</body>
</html>
5. Workflow Event → Email Notification
// File: backend/src/modules/workflow/workflow.service.ts
async executeTransition(workflowId: number, action: string) {
// ... Execute transition logic
// Send notifications
await this.notificationService.notifyWorkflowTransition(
workflowId,
action,
currentUserId,
);
}
// File: backend/src/modules/notification/notification.service.ts
async notifyWorkflowTransition(
workflowId: number,
action: string,
actorId: number,
) {
// Get users to notify
const users = await this.getRelevantUsers(workflowId);
for (const user of users) {
// In-app notification
await this.createNotification({
user_id: user.user_id,
type: 'workflow_transition',
title: `${action} completed`,
message: `Workflow ${workflowId} has been ${action}`,
link: `/workflows/${workflowId}`,
});
// Email (if enabled)
if (user.email_notifications_enabled) {
await this.sendEmailNotification({
to: user.email,
subject: `Workflow Update: ${action}`,
template: 'workflow-transition',
context: {
userName: user.first_name,
action,
workflowId,
documentUrl: `${process.env.FRONTEND_URL}/workflows/${workflowId}`,
},
});
}
// LINE Notify (if enabled)
if (user.line_notify_token) {
await this.sendLineNotification({
token: user.line_notify_token,
message: `[LCBP3-DMS] Workflow ${workflowId}: ${action}`,
});
}
}
}
Consequences
Positive Consequences
- ✅ Performance: API responses ไม่ถูก Block โดยการส่ง Email
- ✅ Reliability: Jobs ถูกเก็บใน Redis ไม่สูญหายหาก Server restart
- ✅ Retry: Automatic retry สำหรับ Failed jobs
- ✅ Monitoring: ดู Job status, Failed jobs ผ่าน Bull Board
- ✅ Scalability: เพิ่ม Email workers ได้ตามต้องการ
- ✅ Multi-Channel: รองรับ Email, LINE, In-app notification
Negative Consequences
- ❌ Delayed Delivery: Email ส่งแบบ Async อาจมี Delay เล็กน้อย
- ❌ Dependency on Redis: หาก Redis down ก็ส่ง Email ไม่ได้
- ❌ Template Management: ต้อง Maintain Handlebars templates แยก
Mitigation Strategies
- Redis Monitoring: ตั้ง Alert หาก Redis down
- Template Versioning: เก็บ Email templates ใน Git
- Fallback: หาก Redis ล้ม อาจ Fallback เป็น Sync sending ชั่วคราว
- Testing: ใช้ Mailtrap/MailHog สำหรับ Testing ใน Development
Related ADRs
- ADR-006: Redis Caching Strategy - ใช้ Redis สำหรับ Queue
- TASK-BE-011: Notification & Audit
References
Last Updated: 2025-12-01 Next Review: 2025-06-01