Newsletter Architecture: Multi-Language Automation
Build a complete newsletter system with Supabase, Resend, and GitHub Actions. Learn database schema design, subscription flow, multi-language support, GDPR compliance, and automated sending pipelines.
Building a newsletter system seems simple on the surface, but a production-ready implementation requires careful consideration of multi-language support, security, compliance, and automation. This guide walks through the complete architecture used by claude-world.com to serve subscribers in three languages.
Why Build Your Own Newsletter System?
Most developers reach for third-party services like Mailchimp or ConvertKit. While these work well for simple use cases, building your own system offers significant advantages:
| Approach | Cost (1,000 subs) | Flexibility | Data Control |
|---|---|---|---|
| Mailchimp | ~$15/month | Limited | Third-party |
| Custom (Supabase + Resend) | ~$3/month | Full | Self-owned |
Key benefits of the custom approach:
- Full control over subscriber data and sending logic
- Multi-language support without per-language pricing
- Integration with your existing database and workflows
- No vendor lock-in for your subscriber list
- Automation through GitHub Actions for release-based newsletters
System Architecture Overview
The newsletter system consists of four interconnected components:
+-------------------+ +-------------------+ +-------------------+
| Subscription | | Sending | | Unsubscription |
| Form |--->| Script |<---| Handler |
| (React/Astro) | | (Node.js) | | (API Route) |
+---------+---------+ +---------+---------+ +---------+---------+
| | |
v v v
+-----------------------------------------------------------------+
| Supabase (PostgreSQL) |
| +-----------------------------------------------------------+ |
| | newsletter_subscribers (id, email, language, status) | |
| +-----------------------------------------------------------+ |
+-----------------------------------------------------------------+
Each component has a specific responsibility:
- Subscription Form: Captures email with language preference
- Sending Script: Batch delivery via Resend API
- Unsubscription Handler: One-click unsubscribe + preference center
- Automation: GitHub Actions triggers newsletter generation from releases
1. Database Schema Design
The database is the foundation of the system. A well-designed schema enables efficient querying, proper security, and GDPR compliance.
Subscribers Table
-- 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
);
-- Indexes for faster queries
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;
-- Comments for documentation
COMMENT ON TABLE newsletter_subscribers IS 'Newsletter subscriber list';
COMMENT ON COLUMN newsletter_subscribers.preferred_language IS 'Newsletter content language preference';
COMMENT ON COLUMN newsletter_subscribers.source IS 'Where the user signed up (website, event, etc.)';
Design decisions explained:
- UUID primary key: Prevents sequential ID enumeration attacks
sourcecolumn: Tracks acquisition channel for analyticspreferred_languagewith CHECK constraint: Enforces valid language codes at database levelunsubscribed_atinstead of deletion: Soft-delete preserves audit trail and allows re-subscription- Partial index on active subscribers: Optimizes the most common query pattern
Row Level Security (RLS)
Security is critical for protecting subscriber data:
-- Enable RLS
ALTER TABLE newsletter_subscribers ENABLE ROW LEVEL SECURITY;
-- Allow anonymous inserts (for signup)
CREATE POLICY "Allow anonymous inserts" ON newsletter_subscribers
FOR INSERT TO anon
WITH CHECK (true);
-- Deny reads to anonymous (protect email list)
CREATE POLICY "Deny anonymous reads" ON newsletter_subscribers
FOR SELECT TO anon
USING (false);
-- Allow updates for unsubscribe/preferences (application-layer validation)
CREATE POLICY "Allow anonymous updates" ON newsletter_subscribers
FOR UPDATE TO anon
USING (true);
Why these policies?
- Insert allowed: Users must be able to sign up without authentication
- Select denied: Protects email list from enumeration
- Update allowed: Enables unsubscribe without authentication (validated at application layer)
Audit Table for Compliance
GDPR requires tracking all data processing activities:
-- Track all subscription events for compliance
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, -- Hashed for privacy
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);
Why hash the IP? Storing raw IP addresses creates liability under GDPR. Hashing preserves the ability to detect abuse patterns while protecting privacy.
2. Subscription Flow
The subscription flow handles new signups, re-subscriptions, and prevents email enumeration attacks.
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;
}
Subscribe Function with Re-subscription Support
/**
* Subscribe to newsletter with language preference
* Uses Single Opt-in (confirmed immediately)
*/
export async function subscribeToNewsletter(
email: string,
source: string = 'website',
language: SupportedLanguage = 'en'
): Promise<{ success: boolean; error?: string; subscriberId?: string }> {
// Validate email
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 {
// Check if already subscribed
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) {
// Re-subscribe: clear unsubscribed status
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 };
}
// Already subscribed - return success (prevents email enumeration)
return { success: true };
}
// New subscription
const { data, error } = await client
.from('newsletter_subscribers')
.insert({
email: email.toLowerCase(),
source,
preferred_language: language,
confirmed: true, // Single opt-in
})
.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.' };
}
}
Security consideration: The function returns success: true for already-subscribed emails. This prevents attackers from using the API to enumerate which emails are in your database.
API Route with Rate Limiting
// 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 protection
if (!validateOrigin(request)) {
return csrfForbiddenResponse();
}
// Rate limiting (5 requests/minute)
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. Multi-Language Support
Multi-language support requires careful design at every layer of the system.
React Form with Language Selection
// 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;
// Auto-detect language from page 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>
);
}
Key features:
- URL-based language detection: Automatically selects language based on
/zh-tw/or/ja/URL prefix - Explicit language selector: Users can override the detected language
- Localized UI: All form labels adapt to the selected interface language
4. Newsletter Sending with Resend
Resend provides excellent developer experience with batch sending support.
Sending Script
// 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, '..');
// Language configuration
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}`,
},
};
// Initialize clients
const resend = new Resend(process.env.RESEND_API_KEY);
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_KEY // Use service key for reading all subscribers
);
async function sendNewsletter(targetDate, filterLang = null) {
console.log('Newsletter Sender (Multi-Language)\n');
// Validate environment
if (!process.env.RESEND_API_KEY || !process.env.SUPABASE_SERVICE_KEY) {
console.error('Missing required environment variables');
process.exit(1);
}
// Find newsletter directory
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);
}
// Check if already sent (prevents double-sending)
const sentLogPath = join(projectRoot, 'newsletters/sent', targetDate, 'send-log.json');
if (existsSync(sentLogPath) && !filterLang) {
console.warn(`Already sent for ${targetDate}`);
process.exit(1);
}
// Load newsletter files for each language
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}`);
}
}
// Get active subscribers grouped by language
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);
}
// Group subscribers by language
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}`);
}
// Send in batches (Resend limit: 100 per batch)
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}`);
}
// Rate limiting between batches
if (i + batchSize < langSubscribers.length) {
await new Promise(r => setTimeout(r, 1000));
}
}
}
// Save send log
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}
========================================
`);
}
// Parse command line arguments
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);
Important considerations:
- Service key vs anon key: The sending script uses
SUPABASE_SERVICE_KEYto bypass RLS and read all subscribers - Batch size: Resend limits batch sends to 100 emails; the script handles this automatically
- Rate limiting: 1-second delay between batches prevents API throttling
- Send log: Prevents accidental double-sending and provides audit trail
Newsletter HTML Template
<!-- 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. Unsubscribe Handling
GDPR and CAN-SPAM require easy, one-click unsubscription.
Unsubscribe API Route
// 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: Actual unsubscribe (requires CSRF check)
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: Redirect from email link to confirmation page (CSRF-safe)
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' },
});
}
// Redirect to confirmation page (actual unsubscribe via POST)
return new Response(null, {
status: 302,
headers: {
Location: `/unsubscribe?email=${encodeURIComponent(email || '')}&id=${id || ''}`,
},
});
};
Security pattern: The GET endpoint (from email links) redirects to a confirmation page rather than immediately unsubscribing. The actual unsubscription happens via POST with CSRF protection. This prevents malicious unsubscribe attacks via image tags or link prefetching.
Preference Center 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: Update preferences
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 Automation
Automation transforms the newsletter from a manual task into a systematic process.
Auto-Generate Newsletter from Release
# .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"
Why create a PR instead of auto-sending? Human review ensures quality and catches any generation issues before emails reach subscribers.
Manual Send Workflow
# .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
Optional language filter: Useful for testing (send to one language first) or re-sending to a specific language group if there was an issue.
Project Directory Structure
project/
├── src/
│ ├── lib/
│ │ └── supabase.ts # Database functions
│ ├── pages/
│ │ └── api/
│ │ ├── newsletter.ts # Subscribe API
│ │ ├── unsubscribe.ts # Unsubscribe API
│ │ └── preferences.ts # Preference center API
│ └── components/
│ └── NewsletterForm.tsx # Signup form
├── scripts/
│ ├── send-newsletter.js # Send script
│ └── generate-newsletter.js # Generation script
├── newsletters/
│ ├── templates/ # Email templates
│ ├── drafts/ # Generated drafts
│ │ └── 2026-01-15/
│ │ ├── newsletter-en.html
│ │ ├── newsletter-zh-tw.html
│ │ ├── newsletter-ja.html
│ │ └── subject-lines.txt
│ └── sent/ # Send logs
│ └── 2026-01-15/
│ └── send-log.json
└── supabase/
└── migrations/
└── 001_newsletter.sql
Security and Compliance Checklist
Security
- Rate limiting on signup endpoint (5 req/min)
- CSRF protection on all POST endpoints
- RLS enabled on database tables
- Service key only in backend scripts (never in client)
- Email enumeration prevention (generic success response)
- Unsubscribe requires confirmation page (CSRF-safe)
- IP hashing for audit logs (privacy)
GDPR Compliance
- Clear consent at signup (no pre-checked boxes)
- Easy one-click unsubscribe in every email
- Preference center for language updates
- Audit trail for all subscription actions
- Data export capability (via Supabase)
Getting Started
Day 1: Database Setup
- Create Supabase project
- Run migration for
newsletter_subscriberstable - Configure RLS policies
Day 2: Subscription Flow
- Implement Supabase client library
- Create API route with rate limiting
- Build subscription form component
Day 3: Sending Infrastructure
- Set up Resend account and API key
- Create newsletter HTML templates
- Build sending script
Day 4: Automation
- Configure GitHub Actions workflows
- Set up repository secrets
- Test end-to-end flow
This newsletter architecture has been battle-tested on claude-world.com, serving subscribers in three languages. The system costs approximately $3/month at the 1,000 subscriber scale, compared to $15+ for equivalent third-party services.
References:
- Resend Documentation - Email API reference
- Supabase RLS Guide - Row Level Security
- GDPR Email Marketing Guidelines - Compliance requirements
- Supabase + Cloudflare Integration - Related architecture guide