A client of ours publishes blog posts across a range of categories — Technology, Design, Marketing, Productivity, Career, and more. Their subscribers want to stay informed, but not overwhelmed. Some want a daily pulse. Others prefer a weekly digest. And nobody wants to read about categories they have no interest in.
So we built them a system that sends personalized blog newsletters to every subscriber, filtered by their topics of interest and delivered at their preferred frequency — daily, weekly, or monthly. The entire thing runs on a single Node.js script, triggered automatically by GitHub Actions. No manual intervention. No campaign builder. No drag-and-drop.
Here's how we did it.
The Problem
Traditional newsletter workflows look something like this: a human curates content, builds an email in Mailchimp's editor, picks an audience segment, and clicks "Send." That works when you're sending one newsletter a week. It doesn't scale when you have:
Multiple blog categories generating new posts (Technology, Design, Marketing, Productivity, etc.)
Three delivery frequencies (daily, weekly, monthly)
Per-subscriber personalization based on which categories they care about
Doing this manually would mean building up to three campaigns every single day, each with carefully filtered content. Our client needed full automation.
The Architecture
The system has four moving parts:
Headless CMS (e.g., Storyblok, Contentful, Sanity) — where blog posts are published, organized by category
Mailchimp — where the subscriber list lives, with Groups tracking each subscriber's category preferences and frequency choice
A Node.js script — the glue that fetches new posts from the CMS, builds personalized HTML emails, creates Mailchimp campaigns with the right audience segments, and sends them
GitHub Actions — the cron scheduler that runs the script daily
CMS (Blog Posts) → Node.js Script → Mailchimp (Campaigns) → Subscribers
↑
GitHub Actions (Cron)Step 1: Subscriber Preferences via Mailchimp Groups
Before any automation, we set up two Groups in the client's Mailchimp audience:
Frequency — with options:
Daily,Weekly,MonthlyTopics — with options for each blog category:
Technology,Design,Marketing,Productivity,Career,Tutorials, etc.
When subscribers sign up, they choose which topics they care about and how often they want to hear from the blog. Mailchimp stores these as Group memberships on each contact.
This is the foundation of personalization — the script reads these Groups at runtime to build dynamic audience segments.
Step 2: Smart Scheduling with a Single Daily Cron
Rather than running three separate scheduled jobs, the script runs once a day and decides which campaigns to create based on the current date:
const CAMPAIGNS = {
daily: {
label: "Daily",
shouldRun: () => true, // Every day
getDateRange: () => getDateRangeForDays(1), // Yesterday's posts
},
weekly: {
label: "Weekly",
shouldRun: () => new Date().getDay() === 0, // Sundays only
getDateRange: () => getDateRangeForDays(7), // Last 7 days
},
monthly: {
label: "Monthly",
shouldRun: () => new Date().getDate() === 1, // 1st of the month
getDateRange: () => getDateRangeForDays(31), // Last 31 days
},
}On a typical Tuesday, only the daily campaign fires. On a Sunday, both daily and weekly go out. On the 1st of the month, all three could run. This keeps the scheduling logic self-contained and the GitHub Actions workflow dead simple — just a single daily cron:
on:
schedule:
- cron: "0 14 * * *" # Every day at 14:00 UTC
workflow_dispatch: {} # Allow manual runs tooThe script also supports CLI flags (--daily, --weekly, --monthly) for manual overrides during testing, which has been invaluable for debugging.
Step 3: Fetching Content from the CMS
Each campaign type determines its own date range, and the script fetches all published blog posts from the CMS within that window. For this particular client we use Storyblok as the headless CMS, but you can adjust this for whichever CMS you use — Contentful, Sanity, Strapi, or any other. The key is that your CMS supports filtering stories/posts by publish date and category.
Here's how the fetch looks with Storyblok:
async function fetchBlogPosts(cms, startDate, endDate) {
const posts = []
const perPage = 100
async function getPage(page) {
const { data, total } = await cms.get("cdn/stories/", {
per_page: perPage,
starts_with: "blog/",
sort_by: "first_published_at:desc",
version: "published",
first_published_at_gt: startDate.toISOString(),
first_published_at_lt: endDate.toISOString(),
page,
})
posts.push(...data.stories)
if (total > posts.length) await getPage(page + 1)
}
await getPage(1)
return posts
}The blog posts in Storyblok are organized by category under paths like blog/technology/..., blog/design/..., blog/marketing/.... The script extracts the category from each post's slug, which is used both for grouping content and for matching against subscriber Groups. If you're using a different CMS, you'd swap out the API call above but keep the same downstream logic — the rest of the pipeline doesn't care where the content came from.
If no blog posts are found for a given date range, the campaign is simply skipped — no empty emails go out.
Step 4: Two Layers of Personalization
This is where it gets interesting. We use two complementary personalization techniques:
Layer 1: Audience Segmentation (Who receives the campaign)
When creating each Mailchimp campaign, the script dynamically builds segment conditions that act as audience filters. The conditions use AND logic (match: "all") with two rules:
Frequency match — The subscriber must have opted into this campaign's frequency (e.g., "Weekly")
Topic match — The subscriber must be interested in at least one of the blog categories that have new posts in this batch
function buildSegmentConditions(interestData, frequencyLabel, categoryNames) {
const conditions = []
// Condition 1: Subscriber opted into this frequency
const frequencyGroup = interestData["Frequency"]
conditions.push({
condition_type: "Interests",
field: `interests-${frequencyGroup.categoryId}`,
op: "interestcontains",
value: [frequencyGroup.interests[frequencyLabel]],
})
// Condition 2: Subscriber is interested in at least one category in this batch
const topicsGroup = interestData["Topics"]
const topicInterestIds = categoryNames
.map((name) => topicsGroup.interests[name])
.filter(Boolean)
conditions.push({
condition_type: "Interests",
field: `interests-${topicsGroup.categoryId}`,
op: "interestcontains",
value: topicInterestIds,
})
return { match: "all", conditions }
}This means a subscriber who chose "Weekly" frequency and is interested in "Technology" and "Design" will only receive the weekly campaign — and only if that week had new blog posts in Technology or Design.
Layer 2: Conditional Content (What they see inside the email)
Even within a single campaign, different subscribers see different content. The email HTML uses Mailchimp's conditional merge tags to show/hide category sections based on each subscriber's Groups:
*|INTERESTED:Topics:Technology|*
<table>
<!-- Technology blog posts -->
</table>
*|END:INTERESTED|*
*|INTERESTED:Topics:Design|*
<table>
<!-- Design blog posts -->
</table>
*|END:INTERESTED|*
*|INTERESTED:Topics:Marketing|*
<table>
<!-- Marketing blog posts -->
</table>
*|END:INTERESTED|*So if the weekly digest contains posts about Technology, Design, and Marketing — a subscriber interested only in Technology and Design will see just those two sections. The Marketing section is hidden for them entirely.
This two-layer approach is key:
Layer 1 prevents subscribers from receiving campaigns that have nothing relevant to them
Layer 2 ensures that even within a relevant campaign, subscribers only see the categories they care about
Step 5: Generating the Email HTML
The script builds the complete email HTML programmatically — no templates stored in Mailchimp, no WYSIWYG editor. Blog posts are grouped by category, each section gets the category's icon, and the whole thing is wrapped in a responsive table-based email layout:
function generateEmailContent(blogPosts, date) {
// Group posts by category
const groupedByCategory = {}
for (const post of blogPosts) {
const category = getCategoryFromSlug(post.full_slug)
if (!groupedByCategory[category]) groupedByCategory[category] = []
groupedByCategory[category].push(post)
}
let categorySections = ""
for (const [category, posts] of Object.entries(groupedByCategory)) {
const entriesHtml = posts.map((post) => `
<tr>
<td style="padding: 20px 0; border-bottom: 1px solid #e5e7eb;">
<h2 style="margin: 0; font-size: 18px;">
<a href="${SITE_URL}/${post.full_slug}">${post.content.title || post.name}</a>
</h2>
<p style="margin: 12px 0 0; color: #4b5563; font-size: 14px;">
${post.summary || ""}
</p>
</td>
</tr>
`).join("")
// Wrap in Mailchimp conditional merge tags
categorySections += `
*|INTERESTED:Topics:${category}|*
<table width="100%">
<tr><td><img src="${categoryIcons[category]}" alt="${category}" /></td></tr>
${entriesHtml}
</table>
*|END:INTERESTED|*
`
}
// ... wrap in full HTML document with header and footer
}Every piece of content comes straight from the CMS. The script is purely a transformation layer.
Step 6: Creating and Sending the Campaign
With the audience segment and HTML content ready, the script creates the campaign via Mailchimp's API and immediately sends it:
// Create the campaign with dynamic segments
const campaign = await mc.campaigns.create({
type: "regular",
recipients: {
list_id: listId,
segment_opts: segmentOpts, // Dynamic frequency + topic filtering
},
settings: {
subject_line: subject,
preview_text: `${posts.length} new posts this week`,
from_name: "Your Blog Name",
reply_to: "hello@yourblog.com",
},
})
// Set the HTML content
await mc.campaigns.setContent(campaign.id, { html: emailHtml })
// Send immediately
await mc.campaigns.send(campaign.id)The subject line is frequency-aware:
Daily: "Blog Digest - Tuesday, February 10, 2026"
Weekly: "Weekly Blog Digest - Week of February 4, 2026"
Monthly: "Monthly Blog Digest - January 2026"
Step 7: CI/CD with GitHub Actions
The GitHub Actions workflow is minimal. It checks out the repo, installs dependencies, and runs the script with the required secrets injected as environment variables:
name: Daily newsletter campaign
on:
schedule:
- cron: "0 14 * * *"
workflow_dispatch: {}
jobs:
send-newsletter:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: node scripts/create-newsletter-campaign.js
env:
MAILCHIMP_API_KEY: ${{ secrets.MAILCHIMP_API_KEY }}
MAILCHIMP_LIST_ID: ${{ secrets.MAILCHIMP_LIST_ID }}
CMS_API_TOKEN: ${{ secrets.CMS_API_TOKEN }}The workflow_dispatch trigger also allows manual runs from the GitHub UI — useful for testing or re-sending after a failed run.
What We Learned
1. Mailchimp's interestcontains operator is powerful but underdocumented. It lets you match subscribers who have at least one of the specified interests, which is exactly what we needed for the topic filter. Finding this in the API docs took some digging.
2. Two layers of personalization is better than one. Segment conditions alone would mean a subscriber interested in 3 categories still sees posts for all categories in the campaign. Merge tag conditionals inside the email body solve this elegantly.
3. A single daily cron that conditionally branches is simpler than multiple workflows. The shouldRun() pattern keeps all the scheduling logic in one place and makes it easy to test any frequency with a CLI flag.
4. Generating HTML programmatically is fine for digest-style emails. We don't need Mailchimp's template editor. The email is data-driven, and keeping the template in code means it's version-controlled and testable.
5. Always handle the "no content" case. If there are no new blog posts for a given period, the script skips that campaign entirely rather than sending an empty email. This seems obvious but is easy to forget.
The Result
Every subscriber gets exactly the newsletter they signed up for:
The right content — only posts from categories they selected
At the right cadence — daily, weekly, or monthly, their choice
With zero manual effort — the pipeline runs autonomously, pulling fresh content from the CMS and delivering it through Mailchimp
The entire system is a single ~800-line Node.js script, a 32-line GitHub Actions workflow, and the Mailchimp Groups that were already part of the subscriber signup flow. Sometimes the best automation is the simplest one.