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:

  1. Headless CMS (e.g., Storyblok, Contentful, Sanity) — where blog posts are published, organized by category

  2. Mailchimp — where the subscriber list lives, with Groups tracking each subscriber's category preferences and frequency choice

  3. 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

  4. 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, Monthly

  • Topics — 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 too

The 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:

  1. Frequency match — The subscriber must have opted into this campaign's frequency (e.g., "Weekly")

  2. 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.