Skip to main content
Featured Newsletter Supabase Resend Email Automation GitHub Actions

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.

January 16, 2026 18 min read By Claude World

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:

ApproachCost (1,000 subs)FlexibilityData Control
Mailchimp~$15/monthLimitedThird-party
Custom (Supabase + Resend)~$3/monthFullSelf-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:

  1. Subscription Form: Captures email with language preference
  2. Sending Script: Batch delivery via Resend API
  3. Unsubscription Handler: One-click unsubscribe + preference center
  4. 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
  • source column: Tracks acquisition channel for analytics
  • preferred_language with CHECK constraint: Enforces valid language codes at database level
  • unsubscribed_at instead 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_KEY to 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

  1. Create Supabase project
  2. Run migration for newsletter_subscribers table
  3. Configure RLS policies

Day 2: Subscription Flow

  1. Implement Supabase client library
  2. Create API route with rate limiting
  3. Build subscription form component

Day 3: Sending Infrastructure

  1. Set up Resend account and API key
  2. Create newsletter HTML templates
  3. Build sending script

Day 4: Automation

  1. Configure GitHub Actions workflows
  2. Set up repository secrets
  3. 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: