電子報系統架構:多語言郵件自動化
使用 Supabase、Resend 和 GitHub Actions 建構完整的電子報系統。學習資料庫結構設計、訂閱流程、多語言支援、GDPR 合規性以及自動化發送管道。
建構電子報系統表面上看起來很簡單,但要達到生產環境等級的實作,需要仔細考量多語言支援、安全性、合規性和自動化。本指南將完整介紹 claude-world.com 用來服務三種語言訂閱者的架構。
為什麼要自建電子報系統?
大多數開發者會選擇第三方服務如 Mailchimp 或 ConvertKit。雖然這些對於簡單的使用情境很好用,但自建系統有顯著的優勢:
| 方案 | 成本(1,000 訂閱) | 彈性 | 資料控制 |
|---|---|---|---|
| Mailchimp | ~$15/月 | 有限 | 第三方 |
| 自建(Supabase + Resend) | ~$3/月 | 完全 | 自有 |
自建方案的主要優勢:
- 完全控制訂閱者資料和發送邏輯
- 多語言支援不需要按語言計費
- 整合現有的資料庫和工作流程
- 無供應商鎖定你的訂閱者名單
- 自動化透過 GitHub Actions 實現基於版本發布的電子報
系統架構概覽
電子報系統由四個相互連接的元件組成:
+-------------------+ +-------------------+ +-------------------+
| Subscription | | Sending | | Unsubscription |
| Form |--->| Script |<---| Handler |
| (React/Astro) | | (Node.js) | | (API Route) |
+---------+---------+ +---------+---------+ +---------+---------+
| | |
v v v
+-----------------------------------------------------------------+
| Supabase (PostgreSQL) |
| +-----------------------------------------------------------+ |
| | newsletter_subscribers (id, email, language, status) | |
| +-----------------------------------------------------------+ |
+-----------------------------------------------------------------+
每個元件有特定的職責:
- 訂閱表單:收集郵件地址和語言偏好
- 發送腳本:透過 Resend API 批次發送
- 取消訂閱處理器:一鍵取消訂閱 + 偏好設定中心
- 自動化:GitHub Actions 從版本發布觸發電子報生成
1. 資料庫結構設計
資料庫是系統的基礎。良好設計的結構能實現高效查詢、適當的安全性和 GDPR 合規性。
訂閱者資料表
-- supabase/migrations/001_newsletter_subscribers.sql
CREATE TABLE IF NOT EXISTS newsletter_subscribers (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
source TEXT DEFAULT 'website',
preferred_language VARCHAR(10) DEFAULT 'en'
CHECK (preferred_language IN ('en', 'zh-tw', 'ja')),
subscribed_at TIMESTAMPTZ DEFAULT NOW(),
confirmed BOOLEAN DEFAULT FALSE,
unsubscribed_at TIMESTAMPTZ
);
-- 加速查詢的索引
CREATE INDEX IF NOT EXISTS idx_newsletter_email ON newsletter_subscribers(email);
CREATE INDEX IF NOT EXISTS idx_newsletter_source ON newsletter_subscribers(source);
CREATE INDEX IF NOT EXISTS idx_newsletter_language ON newsletter_subscribers(preferred_language);
CREATE INDEX IF NOT EXISTS idx_newsletter_active ON newsletter_subscribers(confirmed, unsubscribed_at)
WHERE confirmed = true AND unsubscribed_at IS NULL;
-- 文件說明註解
COMMENT ON TABLE newsletter_subscribers IS '電子報訂閱者名單';
COMMENT ON COLUMN newsletter_subscribers.preferred_language IS '電子報內容語言偏好';
COMMENT ON COLUMN newsletter_subscribers.source IS '使用者註冊來源(網站、活動等)';
設計決策說明:
- UUID 主鍵:防止連續 ID 列舉攻擊
source欄位:追蹤獲取管道以供分析preferred_language搭配 CHECK 約束:在資料庫層級強制有效的語言代碼unsubscribed_at取代刪除:軟刪除保留稽核軌跡並允許重新訂閱- 活躍訂閱者的部分索引:優化最常見的查詢模式
Row Level Security(RLS)
安全性對保護訂閱者資料至關重要:
-- 啟用 RLS
ALTER TABLE newsletter_subscribers ENABLE ROW LEVEL SECURITY;
-- 允許匿名插入(用於註冊)
CREATE POLICY "Allow anonymous inserts" ON newsletter_subscribers
FOR INSERT TO anon
WITH CHECK (true);
-- 拒絕匿名讀取(保護郵件清單)
CREATE POLICY "Deny anonymous reads" ON newsletter_subscribers
FOR SELECT TO anon
USING (false);
-- 允許更新以進行取消訂閱/偏好設定(應用層驗證)
CREATE POLICY "Allow anonymous updates" ON newsletter_subscribers
FOR UPDATE TO anon
USING (true);
為什麼是這些政策?
- 允許插入:使用者必須能夠在未認證的情況下註冊
- 拒絕讀取:保護郵件清單免於列舉攻擊
- 允許更新:允許未認證的取消訂閱(在應用層驗證)
合規性稽核資料表
GDPR 要求追蹤所有資料處理活動:
-- 追蹤所有訂閱事件以符合合規性
CREATE TABLE IF NOT EXISTS newsletter_audit (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
subscriber_id UUID REFERENCES newsletter_subscribers(id),
action TEXT NOT NULL CHECK (action IN ('subscribe', 'unsubscribe', 'update_language', 'resubscribe')),
ip_hash TEXT, -- 為隱私進行雜湊處理
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_subscriber ON newsletter_audit(subscriber_id);
CREATE INDEX IF NOT EXISTS idx_audit_created ON newsletter_audit(created_at);
ALTER TABLE newsletter_audit ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow anonymous inserts" ON newsletter_audit
FOR INSERT TO anon WITH CHECK (true);
為什麼要雜湊 IP? 儲存原始 IP 地址會在 GDPR 下產生責任。雜湊處理保留了偵測濫用模式的能力,同時保護隱私。
2. 訂閱流程
訂閱流程處理新註冊、重新訂閱,並防止郵件列舉攻擊。
Supabase 客戶端
// src/lib/supabase.ts
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL || '';
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY || '';
let supabase: SupabaseClient | null = null;
export function getSupabaseClient(): SupabaseClient | null {
if (!supabaseUrl || !supabaseAnonKey) {
console.warn('Supabase credentials not configured');
return null;
}
if (!supabase) {
supabase = createClient(supabaseUrl, supabaseAnonKey);
}
return supabase;
}
export type SupportedLanguage = 'en' | 'zh-tw' | 'ja';
export interface NewsletterSubscriber {
id?: string;
email: string;
source: string;
preferred_language: SupportedLanguage;
subscribed_at?: string;
confirmed?: boolean;
unsubscribed_at?: string | null;
}
支援重新訂閱的訂閱函式
/**
* 訂閱電子報並設定語言偏好
* 使用單次確認(立即確認)
*/
export async function subscribeToNewsletter(
email: string,
source: string = 'website',
language: SupportedLanguage = 'en'
): Promise<{ success: boolean; error?: string; subscriberId?: string }> {
// 驗證郵件
if (!email || !email.includes('@')) {
return { success: false, error: 'Invalid email address' };
}
const client = getSupabaseClient();
if (!client) {
console.log('Newsletter subscription (no Supabase):', { email, source, language });
return { success: true };
}
try {
// 檢查是否已訂閱
const { data: existing } = await client
.from('newsletter_subscribers')
.select('id, unsubscribed_at, preferred_language')
.eq('email', email.toLowerCase())
.single();
if (existing) {
if (existing.unsubscribed_at) {
// 重新訂閱:清除取消訂閱狀態
const { error } = await client
.from('newsletter_subscribers')
.update({
unsubscribed_at: null,
source,
preferred_language: language,
confirmed: true,
})
.eq('id', existing.id);
if (error) throw error;
return { success: true, subscriberId: existing.id };
}
// 已訂閱 - 回傳成功(防止郵件列舉)
return { success: true };
}
// 新訂閱
const { data, error } = await client
.from('newsletter_subscribers')
.insert({
email: email.toLowerCase(),
source,
preferred_language: language,
confirmed: true, // 單次確認
})
.select('id')
.single();
if (error) throw error;
return { success: true, subscriberId: data?.id };
} catch (err) {
console.error('Newsletter subscription error:', err);
return { success: false, error: 'Failed to subscribe. Please try again.' };
}
}
安全性考量:函式對已訂閱的郵件回傳 success: true。這防止攻擊者使用 API 來列舉你資料庫中有哪些郵件。
具有速率限制的 API 路由
// src/pages/api/newsletter.ts
import type { APIRoute } from 'astro';
import { subscribeToNewsletter, type SupportedLanguage } from '../../lib/supabase';
import { checkRateLimit, getClientId, rateLimitResponse, RATE_LIMIT_CONFIGS } from '../../lib/rate-limit';
import { validateOrigin, csrfForbiddenResponse, SECURITY_HEADERS } from '../../lib/security';
export const prerender = false;
const SUPPORTED_LANGUAGES: SupportedLanguage[] = ['en', 'zh-tw', 'ja'];
export const POST: APIRoute = async ({ request }) => {
// CSRF 保護
if (!validateOrigin(request)) {
return csrfForbiddenResponse();
}
// 速率限制(每分鐘 5 次請求)
const clientId = getClientId(request);
const rateLimitResult = checkRateLimit(clientId, 'newsletter', RATE_LIMIT_CONFIGS.strict);
if (!rateLimitResult.allowed) {
return rateLimitResponse(rateLimitResult);
}
try {
const { email, source = 'website', language = 'en' } = await request.json();
if (!email) {
return new Response(
JSON.stringify({ error: 'Email is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
}
const validLanguage: SupportedLanguage = SUPPORTED_LANGUAGES.includes(language)
? language
: 'en';
const result = await subscribeToNewsletter(email, source, validLanguage);
if (result.success) {
return new Response(
JSON.stringify({ message: 'Successfully subscribed!', subscriberId: result.subscriberId }),
{ status: 200, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
} else {
return new Response(
JSON.stringify({ error: result.error }),
{ status: 400, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
}
} catch (error) {
console.error('Newsletter API error:', error);
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{ status: 500, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
}
};
3. 多語言支援
多語言支援需要在系統的每一層進行仔細設計。
具有語言選擇的 React 表單
// src/components/NewsletterForm.tsx
import { useState, useEffect } from 'react';
interface NewsletterFormProps {
source?: string;
defaultLanguage?: string;
showLanguageSelector?: boolean;
lang?: 'en' | 'zh-tw' | 'ja';
}
const LANGUAGES = [
{ code: 'zh-tw', label: '繁體中文', flag: 'TW' },
{ code: 'en', label: 'English', flag: 'US' },
{ code: 'ja', label: '日本語', flag: 'JP' },
] as const;
const i18nLabels = {
en: {
placeholder: 'you@email.com',
button: 'Subscribe',
buttonLoading: 'Joining...',
success: 'Welcome aboard! Check your inbox for confirmation.',
error: 'Something went wrong. Please try again.',
help: 'Weekly insights on Claude Code mastery. No spam, unsubscribe anytime.',
},
'zh-tw': {
placeholder: 'you@email.com',
button: '訂閱',
buttonLoading: '處理中...',
success: '訂閱成功!請檢查您的收件匣。',
error: '發生錯誤,請稍後再試。',
help: '每週 Claude Code 進階技巧。不發垃圾郵件,隨時可取消訂閱。',
},
ja: {
placeholder: 'you@email.com',
button: '購読',
buttonLoading: '処理中...',
success: '購読完了!確認メールをご確認ください。',
error: 'エラーが発生しました。もう一度お試しください。',
help: '毎週 Claude Code の上級テクニックをお届けします。スパムなし、いつでも解除可能。',
},
};
export default function NewsletterForm({
source = 'website',
defaultLanguage,
showLanguageSelector = true,
lang = 'en',
}: NewsletterFormProps) {
const [email, setEmail] = useState('');
const [language, setLanguage] = useState(defaultLanguage || 'en');
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [message, setMessage] = useState('');
const labels = i18nLabels[lang] || i18nLabels.en;
// 從頁面 URL 自動偵測語言
useEffect(() => {
if (!defaultLanguage && typeof window !== 'undefined') {
const path = window.location.pathname;
if (path.startsWith('/zh-tw')) setLanguage('zh-tw');
else if (path.startsWith('/ja')) setLanguage('ja');
}
}, [defaultLanguage]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !email.includes('@')) {
setStatus('error');
setMessage('Please enter a valid email address.');
return;
}
setStatus('loading');
try {
const response = await fetch('/api/newsletter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, source, language }),
});
if (response.ok) {
setStatus('success');
setMessage(labels.success);
setEmail('');
} else {
const data = await response.json();
setStatus('error');
setMessage(data.error || labels.error);
}
} catch {
setStatus('error');
setMessage('Network error. Please try again.');
}
};
return (
<form onSubmit={handleSubmit} className="w-full max-w-md">
{showLanguageSelector && (
<div className="mb-4">
<div className="flex gap-2">
{LANGUAGES.map((l) => (
<button
key={l.code}
type="button"
onClick={() => setLanguage(l.code)}
className={`px-3 py-2 rounded-lg border ${
language === l.code
? 'border-blue-500 bg-blue-500/10'
: 'border-gray-300'
}`}
>
[{l.flag}] {l.label}
</button>
))}
</div>
</div>
)}
<div className="flex gap-3">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={labels.placeholder}
className="flex-1 px-4 py-3 border rounded-lg"
disabled={status === 'loading'}
/>
<button
type="submit"
disabled={status === 'loading'}
className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{status === 'loading' ? labels.buttonLoading : labels.button}
</button>
</div>
{message && (
<p className={`mt-3 text-sm ${status === 'success' ? 'text-green-600' : 'text-red-600'}`}>
{message}
</p>
)}
<p className="mt-3 text-sm text-gray-500">{labels.help}</p>
</form>
);
}
主要功能:
- 基於 URL 的語言偵測:根據
/zh-tw/或/ja/URL 前綴自動選擇語言 - 明確的語言選擇器:使用者可以覆蓋偵測到的語言
- 在地化 UI:所有表單標籤會根據選擇的介面語言調整
4. 使用 Resend 發送電子報
Resend 提供優秀的開發者體驗和批次發送支援。
發送腳本
// scripts/send-newsletter.js
import { Resend } from 'resend';
import { createClient } from '@supabase/supabase-js';
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, '..');
// 語言設定
const LANGUAGES = {
'zh-tw': {
label: '繁體中文',
defaultSubject: (date) => `[電子報] 每週精選 - ${date}`,
},
'en': {
label: 'English',
defaultSubject: (date) => `[Newsletter] Weekly Highlights - ${date}`,
},
'ja': {
label: '日本語',
defaultSubject: (date) => `[ニュースレター] 今週のハイライト - ${date}`,
},
};
// 初始化客戶端
const resend = new Resend(process.env.RESEND_API_KEY);
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_KEY // 使用 service key 來讀取所有訂閱者
);
async function sendNewsletter(targetDate, filterLang = null) {
console.log('Newsletter Sender (Multi-Language)\n');
// 驗證環境變數
if (!process.env.RESEND_API_KEY || !process.env.SUPABASE_SERVICE_KEY) {
console.error('Missing required environment variables');
process.exit(1);
}
// 尋找電子報目錄
const draftsDir = join(projectRoot, 'newsletters/drafts');
if (!targetDate) {
const dirs = readdirSync(draftsDir)
.filter(d => /^\d{4}-\d{2}-\d{2}/.test(d))
.sort()
.reverse();
targetDate = dirs[0];
console.log(`Using most recent: ${targetDate}`);
}
const newsletterDir = join(draftsDir, targetDate);
if (!existsSync(newsletterDir)) {
console.error(`Newsletter not found: ${targetDate}`);
process.exit(1);
}
// 檢查是否已發送(防止重複發送)
const sentLogPath = join(projectRoot, 'newsletters/sent', targetDate, 'send-log.json');
if (existsSync(sentLogPath) && !filterLang) {
console.warn(`Already sent for ${targetDate}`);
process.exit(1);
}
// 載入每種語言的電子報檔案
const newsletters = {};
const subjects = {};
const subjectFile = join(newsletterDir, 'subject-lines.txt');
let subjectLines = existsSync(subjectFile) ? readFileSync(subjectFile, 'utf-8') : '';
for (const [lang, config] of Object.entries(LANGUAGES)) {
if (filterLang && filterLang !== lang) continue;
const filePath = join(newsletterDir, `newsletter-${lang}.html`);
if (existsSync(filePath)) {
newsletters[lang] = readFileSync(filePath, 'utf-8');
const match = subjectLines.match(new RegExp(`${lang}:\\s*(.+)`));
subjects[lang] = match ? match[1].trim() : config.defaultSubject(targetDate);
console.log(`Loaded ${config.label}`);
}
}
// 取得按語言分組的活躍訂閱者
let query = supabase
.from('newsletter_subscribers')
.select('id, email, preferred_language')
.eq('confirmed', true)
.is('unsubscribed_at', null);
if (filterLang) {
query = query.eq('preferred_language', filterLang);
}
const { data: subscribers, error } = await query;
if (error || !subscribers?.length) {
console.log('No subscribers to send to');
process.exit(0);
}
// 按語言分組訂閱者
const subscribersByLang = {};
for (const sub of subscribers) {
const lang = newsletters[sub.preferred_language] ? sub.preferred_language : 'en';
if (!subscribersByLang[lang]) subscribersByLang[lang] = [];
subscribersByLang[lang].push(sub);
}
console.log(`\n${subscribers.length} 位訂閱者:`);
for (const [lang, subs] of Object.entries(subscribersByLang)) {
console.log(` ${LANGUAGES[lang]?.label}: ${subs.length}`);
}
// 批次發送(Resend 限制:每批 100 封)
const results = { successful: 0, failed: 0, byLanguage: {} };
const batchSize = 100;
for (const [lang, langSubscribers] of Object.entries(subscribersByLang)) {
if (!newsletters[lang]) continue;
console.log(`\n發送 ${LANGUAGES[lang]?.label}...`);
results.byLanguage[lang] = { successful: 0, failed: 0 };
for (let i = 0; i < langSubscribers.length; i += batchSize) {
const batch = langSubscribers.slice(i, i + batchSize);
try {
const emailPayloads = batch.map(sub => {
const unsubscribeUrl = `https://claude-world.com/unsubscribe?id=${sub.id}&email=${encodeURIComponent(sub.email)}`;
return {
from: 'Newsletter <newsletter@claude-world.com>',
to: sub.email,
subject: subjects[lang],
html: newsletters[lang].replace(/\{\{unsubscribe_url\}\}/g, unsubscribeUrl),
};
});
await resend.batch.send(emailPayloads);
results.successful += batch.length;
results.byLanguage[lang].successful += batch.length;
console.log(` 批次:${batch.length} 封已發送`);
} catch (err) {
results.failed += batch.length;
results.byLanguage[lang].failed += batch.length;
console.error(` 批次失敗:${err.message}`);
}
// 批次之間的速率限制
if (i + batchSize < langSubscribers.length) {
await new Promise(r => setTimeout(r, 1000));
}
}
}
// 儲存發送記錄
const sentDir = join(projectRoot, 'newsletters/sent', targetDate);
mkdirSync(sentDir, { recursive: true });
const log = {
sentAt: new Date().toISOString(),
newsletterDate: targetDate,
subjects,
totalSent: subscribers.length,
successful: results.successful,
failed: results.failed,
byLanguage: results.byLanguage,
};
writeFileSync(join(sentDir, 'send-log.json'), JSON.stringify(log, null, 2));
console.log(`
========================================
發送完成
成功:${results.successful}
失敗:${results.failed}
========================================
`);
}
// 解析命令列參數
const args = process.argv.slice(2);
let dateArg = args.find(a => /^\d{4}-\d{2}-\d{2}$/.test(a));
let langArg = args.includes('--lang') ? args[args.indexOf('--lang') + 1] : null;
sendNewsletter(dateArg, langArg);
重要考量:
- Service key vs anon key:發送腳本使用
SUPABASE_SERVICE_KEY來繞過 RLS 並讀取所有訂閱者 - 批次大小:Resend 限制批次發送為 100 封郵件;腳本會自動處理這個限制
- 速率限制:批次之間延遲 1 秒防止 API 限流
- 發送記錄:防止意外的重複發送並提供稽核軌跡
電子報 HTML 範本
<!-- newsletters/templates/base.html -->
<!DOCTYPE html>
<html lang="{{lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{subject}}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; padding: 20px 0; }
.content { padding: 20px 0; }
.footer { text-align: center; padding: 20px 0; color: #666; font-size: 12px; }
.button { display: inline-block; padding: 12px 24px; background: #007bff; color: white; text-decoration: none; border-radius: 4px; }
.unsubscribe { color: #999; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>電子報</h1>
</div>
<div class="content">
{{content}}
</div>
<div class="footer">
<p>
<a href="{{unsubscribe_url}}" class="unsubscribe">取消訂閱</a> |
<a href="https://claude-world.com/preferences?id={{subscriber_id}}">管理偏好設定</a>
</p>
<p>Copyright 2026 Claude World. 版權所有。</p>
</div>
</div>
</body>
</html>
5. 取消訂閱處理
GDPR 和 CAN-SPAM 要求簡單的一鍵取消訂閱。
取消訂閱 API 路由
// src/pages/api/unsubscribe.ts
import type { APIRoute } from 'astro';
import { unsubscribeFromNewsletter } from '../../lib/supabase';
import { validateOrigin, csrfForbiddenResponse, SECURITY_HEADERS } from '../../lib/security';
export const prerender = false;
// POST:實際取消訂閱(需要 CSRF 檢查)
export const POST: APIRoute = async ({ request }) => {
if (!validateOrigin(request)) {
return csrfForbiddenResponse();
}
try {
const { email, id } = await request.json();
if (!email && !id) {
return new Response(
JSON.stringify({ error: 'Email or subscriber ID is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
}
const result = await unsubscribeFromNewsletter(email, id);
if (result.success) {
return new Response(
JSON.stringify({ message: 'Successfully unsubscribed' }),
{ status: 200, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
} else {
return new Response(
JSON.stringify({ error: result.error }),
{ status: 400, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
}
} catch (error) {
console.error('Unsubscribe API error:', error);
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{ status: 500, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
}
};
// GET:從郵件連結重導向到確認頁面(CSRF 安全)
export const GET: APIRoute = async ({ url }) => {
const email = url.searchParams.get('email');
const id = url.searchParams.get('id');
if (!email && !id) {
return new Response(null, {
status: 302,
headers: { Location: '/unsubscribe?error=missing' },
});
}
// 重導向到確認頁面(實際取消訂閱透過 POST)
return new Response(null, {
status: 302,
headers: {
Location: `/unsubscribe?email=${encodeURIComponent(email || '')}&id=${id || ''}`,
},
});
};
安全性模式:GET 端點(來自郵件連結)會重導向到確認頁面,而不是立即取消訂閱。實際的取消訂閱透過有 CSRF 保護的 POST 進行。這防止透過圖片標籤或連結預載進行惡意的取消訂閱攻擊。
偏好設定中心 API
// src/pages/api/preferences.ts
import type { APIRoute } from 'astro';
import { updateSubscriberLanguage, type SupportedLanguage } from '../../lib/supabase';
import { validateOrigin, csrfForbiddenResponse, SECURITY_HEADERS } from '../../lib/security';
export const prerender = false;
const SUPPORTED_LANGUAGES: SupportedLanguage[] = ['en', 'zh-tw', 'ja'];
// POST:更新偏好設定
export const POST: APIRoute = async ({ request }) => {
if (!validateOrigin(request)) {
return csrfForbiddenResponse();
}
try {
const { email, id, language } = await request.json();
if (!email && !id) {
return new Response(
JSON.stringify({ error: 'Email or subscriber ID required' }),
{ status: 400, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
}
if (!SUPPORTED_LANGUAGES.includes(language)) {
return new Response(
JSON.stringify({ error: `Invalid language. Supported: ${SUPPORTED_LANGUAGES.join(', ')}` }),
{ status: 400, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
}
const result = await updateSubscriberLanguage(email || '', language, id || undefined);
if (result.success) {
return new Response(
JSON.stringify({ message: 'Preferences updated' }),
{ status: 200, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
}
return new Response(
JSON.stringify({ error: result.error }),
{ status: 400, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
} catch (error) {
console.error('Preferences API error:', error);
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{ status: 500, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
}
};
6. GitHub Actions 自動化
自動化將電子報從手動任務轉變為系統化流程。
從版本發布自動生成電子報
# .github/workflows/generate-newsletter.yml
name: Generate Newsletter from Release
on:
release:
types: [published]
jobs:
generate:
runs-on: ubuntu-latest
if: github.repository == 'owner/repo'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Generate Newsletter Articles
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
VERSION: ${{ github.event.release.tag_name }}
RELEASE_NAME: ${{ github.event.release.name }}
RELEASE_URL: ${{ github.event.release.html_url }}
RELEASE_BODY_B64: ${{ github.event.release.body && toBase64(github.event.release.body) || '' }}
NEWSLETTER_SLUG: release-${{ github.event.release.tag_name }}
run: node scripts/generate-newsletter-from-release.js
- name: Create Pull Request
uses: peter-evans/create-pull-request@v5
with:
title: "Newsletter: ${{ github.event.release.tag_name }}"
body: "Auto-generated newsletter for release ${{ github.event.release.tag_name }}"
branch: newsletter/${{ github.event.release.tag_name }}
commit-message: "feat(newsletter): add ${{ github.event.release.tag_name }} newsletter"
為什麼建立 PR 而不是自動發送? 人工審查確保品質並在郵件到達訂閱者之前捕捉任何生成問題。
手動發送工作流程
# .github/workflows/send-newsletter.yml
name: Send Newsletter
on:
workflow_dispatch:
inputs:
date:
description: 'Newsletter date (YYYY-MM-DD)'
required: false
language:
description: 'Filter by language (optional)'
required: false
jobs:
send:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Send Newsletter
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
run: |
node scripts/send-newsletter.js \
${{ github.event.inputs.date && format('--date {0}', github.event.inputs.date) || '' }} \
${{ github.event.inputs.language && format('--lang {0}', github.event.inputs.language) || '' }}
- name: Commit send log
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add newsletters/sent/
git commit -m "chore: newsletter sent log" || exit 0
git push
可選的語言過濾:適用於測試(先發送給一種語言)或如果有問題時重新發送給特定語言群組。
專案目錄結構
project/
├── src/
│ ├── lib/
│ │ └── supabase.ts # 資料庫函式
│ ├── pages/
│ │ └── api/
│ │ ├── newsletter.ts # 訂閱 API
│ │ ├── unsubscribe.ts # 取消訂閱 API
│ │ └── preferences.ts # 偏好設定中心 API
│ └── components/
│ └── NewsletterForm.tsx # 註冊表單
├── scripts/
│ ├── send-newsletter.js # 發送腳本
│ └── generate-newsletter.js # 生成腳本
├── newsletters/
│ ├── templates/ # 郵件範本
│ ├── drafts/ # 生成的草稿
│ │ └── 2026-01-15/
│ │ ├── newsletter-en.html
│ │ ├── newsletter-zh-tw.html
│ │ ├── newsletter-ja.html
│ │ └── subject-lines.txt
│ └── sent/ # 發送記錄
│ └── 2026-01-15/
│ └── send-log.json
└── supabase/
└── migrations/
└── 001_newsletter.sql
安全性與合規性檢查清單
安全性
- 註冊端點的速率限制(每分鐘 5 次請求)
- 所有 POST 端點的 CSRF 保護
- 資料庫表已啟用 RLS
- Service key 只在後端腳本使用(永不在客戶端)
- 郵件列舉防護(通用成功回應)
- 取消訂閱需要確認頁面(CSRF 安全)
- 稽核記錄的 IP 雜湊處理(隱私)
GDPR 合規性
- 註冊時明確同意(沒有預先勾選的選框)
- 每封郵件都有簡單的一鍵取消訂閱
- 用於語言更新的偏好設定中心
- 所有訂閱操作的稽核軌跡
- 資料匯出功能(透過 Supabase)
快速開始
第 1 天:資料庫設定
- 建立 Supabase 專案
- 執行
newsletter_subscribers資料表的 migration - 設定 RLS 政策
第 2 天:訂閱流程
- 實作 Supabase 客戶端函式庫
- 建立具有速率限制的 API 路由
- 建構訂閱表單元件
第 3 天:發送基礎設施
- 設定 Resend 帳戶和 API key
- 建立電子報 HTML 範本
- 建構發送腳本
第 4 天:自動化
- 設定 GitHub Actions 工作流程
- 設定 repository secrets
- 測試端到端流程
這個電子報架構已在 claude-world.com 經過實戰測試,服務三種語言的訂閱者。在 1,000 訂閱者規模下,該系統每月成本約 $3,相比同等的第三方服務需要 $15 以上。
參考資料:
- Resend 文件 - Email API 參考
- Supabase RLS 指南 - Row Level Security
- GDPR 電子郵件行銷指南 - 合規性要求
- Supabase + Cloudflare 整合 - 相關架構指南