Files
lcbp3/specs/05-decisions/ADR-008-email-notification-strategy.md

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 เปลี่ยนสถานะ

ปัญหาที่ต้องแก้:

  1. Multi-Channel: รองรับหลายช่องทางการแจ้งเตือน (Email, LINE, In-app)
  2. Reliability: ทำอย่างไรให้การส่ง Email ไม่ Block main request
  3. Retry Logic: จัดการ Email delivery failures อย่างไร
  4. Template Management: จัดการ Email templates อย่างไรให้ Maintainable
  5. 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

  1. Performance: ไม่ Block API response, ส่ง Email แบบ Async
  2. Reliability: Persistent jobs ใน Redis, มี Retry mechanism
  3. Scalability: สามารถ Scale workers แยกได้
  4. Monitoring: ดู Job status, Failed jobs ได้
  5. 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

  1. Performance: API responses ไม่ถูก Block โดยการส่ง Email
  2. Reliability: Jobs ถูกเก็บใน Redis ไม่สูญหายหาก Server restart
  3. Retry: Automatic retry สำหรับ Failed jobs
  4. Monitoring: ดู Job status, Failed jobs ผ่าน Bull Board
  5. Scalability: เพิ่ม Email workers ได้ตามต้องการ
  6. Multi-Channel: รองรับ Email, LINE, In-app notification

Negative Consequences

  1. Delayed Delivery: Email ส่งแบบ Async อาจมี Delay เล็กน้อย
  2. Dependency on Redis: หาก Redis down ก็ส่ง Email ไม่ได้
  3. 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


References


Last Updated: 2025-12-01 Next Review: 2025-06-01