經過 Day 21-23 的測試三部曲,我們已經建立了完整的測試與 CI/CD 體系。今天我們開啟新的篇章:Email 通知服務。作為 Kyo System 企業級微服務生態系的關鍵組件,一個可靠的信件系統能大幅提升用戶體驗與產品價值。我們將深入探討信件服務的架構設計、模板引擎、隊列管理、以及可靠性保證。
在實作信件服務前,我們需要理解其核心挑戰:
/**
* Email 服務挑戰與解決方案
*
* 挑戰 1: 可靠性
* ❌ 問題: 信件伺服器可能暫時無法連接
* ✅ 解決: 重試機制 + 失敗隊列 + 備用提供商
*
* 挑戰 2: 效能
* ❌ 問題: 同步發送會阻塞 API 回應
* ✅ 解決: 非同步隊列 (Bull/BullMQ)
*
* 挑戰 3: 送達率
* ❌ 問題: 信件容易被標記為垃圾信件
* ✅ 解決: SPF/DKIM/DMARC + 信譽管理 + 預熱
*
* 挑戰 4: 模板管理
* ❌ 問題: 硬編碼 HTML 難以維護
* ✅ 解決: 模板引擎 (Handlebars/MJML)
*
* 挑戰 5: 多語系
* ❌ 問題: 不同用戶需要不同語言
* ✅ 解決: i18n 整合 + 動態模板
*
* 挑戰 6: 追蹤與分析
* ❌ 問題: 不知道信件是否被開啟/點擊
* ✅ 解決: 開信追蹤 + 點擊追蹤 + Webhook
*
* 挑戰 7: 成本控制
* ❌ 問題: 信件發送量大時成本高
* ✅ 解決: 批次發送 + 速率限制 + 合併信件
*/
/**
* Email 服務架構
*
* ┌─────────────┐
* │ API │
* │ Handler │
* └──────┬──────┘
* │
* ↓
* ┌─────────────┐ ┌──────────────┐
* │ Email │─────→│ Bull Queue │
* │ Service │ │ (Redis) │
* └─────────────┘ └──────┬───────┘
* │ │
* │ ↓
* │ ┌──────────────┐
* ├────────────→│ Worker │
* │ │ Process │
* │ └──────┬───────┘
* │ │
* ↓ ↓
* ┌─────────────┐ ┌──────────────┐
* │ Template │ │ Provider │
* │ Engine │ │ (AWS SES) │
* └─────────────┘ └──────────────┘
* │ │
* │ ↓
* │ ┌──────────────┐
* │ │ Webhook │
* └────────────→│ Handler │
* └──────────────┘
*/
// 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>;
}
// 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;
}
}
}
// 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 服務架構設計:
隊列 vs 即時發送:
模板引擎選擇:
信件追蹤方式: