diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..71497e52
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,2 @@
+MAILCHIMP_API_KEY=your_key-usX
+MAILCHIMP_SOURCE_CAMPAIGN_ID=your_campaign_id
diff --git a/.gitignore b/.gitignore
index 718a8330..2bbe9698 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ _site
.bundle
vendor
.env
+.env.local
# Script output
tmp/pending_updates.yml
diff --git a/MAILCHIMP_NEWSLETTER_SETUP.md b/MAILCHIMP_NEWSLETTER_SETUP.md
new file mode 100644
index 00000000..e2ce9a86
--- /dev/null
+++ b/MAILCHIMP_NEWSLETTER_SETUP.md
@@ -0,0 +1,80 @@
+# Mailchimp Newsletter Setup
+
+This guide gets you from zero to a draft Mailchimp campaign populated with conference data from this repo.
+
+## 1) Create Mailchimp API credentials
+
+1. In Mailchimp, open `Account -> Extras -> API keys`.
+2. Create a new API key.
+3. Copy the key value (it should end with a datacenter suffix like `-us6`).
+
+## 2) Choose your source campaign
+
+1. Open an existing Mailchimp campaign that has the format you want.
+2. Copy its campaign ID.
+3. Add this placeholder to the campaign HTML where event content should go:
+ - `{{TESTING_CONFERENCES_CONTENT}}`
+
+If the placeholder is missing, the script appends the generated event block near the end of the email.
+
+## 3) Configure local secrets
+
+1. Copy `.env.example` to `.env`.
+2. Set the values in `.env`:
+
+```dotenv
+MAILCHIMP_API_KEY=your_key-usX
+MAILCHIMP_SOURCE_CAMPAIGN_ID=your_campaign_id
+```
+
+## 4) Preview newsletter content
+
+Run a dry run first:
+
+```bash
+ruby tools/mailchimp_replicate_newsletter.rb --dry-run --limit 5
+```
+
+This generates HTML from `_data/current.yml` and prints it locally without calling Mailchimp.
+
+## 5) Create a draft in Mailchimp
+
+```bash
+ruby tools/mailchimp_replicate_newsletter.rb --subject "Testing Conferences: Monthly Update"
+```
+
+What this does:
+
+1. Replicates your source campaign format.
+2. Filters upcoming events from `_data/current.yml` (default: next 60 days).
+3. Replaces `{{TESTING_CONFERENCES_CONTENT}}` with generated event HTML.
+4. Updates title (and subject if provided).
+
+## 6) Review and send
+
+1. Open the new draft in Mailchimp.
+2. Verify formatting and links.
+3. Send a test email.
+4. Schedule or send.
+
+## Useful options
+
+```bash
+# Include up to 12 events (default)
+ruby tools/mailchimp_replicate_newsletter.rb --limit 12
+
+# Change date window (default 60 days)
+ruby tools/mailchimp_replicate_newsletter.rb --days-ahead 45
+
+# Use a custom placeholder token
+ruby tools/mailchimp_replicate_newsletter.rb --placeholder "{{MY_EVENTS_BLOCK}}"
+```
+
+## Troubleshooting
+
+1. `MAILCHIMP_API_KEY is required`:
+ - Check `.env` exists in repo root and key name is exact.
+2. `MAILCHIMP_SOURCE_CAMPAIGN_ID is required`:
+ - Add the campaign ID to `.env` or pass `--source-campaign`.
+3. `No upcoming events found`:
+ - Increase `--days-ahead` or verify event dates in `_data/current.yml`.
diff --git a/README.md b/README.md
index 9d40b138..63357536 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,8 @@ A good _heuristic_ for whether a conference should be included is if its name in
Don't forget to **[sign up](http://eepurl.com/c4paYT)** for our once **monthly newsletter.**
+For Mailchimp API setup and the replicate workflow, follow `MAILCHIMP_NEWSLETTER_SETUP.md`.
+
## Versioning and Deployments
The site uses an automated versioning system to track each deployment:
diff --git a/tools/mailchimp_replicate_newsletter.rb b/tools/mailchimp_replicate_newsletter.rb
new file mode 100755
index 00000000..e1af3993
--- /dev/null
+++ b/tools/mailchimp_replicate_newsletter.rb
@@ -0,0 +1,268 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require 'base64'
+require 'date'
+require 'json'
+require 'net/http'
+require 'optparse'
+require 'uri'
+require 'yaml'
+
+DATA_FILE = File.expand_path('../_data/current.yml', __dir__)
+ENV_FILE = File.expand_path('../.env', __dir__)
+DEFAULT_LIMIT = 12
+DEFAULT_DAYS_AHEAD = 60
+DEFAULT_PLACEHOLDER = '{{TESTING_CONFERENCES_CONTENT}}'
+MONTH_PATTERN = '(?:January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)'
+
+def normalize_month(name)
+ case name.to_s.downcase
+ when 'jan' then 'January'
+ when 'feb' then 'February'
+ when 'mar' then 'March'
+ when 'apr' then 'April'
+ when 'may' then 'May'
+ when 'jun' then 'June'
+ when 'jul' then 'July'
+ when 'aug' then 'August'
+ when 'sep', 'sept' then 'September'
+ when 'oct' then 'October'
+ when 'nov' then 'November'
+ when 'dec' then 'December'
+ else
+ name.to_s.capitalize
+ end
+end
+
+def build_date(month_name, day, year)
+ month_index = Date::MONTHNAMES.index(month_name)
+ return nil unless month_index
+
+ Date.new(year.to_i, month_index, day.to_i)
+rescue ArgumentError
+ nil
+end
+
+def parse_end_date(value)
+ return nil if value.nil?
+
+ s = value.to_s.strip.gsub(/[–—]/, '-')
+
+ if (m = s.match(/(#{MONTH_PATTERN})\s+(\d{1,2})\s*-\s*(#{MONTH_PATTERN})\s*(\d{1,2}),?\s*(\d{4})$/i))
+ return build_date(normalize_month(m[3]), m[4], m[5])
+ end
+
+ if (m = s.match(/(#{MONTH_PATTERN})\s+\d{1,2}\s*-\s*(\d{1,2}),?\s*(\d{4})$/i))
+ return build_date(normalize_month(m[1]), m[2], m[3])
+ end
+
+ if (m = s.match(/(#{MONTH_PATTERN})\s+\d{1,2}\s*-\s*(\d{1,2})\s+(#{MONTH_PATTERN}),?\s*(\d{4})$/i))
+ return build_date(normalize_month(m[3]), m[2], m[4])
+ end
+
+ if (m = s.match(/(#{MONTH_PATTERN})\s+(\d{1,2}),?\s*(\d{4})$/i))
+ return build_date(normalize_month(m[1]), m[2], m[3])
+ end
+
+ Date.parse(s)
+rescue ArgumentError
+ nil
+end
+
+def strip_html(text)
+ text.to_s.gsub(%r{<[^>]+>}, '').gsub(/\s+/, ' ').strip
+end
+
+def html_escape(text)
+ text.to_s
+ .gsub('&', '&')
+ .gsub('<', '<')
+ .gsub('>', '>')
+ .gsub('"', '"')
+ .gsub("'", ''')
+end
+
+def unquote_env_value(value)
+ return value[1..-2].gsub('\"', '"').gsub("\\'", "'") if value.start_with?('"') && value.end_with?('"')
+ return value[1..-2].gsub("\\'", "'").gsub('\\"', '"') if value.start_with?("'") && value.end_with?("'")
+
+ value
+end
+
+def load_dotenv(path)
+ return unless File.exist?(path)
+
+ File.foreach(path) do |line|
+ stripped = line.strip
+ next if stripped.empty? || stripped.start_with?('#')
+
+ stripped = stripped.sub(/^export\s+/, '')
+ key, value = stripped.split('=', 2)
+ next if key.nil? || value.nil?
+
+ key = key.strip
+ value = unquote_env_value(value.strip)
+ next if key.empty? || value.empty?
+ next unless ENV[key].to_s.strip.empty?
+
+ ENV[key] = value
+ end
+end
+
+class MailchimpClient
+ def initialize(api_key)
+ @api_key = api_key
+ dc = api_key.to_s.split('-').last
+ raise ArgumentError, 'MAILCHIMP_API_KEY must end with data center suffix (for example: -us6)' if dc.nil? || dc.empty?
+
+ @base_uri = URI("https://#{dc}.api.mailchimp.com/3.0")
+ end
+
+ def request(method, path, body: nil)
+ uri = URI.join(@base_uri.to_s + '/', path.sub(%r{^/}, ''))
+ req_class = case method.upcase
+ when 'GET' then Net::HTTP::Get
+ when 'POST' then Net::HTTP::Post
+ when 'PATCH' then Net::HTTP::Patch
+ when 'PUT' then Net::HTTP::Put
+ else
+ raise ArgumentError, "Unsupported method: #{method}"
+ end
+
+ req = req_class.new(uri)
+ req['Authorization'] = "Basic #{Base64.strict_encode64("anystring:#{@api_key}")}"
+ req['Content-Type'] = 'application/json'
+ req.body = JSON.generate(body) if body
+
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = true
+ response = http.request(req)
+
+ parsed = response.body.to_s.empty? ? {} : JSON.parse(response.body)
+ return parsed if response.code.to_i.between?(200, 299)
+
+ detail = parsed.is_a?(Hash) ? parsed['detail'] : response.body
+ raise "Mailchimp API error (#{response.code} #{response.message}) on #{method} #{path}: #{detail}"
+ end
+end
+
+def build_events_html(events)
+ items = events.map do |event|
+ name = html_escape(event['name'])
+ url = html_escape(event['url'])
+ dates = html_escape(event['dates'])
+ location = html_escape(event['location'])
+ status = strip_html(event['status'])
+ status_html = status.empty? ? '' : "
#{html_escape(status)}"
+
+ [
+ '
Curated from testingconferences.org.
', + '