commit b3fa7721524b1a6c5dc3ddff8856d7293c86486a Author: abnercoimbre Date: Tue Apr 23 15:07:14 2024 -0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e51b2ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Created by https://www.toptal.com/developers/gitignore/api/go,vim +# Edit at https://www.toptal.com/developers/gitignore?templates=go,vim + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +vendor/ +data/ + +# Go workspace file +go.work + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +# End of https://www.toptal.com/developers/gitignore/api/go,vim diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..70b72de --- /dev/null +++ b/config.toml @@ -0,0 +1,18 @@ +# User Constants - Update as needed +batch_size = 500 # Max 500 messages per batch: https://postmarkapp.com/developer/api/templates-api#send-batch-with-templates +test_email = "" # Fill this in with your own email address + +# Handmade Cities Credentials - DON'T TOUCH +[hmc] +api_url = "https://api.handmadecities.com/v1/meetups/subscribers" +shared_secret = "Land_OF_TERminA__MAJORAS_LAIR#666!" +city = "Termina" + +# Postmark App Credentials - NO TOUCHY TOUCHY +[postmark] +server_token = "37967b9a-6b1c-430e-a65f-de043de3e568" +api_url = "https://api.postmarkapp.com/email/batchWithTemplates" +template_id = 35682307 +sender_name = "Skull Kid" +sender_email = "majora@handmadecities.com" +message_stream = "termina" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d074dc1 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module git.handmadecities.com/abner/meetupinvite2000 + +go 1.21.5 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/spf13/cobra v1.8.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ae51351 --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..4f9352c --- /dev/null +++ b/main.go @@ -0,0 +1,512 @@ +// Meetup Invite 2000 +// @author: Abner Coimbre +// Copyright (c) 2024 Handmade Cities + +// Based off News Blaster 2000 from the Handmade Network: https://git.handmade.network/hmn/newsblaster2000 + +// ============================ Contributors ========================= +// Bug & warning fixes +// Jacob Bell (@MysteriousJ) +// Asaf Gartner +// +// Emotional Support +// Cucui Ganon Rosario +// ========================================================================= + +// WARNING: This program requires a companion config.toml file provided by Abner. Without it we will crash! + +package main + +import ( + "bytes" + "crypto/sha1" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "io/ioutil" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/pelletier/go-toml/v2" + "github.com/russross/blackfriday/v2" + "github.com/spf13/cobra" +) + +type Config struct { + BatchSize int `toml:"batch_size"` + TestEmail string `toml:"test_email"` + HMC struct { + ApiUrl string `toml:"api_url"` + SharedSecret string `toml:"shared_secret"` + City string `toml:"city"` + } + Postmark struct { + ServerToken string `toml:"server_token"` + ApiUrl string `toml:"api_url"` + TemplateId int `toml:"template_id"` + SenderName string `toml:"sender_name"` + SenderEmail string `toml:"sender_email"` + MessageStream string `toml:"message_stream"` + } `toml:"postmark"` +} + +func retrieveSubscribers(cfg *Config) ([]string, error) { + /* Prepare request payload */ + /* *** */ + payload := map[string]string{ + "city": cfg.HMC.City, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("error encoding payload: %v", err) + } + + /* Prepare request */ + /* *** */ + req, err := http.NewRequest("POST", cfg.HMC.ApiUrl, bytes.NewReader(payloadBytes)) + if err != nil { + return nil, fmt.Errorf("error creating request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", cfg.HMC.SharedSecret) + + /* Execute request */ + /* *** */ + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %v", err) + } + defer resp.Body.Close() + + /* Fail if status code is not in the 2xx range */ + /* *** */ + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("error: HTTP status code %d", resp.StatusCode) + } + + /* Read response's body */ + /* *** */ + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %v", err) + } + + /* Parse JSON response */ + /* *** */ + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("error unmarshalling JSON: %v", err) + } + + /* Extract the emails from the response */ + /* *** */ + emails, ok := data["emails"].([]interface{}) + if !ok { + return nil, errors.New("response format error: 'emails' field not found") + } + + /* Convert emails to []string */ + /* *** */ + var subscribers []string + for _, email := range emails { + if emailStr, ok := email.(string); ok { + subscribers = append(subscribers, emailStr) + } + } + + return subscribers, nil +} + +func parseMailFile(contents string) (subject string, body string, valid bool) { + /* Remove leading and trailing newlines */ + /* *** */ + contents = strings.TrimSpace(contents) + lines := strings.Split(contents, "\n") + + /* Check if there is at least one line */ + /* *** */ + if len(lines) == 0 { + return "", "", false + } + + /* Check if the first line is a title with the '#' Markdown symbol */ + /* *** */ + if strings.HasPrefix(lines[0], "#") { // Extract subject without the '#' symbol and leading/trailing spaces + subject = strings.TrimSpace(strings.TrimPrefix(lines[0], "#")) + } else { + // If the first line is not a title, return an error + return "", "", false + } + + /* Concatenate the remaining lines to form the body */ + /* *** */ + body = strings.Join(lines[1:], "\n") + + /* Check if the body is not empty */ + /* *** */ + if body == "" { + return "", "", false + } + + /* If all checks pass, set valid to true */ + /* *** */ + return subject, body, true +} + +func sendTest(cfg *Config, markdownFile string) { + /* Open and parse Markdown file */ + /* *** */ + members := []string{cfg.TestEmail} + contents, err := os.ReadFile(markdownFile) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + fmt.Printf("Can't find file %s\n", markdownFile) + } else { + fmt.Printf("Error reading file %s: %v\n", markdownFile, err) + } + return + } + contents = bytes.ReplaceAll(contents, []byte("\r"), []byte("")) + + subject, body, valid := parseMailFile(string(contents)) + if !valid { + fmt.Printf("Error parsing file %s: invalid format\n", markdownFile) + return + } + + /* Convert Markdown to HTML */ + /* *** */ + html := blackfriday.Run([]byte(body)) + + /* Send the email */ + /* *** */ + trackingFile := markdownFile + ".test.track" + logFile := markdownFile + "." + time.Now().Format("20060102T150405") + ".test.log" + os.Truncate(trackingFile, 0) + + blastMail(cfg, logFile, trackingFile, members, subject, string(html)) + + sha1File := markdownFile + ".sha1" + sum := sha1.Sum(contents) + sumString := fmt.Sprintf("%x", sum) + os.WriteFile(sha1File, []byte(sumString), 0666) +} + +func sendNews(cfg *Config, markdownFile string) { + /* Open and parse Markdown file */ + /* *** */ + contents, err := os.ReadFile(markdownFile) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + fmt.Printf("Can't find file %s\n", markdownFile) + } else { + fmt.Printf("Error reading file %s: %v\n", markdownFile, err) + } + return + } + contents = bytes.ReplaceAll(contents, []byte("\r"), []byte("")) + + subject, body, valid := parseMailFile(string(contents)) + if !valid { + fmt.Printf("Error parsing file %s: invalid format\n", markdownFile) + return + } + + /* Convert Markdown to HTML */ + /* *** */ + html := blackfriday.Run([]byte(body)) + + /* Checksum test */ + /* *** */ + sha1File := markdownFile + ".sha1" + sum := sha1.Sum(contents) + sumString := fmt.Sprintf("%x", sum) + + sha1, err := os.ReadFile(sha1File) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + fmt.Printf("Can't find hash file for this newsletter. Make sure you run the test command first!\n") + } else { + fmt.Printf("Error reading file %s: %v\n", sha1File, err) + } + return + } + if string(sha1) != sumString { + fmt.Printf("Hash doesn't match. Did you change the newsletter's contents? Rerun the test command please!\n") + return + } + + /* Retrieve the mailing list from the city listed in the config file */ + /* *** */ + subscribers, err := retrieveSubscribers(cfg) + + /* Blast the member update! */ + /* *** */ + trackingFile := markdownFile + ".track" + logFile := markdownFile + "." + time.Now().Format("20060102T150405") + ".log" + + blastMail(cfg, logFile, trackingFile, subscribers, subject, string(html)) +} + +func blastMail(cfg *Config, logFile string, trackingFile string, audience []string, subject, body string) { + log, err := os.OpenFile(logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + fmt.Printf("Can't open log file %s: %v\n", logFile, err) + return + } + defer log.Close() + contents, err := os.ReadFile(trackingFile) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + fmt.Printf("Failed to read tracking file %s: %v\n", trackingFile, err) + return + } + var sentToAddresses []string + existingRecipients := strings.Split(string(contents), "\n") + for _, e := range existingRecipients { + if e != "" { + sentToAddresses = append(sentToAddresses, e) + } + } + + var group []string + for _, a := range audience { + if a == "" { + continue + } + found := false + for _, s := range sentToAddresses { + if a == s { + found = true + break + } + } + if !found { + group = append(group, a) + if len(group) == cfg.BatchSize { + results, err := sendMail(cfg, group, subject, body) + if err != nil { + fmt.Printf("Error while sending mail: %v\n", err) + return + } + sentToAddresses = append(sentToAddresses, group...) + os.WriteFile(trackingFile, []byte(strings.Join(sentToAddresses, "\n")), 0666) + for i, res := range results { + log.WriteString(fmt.Sprintf("%s: %s\n", group[i], res.Message)) + } + group = group[0:0] + } + } + } + if len(group) > 0 { + results, err := sendMail(cfg, group, subject, body) + if err != nil { + fmt.Printf("Error while sending mail: %v\n", err) + return + } + sentToAddresses = append(sentToAddresses, group...) + os.WriteFile(trackingFile, []byte(strings.Join(sentToAddresses, "\n")), 0666) + for i, res := range results { + log.WriteString(fmt.Sprintf("%s: %s\n", group[i], res.Message)) + } + } +} + +var postmarkClient = http.Client{} + +type PostmarkTemplateModel struct { + Name string `json:"name"` + Email string `json:"email"` + Subject string `json:"subject"` + Body string `json:"body"` +} + +type PostmarkTemplateMessage struct { + From string `json:"From"` + To string `json:"To"` + TemplateId int `json:"TemplateId"` + TemplateModel PostmarkTemplateModel `json:"TemplateModel"` + MessageStream string `json:"MessageStream"` +} + +type PostmarkBatchWithTemplateBody struct { + Messages []PostmarkTemplateMessage `json:"Messages"` +} + +type PostmarkBatchResult struct { + ErrorCode int `json:"ErrorCode"` + Message string `json:"Message"` +} + +func sendMail(cfg *Config, recipients []string, subject, html string) ([]PostmarkBatchResult, error) { + fmt.Printf("Sending batch [%d recipients]...", len(recipients)) + + from := cfg.Postmark.SenderEmail + if cfg.Postmark.SenderName != "" { + from = fmt.Sprintf("%s <%s>", cfg.Postmark.SenderName, cfg.Postmark.SenderEmail) + } + + body := PostmarkBatchWithTemplateBody{} + for _, r := range recipients { + body.Messages = append(body.Messages, PostmarkTemplateMessage{ + From: from, + To: r, + TemplateId: cfg.Postmark.TemplateId, + TemplateModel: PostmarkTemplateModel{ + Name: cfg.Postmark.SenderName, + Email: cfg.Postmark.SenderEmail, + Subject: subject, + Body: html, + }, + MessageStream: cfg.Postmark.MessageStream, + }) + } + + reqBody, err := json.Marshal(&body) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, "https://api.postmarkapp.com/email/batchWithTemplates", bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Postmark-Server-Token", cfg.Postmark.ServerToken) + res, err := postmarkClient.Do(req) + if err != nil { + return nil, err + } + + var results []PostmarkBatchResult + resBody, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + return nil, err + } + if res.StatusCode != 200 { + return nil, fmt.Errorf("Bad response from postmark: %d", res.StatusCode) + } + err = json.Unmarshal(resBody, &results) + if err != nil { + fmt.Printf("Batch sent successfully, but failed to parse response from postmark.\n") + return nil, nil + } + fmt.Printf("Done.\n") + return results, nil +} + +func main() { + /* Read in credentials into Config struct */ + /* *** */ + configFile := "config.toml" + + data, err := ioutil.ReadFile(configFile) + if err != nil { + fmt.Println("Error reading TOML file:", err) + os.Exit(1) + } + + config := &Config{} + + if err := toml.Unmarshal(data, config); err != nil { + fmt.Println("Error unmarshaling TOML:", err) + os.Exit(1) + } + + /* Setup Cobra */ + /* *** */ + cmd := &cobra.Command{ + Use: "meetupinvite2000", + Short: "MeetupInvite2000", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf(`Instructions: + 1. Create an email file + * Name it whatever you want. Markdown is expected + * Any newlines at the start or end of the file will be removed + * The first line of the file will be used as {{ subject }} in the postmark template. It must start with the # symbol + * The rest of the file will be used as {{{ content_body }}} + + 2. See who's subscribed + * ./meetupinvite2000 dump + * Dumps the mailing list of your meetup group so far + * List grows automatically as more people sign up for your city (at handmadecities.com/meetups) + + 3. Do a test run + * ./meetupinvite2000 test [email file] + * You must send a test email before blasting it to everyone (update config.toml to change test recipient) + * If you modify the email file after testing, you must test again. Otherwise MeetupInvite2000 will complain + + 4. Start blasting! + * ./meetupinvite2000 blast [email file] + * Will batch send your invite using our Email API (Postmark) + * Will produce a .track file that will list all email addresses that we attempted to send to + * In case of error, you can blast again. All emails listed in the .track file will be skipped + * Will produce a .log file with information received back from Postmark +`) + }, + } + + dumpCmd := &cobra.Command{ + Use: "dump", + Short: "Dump the list of subscribers", + Run: func(cmd *cobra.Command, args []string) { + subscribers, err := retrieveSubscribers(config) + if err != nil { + fmt.Printf("Error retrieving subscribers: %v\n", err) + return + } + + fmt.Printf("Handmade Meetups - \033[1m%s\033[0m\n\n", config.HMC.City) + maxIndexWidth := len(strconv.Itoa(len(subscribers))) + for index, email := range subscribers { + indexStr := strconv.Itoa(index + 1) + indexStr = strings.Repeat(" ", maxIndexWidth - len(indexStr)) + indexStr // Right-align the index + fmt.Printf("%s. \033[1m%s\033[0m\n", indexStr, email) + } + fmt.Printf("\nMailing list grows automatically as more subscribe at \033[1mhandmadecities.com/meetups\033[0m\n") + }, + } + + testCmd := &cobra.Command{ + Use: "test [email file]", + Short: "Send a test update to the test address specified in the config", + Run: func(cmd *cobra.Command, args []string) { + if len(args) < 1 { + cmd.Usage() + os.Exit(1) + } + updateFile := args[0] + sendTest(config, updateFile) + }, + } + + blastCmd := &cobra.Command{ + Use: "blast [email file]", + Short: "Blast your update to your members", + Run: func(cmd *cobra.Command, args []string) { + if len(args) < 1 { + cmd.Usage() + os.Exit(1) + } + updateFile := args[0] + sendNews(config, updateFile) + }, + } + + /* Install commands and serve them to the user */ + /* *** */ + cmd.AddCommand(dumpCmd) + cmd.AddCommand(testCmd) + cmd.AddCommand(blastCmd) + + cmd.Execute() +}