meetupinvite2000/main.go

570 lines
16 KiB
Go

// Meetup Invite 2000
// @author: Abner Coimbre <abner@handmadecities.com>
// 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
// =========================================================================
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
* 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()
}