Learn how I built a complete newsletter system using Cloudflare Workers, D1 database, and GitHub Pages. This system handles subscription management, automated email sending, RSS feed monitoring, and bot protection — all serverless and cost-effective.
System Architecture
Frontend Layer
GitHub Pages (Static Site)
→ HTML/CSS/JS
→ RSS Feed (index.xml)
API Layer
Cloudflare Workers
→ REST API Endpoints
→ CRON Jobs (6-hour intervals)
Data Layer
D1 Database (SQLite)
→ Subscribers Table
→ Sent Newsletters
→ Contact Messages
Storage Layer
KV Storage
→ Cached Data
→ Rate Limiting
→ Temporary Queues
Email Layer
Multi-provider Support
→ Gmail SMTP
→ MailerLite API
→ Worker Email
1. Core Components
1.1 Cloudflare Worker Configuration
// src/index.js - Main Worker
import { Hono } from 'hono'
const app = new Hono()
// Database schema
const DB_SCHEMA = `
CREATE TABLE IF NOT EXISTS subscribers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT,
subscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
verification_token TEXT
);
CREATE TABLE IF NOT EXISTS sent_newsletters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_url TEXT UNIQUE NOT NULL,
post_title TEXT,
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`
// Subscribe endpoint
app.post('/api/subscribe', async (c) => {
const { email, name } = await c.req.json()
// Validate email
if (!isValidEmail(email)) {
return c.json({ error: 'Invalid email' }, 400)
}
// Check existing
const existing = await c.env.DB.prepare(
'SELECT id FROM subscribers WHERE email = ? AND is_active = 1'
).bind(email).first()
if (existing) {
return c.json({ error: 'Already subscribed' }, 409)
}
// Insert with verification token
const token = crypto.randomUUID()
await c.env.DB.prepare(
`INSERT INTO subscribers (email, name, verification_token)
VALUES (?, ?, ?)`
).bind(email, name || null, token).run()
// Send welcome email
await sendWelcomeEmail(email, name, token, c.env)
return c.json({
success: true,
message: 'Check your email to confirm subscription'
})
})
// CRON handler for newsletter sending
app.scheduled(async (event, env, ctx) => {
await checkAndSendNewsletters(env)
})
export default app
1.2 Database Schema Design
-- Complete D1 Database Schema
-- Subscribers table
CREATE TABLE subscribers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT,
subscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
unsubscribed_at DATETIME,
is_active BOOLEAN DEFAULT 1,
verification_token TEXT,
verified_at DATETIME,
source TEXT DEFAULT 'website',
metadata TEXT DEFAULT '{}'
);
-- Sent newsletters tracking
CREATE TABLE sent_newsletters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_url TEXT UNIQUE NOT NULL,
post_title TEXT NOT NULL,
post_date DATETIME NOT NULL,
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
recipients_count INTEGER DEFAULT 0,
open_count INTEGER DEFAULT 0,
click_count INTEGER DEFAULT 0,
category TEXT
);
-- Contact messages
CREATE TABLE contact_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
subject TEXT NOT NULL,
message TEXT NOT NULL,
received_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_read BOOLEAN DEFAULT 0,
ip_address TEXT,
user_agent TEXT
);
-- Analytics
CREATE TABLE analytics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
event_data TEXT DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
2. RSS Feed Integration
// RSS Parser and Newsletter Sender
async function checkAndSendNewsletters(env) {
try {
// Fetch RSS feed
const response = await fetch(env.RSS_FEED_URL, {
headers: { 'User-Agent': env.USER_AGENT }
})
const xmlText = await response.text()
// Parse XML
const posts = parseRSSFeed(xmlText)
// Filter new posts
const newPosts = []
for (const post of posts) {
const existing = await env.DB.prepare(
'SELECT id FROM sent_newsletters WHERE post_url = ?'
).bind(post.link).first()
if (!existing) {
newPosts.push(post)
}
}
// Send newsletters for new posts
for (const post of newPosts.slice(0, env.MAX_POSTS_PER_RUN)) {
await sendNewsletterForPost(post, env)
// Store sent record
await env.DB.prepare(
`INSERT INTO sent_newsletters
(post_url, post_title, post_date, recipients_count, category)
VALUES (?, ?, ?, ?, ?)`
).bind(
post.link,
post.title,
post.pubDate,
await countActiveSubscribers(env.DB),
post.category
).run()
}
return { sent: newPosts.length }
} catch (error) {
console.error('Newsletter check failed:', error)
throw error
}
}
function parseRSSFeed(xmlText) {
const posts = []
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(xmlText, 'text/xml')
const items = xmlDoc.querySelectorAll('item')
items.forEach(item => {
posts.push({
title: item.querySelector('title')?.textContent || '',
link: item.querySelector('link')?.textContent || '',
description: item.querySelector('description')?.textContent || '',
pubDate: item.querySelector('pubDate')?.textContent || '',
category: item.querySelector('category')?.textContent || 'general'
})
})
return posts
}
3. Email Sending with Rate Limiting
// Email sender with Gmail SMTP and rate limiting
async function sendNewsletterForPost(post, env) {
// Get active subscribers
const subscribers = await env.DB.prepare(
`SELECT email, name FROM subscribers
WHERE is_active = 1 AND verified_at IS NOT NULL`
).all()
if (subscribers.results.length === 0) {
return 0
}
// Prepare email content
const emailHtml = generateNewsletterEmail(post, env)
// Batch sending to respect Gmail limits
const batches = []
for (let i = 0; i < subscribers.results.length; i += env.BATCH_SIZE) {
batches.push(subscribers.results.slice(i, i + env.BATCH_SIZE))
}
let sentCount = 0
for (const batch of batches) {
const recipients = batch.map(s => s.email)
if (env.EMAIL_PROVIDER === 'gmail') {
await sendViaGmail({
to: env.EMAIL_FROM_ADDRESS, // BCC to sender
bcc: recipients.join(', '),
subject: `📬 ${post.title} | ${env.EMAIL_FROM_NAME}`,
html: emailHtml,
text: stripHtml(emailHtml)
}, env)
}
sentCount += batch.length
// Rate limiting between batches
if (batch.length > 0) {
await new Promise(resolve =>
setTimeout(resolve, env.BATCH_WAIT_MINUTES * 60 * 1000)
)
}
}
return sentCount
}
// Gmail SMTP sender
async function sendViaGmail(emailData, env) {
const transporter = {
host: env.GMAIL_HOST,
port: env.GMAIL_PORT,
secure: false,
auth: {
user: env.GMAIL_USER,
pass: env.GMAIL_PASSWORD
}
}
const message = {
from: `"${env.EMAIL_FROM_NAME}" <${env.GMAIL_USER}>`,
to: emailData.to,
bcc: emailData.bcc,
subject: emailData.subject,
html: emailData.html,
text: emailData.text,
headers: {
'List-Unsubscribe': `<${env.SITE_URL}/unsubscribe>`,
'X-Mailer': 'Sujal-Newsletter-System/2.0'
}
}
// Send via fetch API to SMTP service
const response = await fetch('https://api.smtp2go.com/v3/email/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.SMTP2GO_API_KEY}`
},
body: JSON.stringify({
api_key: env.SMTP2GO_API_KEY,
to: [message.to],
bcc: message.bcc.split(','),
sender: message.from,
subject: message.subject,
html_body: message.html,
text_body: message.text
})
})
return response.ok
}
4. Bot Protection & Security
// Turnstile verification
async function validateTurnstile(token, env) {
const formData = new FormData()
formData.append('secret', env.TURNSTILE_SECRET_KEY)
formData.append('response', token)
const response = await fetch(env.TURNSTILE_VERIFY_URL, {
method: 'POST',
body: formData
})
const data = await response.json()
return data.success === true
}
// Rate limiting with KV
async function checkRateLimit(ip, endpoint, env) {
const key = `rate-limit:${endpoint}:${ip}`
const current = await env.KV.get(key)
if (current) {
const { count, resetTime } = JSON.parse(current)
if (Date.now() < resetTime) {
if (count >= 10) { // 10 requests per window
return false
}
await env.KV.put(key, JSON.stringify({
count: count + 1,
resetTime
}))
} else {
// Reset window
await env.KV.put(key, JSON.stringify({
count: 1,
resetTime: Date.now() + 60000 // 1 minute window
}))
}
} else {
await env.KV.put(key, JSON.stringify({
count: 1,
resetTime: Date.now() + 60000
}))
}
return true
}
5. Performance Optimizations
Database Indexing
CREATE INDEX idx_subscribers_email
ON subscribers(email, is_active);
CREATE INDEX idx_subscribers_active
ON subscribers(is_active, verified_at);
CREATE INDEX idx_newsletters_url
ON sent_newsletters(post_url);
KV Caching Strategy
// Cache frequently accessed data
async function getCachedSubscriberCount(env) {
const cached = await env.KV.get('stats:subscriber-count')
if (cached) {
return JSON.parse(cached)
}
const result = await env.DB.prepare(
'SELECT COUNT(*) as count FROM subscribers WHERE is_active = 1'
).first()
// Cache for 5 minutes
await env.KV.put(
'stats:subscriber-count',
JSON.stringify(result),
{ expirationTtl: 300 }
)
return result
}
6. Deployment Configuration
# wrangler.toml
name = "sujal-newsletter"
main = "src/index.js"
compatibility_date = "2024-12-31"
[triggers]
crons = ["0 */6 * * *"]
[[kv_namespaces]]
binding = "KV"
id = "abc123..."
[[d1_databases]]
binding = "DB"
database_name = "newsletter_db"
database_id = "xyz789..."
[vars]
RSS_FEED_URL = "https://sujal2048.github.io/index.xml"
EMAIL_PROVIDER = "gmail"
BATCH_SIZE = 95
MAX_POSTS_PER_RUN = 2
# Rate limiters
[[ratelimits]]
name = "GLOBAL_RATE_LIMITER"
namespace_id = "1001"
simple = { limit = 30, period = 60 }
Cost Analysis
| Service | Free Tier | 10K Subscribers | 100K Subscribers |
|---|---|---|---|
| Cloudflare Workers | 100K req/day | $5/month | $20/month |
| D1 Database | 5M reads | Included | $5/month |
| KV Storage | 1M reads | Included | $0.50/month |
| Gmail SMTP | 500 emails/day | Free | SMTP2Go $10/month |
| Total | Free | $5/month | $35/month |
Monitoring & Analytics
// Analytics tracking
async function trackEvent(eventType, eventData, env, request) {
await env.DB.prepare(
`INSERT INTO analytics (event_type, event_data, ip_address, user_agent)
VALUES (?, ?, ?, ?)`
).bind(
eventType,
JSON.stringify(eventData),
request.headers.get('CF-Connecting-IP'),
request.headers.get('User-Agent')
).run()
}
// Newsletter open tracking
app.get('/api/track/open/:newsletterId', async (c) => {
const newsletterId = c.req.param('newsletterId')
await env.DB.prepare(
'UPDATE sent_newsletters SET open_count = open_count + 1 WHERE id = ?'
).bind(newsletterId).run()
// Return transparent 1x1 pixel
return new Response(
'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
{
headers: {
'Content-Type': 'image/gif',
'Cache-Control': 'no-store'
}
}
)
})
🚀 Production Ready Features
- ✅ Automated RSS feed monitoring every 6 hours
- ✅ Multi-email provider support (Gmail, MailerLite, Worker Email)
- ✅ Bot protection with Cloudflare Turnstile
- ✅ Rate limiting at multiple levels
- ✅ Database backups with D1
- ✅ Email open/click tracking
- ✅ Unsubscribe management
- ✅ Contact form with spam protection
- ✅ Cost-effective (scales from free to ~$35/month for 100K subs)
Getting Started
# Clone the repository
git clone https://github.com/sujal2048/newsletter-system.git
cd newsletter-system
# Install dependencies
npm install
# Create Cloudflare resources
npx wrangler kv:namespace create "KV"
npx wrangler d1 create "newsletter_db"
# Update wrangler.toml with your IDs
# Set secrets
npx wrangler secret put EMAIL_FROM_ADDRESS
npx wrangler secret put GMAIL_USER
npx wrangler secret put GMAIL_PASSWORD
# Deploy
npx wrangler deploy
# Initialize database
npx wrangler d1 execute newsletter_db --file schema.sql
Live Demo & Source Code
This system powers my own newsletter. You can: