Key Takeaways
- Custom workflows beat platform lock-in by giving you 100% data ownership, custom styling control, and zero platform fees.
- n8n Webhook CORS configuration is crucial when making client-side submissions; restricting origins in production prevents unauthorized cross-site mutations.
- Database defaults don't fire on upsert updates. When re-subscribing users, you must explicitly regenerate verification tokens in your workflow to avoid security vulnerabilities.
- Direct REST API requests to Gemini offer granular payload control over native n8n AI model nodes, enabling strict JSON output constraints.
- Robust HTML templates require deep sanitization. Sanitizing LLM output fields (titles, URLs) prevents Cross-Site Scripting (XSS) in email clients.
- Hosting can be practically free. For small to medium lists, self-hosting n8n on a home server like a Raspberry Pi combined with cloud free tiers reduces monthly SaaS bills to zero.
When I launched this blog, I knew I needed a newsletter to keep in touch with readers. But looking at the standard marketing stack felt frustrating. I didn't want to sign up for Mailchimp or Substack, pay rising subscription fees as my list grew, inject heavy tracking scripts into my clean codebase, or force my readers into cookie-cutter templates that broke the cohesive design system of my Astro site.
I wanted something custom, self-hosted, and secure. Since I had already automated parts of my research workflow, I decided to build my own newsletter engine using the tools I already run: Astro, Supabase, n8n, Resend, and Gemini.
This is the journey of how I built it, the technical hurdles I ran into, and the engineering details that make it secure and scalable.
System Architecture: The Two Workflows
My automated newsletter is divided into two decoupled workflows running on my self-hosted n8n instance: the Subscription Engine (which handles active opt-ins in real time) and the Weekly Curation Engine (which compiles and blasts the digest).
Here is how the data flows between the Astro frontend, the Supabase database, and the external APIs:
1. The Subscription & Verification Flow (Double Opt-In)
This workflow runs in real time, handling new requests, verification clicks, and unsubscriptions.
2. The Weekly Curation & Blast Flow
This scheduled workflow triggers every Wednesday to generate, personalize, and send the weekly digest.
Part 1: Solving the Subscription Flow & The CORS Wall
The subscription flow sounds simple: a user enters their email on the homepage, the email is saved in a database, and a verification link is sent out.
However, building this with a decoupled client-side form and a self-hosted backend presented immediate challenges in CORS security and database consistency.
1. The Supabase Database Setup
I set up a simple subscribers table in Supabase. The critical component here is the automatic generation of a unique verification token for each user:
create table subscribers (
id uuid default gen_random_uuid() primary key,
email text unique not null,
status text default 'pending', -- pending, confirmed, unsubscribed
token uuid default gen_random_uuid() not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
confirmed_at timestamp with time zone,
unsubscribed_at timestamp with time zone
);
2. Hitting the CORS Wall
On the Astro homepage, my subscription form sends a standard JavaScript fetch POST request containing the email address directly to the n8n webhook URL.
The first time I tested it, the console lit up with red errors. Because the request was cross-origin (from peripheral-stack.com to my n8n subdomain), the browser initiated a preflight OPTIONS request. By default, n8n webhooks do not return the necessary CORS headers to allow cross-origin client requests.
To fix this, I had to open the Webhook — Subscribe node settings in n8n and manually configure the custom response headers:
-
Access-Control-Allow-Origin:https://peripheral-stack.com -
Access-Control-Allow-Headers:Content-Type -
Access-Control-Allow-Methods:POST, OPTIONS
[!WARNING]
While setting the origin header to*is functional for quick local prototyping, it allows any external site to trigger mutations on your database. For production deployments, always restrictAccess-Control-Allow-Originto your explicit website domain to prevent cross-site request abuse.
3. The Supabase Upsert Gotcha: Token Staleness
When a user subscribes, my n8n workflow executes a Supabase upsert matching on the email key.
During testing, I uncovered an database quirk: if an existing user re-subscribes (for instance, if they were previously marked as unsubscribed or their previous verification timed out), the upsert statement overwrites the columns, but the database default value default gen_random_uuid() for the token column does not refire. The user is left with their old, stale token.
To resolve this security vulnerability, I modified the n8n workflow to explicitly generate a new UUID using n8n's expression engine {{ $uuid }} and pass it directly inside the Supabase Upsert payload:
{
"email": "{{ $json.body.email }}",
"status": "pending",
"token": "{{ $uuid }}"
}
This ensures a fresh token is generated and emailed to the subscriber on every subscription request, resetting the verification handshake.
Part 2: Automating Curation with AI REST Pipelines
Once the subscription system was active, I had to figure out how to compile and distribute the newsletter. I wanted to send a weekly summary of the latest articles, alongside exactly three curated developer tools or ergonomic tips.
1. Direct REST Calls vs. Native n8n AI Nodes
n8n has native nodes for connecting to Google Gemini. However, these nodes are built on LangChain abstractions, designed primarily for conversational chat models.
For my weekly curation, I didn't want a conversation. I needed a single, deterministic response constrained to a strict JSON structure.
I bypassed the native AI nodes and opted for a standard HTTP Request node targeting Google’s raw Gemini REST API (https://generativelanguage.googleapis.com/v1beta/models/...). This gave me raw control over system instructions, temperature, and response parameters:
{
"contents": [{
"parts": [{
"text": "You are the editorial assistant for 'The Peripheral Stack'. You need to write the weekly newsletter intro and generate 3 fresh curated finds/tips.\n\nFirst, write a friendly, engaging newsletter intro paragraph (max 100 words, no emojis, in English) that summarizes the following articles of this week. Tone: smart, casual, and directly addressing developers. Do not use phrases like 'Welcome to...' or emojis.\nARTICLES:\n{{ $json.articlesList }}\n\nSecond, generate exactly 3 fresh, highly relevant developer tools or ergonomic/workflow micro-tips. To avoid repetition, you MUST NOT generate topics similar to these: {{ $json.pastTipsList }}.\n\nRespond ONLY with a valid JSON object: \n{\n \"intro\": \"paragraph string\",\n \"tips\": [\n { \"title\": \"Tool Name\", \"url\": \"https://github.com/...\", \"text\": \"Description\", \"type\": \"link\" }\n ]\n}"
}]
}]
}
By querying the REST API, the output returns as clean, raw JSON which is immediately parsed by the subsequent code node without conversational fluff or markdown wrapper formatting.
2. XSS Sanitization in Custom HTML Compilers
Once Gemini generates the new intro and tips, the workflow retrieves all confirmed subscribers and maps them.
Because we are injecting LLM-generated strings directly into an HTML email template, we have to guard against Cross-Site Scripting (XSS). If Gemini generated a malicious title or returned a payload with a javascript:... link, the recipient's mail client could run unauthorized scripts.
My compiler JavaScript node in n8n enforces both HTML entity escaping and strict URL validation:
const subscribers = $input.all().map(item => item.json);
// Escape raw HTML entities to prevent markup breakdown and XSS
function sanitizeHTML(text) {
if (!text) return "";
return text.trim()
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// Ensure URLs are valid protocols and prevent javascript: injection
function validateURL(url) {
if (!url) return null;
const trimmed = url.trim();
if (trimmed.startsWith("https://") || trimmed.startsWith("http://")) {
return trimmed;
}
return null;
}
let introText = sanitizeHTML($('Parse Gemini Content').first().json.intro);
let selectedTips = $('Parse Gemini Content').all().map(item => item.json);
let curatedHtml = "";
for (const tip of selectedTips) {
const cleanTitle = sanitizeHTML(tip.title);
const cleanText = sanitizeHTML(tip.text);
const cleanUrl = validateURL(tip.url);
if (cleanUrl) {
curatedHtml += `<li style="margin-bottom: 10px;"><strong><a href="${cleanUrl}" style="color: #818cf8; text-decoration: underline;">${cleanTitle}</a></strong> - ${cleanText}</li>\n`;
} else {
curatedHtml += `<li style="margin-bottom: 10px;"><strong>${cleanTitle}:</strong> ${cleanText}</li>\n`;
}
}
return subscribers.map(sub => {
const unsubscribeUrl = `https://peripheral-stack.com/newsletter/unsubscribe?email=${encodeURIComponent(sub.email)}&token=${sub.token}`;
let html = masterTemplate
.replace('[INTRO_TEXT]', introText)
.replace('[CURATED_LINKS]', curatedHtml)
.replace('[UNSUBSCRIBE_URL]', unsubscribeUrl);
return {
json: {
to: sub.email,
subject: "The Peripheral Stack Weekly ⚡",
html: html
}
};
});
3. Serial Dispatch vs. Resend Concurrency
My n8n workflow passes the compiled array of personalized emails to the Resend API node.
[!NOTE]
By default, n8n processes multiple input items in a sequential (serial) loop. This prevents API rate limits (Resend free tier enforces a rate limit of 10 requests per second) but means larger subscriber lists will take longer to complete. For lists scaling beyond thousands of users, the workflow should be refactored to utilize Resend’s native Batch Send API endpoint, allowing up to 150 emails to be dispatched in a single payload.
Reflections: Shifting Costs to Local Infrastructure
Self-hosting our automation setup has proven highly cost-effective, but it shifts our constraints:
- Infrastructure Hosting: n8n is extremely resource-efficient. While you can run it on a cheap VPS (like a $5/month Hetzner/DigitalOcean droplet), it runs perfectly on local hardware. I have hosted n8n on a simple Raspberry Pi connected to home fiber, rendering hosting costs virtually zero.
- Database Limits: Supabase's Free Tier provides plenty of space for small-scale lists (500MB of database storage), easily housing tens of thousands of subscriber rows.
- API Outbound limits: Resend allows 3,000 free emails per month, which covers a weekly list of 750 subscribers completely free of charge.
By investing time in setting up explicit CORS protection, addressing token updates, and implementing template sanitization, I built a reliable, secure newsletter engine. For developers, taking control of your own infrastructure is always worth the effort.
Top comments (0)