Vercel + Resend + Gumroad 自動化消費 E-MAIL 實作指南
📋 概述
本指南記錄了使用 Vercel Functions 與 Resend 建立自動化 Email 發送系統的實務經驗,包含踩過的坑與解決方案。
適用場景
- 訂單確認信
- 授權碼發送
- 通知系統
- Webhook 整合
技術架構
[外部服務] → [Vercel API] → [業務邏輯] → [Resend] → [用戶信箱]
↓
[資料庫]
🛠️ 環境準備
1. 必要帳號
- Vercel: https://vercel.com
- Resend: https://resend.com
- Upstash Redis: https://upstash.com (選用)
2. 專案結構
project/
├── api/
│ ├── webhook.js # Webhook 端點
│ └── test-webhook.js # 測試工具
├── lib/
│ ├── email.js # Email 服務
│ └── database.js # 資料庫連接
├── public/
│ └── assets/ # 靜態資源
├── package.json
└── vercel.json
3. package.json 設定
{
"name": "email-automation-system",
"version": "1.0.0",
"type": "module", // 重要:使用 ES Modules
"dependencies": {
"@upstash/redis": "^1.28.0",
"resend": "^6.0.2"
},
"devDependencies": {
"vercel": "^32.0.0"
}
}
🚀 Vercel 專案設置
1. 初始化專案
# 安裝 Vercel CLI
npm i -g vercel
# 初始化專案
vercel
# 連結現有專案
vercel link
2. 環境變數設定
方法一:透過 CLI
vercel env add RESEND_API_KEY production
vercel env add DATABASE_URL production
方法二:透過 Dashboard
- 前往 Vercel Dashboard
- Settings → Environment Variables
- 添加變數(記得選擇所有環境)
3. 常見環境變數問題
問題:環境變數讀不到
// ❌ 錯誤:可能讀到 undefined
const apiKey = process.env.API_KEY;
// ✅ 正確:加入備用機制
const apiKey = process.env.API_KEY || 'default-key';
解決方案檢查清單
- 環境變數名稱是否正確
- 是否重新部署
- 環境(production/preview/development)是否正確
- 使用
vercel env pull
測試
4. 部署保護問題
如遇到「Authentication Required」錯誤:
// 在 API 中加入 CORS 頭部
export default async function handler(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
// ...
}
或關閉部署保護: Settings → Deployment Protection → Disabled
📧 Resend 整合
1. 取得 API Key
- 註冊 Resend 帳號
- 驗證 Email
- Dashboard → API Keys → Create API Key
2. 網域驗證(重要!)
1. Domains → Add Domain
2. 輸入您的網域
3. 添加 DNS 記錄:
- SPF: TXT record
- DKIM: TXT record
- MX: Mail record (選用)
4. 等待驗證(通常 5-30 分鐘)
3. SDK vs API 呼叫
❌ 直接 API 呼叫(不推薦)
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({...})
});
✅ 使用官方 SDK(推薦)
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.emails.send({
from: '[email protected]',
to: ['[email protected]'],
subject: 'Hello',
html: '<p>Email content</p>'
});
4. Email 模板最佳實踐
function getEmailTemplate(data) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
/* 內嵌 CSS 確保相容性 */
.container { max-width: 600px; margin: 0 auto; }
/* 避免使用外部 CSS */
</style>
</head>
<body>
<!-- 使用 table 佈局提高相容性 -->
<table class="container">
<!-- 內容 -->
</table>
</body>
</html>
`;
}
📦 Gumroad Webhook 整合
實際發現與解決方案
1. Webhook Headers 問題
發現:Gumroad 不會發送簽名頭(gumroad-signature
)
// 實際收到的 headers(沒有 gumroad-signature)
[
'x-vercel-proxy-signature',
'user-agent',
'content-type',
'host',
// ... 其他 Vercel headers,但沒有 Gumroad 簽名
]
解決方案:改為檢查必要欄位
function verifyGumroadSignature(req) {
const signature = req.headers['gumroad-signature'];
if (!signature) {
// 檢查必要欄位來驗證請求有效性
const hasRequiredFields = req.body &&
req.body.sale_id &&
req.body.email &&
req.body.product_id;
return hasRequiredFields;
}
return true;
}
2. 欄位名稱差異
發現:
- 使用
disputed
而非dispute
- 布林值是字串格式("true"/"false")
- 可能沒有
license_key
欄位
實際資料格式:
{
"sale_id": "omZ7ovS1rAifmdTIEG2E6Q==",
"product_id": "MdpyDjIsa47IN4QVptZ7QQ==",
"email": "[email protected]",
"test": "true", // 字串,非布林
"refunded": "false", // 字串,非布林
"disputed": "false", // 注意:disputed 而非 dispute
"dispute_won": "false",
// license_key 可能不存在
}
解決方案:
// 正確的布林值判斷
const isRefunded = refunded === 'true' || refunded === true;
const isDisputed = disputed === 'true' || disputed === true;
const isTestMode = test === 'true' || test === true;
// 處理缺少 license_key
if (gumroadData.license_key) {
// 使用 license_key 映射
const mappingKey = `gumroad:${gumroadData.license_key}`;
} else {
// 使用 sale_id 作為備用映射
const mappingKey = `gumroad:sale:${gumroadData.sale_id}`;
}
3. 訂單處理建議
// 儲存完整的 Gumroad 資料以便追蹤
await redis.set(orderKey, {
serial: serial,
processed_at: new Date().toISOString(),
test_mode: isTestMode,
gumroad_data: {
sale_id,
order_number,
product_id,
license_key: license_key || 'N/A'
}
});
4. 完整的錯誤處理流程
try {
// 驗證請求
if (!verifyGumroadSignature(req)) {
return res.status(401).json({ error: 'Invalid request' });
}
// 記錄所有請求以便除錯
console.log('===== Gumroad Webhook 開始處理 =====');
console.log('收到的 Headers:', Object.keys(req.headers));
console.log('收到的資料:', JSON.stringify(req.body, null, 2));
// 處理業務邏輯...
} catch (error) {
console.error('Webhook 處理錯誤:', error);
// 即使發生錯誤也要回應 200,避免 Gumroad 重試
return res.status(200).json({
success: false,
error: 'Internal error'
});
}
5. Gumroad 測試模式處理
// 測試購買會有 test: "true"
if (test === 'true') {
console.log('⚠️ 測試模式購買');
// 可以選擇:
// 1. 正常處理但標記為測試
// 2. 生成特殊的測試序號
// 3. 跳過 Email 發送
}
🐛 常見問題與解決方案
問題 1:環境變數在 Vercel 讀不到
症狀:
日誌顯示:未設定 Email 服務
process.env.API_KEY 返回 undefined
解決方案:
- 確認環境變數已設定
- 強制重新部署:
vercel --prod --force
- 使用備用值:
process.env.API_KEY || 'backup-key'
問題 2:Resend API 無回應
症狀:
Resend 後台顯示 0 請求
API 呼叫沒有錯誤但也沒有成功
解決方案:
- 改用官方 SDK
- 檢查 API Key 權限
- 確認網域已驗證
問題 3:Email 進入垃圾信箱
解決方案:
- 完成網域驗證(SPF、DKIM)
- 使用專業的發送者名稱
- 避免垃圾信關鍵字
- 加入取消訂閱連結
問題 4:Webhook 簽名驗證失敗
解決方案:
// 開發環境可以暫時跳過
if (process.env.NODE_ENV === 'development') {
console.warn('跳過簽名驗證(開發環境)');
return true;
}
// 生產環境實作簽名驗證
const signature = req.headers['x-webhook-signature'];
const isValid = verifySignature(req.body, signature, secret);
✨ 最佳實踐
1. 錯誤處理
try {
const result = await sendEmail(data);
return res.status(200).json({ success: true });
} catch (error) {
console.error('Error:', error);
// 不要洩漏詳細錯誤給用戶
return res.status(500).json({
error: '處理失敗',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
2. 速率限制
// 使用 Redis 實作速率限制
const key = `rate:${email}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, 3600); // 1 小時
}
if (count > 10) {
return res.status(429).json({ error: '請求過於頻繁' });
}
3. 冪等性處理
// 避免重複處理
const orderKey = `order:${orderId}`;
const existing = await redis.get(orderKey);
if (existing) {
return res.status(200).json({
message: '已處理',
result: existing
});
}
4. 日誌記錄
// 記錄但不包含敏感資訊
console.log('Processing order:', {
orderId: order.id,
email: order.email.replace(/(.{2}).*(@.*)/, '$1***$2'),
timestamp: new Date().toISOString()
});
🔒 安全建議
1. API Key 管理
- 永遠不要將 API Key 寫死在程式碼
- 使用環境變數
- 定期輪換 Key
- 設定 Key 權限範圍
2. 輸入驗證
// Email 格式驗證
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ error: '無效的 Email' });
}
3. 測試端點保護
// 密碼保護測試頁面
const password = process.env.TEST_PASSWORD || 'default-pwd';
if (req.query.pwd !== password) {
return res.status(404).json({ error: 'Not found' });
}
4. HTTPS Only
確保所有通訊都使用 HTTPS:
- Webhook URL 使用 https://
- 圖片資源使用 https://
- API 呼叫使用 https://
💡 實戰範例
完整的 Webhook 處理器
export default async function handler(req, res) {
// 1. 方法檢查
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method Not Allowed' });
}
try {
// 2. 解析資料
const { email, productName, orderId } = req.body;
// 3. 驗證輸入
if (!email || !orderId) {
return res.status(400).json({ error: '缺少必要參數' });
}
// 4. 冪等性檢查
const existing = await checkExisting(orderId);
if (existing) {
return res.status(200).json({
success: true,
message: '已處理'
});
}
// 5. 業務邏輯
const result = await processOrder(orderId);
// 6. 發送 Email
const emailSent = await sendEmail(email, result);
// 7. 記錄結果
await saveResult(orderId, result);
// 8. 回應
return res.status(200).json({
success: true,
emailSent,
orderId
});
} catch (error) {
// 9. 錯誤處理
console.error('Webhook error:', error);
await logError(error);
return res.status(500).json({
error: '處理失敗',
message: process.env.NODE_ENV === 'development'
? error.message
: '請稍後再試'
});
}
}
📈 效能優化
1. 快取策略
// 使用 Redis 快取常用資料
const cacheKey = `template:${templateId}`;
let template = await redis.get(cacheKey);
if (!template) {
template = await loadTemplate(templateId);
await redis.set(cacheKey, template, { ex: 3600 });
}
2. 批次處理
// 批次發送 Email
const chunks = emails.reduce((acc, email, i) => {
const chunkIndex = Math.floor(i / 100);
if (!acc[chunkIndex]) acc[chunkIndex] = [];
acc[chunkIndex].push(email);
return acc;
}, []);
for (const chunk of chunks) {
await Promise.all(chunk.map(sendEmail));
await sleep(1000); // 避免超過速率限制
}
3. 圖片優化
- 使用 CDN 託管圖片
- 壓縮圖片大小
- 使用適當的圖片格式(WebP、PNG)
- 設定快取頭部
🎯 總結
關鍵要點
- 使用官方 SDK 而非直接 API 呼叫
- 環境變數 需要重新部署才生效
- 網域驗證 是必要的
- 錯誤處理 要完善
- 安全性 永遠是第一優先
推薦工具
- Vercel CLI: 部署管理
- Postman: API 測試
- Redis Insight: 資料庫管理
- Resend Dashboard: Email 監控
相關資源
📝 更新記錄
-
2025/08/31 v3.1: 新增 Gumroad 整合經驗
- Gumroad webhook 實際資料格式
- 欄位名稱差異處理(disputed vs dispute)
- 字串布林值處理方案
- 缺少 license_key 的備用方案
- 添加 HMEA 標頭格式
-
2025/08/31 v3.0: 初版發布
- 包含 Vercel + Resend 整合經驗
- 環境變數問題解決方案
- Email 模板最佳實踐
作者: rj0217 × Claude 4.1 Opus
專案: RagnarokMonitor PAID Edition
授權: MIT License
貢獻: 歡迎提交 Issue 和 PR
免責聲明
本指南基於實際專案經驗整理,但每個專案情況不同,請根據實際需求調整並獲取更多相關知識。作者不對使用本指南造成的任何損失負責。
[END OF GUIDE]