Compare commits
15 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
e826c9aa08 | |
|
|
4f10099b41 | |
|
|
c8fec23964 | |
|
|
05fb63e633 | |
|
|
ed17240593 | |
|
|
320bb5c34a | |
|
|
b35ac045f3 | |
|
|
3de25244fb | |
|
|
1a9f605208 | |
|
|
2211488f9a | |
|
|
e97f048eb6 | |
|
|
e6aeb0158a | |
|
|
19508f38e8 | |
|
|
90c356584e | |
|
|
585445cb6a |
18
config.toml
18
config.toml
|
|
@ -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"
|
|
||||||
697
main.go
697
main.go
|
|
@ -1,23 +1,24 @@
|
||||||
// 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 (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -55,11 +56,16 @@ type Config struct {
|
||||||
} `toml:"postmark"`
|
} `toml:"postmark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
MEETUP_INVITE_CLIENT_VERSION = "v3.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
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,
|
||||||
|
"client": MEETUP_INVITE_CLIENT_VERSION,
|
||||||
}
|
}
|
||||||
|
|
||||||
payloadBytes, err := json.Marshal(payload)
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
|
@ -113,15 +119,22 @@ func retrieveSubscribers(cfg *Config) ([]string, error) {
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Convert the map keys back to a slice */
|
||||||
|
/* *** */
|
||||||
|
var subscribers []string
|
||||||
|
for email := range emailMap {
|
||||||
|
subscribers = append(subscribers, email)
|
||||||
|
}
|
||||||
|
|
||||||
return subscribers, nil
|
return subscribers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,6 +195,15 @@ func sendTest(cfg *Config, markdownFile string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Convert Markdown to Blog Post */
|
||||||
|
/* *** */
|
||||||
|
url, valid := bloggify(cfg, subject, body, false)
|
||||||
|
if !valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body = fmt.Sprintf("[View Online](%s)\n\n%s", url, body)
|
||||||
|
|
||||||
/* Convert Markdown to HTML */
|
/* Convert Markdown to HTML */
|
||||||
/* *** */
|
/* *** */
|
||||||
html := blackfriday.Run([]byte(body))
|
html := blackfriday.Run([]byte(body))
|
||||||
|
|
@ -200,6 +222,117 @@ func sendTest(cfg *Config, markdownFile string) {
|
||||||
os.WriteFile(sha1File, []byte(sumString), 0666)
|
os.WriteFile(sha1File, []byte(sumString), 0666)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func bloggify(cfg *Config, subject, body string, isLive bool) (string, bool) {
|
||||||
|
if !isLive {
|
||||||
|
return "https://handmadecities.com/meetupinvite2000", true
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prepare request payload */
|
||||||
|
/* *** */
|
||||||
|
payload := map[string]string{
|
||||||
|
"city": cfg.HMC.City,
|
||||||
|
"author": cfg.Postmark.SenderName,
|
||||||
|
"email": cfg.Postmark.SenderEmail,
|
||||||
|
"subject": subject,
|
||||||
|
"body": body,
|
||||||
|
"client": MEETUP_INVITE_CLIENT_VERSION,
|
||||||
|
}
|
||||||
|
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error encoding payload: %v\n", err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prepare request */
|
||||||
|
/* *** */
|
||||||
|
req, err := http.NewRequest("POST", "https://deploy.handmadecities.com/v1/bloggify", bytes.NewReader(payloadBytes))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error creating request: %v\n", err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", cfg.HMC.SharedSecret)
|
||||||
|
|
||||||
|
/* 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📝 Bloggifying your email... %c", frames[i%len(frames)])
|
||||||
|
i++
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
/* Execute request */
|
||||||
|
/* *** */
|
||||||
|
client := http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
close(stop)
|
||||||
|
<-done
|
||||||
|
fmt.Printf("\r\033[31m📝 Failed to bloggify: %v\033[0m\n", err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
/* Fail if status code is not in the 2xx range */
|
||||||
|
/* *** */
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
close(stop)
|
||||||
|
<-done
|
||||||
|
fmt.Printf("\r\033[31m📝 Bloggify error: HTTP status code %d\033[0m\n", resp.StatusCode)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read response's body */
|
||||||
|
/* *** */
|
||||||
|
respBody, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
close(stop)
|
||||||
|
<-done
|
||||||
|
fmt.Printf("\r\033[31m📝 Error reading bloggify response: %v\033[0m\n", err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parse JSON response */
|
||||||
|
/* *** */
|
||||||
|
var data map[string]interface{}
|
||||||
|
if err := json.Unmarshal(respBody, &data); err != nil {
|
||||||
|
close(stop)
|
||||||
|
<-done
|
||||||
|
fmt.Printf("\r\033[31m📝 Error parsing bloggify JSON: %v\033[0m\n", err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extract the URL from the response */
|
||||||
|
/* *** */
|
||||||
|
url, ok := data["url"].(string)
|
||||||
|
if !ok || url == "" {
|
||||||
|
close(stop)
|
||||||
|
<-done
|
||||||
|
fmt.Printf("\r\033[31m📝 Bloggify response missing 'url' field\033[0m\n")
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
close(stop)
|
||||||
|
<-done
|
||||||
|
fmt.Printf("\r\033[32m📝 Your email is a blog post at %s\033[0m\n", url)
|
||||||
|
|
||||||
|
return url, true
|
||||||
|
}
|
||||||
|
|
||||||
func sendNews(cfg *Config, markdownFile string) {
|
func sendNews(cfg *Config, markdownFile string) {
|
||||||
/* Open and parse Markdown file */
|
/* Open and parse Markdown file */
|
||||||
/* *** */
|
/* *** */
|
||||||
|
|
@ -220,6 +353,16 @@ func sendNews(cfg *Config, markdownFile string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Convert Markdown to Blog Post */
|
||||||
|
/* *** */
|
||||||
|
url, valid := bloggify(cfg, subject, body, true)
|
||||||
|
if !valid {
|
||||||
|
fmt.Printf("Error converting '%s' into a blog post\n%v\n", markdownFile, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body = fmt.Sprintf("[View Online](%s)\n\n%s", url, body)
|
||||||
|
|
||||||
/* Convert Markdown to HTML */
|
/* Convert Markdown to HTML */
|
||||||
/* *** */
|
/* *** */
|
||||||
html := blackfriday.Run([]byte(body))
|
html := blackfriday.Run([]byte(body))
|
||||||
|
|
@ -276,6 +419,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 +443,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,15 +463,416 @@ 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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{}
|
var postmarkClient = http.Client{}
|
||||||
|
|
@ -345,8 +901,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 != "" {
|
||||||
|
|
@ -374,15 +969,23 @@ func sendMail(cfg *Config, recipients []string, subject, html string) ([]Postmar
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, "https://api.postmarkapp.com/email/batchWithTemplates", bytes.NewReader(reqBody))
|
req, err := http.NewRequest(http.MethodPost, cfg.Postmark.ApiUrl, 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,17 +993,30 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -463,10 +1079,10 @@ func main() {
|
||||||
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
|
||||||
|
|
@ -475,15 +1091,21 @@ 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
|
* You can always blast again: addresses in .track files are skipped
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
(Optional) Chat server invites
|
||||||
|
* ./meetupinvite2000 chat [email address]
|
||||||
|
* Emails an invitation to join Handmade Cities chat
|
||||||
`)
|
`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -505,7 +1127,7 @@ func main() {
|
||||||
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)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -530,16 +1152,41 @@ func main() {
|
||||||
cmd.Usage()
|
cmd.Usage()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
fmt.Printf("Ready to blast to everyone in %s? (Y/n): ", config.HMC.City)
|
||||||
|
resp, _ := reader.ReadString('\n')
|
||||||
|
resp = strings.TrimSpace(strings.ToLower(resp))
|
||||||
|
|
||||||
|
if resp != "" && resp != "y" && resp != "yes" {
|
||||||
|
fmt.Println("Blast cancelled.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
updateFile := args[0]
|
updateFile := args[0]
|
||||||
sendNews(config, updateFile)
|
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 */
|
/* 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.AddCommand(chatCmd)
|
||||||
|
|
||||||
cmd.Execute()
|
cmd.Execute()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue