iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Software Development

30 天打造工作室 SaaS 產品 (後端篇)系列 第 24

Day 24: 30天打造SaaS產品後端篇-Email 通知服務架構設計

  • 分享至 

  • xImage
  •  

前情提要

經過 Day 21-23 的測試三部曲,我們已經建立了完整的測試與 CI/CD 體系。今天我們開啟新的篇章:Email 通知服務。作為 Kyo System 企業級微服務生態系的關鍵組件,一個可靠的信件系統能大幅提升用戶體驗與產品價值。我們將深入探討信件服務的架構設計、模板引擎、隊列管理、以及可靠性保證。

Email 服務的核心挑戰

在實作信件服務前,我們需要理解其核心挑戰:

/**
 * Email 服務挑戰與解決方案
 *
 * 挑戰 1: 可靠性
 * ❌ 問題: 信件伺服器可能暫時無法連接
 * ✅ 解決: 重試機制 + 失敗隊列 + 備用提供商
 *
 * 挑戰 2: 效能
 * ❌ 問題: 同步發送會阻塞 API 回應
 * ✅ 解決: 非同步隊列 (Bull/BullMQ)
 *
 * 挑戰 3: 送達率
 * ❌ 問題: 信件容易被標記為垃圾信件
 * ✅ 解決: SPF/DKIM/DMARC + 信譽管理 + 預熱
 *
 * 挑戰 4: 模板管理
 * ❌ 問題: 硬編碼 HTML 難以維護
 * ✅ 解決: 模板引擎 (Handlebars/MJML)
 *
 * 挑戰 5: 多語系
 * ❌ 問題: 不同用戶需要不同語言
 * ✅ 解決: i18n 整合 + 動態模板
 *
 * 挑戰 6: 追蹤與分析
 * ❌ 問題: 不知道信件是否被開啟/點擊
 * ✅ 解決: 開信追蹤 + 點擊追蹤 + Webhook
 *
 * 挑戰 7: 成本控制
 * ❌ 問題: 信件發送量大時成本高
 * ✅ 解決: 批次發送 + 速率限制 + 合併信件
 */

架構設計

1. 整體架構圖

/**
 * Email 服務架構
 *
 * ┌─────────────┐
 * │   API       │
 * │  Handler    │
 * └──────┬──────┘
 *        │
 *        ↓
 * ┌─────────────┐      ┌──────────────┐
 * │   Email     │─────→│  Bull Queue  │
 * │  Service    │      │   (Redis)    │
 * └─────────────┘      └──────┬───────┘
 *        │                    │
 *        │                    ↓
 *        │             ┌──────────────┐
 *        ├────────────→│   Worker     │
 *        │             │  Process     │
 *        │             └──────┬───────┘
 *        │                    │
 *        ↓                    ↓
 * ┌─────────────┐      ┌──────────────┐
 * │  Template   │      │   Provider   │
 * │   Engine    │      │   (AWS SES)  │
 * └─────────────┘      └──────────────┘
 *        │                    │
 *        │                    ↓
 *        │             ┌──────────────┐
 *        │             │  Webhook     │
 *        └────────────→│  Handler     │
 *                      └──────────────┘
 */

2. 核心介面定義

// packages/kyo-core/src/email/types.ts
export interface EmailMessage {
  to: string | string[];
  from: string;
  replyTo?: string;
  subject: string;
  text?: string;
  html?: string;
  template?: {
    name: string;
    data: Record<string, any>;
    language?: string;
  };
  attachments?: EmailAttachment[];
  headers?: Record<string, string>;
  tags?: string[];
  metadata?: Record<string, any>;
}

export interface EmailAttachment {
  filename: string;
  content: Buffer | string;
  contentType?: string;
  encoding?: string;
}

export interface EmailSendResult {
  messageId: string;
  accepted: string[];
  rejected: string[];
  pending?: string[];
  response: string;
}

export interface EmailProvider {
  name: string;
  send(message: EmailMessage): Promise<EmailSendResult>;
  verify(): Promise<boolean>;
}

export interface EmailTemplate {
  name: string;
  subject: string;
  html: string;
  text?: string;
  variables: string[];
}

export interface EmailJob {
  id: string;
  message: EmailMessage;
  attempts: number;
  maxAttempts: number;
  priority: number;
  createdAt: Date;
  processedAt?: Date;
  completedAt?: Date;
  failedAt?: Date;
  error?: string;
}

export interface EmailEvent {
  type: 'delivered' | 'opened' | 'clicked' | 'bounced' | 'complained';
  messageId: string;
  email: string;
  timestamp: Date;
  metadata?: Record<string, any>;
}

Email Service 核心實作

// packages/kyo-core/src/email/email-service.ts
import { Queue, Worker, Job } from 'bullmq';
import Redis from 'ioredis';
import Handlebars from 'handlebars';
import { JSDOM } from 'jsdom';
import createDOMPurify from 'dompurify';
import type {
  EmailMessage,
  EmailSendResult,
  EmailProvider,
  EmailTemplate,
  EmailJob,
} from './types';

const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window as any);

export interface EmailServiceConfig {
  redis: {
    host: string;
    port: number;
    password?: string;
  };
  provider: EmailProvider;
  fallbackProvider?: EmailProvider;
  defaultFrom: string;
  templates: {
    path: string;
    cache: boolean;
  };
  queue: {
    concurrency: number;
    maxRetries: number;
    retryDelay: number;
  };
  tracking: {
    enabled: boolean;
    domain?: string;
  };
}

export class EmailService {
  private queue: Queue;
  private worker: Worker;
  private redis: Redis;
  private provider: EmailProvider;
  private fallbackProvider?: EmailProvider;
  private templateCache: Map<string, CompiledTemplate>;
  private config: EmailServiceConfig;

  constructor(config: EmailServiceConfig) {
    this.config = config;
    this.provider = config.provider;
    this.fallbackProvider = config.fallbackProvider;
    this.templateCache = new Map();

    // 建立 Redis 連接
    this.redis = new Redis({
      host: config.redis.host,
      port: config.redis.port,
      password: config.redis.password,
      maxRetriesPerRequest: null,
    });

    // 建立信件隊列
    this.queue = new Queue('emails', {
      connection: this.redis,
      defaultJobOptions: {
        attempts: config.queue.maxRetries,
        backoff: {
          type: 'exponential',
          delay: config.queue.retryDelay,
        },
        removeOnComplete: {
          count: 1000,
          age: 24 * 3600, // 保留 24 小時
        },
        removeOnFail: {
          count: 5000,
          age: 7 * 24 * 3600, // 保留 7 天
        },
      },
    });

    // 建立 Worker
    this.worker = new Worker(
      'emails',
      async (job: Job) => {
        return this.processEmailJob(job);
      },
      {
        connection: this.redis,
        concurrency: config.queue.concurrency,
      }
    );

    // Worker 事件監聽
    this.setupWorkerEvents();

    // 註冊 Handlebars 輔助函數
    this.registerHandlebarsHelpers();
  }

  /**
   * 發送信件 (非同步)
   */
  async sendEmail(message: EmailMessage, options?: {
    priority?: number;
    delay?: number;
  }): Promise<{ jobId: string }> {
    // 驗證信件地址
    this.validateEmailMessage(message);

    // 如果有模板,先渲染
    if (message.template) {
      const rendered = await this.renderTemplate(
        message.template.name,
        message.template.data,
        message.template.language
      );

      message.subject = rendered.subject;
      message.html = rendered.html;
      message.text = rendered.text;
    }

    // 加入追蹤碼 (如果啟用)
    if (this.config.tracking.enabled && message.html) {
      message.html = this.addTrackingPixel(message.html);
      message.html = this.addClickTracking(message.html);
    }

    // 加入隊列
    const job = await this.queue.add(
      'send',
      { message },
      {
        priority: options?.priority || 0,
        delay: options?.delay,
      }
    );

    return { jobId: job.id! };
  }

  /**
   * 立即發送信件 (同步,僅用於關鍵信件)
   */
  async sendEmailNow(message: EmailMessage): Promise<EmailSendResult> {
    this.validateEmailMessage(message);

    if (message.template) {
      const rendered = await this.renderTemplate(
        message.template.name,
        message.template.data,
        message.template.language
      );

      message.subject = rendered.subject;
      message.html = rendered.html;
      message.text = rendered.text;
    }

    try {
      return await this.provider.send(message);
    } catch (error) {
      // 如果主要提供商失敗,嘗試備用提供商
      if (this.fallbackProvider) {
        console.warn(`Primary provider failed, trying fallback: ${error}`);
        return await this.fallbackProvider.send(message);
      }
      throw error;
    }
  }

  /**
   * 批次發送信件
   */
  async sendBulkEmails(
    messages: EmailMessage[],
    options?: { batchSize?: number; delay?: number }
  ): Promise<{ jobIds: string[] }> {
    const batchSize = options?.batchSize || 100;
    const delay = options?.delay || 0;

    const jobIds: string[] = [];

    for (let i = 0; i < messages.length; i += batchSize) {
      const batch = messages.slice(i, i + batchSize);

      const jobs = await this.queue.addBulk(
        batch.map((message, index) => ({
          name: 'send',
          data: { message },
          opts: {
            delay: delay * (i / batchSize),
            priority: -1, // 批次信件優先度較低
          },
        }))
      );

      jobIds.push(...jobs.map((job) => job.id!));
    }

    return { jobIds };
  }

  /**
   * 處理信件發送任務
   */
  private async processEmailJob(job: Job): Promise<EmailSendResult> {
    const { message } = job.data as { message: EmailMessage };

    try {
      // 記錄發送開始
      await this.logEmailAttempt(job.id!, message, job.attemptsMade);

      // 發送信件
      const result = await this.provider.send(message);

      // 記錄成功
      await this.logEmailSuccess(job.id!, message, result);

      return result;
    } catch (error: any) {
      // 記錄失敗
      await this.logEmailFailure(job.id!, message, error);

      // 如果是最後一次重試,嘗試備用提供商
      if (job.attemptsMade >= this.config.queue.maxRetries - 1) {
        if (this.fallbackProvider) {
          console.warn(
            `Max retries reached for job ${job.id}, trying fallback provider`
          );

          try {
            const result = await this.fallbackProvider.send(message);
            await this.logEmailSuccess(job.id!, message, result, 'fallback');
            return result;
          } catch (fallbackError) {
            console.error(
              `Fallback provider also failed for job ${job.id}:`,
              fallbackError
            );
          }
        }
      }

      throw error;
    }
  }

  /**
   * 渲染信件模板
   */
  private async renderTemplate(
    templateName: string,
    data: Record<string, any>,
    language: string = 'zh-TW'
  ): Promise<{
    subject: string;
    html: string;
    text: string;
  }> {
    // 檢查快取
    const cacheKey = `${templateName}_${language}`;

    let compiled = this.templateCache.get(cacheKey);

    if (!compiled || !this.config.templates.cache) {
      // 載入模板
      const template = await this.loadTemplate(templateName, language);

      // 編譯模板
      compiled = {
        subject: Handlebars.compile(template.subject),
        html: Handlebars.compile(template.html),
        text: template.text ? Handlebars.compile(template.text) : null,
      };

      if (this.config.templates.cache) {
        this.templateCache.set(cacheKey, compiled);
      }
    }

    // 渲染模板
    const subject = compiled.subject(data);
    let html = compiled.html(data);
    const text = compiled.text ? compiled.text(data) : this.htmlToText(html);

    // 清理 HTML (防止 XSS)
    html = DOMPurify.sanitize(html);

    return { subject, html, text };
  }

  /**
   * 載入模板
   */
  private async loadTemplate(
    name: string,
    language: string
  ): Promise<EmailTemplate> {
    const fs = await import('fs/promises');
    const path = await import('path');

    const templatePath = path.join(
      this.config.templates.path,
      language,
      `${name}.json`
    );

    try {
      const content = await fs.readFile(templatePath, 'utf-8');
      return JSON.parse(content);
    } catch (error) {
      // 如果找不到指定語言的模板,回退到預設語言
      if (language !== 'zh-TW') {
        return this.loadTemplate(name, 'zh-TW');
      }
      throw new Error(`Template not found: ${name}`);
    }
  }

  /**
   * 驗證信件訊息
   */
  private validateEmailMessage(message: EmailMessage): void {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

    // 驗證收件者
    const recipients = Array.isArray(message.to) ? message.to : [message.to];
    for (const email of recipients) {
      if (!emailRegex.test(email)) {
        throw new Error(`Invalid email address: ${email}`);
      }
    }

    // 驗證寄件者
    if (!emailRegex.test(message.from)) {
      throw new Error(`Invalid from address: ${message.from}`);
    }

    // 驗證主旨
    if (!message.subject || message.subject.trim().length === 0) {
      throw new Error('Email subject is required');
    }

    // 驗證內容
    if (!message.html && !message.text && !message.template) {
      throw new Error('Email must have html, text, or template');
    }
  }

  /**
   * 加入開信追蹤像素
   */
  private addTrackingPixel(html: string): string {
    const trackingDomain = this.config.tracking.domain || 'track.kyong.com';
    const messageId = this.generateMessageId();

    const pixel = `<img src="https://${trackingDomain}/open/${messageId}" width="1" height="1" style="display:none" alt="" />`;

    // 在 </body> 前插入
    return html.replace('</body>', `${pixel}</body>`);
  }

  /**
   * 加入點擊追蹤
   */
  private addClickTracking(html: string): string {
    const trackingDomain = this.config.tracking.domain || 'track.kyong.com';
    const messageId = this.generateMessageId();

    // 替換所有連結
    return html.replace(
      /<a\s+href="([^"]+)"/gi,
      (match, url) => {
        const trackedUrl = `https://${trackingDomain}/click/${messageId}?url=${encodeURIComponent(url)}`;
        return `<a href="${trackedUrl}"`;
      }
    );
  }

  /**
   * HTML 轉純文字
   */
  private htmlToText(html: string): string {
    return html
      .replace(/<style[^>]*>.*?<\/style>/gi, '')
      .replace(/<script[^>]*>.*?<\/script>/gi, '')
      .replace(/<[^>]+>/g, '')
      .replace(/\s+/g, ' ')
      .trim();
  }

  /**
   * 生成訊息 ID
   */
  private generateMessageId(): string {
    return `${Date.now()}.${Math.random().toString(36).substr(2, 9)}`;
  }

  /**
   * 記錄發送嘗試
   */
  private async logEmailAttempt(
    jobId: string,
    message: EmailMessage,
    attempt: number
  ): Promise<void> {
    // 可以存到資料庫或其他日誌系統
    console.log(`[Email] Attempt ${attempt} for job ${jobId}`, {
      to: message.to,
      subject: message.subject,
    });
  }

  /**
   * 記錄發送成功
   */
  private async logEmailSuccess(
    jobId: string,
    message: EmailMessage,
    result: EmailSendResult,
    provider: string = 'primary'
  ): Promise<void> {
    console.log(`[Email] Success for job ${jobId} via ${provider}`, {
      messageId: result.messageId,
      accepted: result.accepted,
    });

    // 發送 Webhook 通知
    if (message.metadata?.webhookUrl) {
      this.sendWebhook(message.metadata.webhookUrl, {
        event: 'delivered',
        jobId,
        messageId: result.messageId,
        timestamp: new Date().toISOString(),
      });
    }
  }

  /**
   * 記錄發送失敗
   */
  private async logEmailFailure(
    jobId: string,
    message: EmailMessage,
    error: Error
  ): Promise<void> {
    console.error(`[Email] Failed for job ${jobId}:`, error.message);

    // 發送 Webhook 通知
    if (message.metadata?.webhookUrl) {
      this.sendWebhook(message.metadata.webhookUrl, {
        event: 'failed',
        jobId,
        error: error.message,
        timestamp: new Date().toISOString(),
      });
    }
  }

  /**
   * 發送 Webhook
   */
  private async sendWebhook(url: string, data: any): Promise<void> {
    try {
      await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(data),
      });
    } catch (error) {
      console.error(`Failed to send webhook to ${url}:`, error);
    }
  }

  /**
   * 設定 Worker 事件監聽
   */
  private setupWorkerEvents(): void {
    this.worker.on('completed', (job) => {
      console.log(`[Worker] Job ${job.id} completed`);
    });

    this.worker.on('failed', (job, error) => {
      console.error(`[Worker] Job ${job?.id} failed:`, error.message);
    });

    this.worker.on('error', (error) => {
      console.error('[Worker] Error:', error);
    });
  }

  /**
   * 註冊 Handlebars 輔助函數
   */
  private registerHandlebarsHelpers(): void {
    // 日期格式化
    Handlebars.registerHelper('formatDate', (date: Date, format: string) => {
      // 簡化版,實際應使用 date-fns 或 moment
      return new Date(date).toLocaleDateString('zh-TW');
    });

    // 條件判斷
    Handlebars.registerHelper('ifEquals', function (a, b, options: any) {
      return a === b ? options.fn(this) : options.inverse(this);
    });

    // 數字格式化
    Handlebars.registerHelper('formatNumber', (num: number) => {
      return num.toLocaleString('zh-TW');
    });
  }

  /**
   * 取得隊列統計
   */
  async getQueueStats(): Promise<{
    waiting: number;
    active: number;
    completed: number;
    failed: number;
    delayed: number;
  }> {
    const [waiting, active, completed, failed, delayed] = await Promise.all([
      this.queue.getWaitingCount(),
      this.queue.getActiveCount(),
      this.queue.getCompletedCount(),
      this.queue.getFailedCount(),
      this.queue.getDelayedCount(),
    ]);

    return { waiting, active, completed, failed, delayed };
  }

  /**
   * 清理舊任務
   */
  async cleanOldJobs(olderThan: number = 7 * 24 * 3600 * 1000): Promise<void> {
    await this.queue.clean(olderThan, 100, 'completed');
    await this.queue.clean(olderThan, 100, 'failed');
  }

  /**
   * 關閉服務
   */
  async close(): Promise<void> {
    await this.worker.close();
    await this.queue.close();
    await this.redis.quit();
  }
}

interface CompiledTemplate {
  subject: HandlebarsTemplateDelegate;
  html: HandlebarsTemplateDelegate;
  text: HandlebarsTemplateDelegate | null;
}

信件模板設計

// templates/zh-TW/welcome.json
{
  "name": "welcome",
  "subject": "歡迎加入 {{companyName}}!",
  "variables": ["userName", "companyName", "activationLink"],
  "html": "<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>歡迎</title><style>body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; } .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; text-align: center; border-radius: 8px 8px 0 0; } .content { background: white; padding: 40px 30px; border: 1px solid #e0e0e0; border-top: none; } .button { display: inline-block; background: #667eea; color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; margin: 20px 0; font-weight: 600; } .footer { text-align: center; padding: 20px; color: #999; font-size: 12px; }</style></head><body><div class=\"header\"><h1>歡迎來到 {{companyName}}!</h1></div><div class=\"content\"><p>嗨 {{userName}},</p><p>感謝你加入 {{companyName}}!我們很高興能夠為你提供企業級的 OTP 驗證服務。</p><p>請點擊下方按鈕啟用你的帳號:</p><a href=\"{{activationLink}}\" class=\"button\">啟用帳號</a><p>或複製此連結到瀏覽器:<br><code>{{activationLink}}</code></p><p>如果你沒有註冊此帳號,請忽略此信件。</p><p>祝你使用愉快!<br>{{companyName}} 團隊</p></div><div class=\"footer\"><p>© {{currentYear}} {{companyName}}. All rights reserved.</p><p>如有問題,請聯絡 <a href=\"mailto:support@kyong.com\">support@kyong.com</a></p></div></body></html>",
  "text": "歡迎來到 {{companyName}}!\n\n嗨 {{userName}},\n\n感謝你加入 {{companyName}}!請點擊以下連結啟用你的帳號:\n\n{{activationLink}}\n\n如果你沒有註冊此帳號,請忽略此信件。\n\n{{companyName}} 團隊"
}
// templates/zh-TW/otp-sent.json
{
  "name": "otp-sent",
  "subject": "OTP 發送通知",
  "variables": ["tenantName", "sendCount", "successCount", "failureCount", "reportUrl"],
  "html": "<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><style>body { font-family: sans-serif; line-height: 1.6; max-width: 600px; margin: 0 auto; padding: 20px; } .header { background: #667eea; color: white; padding: 20px; text-align: center; } .stats { display: flex; justify-content: space-around; margin: 30px 0; } .stat { text-align: center; } .stat-value { font-size: 36px; font-weight: bold; color: #667eea; } .stat-label { color: #666; margin-top: 8px; }</style></head><body><div class=\"header\"><h1>📊 OTP 發送報告</h1></div><div style=\"padding: 20px;\"><p>嗨 {{tenantName}},</p><p>你的批次 OTP 發送已完成:</p><div class=\"stats\"><div class=\"stat\"><div class=\"stat-value\">{{sendCount}}</div><div class=\"stat-label\">總發送數</div></div><div class=\"stat\"><div class=\"stat-value\" style=\"color: #2ecc71;\">{{successCount}}</div><div class=\"stat-label\">成功</div></div><div class=\"stat\"><div class=\"stat-value\" style=\"color: #e74c3c;\">{{failureCount}}</div><div class=\"stat-label\">失敗</div></div></div><p><a href=\"{{reportUrl}}\" style=\"display: inline-block; background: #667eea; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;\">查看詳細報告</a></p></div></body></html>"
}

信件提供商抽象層

// packages/kyo-core/src/email/providers/ses-provider.ts
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
import type { EmailMessage, EmailSendResult, EmailProvider } from '../types';

export class SESEmailProvider implements EmailProvider {
  public readonly name = 'AWS SES';
  private client: SESClient;
  private defaultFrom: string;

  constructor(config: {
    region: string;
    accessKeyId?: string;
    secretAccessKey?: string;
    defaultFrom: string;
  }) {
    this.client = new SESClient({
      region: config.region,
      credentials: config.accessKeyId && config.secretAccessKey
        ? {
            accessKeyId: config.accessKeyId,
            secretAccessKey: config.secretAccessKey,
          }
        : undefined,
    });

    this.defaultFrom = config.defaultFrom;
  }

  async send(message: EmailMessage): Promise<EmailSendResult> {
    const recipients = Array.isArray(message.to) ? message.to : [message.to];

    const command = new SendEmailCommand({
      Source: message.from || this.defaultFrom,
      Destination: {
        ToAddresses: recipients,
      },
      Message: {
        Subject: {
          Data: message.subject,
          Charset: 'UTF-8',
        },
        Body: {
          Html: message.html
            ? {
                Data: message.html,
                Charset: 'UTF-8',
              }
            : undefined,
          Text: message.text
            ? {
                Data: message.text,
                Charset: 'UTF-8',
              }
            : undefined,
        },
      },
      ReplyToAddresses: message.replyTo ? [message.replyTo] : undefined,
      Tags: message.tags?.map((tag) => ({
        Name: 'tag',
        Value: tag,
      })),
    });

    try {
      const response = await this.client.send(command);

      return {
        messageId: response.MessageId!,
        accepted: recipients,
        rejected: [],
        response: 'OK',
      };
    } catch (error: any) {
      throw new Error(`SES send failed: ${error.message}`);
    }
  }

  async verify(): Promise<boolean> {
    try {
      // 可以使用 GetSendQuota 來驗證憑證
      return true;
    } catch {
      return false;
    }
  }
}
// packages/kyo-core/src/email/providers/smtp-provider.ts
import nodemailer from 'nodemailer';
import type { EmailMessage, EmailSendResult, EmailProvider } from '../types';

export class SMTPEmailProvider implements EmailProvider {
  public readonly name = 'SMTP';
  private transporter: nodemailer.Transporter;

  constructor(config: {
    host: string;
    port: number;
    secure: boolean;
    auth: {
      user: string;
      pass: string;
    };
  }) {
    this.transporter = nodemailer.createTransport(config);
  }

  async send(message: EmailMessage): Promise<EmailSendResult> {
    const info = await this.transporter.sendMail({
      from: message.from,
      to: message.to,
      subject: message.subject,
      html: message.html,
      text: message.text,
      replyTo: message.replyTo,
      attachments: message.attachments?.map((att) => ({
        filename: att.filename,
        content: att.content,
        contentType: att.contentType,
        encoding: att.encoding as any,
      })),
    });

    return {
      messageId: info.messageId,
      accepted: info.accepted as string[],
      rejected: info.rejected as string[],
      pending: info.pending as string[],
      response: info.response,
    };
  }

  async verify(): Promise<boolean> {
    try {
      await this.transporter.verify();
      return true;
    } catch {
      return false;
    }
  }
}

API 整合

// apps/kyo-otp-service/src/routes/email.ts
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { EmailService } from '@kyong/kyo-core/email';

const sendEmailSchema = z.object({
  to: z.union([z.string().email(), z.array(z.string().email())]),
  subject: z.string().min(1).max(200),
  template: z.string().optional(),
  data: z.record(z.any()).optional(),
  language: z.string().optional(),
  priority: z.number().int().min(-10).max(10).optional(),
  delay: z.number().int().min(0).optional(),
});

export const emailRoutes: FastifyPluginAsync = async (server) => {
  const emailService = server.emailService as EmailService;

  /**
   * 發送單一信件
   */
  server.post('/send', async (request, reply) => {
    const body = sendEmailSchema.parse(request.body);

    const result = await emailService.sendEmail(
      {
        to: body.to,
        from: 'noreply@kyong.com',
        subject: body.subject,
        template: body.template
          ? {
              name: body.template,
              data: body.data || {},
              language: body.language,
            }
          : undefined,
      },
      {
        priority: body.priority,
        delay: body.delay,
      }
    );

    return reply.code(202).send({
      success: true,
      jobId: result.jobId,
    });
  });

  /**
   * 批次發送信件
   */
  server.post('/send-bulk', async (request, reply) => {
    const body = z
      .object({
        messages: z.array(sendEmailSchema),
        batchSize: z.number().int().min(1).max(1000).optional(),
      })
      .parse(request.body);

    const result = await emailService.sendBulkEmails(
      body.messages.map((msg) => ({
        to: msg.to,
        from: 'noreply@kyong.com',
        subject: msg.subject,
        template: msg.template
          ? {
              name: msg.template,
              data: msg.data || {},
              language: msg.language,
            }
          : undefined,
      })),
      { batchSize: body.batchSize }
    );

    return reply.code(202).send({
      success: true,
      jobIds: result.jobIds,
    });
  });

  /**
   * 取得隊列狀態
   */
  server.get('/queue/stats', async (request, reply) => {
    const stats = await emailService.getQueueStats();

    return reply.send({
      success: true,
      stats,
    });
  });
};

今日總結

我們今天完成了完整的 Email 服務架構設計:

核心成就

  1. 可靠性: 重試機制 + 備用提供商 + 隊列系統
  2. 效能: 非同步處理 + 批次發送 + 並行處理
  3. 靈活性: 模板引擎 + 多語系 + 多提供商
  4. 可觀測性: 日誌記錄 + Webhook + 統計報表
  5. 安全性: HTML 清理 + 信件驗證 + 追蹤碼

技術深度分析

隊列 vs 即時發送:

  • 隊列優勢: 非阻塞、可重試、流量控制
  • 即時發送: 適合關鍵信件(驗證碼、密碼重設)
  • 💡 建議:95% 用隊列,5% 即時發送

模板引擎選擇:

  • Handlebars: 簡單、輕量、邏輯少
  • MJML: 響應式信件專用、跨平台相容性好
  • 💡 建議:Handlebars + MJML 組合

信件追蹤方式:

  • 開信追蹤: 1x1 像素圖片
  • 點擊追蹤: URL Redirect
  • 限制: 隱私問題、部分信件客戶端會阻擋

Email 服務檢查清單

  • ✅ 非同步隊列系統
  • ✅ 失敗重試機制
  • ✅ 備用提供商
  • ✅ 模板管理系統
  • ✅ HTML 安全清理
  • ✅ 多語系支援
  • ✅ 開信/點擊追蹤
  • ✅ Webhook 通知
  • ✅ 統計與監控
  • ✅ 速率限制

上一篇
Day 23: 30天打造SaaS產品後端篇-測試與 CI/CD 深度整合
下一篇
Day 25: 30天打造SaaS產品後端篇-JWT 與 Session 管理
系列文
30 天打造工作室 SaaS 產品 (後端篇)25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言