通知系統的幾層問題
直接在 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);
});- email provider 慢 → response 就慢
- email provider 掛 → 整個下單流程失敗
- 沒有重試 → 送失敗就沒了
- 沒有記錄 → 用戶說「我沒收到」你根本不知道有沒有送
通知系統要解的核心問題:送達保證、可觀測性、用戶控制。
架構: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 | 免費額度 | 特點 |
|---|---|---|
| SendGrid | 100/天 | 文件好,analytics 完整 |
| AWS SES | 62,000/月(EC2 發出) | 最便宜,但需要自己管 bounce |
| Resend | 3,000/月 | 開發者友善,modern API |
| Mailgun | 100/天 | 歷史悠久,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
