Zum Hauptinhalt springen

Vercel + Resend + Gumroad 自動化消費 E-MAIL 實作指南

📋 概述

本指南記錄了使用 Vercel Functions 與 Resend 建立自動化 Email 發送系統的實務經驗,包含踩過的坑與解決方案。

適用場景

  • 訂單確認信
  • 授權碼發送
  • 通知系統
  • Webhook 整合

技術架構

[外部服務] → [Vercel API] → [業務邏輯] → [Resend] → [用戶信箱]

[資料庫]

🛠️ 環境準備

1. 必要帳號

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

  1. 前往 Vercel Dashboard
  2. Settings → Environment Variables
  3. 添加變數(記得選擇所有環境)

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

  1. 註冊 Resend 帳號
  2. 驗證 Email
  3. 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

解決方案

  1. 確認環境變數已設定
  2. 強制重新部署:vercel --prod --force
  3. 使用備用值:process.env.API_KEY || 'backup-key'

問題 2:Resend API 無回應

症狀

Resend 後台顯示 0 請求
API 呼叫沒有錯誤但也沒有成功

解決方案

  1. 改用官方 SDK
  2. 檢查 API Key 權限
  3. 確認網域已驗證

問題 3:Email 進入垃圾信箱

解決方案

  1. 完成網域驗證(SPF、DKIM)
  2. 使用專業的發送者名稱
  3. 避免垃圾信關鍵字
  4. 加入取消訂閱連結

問題 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)
  • 設定快取頭部

🎯 總結

關鍵要點

  1. 使用官方 SDK 而非直接 API 呼叫
  2. 環境變數 需要重新部署才生效
  3. 網域驗證 是必要的
  4. 錯誤處理 要完善
  5. 安全性 永遠是第一優先

推薦工具

  • 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]