1047 lines
28 KiB
Go
1047 lines
28 KiB
Go
// 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(®isterBody)
|
||
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, ®isterResults)
|
||
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()
|
||
}
|