14 Commits

Author SHA1 Message Date
Abner Coimbre
320bb5c34a Add progress indicator/animation 2025-10-22 00:58:20 -07:00
abnercoimbre
b35ac045f3 run go fmt 2024-10-19 14:15:56 -07:00
abnercoimbre
3de25244fb Show specific meetups link for the target city 2024-10-19 13:27:58 -07:00
abnercoimbre
1a9f605208 Simplify instructions 2024-10-19 13:26:04 -07:00
abnercoimbre
2211488f9a Remove stale warning: thanks to Asaf we don't crash if config.toml is missing 2024-10-19 13:04:41 -07:00
abnercoimbre
e97f048eb6 Improved feedback: Notify when the same email file is being reused, and whether or not that invite was sent to new subscribers 2024-10-18 20:06:22 -07:00
abnercoimbre
e6aeb0158a De-duplicate addresses before returning email subscribers 2024-10-18 15:10:16 -07:00
abnercoimbre
19508f38e8 Credit new contributors 2024-05-01 19:37:21 -07:00
abnercoimbre
90c356584e Merge branch 'master' of https://git.handmadecities.com/meetups/meetupinvite2000 into master 2024-05-01 15:36:29 -07:00
abnercoimbre
585445cb6a Deprecate this config.toml because our repo is going public 2024-05-01 15:35:14 -07:00
Abner Coimbre
668402059f Update gitignore 2024-04-30 14:51:37 -07:00
Abner Coimbre
0ba1c69a7d Add .goreleaser.yaml 2024-04-30 14:50:30 -07:00
42bb6f8bad Added config validation 2024-04-23 23:48:29 +01:00
Jack Punter
b59ba24b35 Go fmt and add missing toml:"hmc" tag 2024-04-23 23:20:47 +01:00
4 changed files with 302 additions and 186 deletions

4
.gitignore vendored
View File

@@ -46,4 +46,8 @@ tags
# Persistent undo # Persistent undo
[._]*.un~ [._]*.un~
# Misc
.DS_Store
dist/
# End of https://www.toptal.com/developers/gitignore/api/go,vim # End of https://www.toptal.com/developers/gitignore/api/go,vim

5
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,5 @@
gitea_urls:
api: https://git.handmadecities.com/api/v1
download: https://git.handmadecities.com/
# set to true if you use a self-signed certificate
skip_tls_verify: false

View File

@@ -1,18 +0,0 @@
# User Constants - Update as needed
batch_size = 500 # Max 500 messages per batch: https://postmarkapp.com/developer/api/templates-api#send-batch-with-templates
test_email = "" # Fill this in with your own email address
# Handmade Cities Credentials - DON'T TOUCH
[hmc]
api_url = "https://api.handmadecities.com/v1/meetups/subscribers"
shared_secret = "Land_OF_TERminA__MAJORAS_LAIR#666!"
city = "Termina"
# Postmark App Credentials - NO TOUCHY TOUCHY
[postmark]
server_token = "37967b9a-6b1c-430e-a65f-de043de3e568"
api_url = "https://api.postmarkapp.com/email/batchWithTemplates"
template_id = 35682307
sender_name = "Skull Kid"
sender_email = "majora@handmadecities.com"
message_stream = "termina"

461
main.go
View File

@@ -1,144 +1,150 @@
// Meetup Invite 2000 // Meetup Invite 2000
// @author: Abner Coimbre <abner@handmadecities.com> // @author: Abner Coimbre <abner@handmadecities.com>
// Copyright (c) 2024 Handmade Cities // Copyright (c) Handmade Cities LLC
// Based off News Blaster 2000 from the Handmade Network: https://git.handmade.network/hmn/newsblaster2000 // Based off News Blaster 2000 from the Handmade Network: https://git.handmade.network/hmn/newsblaster2000
// ============================ Contributors ========================= // ============================ Contributors =========================
// Bug & warning fixes // Bug & warning fixes
// Jacob Bell (@MysteriousJ) // Jacob Bell (@MysteriousJ)
// Asaf Gartner // Asaf Gartner (@AsafGartner)
// Joshua Barnett (@jshbrntt)
// Jack Punter (@TarriestPython)
// //
// Emotional Support // Emotional Support
// Cucui Ganon Rosario // Cucui Ganon Rosario: https://www.youtube.com/watch?v=bvCsTca0uBc
// ========================================================================= // =========================================================================
// WARNING: This program requires a companion config.toml file provided by Abner. Without it we will crash!
package main package main
import ( import (
"bytes" "bytes"
"crypto/sha1" "crypto/sha1"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
"github.com/russross/blackfriday/v2" "github.com/russross/blackfriday/v2"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
type Config struct { type Config struct {
BatchSize int `toml:"batch_size"` BatchSize int `toml:"batch_size"`
TestEmail string `toml:"test_email"` TestEmail string `toml:"test_email"`
HMC struct { HMC struct {
ApiUrl string `toml:"api_url"` ApiUrl string `toml:"api_url"`
SharedSecret string `toml:"shared_secret"` SharedSecret string `toml:"shared_secret"`
City string `toml:"city"` City string `toml:"city"`
} } `toml:"hmc"`
Postmark struct { Postmark struct {
ServerToken string `toml:"server_token"` ServerToken string `toml:"server_token"`
ApiUrl string `toml:"api_url"` ApiUrl string `toml:"api_url"`
TemplateId int `toml:"template_id"` TemplateId int `toml:"template_id"`
SenderName string `toml:"sender_name"` SenderName string `toml:"sender_name"`
SenderEmail string `toml:"sender_email"` SenderEmail string `toml:"sender_email"`
MessageStream string `toml:"message_stream"` MessageStream string `toml:"message_stream"`
} `toml:"postmark"` } `toml:"postmark"`
} }
func retrieveSubscribers(cfg *Config) ([]string, error) { func retrieveSubscribers(cfg *Config) ([]string, error) {
/* Prepare request payload */ /* Prepare request payload */
/* *** */ /* *** */
payload := map[string]string{ payload := map[string]string{
"city": cfg.HMC.City, "city": cfg.HMC.City,
} }
payloadBytes, err := json.Marshal(payload) payloadBytes, err := json.Marshal(payload)
if err != nil { if err != nil {
return nil, fmt.Errorf("error encoding payload: %v", err) return nil, fmt.Errorf("error encoding payload: %v", err)
} }
/* Prepare request */ /* Prepare request */
/* *** */ /* *** */
req, err := http.NewRequest("POST", cfg.HMC.ApiUrl, bytes.NewReader(payloadBytes)) req, err := http.NewRequest("POST", cfg.HMC.ApiUrl, bytes.NewReader(payloadBytes))
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating request: %v", err) return nil, fmt.Errorf("error creating request: %v", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", cfg.HMC.SharedSecret) req.Header.Set("Authorization", cfg.HMC.SharedSecret)
/* Execute request */ /* Execute request */
/* *** */ /* *** */
client := http.Client{} client := http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("error making request: %v", err) return nil, fmt.Errorf("error making request: %v", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
/* Fail if status code is not in the 2xx range */ /* Fail if status code is not in the 2xx range */
/* *** */ /* *** */
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("error: HTTP status code %d", resp.StatusCode) return nil, fmt.Errorf("error: HTTP status code %d", resp.StatusCode)
} }
/* Read response's body */ /* Read response's body */
/* *** */ /* *** */
body, err := ioutil.ReadAll(resp.Body) body, err := ioutil.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("error reading response body: %v", err) return nil, fmt.Errorf("error reading response body: %v", err)
} }
/* Parse JSON response */ /* Parse JSON response */
/* *** */ /* *** */
var data map[string]interface{} var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil { if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("error unmarshalling JSON: %v", err) return nil, fmt.Errorf("error unmarshalling JSON: %v", err)
} }
/* Extract the emails from the response */ /* Extract the emails from the response */
/* *** */ /* *** */
emails, ok := data["emails"].([]interface{}) emails, ok := data["emails"].([]interface{})
if !ok { if !ok {
return nil, errors.New("response format error: 'emails' field not found") return nil, errors.New("response format error: 'emails' field not found")
} }
/* Convert emails to []string */ /* De-duplicate emails using a map */
/* *** */ /* *** */
var subscribers []string emailMap := make(map[string]bool)
for _, email := range emails { for _, email := range emails {
if emailStr, ok := email.(string); ok { if emailStr, ok := email.(string); ok {
subscribers = append(subscribers, emailStr) emailMap[emailStr] = true
} }
} }
return subscribers, nil /* 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) { func parseMailFile(contents string) (subject string, body string, valid bool) {
/* Remove leading and trailing newlines */ /* Remove leading and trailing newlines */
/* *** */ /* *** */
contents = strings.TrimSpace(contents) contents = strings.TrimSpace(contents)
lines := strings.Split(contents, "\n") lines := strings.Split(contents, "\n")
/* Check if there is at least one line */ /* Check if there is at least one line */
/* *** */ /* *** */
if len(lines) == 0 { if len(lines) == 0 {
return "", "", false return "", "", false
} }
/* Check if the first line is a title with the '#' Markdown symbol */ /* 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 if strings.HasPrefix(lines[0], "#") { // Extract subject without the '#' symbol and leading/trailing spaces
subject = strings.TrimSpace(strings.TrimPrefix(lines[0], "#")) subject = strings.TrimSpace(strings.TrimPrefix(lines[0], "#"))
} else { } else {
@@ -147,24 +153,24 @@ func parseMailFile(contents string) (subject string, body string, valid bool) {
} }
/* Concatenate the remaining lines to form the body */ /* Concatenate the remaining lines to form the body */
/* *** */ /* *** */
body = strings.Join(lines[1:], "\n") body = strings.Join(lines[1:], "\n")
/* Check if the body is not empty */ /* Check if the body is not empty */
/* *** */ /* *** */
if body == "" { if body == "" {
return "", "", false return "", "", false
} }
/* If all checks pass, set valid to true */ /* If all checks pass, set valid to true */
/* *** */ /* *** */
return subject, body, true return subject, body, true
} }
func sendTest(cfg *Config, markdownFile string) { func sendTest(cfg *Config, markdownFile string) {
/* Open and parse Markdown file */ /* Open and parse Markdown file */
/* *** */ /* *** */
members := []string{cfg.TestEmail} members := []string{cfg.TestEmail}
contents, err := os.ReadFile(markdownFile) contents, err := os.ReadFile(markdownFile)
if err != nil { if err != nil {
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
@@ -174,20 +180,20 @@ func sendTest(cfg *Config, markdownFile string) {
} }
return return
} }
contents = bytes.ReplaceAll(contents, []byte("\r"), []byte("")) contents = bytes.ReplaceAll(contents, []byte("\r"), []byte(""))
subject, body, valid := parseMailFile(string(contents)) subject, body, valid := parseMailFile(string(contents))
if !valid { if !valid {
fmt.Printf("Error parsing file %s: invalid format\n", markdownFile) fmt.Printf("Error parsing file %s: invalid format\n", markdownFile)
return return
} }
/* Convert Markdown to HTML */ /* Convert Markdown to HTML */
/* *** */ /* *** */
html := blackfriday.Run([]byte(body)) html := blackfriday.Run([]byte(body))
/* Send the email */ /* Send the email */
/* *** */ /* *** */
trackingFile := markdownFile + ".test.track" trackingFile := markdownFile + ".test.track"
logFile := markdownFile + "." + time.Now().Format("20060102T150405") + ".test.log" logFile := markdownFile + "." + time.Now().Format("20060102T150405") + ".test.log"
os.Truncate(trackingFile, 0) os.Truncate(trackingFile, 0)
@@ -201,8 +207,8 @@ func sendTest(cfg *Config, markdownFile string) {
} }
func sendNews(cfg *Config, markdownFile string) { func sendNews(cfg *Config, markdownFile string) {
/* Open and parse Markdown file */ /* Open and parse Markdown file */
/* *** */ /* *** */
contents, err := os.ReadFile(markdownFile) contents, err := os.ReadFile(markdownFile)
if err != nil { if err != nil {
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
@@ -210,22 +216,22 @@ func sendNews(cfg *Config, markdownFile string) {
} else { } else {
fmt.Printf("Error reading file %s: %v\n", markdownFile, err) fmt.Printf("Error reading file %s: %v\n", markdownFile, err)
} }
return return
} }
contents = bytes.ReplaceAll(contents, []byte("\r"), []byte("")) contents = bytes.ReplaceAll(contents, []byte("\r"), []byte(""))
subject, body, valid := parseMailFile(string(contents)) subject, body, valid := parseMailFile(string(contents))
if !valid { if !valid {
fmt.Printf("Error parsing file %s: invalid format\n", markdownFile) fmt.Printf("Error parsing file %s: invalid format\n", markdownFile)
return return
} }
/* Convert Markdown to HTML */ /* Convert Markdown to HTML */
/* *** */ /* *** */
html := blackfriday.Run([]byte(body)) html := blackfriday.Run([]byte(body))
/* Checksum test */ /* Checksum test */
/* *** */ /* *** */
sha1File := markdownFile + ".sha1" sha1File := markdownFile + ".sha1"
sum := sha1.Sum(contents) sum := sha1.Sum(contents)
sumString := fmt.Sprintf("%x", sum) sumString := fmt.Sprintf("%x", sum)
@@ -237,19 +243,19 @@ func sendNews(cfg *Config, markdownFile string) {
} else { } else {
fmt.Printf("Error reading file %s: %v\n", sha1File, err) fmt.Printf("Error reading file %s: %v\n", sha1File, err)
} }
return return
} }
if string(sha1) != sumString { if string(sha1) != sumString {
fmt.Printf("Hash doesn't match. Did you change the newsletter's contents? Rerun the test command please!\n") fmt.Printf("Hash doesn't match. Did you change the newsletter's contents? Rerun the test command please!\n")
return return
} }
/* Retrieve the mailing list from the city listed in the config file */ /* Retrieve the mailing list from the city listed in the config file */
/* *** */ /* *** */
subscribers, err := retrieveSubscribers(cfg) subscribers, err := retrieveSubscribers(cfg)
/* Blast the member update! */ /* Blast the member update! */
/* *** */ /* *** */
trackingFile := markdownFile + ".track" trackingFile := markdownFile + ".track"
logFile := markdownFile + "." + time.Now().Format("20060102T150405") + ".log" logFile := markdownFile + "." + time.Now().Format("20060102T150405") + ".log"
@@ -276,6 +282,13 @@ func blastMail(cfg *Config, logFile string, trackingFile string, audience []stri
} }
} }
existingReaderCount := len(sentToAddresses)
newReaderCount := 0
if existingReaderCount > 0 {
fmt.Println("Same email file specified: Sending only to new subscribers...")
}
var group []string var group []string
for _, a := range audience { for _, a := range audience {
if a == "" { if a == "" {
@@ -293,14 +306,19 @@ func blastMail(cfg *Config, logFile string, trackingFile string, audience []stri
if len(group) == cfg.BatchSize { if len(group) == cfg.BatchSize {
results, err := sendMail(cfg, group, subject, body) results, err := sendMail(cfg, group, subject, body)
if err != nil { if err != nil {
fmt.Printf("Error while sending mail: %v\n", err)
return return
} }
sentToAddresses = append(sentToAddresses, group...) sentToAddresses = append(sentToAddresses, group...)
os.WriteFile(trackingFile, []byte(strings.Join(sentToAddresses, "\n")), 0666) os.WriteFile(trackingFile, []byte(strings.Join(sentToAddresses, "\n")), 0666)
var failed []string
for i, res := range results { for i, res := range results {
log.WriteString(fmt.Sprintf("%s: %s\n", group[i], res.Message)) 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] group = group[0:0]
} }
} }
@@ -308,14 +326,28 @@ func blastMail(cfg *Config, logFile string, trackingFile string, audience []stri
if len(group) > 0 { if len(group) > 0 {
results, err := sendMail(cfg, group, subject, body) results, err := sendMail(cfg, group, subject, body)
if err != nil { if err != nil {
fmt.Printf("Error while sending mail: %v\n", err)
return return
} }
sentToAddresses = append(sentToAddresses, group...) sentToAddresses = append(sentToAddresses, group...)
os.WriteFile(trackingFile, []byte(strings.Join(sentToAddresses, "\n")), 0666) os.WriteFile(trackingFile, []byte(strings.Join(sentToAddresses, "\n")), 0666)
var failed []string
for i, res := range results { for i, res := range results {
log.WriteString(fmt.Sprintf("%s: %s\n", group[i], res.Message)) 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")
} }
} }
@@ -324,7 +356,7 @@ var postmarkClient = http.Client{}
type PostmarkTemplateModel struct { type PostmarkTemplateModel struct {
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
Subject string `json:"subject"` Subject string `json:"subject"`
Body string `json:"body"` Body string `json:"body"`
} }
@@ -345,8 +377,47 @@ type PostmarkBatchResult struct {
Message string `json:"Message"` 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) { func sendMail(cfg *Config, recipients []string, subject, html string) ([]PostmarkBatchResult, error) {
fmt.Printf("Sending batch [%d recipients]...", len(recipients)) /* 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 from := cfg.Postmark.SenderEmail
if cfg.Postmark.SenderName != "" { if cfg.Postmark.SenderName != "" {
@@ -356,14 +427,14 @@ func sendMail(cfg *Config, recipients []string, subject, html string) ([]Postmar
body := PostmarkBatchWithTemplateBody{} body := PostmarkBatchWithTemplateBody{}
for _, r := range recipients { for _, r := range recipients {
body.Messages = append(body.Messages, PostmarkTemplateMessage{ body.Messages = append(body.Messages, PostmarkTemplateMessage{
From: from, From: from,
To: r, To: r,
TemplateId: cfg.Postmark.TemplateId, TemplateId: cfg.Postmark.TemplateId,
TemplateModel: PostmarkTemplateModel{ TemplateModel: PostmarkTemplateModel{
Name: cfg.Postmark.SenderName, Name: cfg.Postmark.SenderName,
Email: cfg.Postmark.SenderEmail, Email: cfg.Postmark.SenderEmail,
Subject: subject, Subject: subject,
Body: html, Body: html,
}, },
MessageStream: cfg.Postmark.MessageStream, MessageStream: cfg.Postmark.MessageStream,
}) })
@@ -376,13 +447,21 @@ func sendMail(cfg *Config, recipients []string, subject, html string) ([]Postmar
req, err := http.NewRequest(http.MethodPost, "https://api.postmarkapp.com/email/batchWithTemplates", bytes.NewReader(reqBody)) req, err := http.NewRequest(http.MethodPost, "https://api.postmarkapp.com/email/batchWithTemplates", bytes.NewReader(reqBody))
if err != nil { if err != nil {
// stop spinner before returning
close(stop)
<-done
fmt.Printf("\r")
return nil, err return nil, err
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Postmark-Server-Token", cfg.Postmark.ServerToken) req.Header.Set("X-Postmark-Server-Token", cfg.Postmark.ServerToken)
res, err := postmarkClient.Do(req) res, err := postmarkClient.Do(req)
if err != nil { 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 return nil, err
} }
@@ -390,50 +469,96 @@ func sendMail(cfg *Config, recipients []string, subject, html string) ([]Postmar
resBody, err := io.ReadAll(res.Body) resBody, err := io.ReadAll(res.Body)
res.Body.Close() res.Body.Close()
if err != nil { if err != nil {
close(stop)
<-done
fmt.Printf("\r")
return nil, err return nil, err
} }
if res.StatusCode != 200 { 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) return nil, fmt.Errorf("Bad response from postmark: %d", res.StatusCode)
} }
err = json.Unmarshal(resBody, &results) err = json.Unmarshal(resBody, &results)
if err != nil { if err != nil {
fmt.Printf("Batch sent successfully, but failed to parse response from postmark.\n") close(stop)
<-done
fmt.Printf("\r\033[33m⚠ Batch sent but failed to parse Postmark response\033[0m\n")
return nil, nil return nil, nil
} }
fmt.Printf("Done.\n")
close(stop)
<-done
fmt.Printf("\r\033[32m✅ Emailed batch of %d recipients\033[0m\n", len(recipients))
return results, nil return results, nil
} }
func ComplainEmpty(val string, complaint string) bool {
if val == "" {
fmt.Println(complaint)
return false
}
return true
}
func main() { func main() {
/* Read in credentials into Config struct */ /* Read in credentials into Config struct */
/* *** */ /* *** */
configFile := "config.toml" configFile := "config.toml"
data, err := ioutil.ReadFile(configFile) data, err := ioutil.ReadFile(configFile)
if err != nil { if err != nil {
fmt.Println("Error reading TOML file:", err) fmt.Println("Error reading TOML file:", err)
os.Exit(1) os.Exit(1)
} }
config := &Config{} config := &Config{}
if err := toml.Unmarshal(data, config); err != nil { if err := toml.Unmarshal(data, config); err != nil {
fmt.Println("Error unmarshaling TOML:", err) fmt.Println("Error unmarshaling TOML:", err)
os.Exit(1) os.Exit(1)
} }
/* Setup Cobra */ 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{ cmd := &cobra.Command{
Use: "meetupinvite2000", Use: "meetupinvite2000",
Short: "MeetupInvite2000", Short: "MeetupInvite2000",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Printf(`Instructions: fmt.Printf(`Instructions:
1. Create an email file 1. Create an email file
* Name it whatever you want. Markdown is expected * Call it whatever you want. Markdown format is expected
* Any newlines at the start or end of the file will be removed * Newlines at the beginning and end will be trimmed
* The first line of the file will be used as {{ subject }} in the postmark template. It must start with the # symbol * The first line is your email subject. It MUST start with the # symbol
* The rest of the file will be used as {{{ content_body }}} * The rest of the file is your email's body
2. See who's subscribed 2. See who's subscribed
* ./meetupinvite2000 dump * ./meetupinvite2000 dump
@@ -442,39 +567,39 @@ func main() {
3. Do a test run 3. Do a test run
* ./meetupinvite2000 test [email file] * ./meetupinvite2000 test [email file]
* You must send a test email before blasting it to everyone (update config.toml to change test recipient) * You must send a test before blasting to everyone (please update config.toml to change test recipient)
* If you modify the email file after testing, you must test again. Otherwise MeetupInvite2000 will complain * If you modify the email after testing, you must test again. Otherwise we will complain
4. Start blasting! 4. Start blasting!
* ./meetupinvite2000 blast [email file] * ./meetupinvite2000 blast [email file]
* Will batch send your invite using our Email API (Postmark) * Batch sends your invite using our Email API (Postmark)
* Will produce a .track file that will list all email addresses that we attempted to send to * Produces .track file that tracks email addresses we attempted to send to
* In case of error, you can blast again. All emails listed in the .track file will be skipped * Produces .log file with information received back from Postmark
* Will produce a .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{ dumpCmd := &cobra.Command{
Use: "dump", Use: "dump",
Short: "Dump the list of subscribers", Short: "Dump the list of subscribers",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
subscribers, err := retrieveSubscribers(config) subscribers, err := retrieveSubscribers(config)
if err != nil { if err != nil {
fmt.Printf("Error retrieving subscribers: %v\n", err) fmt.Printf("Error retrieving subscribers: %v\n", err)
return return
} }
fmt.Printf("Handmade Meetups - \033[1m%s\033[0m\n\n", config.HMC.City) fmt.Printf("Handmade Meetups - \033[1m%s\033[0m\n\n", config.HMC.City)
maxIndexWidth := len(strconv.Itoa(len(subscribers))) maxIndexWidth := len(strconv.Itoa(len(subscribers)))
for index, email := range subscribers { for index, email := range subscribers {
indexStr := strconv.Itoa(index + 1) indexStr := strconv.Itoa(index + 1)
indexStr = strings.Repeat(" ", maxIndexWidth - len(indexStr)) + indexStr // Right-align the index indexStr = strings.Repeat(" ", maxIndexWidth-len(indexStr)) + indexStr // Right-align the index
fmt.Printf("%s. \033[1m%s\033[0m\n", indexStr, email) 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") fmt.Printf("\nMailing list grows automatically as more subscribe at \033[1mhandmadecities.com/meetups/%s\033[0m\n", config.Postmark.MessageStream)
}, },
} }
testCmd := &cobra.Command{ testCmd := &cobra.Command{
Use: "test [email file]", Use: "test [email file]",
@@ -502,11 +627,11 @@ func main() {
}, },
} }
/* Install commands and serve them to the user */ /* Install commands and serve them to the user */
/* *** */ /* *** */
cmd.AddCommand(dumpCmd) cmd.AddCommand(dumpCmd)
cmd.AddCommand(testCmd) cmd.AddCommand(testCmd)
cmd.AddCommand(blastCmd) cmd.AddCommand(blastCmd)
cmd.Execute() cmd.Execute()
} }