meetupinvite2000/main.go

1047 lines
28 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 (
"bufio"
"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")
}
}
func sendChat(cfg *Config, address string) {
reader := bufio.NewReader(os.Stdin)
ask := func(prompt string, defaultYes bool) bool {
var hint string
if defaultYes {
hint = "(Y/n)"
} else {
hint = "(y/N)"
}
fmt.Printf("%s %s: ", prompt, hint)
resp, _ := reader.ReadString('\n')
resp = strings.TrimSpace(resp)
if resp == "" {
return defaultYes
}
r := strings.ToLower(resp)
return r == "y" || r == "yes"
}
/* 1) Can you vouch? */
/* *** */
vouch := ask(fmt.Sprintf("Can you vouch for the human identity of %s?", address), true)
if !vouch {
fmt.Println("You must vouch for the user to invite them. Aborting.")
os.Exit(1)
}
/* 2) Met in meatspace? */
/* *** */
var grant_access string
met := ask("Have you met this person in meatspace?", true)
if met {
grant_access = "This user WILL be granted the Verified Human badge."
} else {
grant_access = "This user will NOT be granted the Verified Human badge."
}
fmt.Println(grant_access)
/* 3) Notify Handmade Cities */
/* *** */
from := cfg.Postmark.SenderEmail
if cfg.Postmark.SenderName != "" {
from = fmt.Sprintf("%s <%s>", cfg.Postmark.SenderName, cfg.Postmark.SenderEmail)
}
body := PostmarkBatchWithTemplateBody{}
body.Messages = append(body.Messages, PostmarkTemplateMessage{
From: from,
To: "support@handmadecities.com",
TemplateId: cfg.Postmark.TemplateId,
TemplateModel: PostmarkTemplateModel{
Name: cfg.Postmark.SenderName,
Email: cfg.Postmark.SenderEmail,
Subject: "I'm sending a chat invite",
Body: fmt.Sprintf("I vouch for the human identity of %s. %s", address, grant_access),
},
MessageStream: cfg.Postmark.MessageStream,
})
reqBody, err := json.Marshal(&body)
if err != nil {
fmt.Printf("Failed to prepare request: %v\n", err)
os.Exit(1)
}
req, err := http.NewRequest(http.MethodPost, cfg.Postmark.ApiUrl, bytes.NewReader(reqBody))
if err != nil {
fmt.Printf("Failed to create request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Postmark-Server-Token", cfg.Postmark.ServerToken)
/* Spinner while request runs */
/* *** */
stop := make(chan struct{})
done := make(chan struct{})
go func() {
frames := []rune{'|', '/', '-', '\\'}
i := 0
for {
select {
case <-stop:
close(done)
return
default:
fmt.Printf("\r📧 Notifying support@handmadecities.com... %c", frames[i%len(frames)])
i++
time.Sleep(120 * time.Millisecond)
}
}
}()
failed_notify := "\r\033[31m📧 Failed to notify support@handmadecities.com: %v\033[0m\n"
res, err := postmarkClient.Do(req)
if err != nil {
close(stop)
<-done
fmt.Printf(failed_notify, err)
os.Exit(1)
}
var results []PostmarkBatchResult
resBody, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
close(stop)
<-done
fmt.Printf(failed_notify, err)
os.Exit(1)
}
if res.StatusCode != 200 {
close(stop)
<-done
fmt.Printf("\r\033[31m📧 Failed to notify support@handmadecities.com: %d - %s\033[0m\n", res.StatusCode, strings.TrimSpace(string(resBody)))
os.Exit(1)
}
err = json.Unmarshal(resBody, &results)
if err != nil {
close(stop)
<-done
fmt.Printf("\r\033[33m📧 Notified support@handmadecities.com but failed to parse Postmark response: %v\033[0m\n", err)
os.Exit(1)
}
close(stop)
<-done
fmt.Printf("\r\033[32m📧 Finished notifying support@handmadecities.com\033[0m\n")
/* 4) Request invite code from Handmade Cities */
/* *** */
inviteEndpoint := "https://api.handmadecities.com/v1/meetups/chat/invite"
hmcPayload := map[string]string{
"city": cfg.HMC.City,
}
hmcReqBody, err := json.Marshal(hmcPayload)
if err != nil {
fmt.Printf("Failed to prepare invite request: %v\n", err)
os.Exit(1)
}
hmcReq, err := http.NewRequest(http.MethodPost, inviteEndpoint, bytes.NewReader(hmcReqBody))
if err != nil {
fmt.Printf("Failed to create invite request: %v\n", err)
os.Exit(1)
}
hmcReq.Header.Set("Content-Type", "application/json")
hmcReq.Header.Set("Authorization", cfg.HMC.SharedSecret)
/* Spinner while requesting invite code */
/* *** */
stopInvite := make(chan struct{})
doneInvite := make(chan struct{})
go func() {
frames := []rune{'⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'}
i := 0
for {
select {
case <-stopInvite:
close(doneInvite)
return
default:
fmt.Printf("\r🔑 Requesting invite code... %c", frames[i%len(frames)])
i++
time.Sleep(100 * time.Millisecond)
}
}
}()
hmcClient := http.Client{}
hmcRes, err := hmcClient.Do(hmcReq)
if err != nil {
close(stopInvite)
<-doneInvite
fmt.Printf("\r\033[31m🔑 Failed to retrieve invite code: %v\033[0m\n", err)
os.Exit(1)
}
hmcResBody, _ := io.ReadAll(hmcRes.Body)
hmcRes.Body.Close()
if hmcRes.StatusCode < 200 || hmcRes.StatusCode >= 300 {
close(stopInvite)
<-doneInvite
fmt.Printf("\r\033[31m🔑 Invite request failed: %d - %s\033[0m\n", hmcRes.StatusCode, strings.TrimSpace(string(hmcResBody)))
os.Exit(1)
}
var inviteResp struct {
InviteCode string `json:"invite_code"`
InviteLink string `json:"invite_link"`
ServerToken string `json:"postmark_server_token"`
RegisterTemplateId int `json:"postmark_template_id_register"`
JoinTemplateId int `json:"postmark_template_id_join"`
}
if err := json.Unmarshal(hmcResBody, &inviteResp); err != nil {
close(stopInvite)
<-doneInvite
fmt.Printf("\r\033[33m🔑 Invite request succeeded but failed to parse Postmark response: %v\033[0m\n", err)
os.Exit(1)
}
close(stopInvite)
<-doneInvite
fmt.Printf("\r\033[32m🔑 Invite code retrieved and stored\033[0m\n")
registerBody := map[string]interface{}{
"Messages": []map[string]interface{}{
{
"From": from,
"To": address,
"TemplateId": inviteResp.RegisterTemplateId,
"TemplateModel": map[string]string{"token": inviteResp.InviteCode},
"MessageStream": "chat",
},
},
}
reqBody, err = json.Marshal(&registerBody)
if err != nil {
fmt.Printf("Failed to prepare request: %v\n", err)
os.Exit(1)
}
req, err = http.NewRequest(http.MethodPost, cfg.Postmark.ApiUrl, bytes.NewReader(reqBody))
if err != nil {
fmt.Printf("Failed to create request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Postmark-Server-Token", inviteResp.ServerToken)
/* Spinner while emailing registration */
/* *** */
stopRegister := make(chan struct{})
doneRegister := make(chan struct{})
go func() {
frames := []rune{'⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'}
i := 0
for {
select {
case <-stopRegister:
close(doneRegister)
return
default:
fmt.Printf("\rEmailing account creation... %c", frames[i%len(frames)])
i++
time.Sleep(100 * time.Millisecond)
}
}
}()
failedRegister := "\r\033[31m❌ Failed to email account creation: %v\033[0m\n"
res, err = postmarkClient.Do(req)
if err != nil {
close(stopRegister)
<-doneRegister
fmt.Printf(failedRegister, address, err)
os.Exit(1)
}
var registerResults []PostmarkBatchResult
resBody, err = io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
close(stopRegister)
<-doneRegister
fmt.Printf(failedRegister, address, err)
os.Exit(1)
}
if res.StatusCode != 200 {
close(stopRegister)
<-doneRegister
fmt.Printf("\r\033[31m❌ Failed to email account creation: %d - %s\033[0m\n", res.StatusCode, strings.TrimSpace(string(resBody)))
os.Exit(1)
}
err = json.Unmarshal(resBody, &registerResults)
if err != nil {
close(stopRegister)
<-doneRegister
fmt.Printf("\r\033[33m⚠ Emailed account creation but failed to parse Postmark response: %v\033[0m\n", err)
os.Exit(1)
}
close(stopRegister)
<-doneRegister
fmt.Printf("\r\033[32m✅ Emailed account creation to %s\033[0m\n", address)
/* Spinner while emailing server link */
/* *** */
stopJoin := make(chan struct{})
doneJoin := make(chan struct{})
go func() {
frames := []rune{'⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'}
i := 0
for {
select {
case <-stopJoin:
close(doneJoin)
return
default:
fmt.Printf("\rEmailing server link... %c", frames[i%len(frames)])
i++
time.Sleep(100 * time.Millisecond)
}
}
}()
failedJoin := "\r\033[31m❌ Failed to email server link: %v\033[0m\n"
joinBody := map[string]interface{}{
"Messages": []map[string]interface{}{
{
"From": from,
"To": address,
"TemplateId": inviteResp.JoinTemplateId,
"TemplateModel": map[string]string{"invite_link": inviteResp.InviteLink},
"MessageStream": "chat",
},
},
}
reqBody, err = json.Marshal(&joinBody)
if err != nil {
fmt.Printf("Failed to prepare request: %v\n", err)
os.Exit(1)
}
req, err = http.NewRequest(http.MethodPost, cfg.Postmark.ApiUrl, bytes.NewReader(reqBody))
if err != nil {
fmt.Printf("Failed to create request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Postmark-Server-Token", inviteResp.ServerToken)
res, err = postmarkClient.Do(req)
if err != nil {
close(stopJoin)
<-doneJoin
fmt.Printf(failedJoin, address, err)
os.Exit(1)
}
var joinResults []PostmarkBatchResult
resBody, err = io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
close(stopJoin)
<-doneJoin
fmt.Printf(failedJoin, address, err)
os.Exit(1)
}
if res.StatusCode != 200 {
close(stopJoin)
<-doneJoin
fmt.Printf("\r\033[31m❌ Failed to email server link: %d - %s\033[0m\n", res.StatusCode, strings.TrimSpace(string(resBody)))
os.Exit(1)
}
err = json.Unmarshal(resBody, &joinResults)
if err != nil {
close(stopJoin)
<-doneJoin
fmt.Printf("\r\033[33m⚠ Emailed server link but failed to parse Postmark response: %v\033[0m\n", err)
os.Exit(1)
}
close(stopJoin)
<-done
fmt.Printf("\r\033[32m✅ Emailed server link to %s\033[0m\n", address)
}
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, cfg.Postmark.ApiUrl, 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
* You can always blast again: useful when emailing new subscribers. Addresses already in .track file will be skipped
---
(Optional) Chat server invites
* ./meetupinvite2000 chat [email address]
* Emails an invitation to join Handmade Cities chat
`)
},
}
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)
},
}
chatCmd := &cobra.Command{
Use: "chat [email address]",
Short: "Invite a member to the chat server",
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 1 {
cmd.Usage()
os.Exit(1)
}
emailAddress := args[0]
sendChat(config, emailAddress)
},
}
/* Install commands and serve them to the user */
/* *** */
cmd.AddCommand(dumpCmd)
cmd.AddCommand(testCmd)
cmd.AddCommand(blastCmd)
cmd.AddCommand(chatCmd)
cmd.Execute()
}