跳至主要內容
精選 Newsletter Supabase Resend Email Automation GitHub Actions

電子報系統架構:多語言郵件自動化

使用 Supabase、Resend 和 GitHub Actions 建構完整的電子報系統。學習資料庫結構設計、訂閱流程、多語言支援、GDPR 合規性以及自動化發送管道。

2026年1月16日 18 分鐘閱讀 作者:Claude World

建構電子報系統表面上看起來很簡單,但要達到生產環境等級的實作,需要仔細考量多語言支援、安全性、合規性和自動化。本指南將完整介紹 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)     |  |
|  +-----------------------------------------------------------+  |
+-----------------------------------------------------------------+

每個元件有特定的職責:

  1. 訂閱表單:收集郵件地址和語言偏好
  2. 發送腳本:透過 Resend API 批次發送
  3. 取消訂閱處理器:一鍵取消訂閱 + 偏好設定中心
  4. 自動化: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 天:資料庫設定

  1. 建立 Supabase 專案
  2. 執行 newsletter_subscribers 資料表的 migration
  3. 設定 RLS 政策

第 2 天:訂閱流程

  1. 實作 Supabase 客戶端函式庫
  2. 建立具有速率限制的 API 路由
  3. 建構訂閱表單元件

第 3 天:發送基礎設施

  1. 設定 Resend 帳戶和 API key
  2. 建立電子報 HTML 範本
  3. 建構發送腳本

第 4 天:自動化

  1. 設定 GitHub Actions 工作流程
  2. 設定 repository secrets
  3. 測試端到端流程

這個電子報架構已在 claude-world.com 經過實戰測試,服務三種語言的訂閱者。在 1,000 訂閱者規模下,該系統每月成本約 $3,相比同等的第三方服務需要 $15 以上。


參考資料: