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, subtitle string, body string, valid bool) { valid = false parts := strings.SplitN(string(contents), "\n\n", 2) if len(parts) < 2 { fmt.Printf("File does not contain subject and body\n") return } subjectAndSubtitle := strings.TrimSpace(parts[0]) subjectParts := strings.SplitN(subjectAndSubtitle, "\n", 2) subject = subjectParts[0] if len(subjectParts) > 1 { subtitle = subjectParts[1] } 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, subtitle, 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, subtitle, 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, subtitle, 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, subtitle, body) } func blastMail(cfg Config, logFile string, trackingFile string, audience []string, subject, subtitle, 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, subtitle, 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, subtitle, 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"` Subtitle string `json:"subtitle,omitempty"` 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, subtitle, contentHtml string) ([]PostmarkBatchResult, error) { fmt.Printf("Sending batch [%d recipients]...", len(recipients)) 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, Subtitle: subtitle, 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 } fmt.Printf("Done.\n") 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 {{ subject }} in the postmark template. * The second line of the file, if not empty, will be used as {{ subtitle }} in the postmark template. * After a blank line, the rest of the file will be used as {{{ content_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() }