// 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 (@AsafGartner) // Joshua Barnett (@jshbrntt) // Jack Punter (@TarriestPython) // // 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"` } `toml:"hmc"` 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") } /* De-duplicate emails using a map */ /* *** */ emailMap := make(map[string]bool) for _, email := range emails { if emailStr, ok := email.(string); ok { emailMap[emailStr] = true } } /* Convert the map keys back to a slice */ var subscribers []string for email := range emailMap { subscribers = append(subscribers, email) } 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) } } existingReaderCount := len(sentToAddresses) newReaderCount := 0 if existingReaderCount > 0 { fmt.Println("Same email file specified: Sending only to new subscribers...") } 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)) } newReaderCount += len(group) 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)) } newReaderCount += len(group) } if newReaderCount > 0 { newSubscribers := existingReaderCount > 0 if newSubscribers { fmt.Printf("Sent to %d new subscribers!\n", newReaderCount) } } else { fmt.Printf("Not sent (no new subscribers)\n") } } 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 ComplainEmpty(val string, complaint string) bool { if val == "" { fmt.Println(complaint) return false } return true } 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) } if config.BatchSize == 0 { config.BatchSize = 500 } validConfig := true validConfig = ComplainEmpty(config.TestEmail, "* Test email address is missing in config") && validConfig validConfig = ComplainEmpty(config.HMC.ApiUrl, "* HMC API url is missing in config") && validConfig validConfig = ComplainEmpty(config.HMC.SharedSecret, "* HMC Shared secret is missing in config") && validConfig validConfig = ComplainEmpty(config.HMC.City, "* HMC City is missing in config") && validConfig validConfig = ComplainEmpty(config.Postmark.ServerToken, "* Postmark server token is missing in config") && validConfig validConfig = ComplainEmpty(config.Postmark.ApiUrl, "* Postmark API url is missing in config") && validConfig if config.Postmark.TemplateId == 0 { fmt.Println("* Postmark template ID is missing in config") validConfig = false } validConfig = ComplainEmpty(config.Postmark.SenderName, "* Sender name is missing in config") && validConfig validConfig = ComplainEmpty(config.Postmark.SenderEmail, "* Sender email is missing in config") && validConfig validConfig = ComplainEmpty(config.Postmark.MessageStream, "* Postmark message stream is missing in config") && validConfig if !validConfig { fmt.Println("Please fix your config and try again") 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() }