Emacs: Updating a Mailchimp campaign using a template, sending test e-mails, and scheduling it

| emacs

I'm helping other volunteers get on board with doing the Bike Brigade newsletter. Since not everyone has access to (or the patience for) MailChimp, we've been using Google Docs to draft the newsletter and share it with other people behind the scenes. I've previously written about getting a Google Docs draft ready for Mailchimp via Emacs and Org Mode, which built on my code for transforming HTML clipboard contents to smooth out Mailchimp annoyances: dates, images, comments, colours. Now I've figured out how to update, test, and schedule the MailChimp campaign directly from Emacs so that I don't even have to go into the MailChimp web interface at all. I added those functions to sachac/mailchimp-el.

I used to manually download a ZIP of the Google Docs newsletter draft. I didn't feel like figuring out authentication and Google APIs from Emacs, so I did that in a NodeJS script instead. convert-newsletter.js can either create or download the latest newsletter doc from our Google Shared Drive. (google-api might be helpful if I want to do this in Emacs, not sure.) If I call convert-newsletter.js with the download argument, it unpacks the zip into ~/proj/bike-brigade/temp_newsletter, where my Emacs Lisp function for processing the latest newsletter draft with images can turn it into the HTML to insert into the HTML template I've previously created. I've been thinking about whether I want to move my HTML transformation code to NodeJS as well so that I could run the whole thing from the command-line and possibly have other people run this in the future, or if I should just leave it in Emacs for my convenience.

Updating the campaign through the Mailchimp API means that I don't have to log in, replicate the campaign, click on the code block, and paste in the code. Very nice, no clicks needed. I also use TRAMP to write the HTML to a file on my server (my-bike-brigade-output-file is of the form /ssh:hostname:/path/to/file) so that other volunteers can get a web preview without waiting for the test email.

(defun my-brigade-next-campaign (&optional date)
  (setq date (or date (org-read-date nil nil "+Sun")))
  (seq-find
   (lambda (o)
     (string-match (concat "^" date)
                   (alist-get 'title (alist-get 'settings o))))
   (alist-get 'campaigns (mailchimp-campaigns 5))))

(defvar my-bike-brigade-output-file nil)

(defun my-brigade-download-newsletter-from-google-docs ()
  "Download the newsletter from Google Docs and puts it in ~/proj/bike-brigade/temp_newsletter/."
  (interactive)
  (let ((default-directory "~/proj/bike-brigade"))
    (with-current-buffer (get-buffer-create "*Newsletter*")
      (erase-buffer)
      (display-buffer (current-buffer))
      (call-process "node" nil t t "convert-newsletter.js" "download"))))

(defun my-brigade-create-or-update-campaign ()
  (interactive)
  (let* ((date (org-read-date nil nil "+Sun"))
         (template-name "Bike Brigade weekly update")
         (list-name "Bike Brigade")
         (template-id
          (alist-get
           'id
           (seq-find
            (lambda (o)
              (string= template-name (alist-get 'name o)))
            (alist-get 'templates (mailchimp--request-json "templates")))))
         (list-id (seq-find
                   (lambda (o)
                     (string= list-name
                              (alist-get 'name o)))
                   (alist-get 'lists (mailchimp--request-json "lists"))))
         (campaign (my-brigade-next-campaign date))
         (body `((type . "regular")
                 (recipients (list_id . ,(alist-get 'id list-id)))
                 (settings
                  (title . ,date)
                  (subject_line . "Bike Brigade: Weekly update")
                  (from_name . "Bike Brigade")
                  (reply_to . "info@bikebrigade.ca")
                  (tracking
                   (opens . t)
                   (html_clicks . t))))))
    (unless campaign
      (setq campaign (mailchimp--request-json
                      "/campaigns"
                      :method "POST"
                      :body
                      body)))
    ;; Download the HTML
    (my-brigade-download-newsletter-from-google-docs)
    ;; Upload to Mailchimp
    (mailchimp-campaign-update-from-template
     (alist-get 'id campaign)
     template-id
     (list
      (cons "main_content_area"
            (my-brigade-process-latest-newsletter-draft-with-images
             date))))
    (when my-bike-brigade-output-file
      (with-temp-file my-bike-brigade-output-file
        (insert (alist-get 'html (mailchimp--request-json (format "/campaigns/%s/content" (alist-get 'id campaign)))))))
    (message "%s" "Done!")))

Now to send the test e-mails…

(defvar my-brigade-test-emails nil "Set to a list of e-mail addresses.")
(defun my-brigade-send-test-to-me ()
  (interactive)
  (mailchimp-campaign-send-test-email (my-brigade-next-campaign) user-mail-address))

(defun my-brigade-send-test ()
  (interactive)
  (if my-brigade-test-emails
      (mailchimp-campaign-send-test-email (my-brigade-next-campaign) my-brigade-test-emails)
    (error "Set `my-brigade-test-emails'.")))

And schedule it:

(defun my-brigade-schedule ()
  (interactive)
  (let ((sched (format-time-string "%FT%T%z" (org-read-date t t "+Sun 11:00") t))
        (campaign (my-brigade-next-campaign)))
    (mailchimp-campaign-schedule campaign sched)
    (message "Scheduled %s" (alist-get 'title (alist-get 'settings campaign)))))

Progress, bit by bit! Here's a screenshot showing the Google Docs draft on one side and my web preview in the other:

2026-01-17_13-00-27.png
Figure 1: Google Docs and Mailchimp campaign preview

It'll be even cooler if I can get some of this working via systemd persistent tasks so that they happen automatically, or have some kind of way for the other newsletter volunteers to trigger a rebuild. Anyway, here's https://github.com/sachac/mailchimp-el in case the code is useful for anyone else.

This is part of my Emacs configuration.
View org source for this post