From 8c5f152de896f3d79c95fe3ede91331a9e94e383 Mon Sep 17 00:00:00 2001 From: Asaf Gartner Date: Wed, 13 Sep 2023 00:15:40 +0300 Subject: [PATCH] Newsblaster using the Postmark template batch API --- .gitignore | 2 + go.mod | 10 ++ go.sum | 10 ++ main.go | 393 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 415 insertions(+) create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..997ac9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.json +data/ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8ac3355 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module git.handmade.network/hmn/newsblaster2000 + +go 1.18 + +require github.com/spf13/cobra v1.7.0 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f3366a9 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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..66a16e7 --- /dev/null +++ b/main.go @@ -0,0 +1,393 @@ +package main + +import ( + "bytes" + "crypto/sha1" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "strings" + "time" + + "github.com/spf13/cobra" +) + +type Config struct { + PostmarkAPIToken string + PostmarkTemplateAlias string + PostmarkMessageStream string + TestEmailAddress string + FromAddress string + FromName string + BatchSize int +} + +func verifyConfig() (Config, bool) { + defaultConfig := Config{BatchSize: 500} // NOTE(asaf): Max 500 messages per batch: https://postmarkapp.com/developer/api/templates-api#send-batch-with-templates + defaultContents, err := json.MarshalIndent(&defaultConfig, "", " ") + contents, err := os.ReadFile("config.json") + if err != nil { + contents = defaultContents + if errors.Is(err, fs.ErrNotExist) { + os.WriteFile("config.json", contents, 0666) + fmt.Printf("Created config.json file.\n") + } else { + fmt.Printf("Error while reading config.json: %v\n", err) + } + } + + var config Config + err = json.Unmarshal(contents, &config) + + valid := true + if config.PostmarkAPIToken == "" { + fmt.Printf("Postmark API token missing in config.\n") + valid = false + } + if config.PostmarkTemplateAlias == "" { + fmt.Printf("Postmark template alias missing in config.\n") + valid = false + } + if config.PostmarkMessageStream == "" { + fmt.Printf("Postmark message stream missing in config.\n") + valid = false + } + if config.TestEmailAddress == "" { + fmt.Printf("Test email address is missing in config.\n") + valid = false + } + if config.FromAddress == "" { + fmt.Printf("FromAddress is missing in config. That's the return address that will appear on the email.\n") + valid = false + } + if config.FromName == "" { + fmt.Printf("FromName is missing in config. That's the name that will appear next to the 'from' field in the email.\n") + valid = false + } + if !valid { + fmt.Printf("Please edit config.json and rerun.\n") + } + + return config, valid +} + +func parseMailFile(contents string) (subject string, body string, valid bool) { + valid = false + parts := strings.SplitN(string(contents), "\n", 2) + if len(parts) < 2 { + fmt.Printf("File does not contain subject and body\n") + return + } + subject = strings.TrimSpace(parts[0]) + rawBody := strings.Trim(parts[1], "\n") + if subject == "" { + fmt.Printf("Subject is empty\n") + return + } + body = rawBody + valid = true + return +} + +func sendTest(newsletterFile string) { + cfg, valid := verifyConfig() + if !valid { + return + } + audience := []string{cfg.TestEmailAddress} + contents, err := os.ReadFile(newsletterFile) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + fmt.Printf("Can't find file %s\n", newsletterFile) + } else { + fmt.Printf("Error reading file %s: %v\n", newsletterFile, err) + } + return + } + subject, body, valid := parseMailFile(string(contents)) + if !valid { + return + } + + trackingFile := newsletterFile + ".test.track" + logFile := newsletterFile + "." + time.Now().Format("20060102T150405") + ".test.log" + os.Truncate(trackingFile, 0) + + blastMail(cfg, logFile, trackingFile, audience, subject, body) + + sha1File := newsletterFile + ".sha1" + sum := sha1.Sum(contents) + sumString := fmt.Sprintf("%x", sum) + os.WriteFile(sha1File, []byte(sumString), 0666) +} + +func sendNews(audienceFile string, newsletterFile string) { + cfg, valid := verifyConfig() + if !valid { + return + } + contents, err := os.ReadFile(newsletterFile) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + fmt.Printf("Can't find file %s\n", newsletterFile) + } else { + fmt.Printf("Error reading file %s: %v\n", newsletterFile, err) + } + return + } + subject, body, valid := parseMailFile(string(contents)) + if !valid { + return + } + + audienceContents, err := os.ReadFile(audienceFile) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + fmt.Printf("Can't find file %s\n", audienceFile) + } else { + fmt.Printf("Error reading file %s: %v\n", audienceFile, err) + } + return + } + + audience := strings.Split(string(audienceContents), "\n") + for i, a := range audience { + audience[i] = strings.TrimSpace(a) + } + + sha1File := newsletterFile + ".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", audienceFile, 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 + } + + trackingFile := newsletterFile + ".track" + logFile := newsletterFile + "." + time.Now().Format("20060102T150405") + ".log" + + blastMail(cfg, logFile, trackingFile, audience, subject, body) +} + +func blastMail(cfg Config, logFile string, trackingFile string, audience []string, subject string, 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 { + Subject string `json:"subject"` + Body string `json:"content_body"` +} + +type PostmarkTemplateMessage struct { + From string `json:"From"` + To string `json:"To"` + TemplateAlias string `json:"TemplateAlias"` + TemplateModel PostmarkTemplateModel `json:"TemplateModel"` + TrackOpens bool `json:"TrackOpens"` + TrackLinks string `json:"TrackLinks"` + 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, contentHtml string) ([]PostmarkBatchResult, error) { + from := cfg.FromAddress + if cfg.FromName != "" { + from = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.FromAddress) + } + body := PostmarkBatchWithTemplateBody{} + for _, r := range recipients { + body.Messages = append(body.Messages, PostmarkTemplateMessage{ + From: from, + To: r, + TemplateAlias: cfg.PostmarkTemplateAlias, + TemplateModel: PostmarkTemplateModel{ + Subject: subject, + Body: contentHtml, + }, + TrackOpens: false, + TrackLinks: "None", + MessageStream: cfg.PostmarkMessageStream, + }) + } + + 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.PostmarkAPIToken) + 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 + } + return results, nil +} + +func main() { + cmd := &cobra.Command{ + Use: "newsblaster2000", + Short: "NewsBlaster2000", + Run: func(cmd *cobra.Command, args []string) { + _, valid := verifyConfig() + if valid { + fmt.Printf(`Instructions: + 1. Create an audience file + * Name it whatever you want. + * The file should be a list of email addresses. One email per line. + + 2. Create an email file + * Name it whatever you want. + * The first line of the file will be used as the subject. + * The rest of the file will be used as the body. + * Newlines between the subject and body will be removed. + + 3. Do a test run + * go run . test [email filename] + * You must send the test email before blasting it to everyone. + * If you modify the email file after testing, you must test again. Otherwise NewsBlaster2000 will complain. + + 4. Start blasting + * go run . blast [audience file] [email file] + * Will batch send using the Postmark batch API. + * 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. +`) + } + }, + } + + testCmd := &cobra.Command{ + Use: "test [email file]", + Short: "Send a test mail to the test address specified in the config", + Run: func(cmd *cobra.Command, args []string) { + if len(args) < 1 { + cmd.Usage() + os.Exit(1) + } + newsletterFile := args[0] + sendTest(newsletterFile) + }, + } + + blastCmd := &cobra.Command{ + Use: "blast [audience file] [email file]", + Short: "Blast your newsletter to your followers", + Run: func(cmd *cobra.Command, args []string) { + if len(args) < 2 { + cmd.Usage() + os.Exit(1) + } + audienceFile := args[0] + newsletterFile := args[1] + sendNews(audienceFile, newsletterFile) + }, + } + + cmd.AddCommand(testCmd) + cmd.AddCommand(blastCmd) + + cmd.Execute() +}