ニュースレターシステムアーキテクチャ:多言語メール自動化
Supabase、Resend、GitHub Actionsを使用した完全なニュースレターシステムの構築方法。データベーススキーマ設計、購読フロー、多言語サポート、GDPRコンプライアンス、自動送信パイプラインについて解説します。
ニュースレターシステムの構築は一見シンプルに見えますが、本番環境に対応した実装には、多言語サポート、セキュリティ、コンプライアンス、自動化について慎重な検討が必要です。本ガイドでは、claude-world.comが3言語で購読者にサービスを提供するために使用している完全なアーキテクチャを解説します。
なぜ独自のニュースレターシステムを構築するのか?
多くの開発者はMailchimpやConvertKitなどのサードパーティサービスを選択します。これらはシンプルなユースケースには有効ですが、独自システムの構築には大きな利点があります。
| アプローチ | コスト(1,000購読者) | 柔軟性 | データ管理 |
|---|---|---|---|
| Mailchimp | 約$15/月 | 限定的 | サードパーティ |
| カスタム(Supabase + Resend) | 約$3/月 | 完全 | 自己所有 |
カスタムアプローチの主な利点:
- 購読者データと送信ロジックの完全な制御
- 言語ごとの追加料金なしの多言語サポート
- 既存のデータベースやワークフローとの統合
- 購読者リストのベンダーロックインなし
- GitHub Actionsによるリリースベースのニュースレター自動化
システムアーキテクチャ概要
ニュースレターシステムは4つの相互接続されたコンポーネントで構成されています。
+-------------------+ +-------------------+ +-------------------+
| 購読フォーム | | 送信スクリプト | | 購読解除ハンドラー |
| (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 'ユーザーが登録した場所(website、eventなど)';
設計上の決定事項:
- UUIDプライマリキー:連番ID列挙攻撃を防止
sourceカラム:分析のための獲得チャネル追跡- CHECK制約付き
preferred_language:データベースレベルで有効な言語コードを強制 - 削除ではなく
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 Client
// 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: 'Traditional Chinese', flag: 'TW' },
{ code: 'en', label: 'English', flag: 'US' },
{ code: 'ja', label: 'Japanese', 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: 'Subscribe',
buttonLoading: 'Processing...',
success: 'Subscription successful! Please check your inbox.',
error: 'An error occurred. Please try again later.',
help: 'Weekly Claude Code advanced tips. No spam, unsubscribe anytime.',
},
ja: {
placeholder: 'you@email.com',
button: 'Subscribe',
buttonLoading: 'Processing...',
success: 'Subscription complete! Please check your confirmation email.',
error: 'An error occurred. Please try again.',
help: 'Weekly Claude Code advanced techniques. No spam, unsubscribe anytime.',
},
};
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: 'Traditional Chinese',
defaultSubject: (date) => `[Newsletter] Weekly Highlights - ${date}`,
},
'en': {
label: 'English',
defaultSubject: (date) => `[Newsletter] Weekly Highlights - ${date}`,
},
'ja': {
label: 'Japanese',
defaultSubject: (date) => `[Newsletter] This Week's Highlights - ${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} subscriber(s):`);
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(`\nSending ${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: ${batch.length} sent`);
} catch (err) {
results.failed += batch.length;
results.byLanguage[lang].failed += batch.length;
console.error(` Batch failed: ${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(`
========================================
Send Complete
Successful: ${results.successful}
Failed: ${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>Newsletter</h1>
</div>
<div class="content">
{{content}}
</div>
<div class="footer">
<p>
<a href="{{unsubscribe_url}}" class="unsubscribe">Unsubscribe</a> |
<a href="https://claude-world.com/preferences?id={{subscriber_id}}">Manage Preferences</a>
</p>
<p>Copyright 2026 Claude World. All rights reserved.</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
オプションの言語フィルター:テスト(1言語を先に送信)や、問題があった場合に特定の言語グループに再送信するのに便利です。
プロジェクトディレクトリ構造
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テーブルのマイグレーションを実行- RLSポリシーを設定
2日目:購読フロー
- Supabaseクライアントライブラリを実装
- レート制限付きAPIルートを作成
- 購読フォームコンポーネントを構築
3日目:送信インフラ
- Resendアカウントとキーをセットアップ
- ニュースレターHTMLテンプレートを作成
- 送信スクリプトを構築
4日目:自動化
- GitHub Actionsワークフローを設定
- リポジトリシークレットをセットアップ
- エンドツーエンドフローをテスト
このニュースレターアーキテクチャはclaude-world.comで実証され、3言語で購読者にサービスを提供しています。システムコストは1,000購読者規模で約$3/月であり、同等のサードパーティサービスの$15以上と比較して大幅に削減されています。
参考資料:
- Resend Documentation - Email APIリファレンス
- Supabase RLS Guide - Row Level Security
- GDPR Email Marketing Guidelines - コンプライアンス要件
- Supabase + Cloudflare Integration - 関連アーキテクチャガイド