通知系統的幾層問題

直接在 HTTP handler 裡呼叫 email API 有什麼問題?

// ❌ 這樣做的問題
app.post('/orders', async (req, res) => {
  const order = await orderService.create(req.body);
  await sendgrid.send({ to: user.email, subject: '訂單確認', html: template });
  res.json(order);
});
  1. email provider 慢 → response 就慢
  2. email provider 掛 → 整個下單流程失敗
  3. 沒有重試 → 送失敗就沒了
  4. 沒有記錄 → 用戶說「我沒收到」你根本不知道有沒有送

通知系統要解的核心問題:送達保證、可觀測性、用戶控制


架構:Queue + Worker + 記錄

// 下單後把通知任務丟進 queue,立刻回傳
async function handleOrderCreated(order: Order, user: User) {
  await notificationQueue.add('send-notification', {
    type: 'order-confirmation',
    userId: user.id,
    channels: ['email', 'push'],  // 多渠道
    data: { orderId: order.id, total: order.total },
  });
}
 
// Worker 負責實際發送
const notificationWorker = new Worker('send-notification', async (job) => {
  const { type, userId, channels, data } = job.data;
 
  // 查用戶的通知偏好
  const preferences = await notificationPrefRepo.findByUserId(userId);
 
  for (const channel of channels) {
    // 用戶有沒有關掉這個渠道的這種類型通知
    if (!preferences.isEnabled(channel, type)) continue;
 
    await sendByChannel(channel, type, userId, data);
  }
}, {
  connection: redis,
  defaultJobOptions: { attempts: 3, backoff: { type: 'exponential', delay: 5000 } },
});

Email 設計

Provider 選型

Provider免費額度特點
SendGrid100/天文件好,analytics 完整
AWS SES62,000/月(EC2 發出)最便宜,但需要自己管 bounce
Resend3,000/月開發者友善,modern API
Mailgun100/天歷史悠久,transactional 穩

避免使用 Gmail SMTP 發 production email——送達率差、很快被標記 spam、沒有 bounce 處理。

模板管理

// 模板存 DB 或檔案系統,支援多語系
interface EmailTemplate {
  slug: string;
  locale: string;  // 'zh-TW', 'en'
  subject: string;
  htmlBody: string;
  textBody: string;  // 純文字版,提高送達率
}
 
async function sendEmail(params: {
  to: string;
  templateSlug: string;
  locale?: string;
  variables: Record<string, string>;
}) {
  const template = await emailTemplateRepo.findBySlug(
    params.templateSlug,
    params.locale ?? 'zh-TW'
  );
 
  // 簡單的變數替換(或用 Handlebars / Mustache)
  const subject = interpolate(template.subject, params.variables);
  const html = interpolate(template.htmlBody, params.variables);
 
  await provider.send({ to: params.to, subject, html, text: interpolate(template.textBody, params.variables) });
 
  // 記錄發送
  await notificationLogRepo.create({
    type: 'email',
    templateSlug: params.templateSlug,
    recipient: params.to,
    status: 'sent',
    sentAt: new Date(),
  });
}

Bounce / Spam 處理

送出去不等於送達。Email provider 會透過 webhook 通知你送達狀態:

// SendGrid webhook:處理 bounce、spam complaint
router.post('/webhooks/sendgrid', express.raw({ type: 'application/json' }), async (req, res) => {
  const events = JSON.parse(req.body.toString());
 
  for (const event of events) {
    switch (event.event) {
      case 'bounce':
      case 'dropped':
        // 硬退信:永久封鎖這個 email 地址
        await emailSuppressRepo.upsert({
          email: event.email,
          reason: event.event,
          addedAt: new Date(),
        });
        break;
      case 'spamreport':
        // 用戶標記 spam:停止所有行銷信,保留交易信
        await notificationPrefRepo.disableMarketing(event.email);
        break;
    }
  }
 
  res.status(200).json({ received: true });
});
 
// 發送前先查 suppression list
async function isEmailSuppressed(email: string): Promise<boolean> {
  return !!(await emailSuppressRepo.findOne({ where: { email } }));
}

Push Notification(行動裝置)

// 用 Firebase Cloud Messaging(FCM)
import { initializeApp } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
 
// 儲存用戶的 device token
interface DeviceToken {
  userId: string;
  token: string;
  platform: 'ios' | 'android' | 'web';
  createdAt: Date;
}
 
async function sendPushNotification(params: {
  userId: string;
  title: string;
  body: string;
  data?: Record<string, string>;
}) {
  const tokens = await deviceTokenRepo.findByUserId(params.userId);
  if (tokens.length === 0) return;
 
  const messaging = getMessaging();
 
  // 批次發送
  const response = await messaging.sendEachForMulticast({
    tokens: tokens.map(t => t.token),
    notification: { title: params.title, body: params.body },
    data: params.data,
  });
 
  // 清理失效的 token
  response.responses.forEach((result, index) => {
    if (!result.success) {
      const error = result.error;
      if (
        error?.code === 'messaging/invalid-registration-token' ||
        error?.code === 'messaging/registration-token-not-registered'
      ) {
        // Token 已失效,刪掉
        deviceTokenRepo.delete(tokens[index].id);
      }
    }
  });
}

Token 管理

  • App 啟動時更新 token(token 會定期 rotate)
  • 用戶登出時刪除 token
  • 送失敗時清理失效 token

退訂管理

用戶必須能夠退訂,而且退訂要立刻生效:

// 通知偏好 schema
CREATE TABLE notification_preferences (
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  channel VARCHAR(20) NOT NULL,  -- 'email', 'push', 'sms'
  type VARCHAR(50) NOT NULL,     -- 'marketing', 'transactional', 'order-status'
  enabled BOOLEAN NOT NULL DEFAULT true,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (user_id, channel, type)
);
 
// 退訂 API
router.post('/notifications/unsubscribe', authenticate, async (req, res) => {
  const { channel, type } = req.body;
 
  await notificationPrefRepo.upsert({
    userId: req.user.id,
    channel,
    type,
    enabled: false,
  });
 
  res.json({ success: true });
});

法規要求

  • GDPR:用戶有權要求停止所有行銷通知
  • CAN-SPAM:行銷 email 必須有退訂連結,退訂後 10 個工作天內生效
  • 交易通知(訂單確認、密碼重設):不需要退訂選項,但不能包含行銷內容

Email 退訂連結要帶 signed token,不需要用戶登入就能退訂:

// 產生退訂連結
function generateUnsubscribeUrl(userId: string, type: string): string {
  const token = jwt.sign({ userId, type, action: 'unsubscribe' }, process.env.JWT_SECRET, { expiresIn: '30d' });
  return `${process.env.APP_URL}/unsubscribe?token=${token}`;
}
 
// 退訂 endpoint(不需要登入)
router.get('/unsubscribe', async (req, res) => {
  const { token } = req.query;
  const payload = jwt.verify(token as string, process.env.JWT_SECRET);
  await notificationPrefRepo.disable(payload.userId, 'email', payload.type);
  res.send('已成功退訂');
});

通知記錄與可觀測性

// 每封 notification 都要記錄
CREATE TABLE notification_logs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id),
  channel VARCHAR(20) NOT NULL,
  type VARCHAR(50) NOT NULL,
  status VARCHAR(20) NOT NULL,  -- 'sent', 'delivered', 'bounced', 'failed'
  provider VARCHAR(50),
  provider_message_id VARCHAR(255),  -- provider 回傳的 ID,方便追蹤
  error TEXT,
  sent_at TIMESTAMPTZ,
  delivered_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

用戶說「我沒收到通知」的時候,你能立刻查到:

  • 有沒有送出去
  • 什麼時候送
  • provider 說送達了沒
  • 有沒有 bounce

延伸閱讀