meetupinvite2000/main.go

638 lines
17 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

// Meetup Invite 2000
// @author: Abner Coimbre <abner@handmadecities.com>
// Copyright (c) Handmade Cities LLC
// 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: https://www.youtube.com/watch?v=bvCsTca0uBc
// =========================================================================
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 {
return
}
sentToAddresses = append(sentToAddresses, group...)
os.WriteFile(trackingFile, []byte(strings.Join(sentToAddresses, "\n")), 0666)
var failed []string
for i, res := range results {
log.WriteString(fmt.Sprintf("%s: %s\n", group[i], res.Message))
if res.ErrorCode != 0 {
failed = append(failed, group[i])
}
}
printBatchSummary(group, failed)
newReaderCount += len(group)
group = group[0:0]
}
}
}
if len(group) > 0 {
results, err := sendMail(cfg, group, subject, body)
if err != nil {
return
}
sentToAddresses = append(sentToAddresses, group...)
os.WriteFile(trackingFile, []byte(strings.Join(sentToAddresses, "\n")), 0666)
var failed []string
for i, res := range results {
log.WriteString(fmt.Sprintf("%s: %s\n", group[i], res.Message))
if res.ErrorCode != 0 {
failed = append(failed, group[i])
}
}
printBatchSummary(group, failed)
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 printBatchSummary(sent, failed []string) {
if len(failed) == 0 {
fmt.Printf("\033[32m✅ Sent %d messages\033[0m\n", len(sent))
} else {
successCount := len(sent) - len(failed)
if successCount < 0 {
successCount = 0
}
fmt.Printf("\033[31m❌ Sent %d, failed %d\033[0m\n", successCount, len(failed))
for _, f := range failed {
fmt.Printf(" - %s\n", f)
}
}
}
func sendMail(cfg *Config, recipients []string, subject, html string) ([]PostmarkBatchResult, error) {
/* Start a tiny spinner while the HTTP call runs.
* Uses \r to update the same line. Stops as soon as the network call returns.
*/
stop := make(chan struct{})
done := make(chan struct{})
go func() {
frames := []rune{'|', '/', '-', '\\'}
i := 0
for {
select {
case <-stop:
close(done)
return
default:
fmt.Printf("\rSending batch [%d recipients]... %c", len(recipients), frames[i%len(frames)])
i++
time.Sleep(120 * time.Millisecond)
}
}
}()
/*
* Prepare and make the network call
*/
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 {
// stop spinner before returning
close(stop)
<-done
fmt.Printf("\r")
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 {
close(stop)
<-done
fmt.Printf("\r\033[31m❌ Sending batch [%d recipients]... error: %v\033[0m\n", len(recipients), err)
return nil, err
}
var results []PostmarkBatchResult
resBody, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
close(stop)
<-done
fmt.Printf("\r")
return nil, err
}
if res.StatusCode != 200 {
close(stop)
<-done
fmt.Printf("\r\033[31m❌ Bad response from postmark: %d\033[0m\n", res.StatusCode)
return nil, fmt.Errorf("Bad response from postmark: %d", res.StatusCode)
}
err = json.Unmarshal(resBody, &results)
if err != nil {
close(stop)
<-done
fmt.Printf("\r\033[33m⚠ Batch sent but failed to parse Postmark response\033[0m\n")
return nil, nil
}
close(stop)
<-done
fmt.Printf("\r\033[32m✅ Emailed batch of %d recipients\033[0m\n", len(recipients))
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
* Call it whatever you want. Markdown format is expected
* Newlines at the beginning and end will be trimmed
* The first line is your email subject. It MUST start with the # symbol
* The rest of the file is your email's 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 before blasting to everyone (please update config.toml to change test recipient)
* If you modify the email after testing, you must test again. Otherwise we will complain
4. Start blasting!
* ./meetupinvite2000 blast [email file]
* Batch sends your invite using our Email API (Postmark)
* Produces .track file that tracks email addresses we attempted to send to
* Produces .log file with information received back from Postmark
* In case of errors, you can always blast again. Addresses already in .track file will be skipped
`)
},
}
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/%s\033[0m\n", config.Postmark.MessageStream)
},
}
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()
}