├── Gemfile ├── LICENSE ├── README.md ├── mailchimp.rb ├── messages └── 2021-july.md └── template.html /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gem "httparty" 3 | gem "kramdown" 4 | gem "front_matter_parser" 5 | gem "MailchimpMarketing" 6 | gem "roadie", "~> 4.0" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Robin Sloan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## A minimalist Mailchimp CLI 2 | 3 | I have not created a campaign by hand in Mailchimp in six months, instead happily managing them using this script, so I thought I'd share it. 4 | 5 | Here's how it works. Start by drafting your email in `messages/`. The message's "slug" thereafter will be the filename before `.md`; the slug, for example, of the message included in this project is `2021-july`. 6 | 7 | To create the campaign in Mailchimp: 8 | 9 | `ruby mailchimp.rb 2021-july create` 10 | 11 | Then, send a test email to an address of your choosing: 12 | 13 | `ruby mailchimp.rb 2021-july test frodo@well.com` 14 | 15 | Spot an error? Edit the Markdown file, then update the campaign in Mailchimp: 16 | 17 | `ruby mailchimp.rb 2021-july update` 18 | 19 | You can also delete the campaign from Mailchimp (leaving the Markdown file intact): 20 | 21 | `ruby mailchimp.rb 2021-july delete` 22 | 23 | That's it! There is currently no way to send from the command line, because the thought of making a mistake freaked me out too much. Better to log in, give everything a final look, and send from the Mailchimp website. 24 | 25 | The template is in `template.html` and is presently very minimal. You can replace it, of course, with an HTML email template from elsewhere on Github or one of your own devising. Note the `__YIELD__`. 26 | 27 | The basic configuration, including your Mailchimp API key, is stored in a file called `config.yaml` which you'll have to create yourself. It has this form: 28 | 29 | ``` 30 | api_key: "foo" 31 | list_id: "bar" 32 | data_center: "blee" 33 | from_name: "Frodo Baggins" 34 | reply_to: "frodo@well.com" 35 | ``` 36 | 37 | Note that by `list_id` I mean the ID of your Mailchimp audience; [instructions for finding that ID are here](https://mailchimp.com/help/find-audience-id/). 38 | 39 | As you'll see, this system is designed to send campaigns to audience segments based on tags. That is *probably* not what you want, so you'll have to rip out that logic, which is easily done: just delete `"segment_opts" => segment_options` in `update_campaign_content`. 40 | 41 | I don't really expect this to be used as-is by anyone else, but it would have been a helpful starting point for me, six months ago, so now it can be a starting point for you! -------------------------------------------------------------------------------- /mailchimp.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "kramdown" 3 | require "front_matter_parser" 4 | require "yaml/store" 5 | require "MailchimpMarketing" 6 | require "roadie" 7 | 8 | unless File.exist?("config.yaml") 9 | puts "You need to create a file called config.yaml that contains your Mailchimp API key and some other configuration info. Look in mailchimp.rb for a template." 10 | exit 11 | end 12 | 13 | # config.yaml template: 14 | # --- 15 | # api_key: "foo" 16 | # list_id: "bar" 17 | # data_center: "blee" 18 | # from_name: "Frodo Baggins" 19 | # reply_to: "frodo@well.com" 20 | 21 | CONFIG = YAML.load_file("config.yaml") 22 | API_KEY = CONFIG["api_key"] 23 | LIST_ID = CONFIG["list_id"] 24 | DATA_CENTER = CONFIG["data_center"] 25 | FROM_NAME = CONFIG["from_name"] 26 | REPLY_TO = CONFIG["reply_to"] 27 | 28 | $db = YAML::Store.new "campaigns.yaml" 29 | $client = MailchimpMarketing::Client.new 30 | $client.set_config( { api_key: API_KEY, server: DATA_CENTER } ) 31 | 32 | $tag_ids = {} 33 | tag_lookup = $client.lists.list_segments( LIST_ID ) 34 | tag_lookup["segments"].each do |segment| 35 | $tag_ids[segment["name"]] = segment["id"] 36 | end 37 | 38 | def update_tags 39 | $tag_ids.each do |tag_name, tag_id| 40 | $client.lists.update_segment( LIST_ID, tag_id, { "name" => tag_name } ) 41 | end 42 | end 43 | 44 | def create_new_campaign( with_slug: ) 45 | 46 | $db.transaction do 47 | if $db[with_slug] != nil 48 | puts "You already have a message with that slug!" 49 | return 50 | end 51 | end 52 | 53 | filename = "messages/#{with_slug}.md" 54 | 55 | unless File.exist?(filename) 56 | puts "There's no file at #{filename}..." 57 | return 58 | end 59 | 60 | parsed_content = FrontMatterParser::Parser.parse_file( filename ) 61 | 62 | header = parsed_content.front_matter 63 | 64 | response = $client.campaigns.create( 65 | { "type" => "regular", 66 | "recipients" => { "list_id" => LIST_ID }, 67 | "settings" => { "subject_line" => header["subject_line"], 68 | "title" => header["title"], 69 | "preview_text" => header["preview"], 70 | "from_name" => FROM_NAME, 71 | "reply_to" => REPLY_TO, 72 | "auto_footer" => false }, 73 | "tracking" => { "opens" => true, 74 | "html_clicks" => true } 75 | } ) 76 | 77 | campaign_id = response["id"] 78 | 79 | $db.transaction do 80 | unless $db[with_slug] 81 | $db[with_slug] = { "id" => campaign_id, 82 | "subject_line" => header["subject_line"], 83 | "title" => header["title"], 84 | "preview_text" => header["preview"], 85 | "created_at" => Time.now } 86 | end 87 | end 88 | 89 | update_campaign_content( with_slug: with_slug ) 90 | 91 | return campaign_id 92 | end 93 | 94 | TEMPLATE_PATH = "template.html" 95 | YIELD_STRING = "__YIELD__" 96 | 97 | # I'm putting this here, rather than in the template, because Markdown has 98 | # problems with the pipe character used in Mailchimp's special codes and 99 | # this was the easiest way to solve the problem! 100 | 101 | UNSUBSCRIBE_CONTENT_HTML = <<~HEREDOC 102 |
103 |

You’re receiving this message because you are a member in good standing of the Society of the Double Dagger. I’m Robin Sloan, author of the novels Sourdough and Mr. Penumbra’s 24-Hour Bookstore.

104 |

You can unsubscribe from all emails instantly.

105 | HEREDOC 106 | 107 | UNSUBSCRIBE_CONTENT_TEXT = <<~HEREDOC 108 | You're receiving this message because you are a member in good standing of the Society of the Double Dagger. I'm Robin Sloan, author of the novels Sourdough and Mr. Penumbra's 24-Hour Bookstore. 109 | 110 | You can unsubscribe from all emails instantly: *|UNSUB|* 111 | HEREDOC 112 | 113 | def update_campaign_content( with_slug: ) 114 | 115 | $db.transaction do 116 | unless $db[with_slug] 117 | puts "There's no campaign with that slug..." 118 | return 119 | end 120 | end 121 | 122 | filename = "messages/#{with_slug}.md" 123 | 124 | parsed_content = FrontMatterParser::Parser.parse_file(filename) 125 | header = parsed_content.front_matter 126 | 127 | to_tags = header["tags"] 128 | 129 | # By including this dummy condition, you force Mailchimp to dynamically 130 | # recalculate the segment each time, which is what you want; without it, 131 | # I found that Mailchimp would sometimes send to "stale" segments. 132 | # It's a bit gross, I know! 133 | 134 | dummy_condition = [{ "condition_type" => "TextMerge", 135 | "field" => "HASH", 136 | "op" => "is", 137 | "value" => "foo" 138 | }] 139 | 140 | segment_options = { "match" => "any", 141 | "conditions" => dummy_condition + to_tags.map do |tag| 142 | { "condition_type" => "StaticSegment", 143 | "field" => "static_segment", 144 | "op" => "static_is", 145 | "value" => $tag_ids[tag] 146 | } 147 | end 148 | } 149 | 150 | markdown_content = parsed_content.content 151 | html_content = Kramdown::Document.new(markdown_content).to_html 152 | 153 | template = File.open(TEMPLATE_PATH).read 154 | html_email = template.gsub(YIELD_STRING, html_content + UNSUBSCRIBE_CONTENT_HTML) 155 | 156 | document_to_be_inlined = Roadie::Document.new(html_email) 157 | inlined_html_email = document_to_be_inlined.transform 158 | inlined_html_email = inlined_html_email.gsub("%7C", "|") # come ON 159 | 160 | text_email = markdown_content.strip + "\n\n" + UNSUBSCRIBE_CONTENT_TEXT 161 | 162 | $db.transaction do 163 | if $db[with_slug] 164 | campaign_id = $db[with_slug]["id"] 165 | $client.campaigns.update( campaign_id, 166 | { "recipients" => { "list_id" => LIST_ID, 167 | "segment_opts" => segment_options }, 168 | "settings" => { "subject_line" => header["subject_line"], 169 | "title" => header["title"], 170 | "preview_text" => header["preview"], 171 | "from_name" => FROM_NAME, 172 | "reply_to" => REPLY_TO, 173 | "auto_footer" => false }, 174 | "tracking" => { "opens" => true, 175 | "html_clicks" => true } 176 | } ) 177 | 178 | $client.campaigns.set_content( campaign_id, {"html" => inlined_html_email, "plain_text" => text_email } ) 179 | 180 | created_at = $db[with_slug]["created_at"] 181 | 182 | $db[with_slug] = { "id" => campaign_id, 183 | "subject_line" => header["subject_line"], 184 | "title" => header["title"], 185 | "preview_text" => header["preview"], 186 | "to_tags" => to_tags, 187 | "created_at" => created_at, 188 | "modified_at" => Time.now } 189 | end 190 | end 191 | 192 | end 193 | 194 | def test_campaign( with_slug:, to_email: ) 195 | $db.transaction do 196 | if $db[with_slug] 197 | campaign_id = $db[with_slug]["id"] 198 | response = $client.campaigns.send_test_email( campaign_id, 199 | { "test_emails" => [to_email], 200 | "send_type" => "html" } ) 201 | puts response 202 | puts "Sent test email!" 203 | else 204 | puts "There's no campaign with that slug..." 205 | return 206 | end 207 | end 208 | end 209 | 210 | def delete_campaign( with_slug: ) 211 | $db.transaction do 212 | if $db[with_slug] 213 | campaign_id = $db[with_slug]["id"] 214 | $client.campaigns.remove(campaign_id) 215 | $db[with_slug] = nil 216 | else 217 | puts "There's no campaign with that slug..." 218 | return 219 | end 220 | end 221 | 222 | puts "Deleted campaign with slug #{with_slug}." 223 | end 224 | 225 | unless ARGV.length > 1 226 | puts "You need some arguments!" 227 | exit 228 | end 229 | 230 | slug = ARGV[0] 231 | 232 | case ARGV[1] 233 | when "create" 234 | id = create_new_campaign( with_slug: slug ) 235 | puts "Created new campaign with slug #{slug} and id #{id}" 236 | when "delete" 237 | delete_campaign( with_slug: slug ) 238 | when "update" 239 | id = update_campaign_content( with_slug: slug ) 240 | puts "Updated campaign with slug #{slug}" 241 | when "test" 242 | if ARGV[2] 243 | test_campaign( with_slug: slug, to_email: ARGV[2] ) 244 | else 245 | test_campaign( with_slug: slug, to_email: "rsloan@gmail.com" ) 246 | end 247 | when "delete" 248 | delete_campaign( with_slug: slug ) 249 | when "send" 250 | puts "Sending from the command line is disabled. Go press the button in Mailchimp!" 251 | else 252 | puts "That's not a valid command." 253 | end -------------------------------------------------------------------------------- /messages/2021-july.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: July 2021 Github Test 3 | subject_line: Testing the Mailchimp CLI 4 | tags: ["main"] 5 | preview: This is just a test message. 6 | --- 7 | Hello! This is the email, right here. 8 | 9 | [And this is a link.](https://society.robinsloan.com) 10 | 11 | Bye! -------------------------------------------------------------------------------- /template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 89 | 90 | 91 |
92 | __YIELD__ 93 |
94 | 95 | --------------------------------------------------------------------------------