Compare commits

..

No commits in common. "master" and "v1.1.0" have entirely different histories.

1 changed files with 25 additions and 648 deletions

673
main.go
View File

@ -1,6 +1,6 @@
// Meetup Invite 2000
// @author: Abner Coimbre <abner@handmadecities.com>
// Copyright (c) Handmade Cities LLC
// Copyright (c) 2024 Handmade Cities
// Based off News Blaster 2000 from the Handmade Network: https://git.handmade.network/hmn/newsblaster2000
@ -12,13 +12,12 @@
// Jack Punter (@TarriestPython)
//
// Emotional Support
// Cucui Ganon Rosario: https://www.youtube.com/watch?v=bvCsTca0uBc
// Cucui Ganon Rosario
// =========================================================================
package main
import (
"bufio"
"bytes"
"crypto/sha1"
"encoding/json"
@ -56,16 +55,11 @@ type Config struct {
} `toml:"postmark"`
}
const (
MEETUP_INVITE_CLIENT_VERSION = "v3.0.0"
)
func retrieveSubscribers(cfg *Config) ([]string, error) {
/* Prepare request payload */
/* *** */
payload := map[string]string{
"city": cfg.HMC.City,
"client": MEETUP_INVITE_CLIENT_VERSION,
}
payloadBytes, err := json.Marshal(payload)
@ -119,7 +113,7 @@ func retrieveSubscribers(cfg *Config) ([]string, error) {
return nil, errors.New("response format error: 'emails' field not found")
}
/* De-duplicate emails using a map */
/* De-duplicate emails using a map */
/* *** */
emailMap := make(map[string]bool)
for _, email := range emails {
@ -129,7 +123,6 @@ func retrieveSubscribers(cfg *Config) ([]string, error) {
}
/* Convert the map keys back to a slice */
/* *** */
var subscribers []string
for email := range emailMap {
subscribers = append(subscribers, email)
@ -195,15 +188,6 @@ func sendTest(cfg *Config, markdownFile string) {
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 */
/* *** */
html := blackfriday.Run([]byte(body))
@ -222,117 +206,6 @@ func sendTest(cfg *Config, markdownFile string) {
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) {
/* Open and parse Markdown file */
/* *** */
@ -353,16 +226,6 @@ func sendNews(cfg *Config, markdownFile string) {
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 */
/* *** */
html := blackfriday.Run([]byte(body))
@ -419,12 +282,12 @@ func blastMail(cfg *Config, logFile string, trackingFile string, audience []stri
}
}
existingReaderCount := len(sentToAddresses)
newReaderCount := 0
existingReaderCount := len(sentToAddresses)
newReaderCount := 0
if existingReaderCount > 0 {
fmt.Println("Same email file specified: Sending only to new subscribers...")
}
if existingReaderCount > 0 {
fmt.Println("Same email file specified: Sending only to new subscribers...")
}
var group []string
for _, a := range audience {
@ -443,19 +306,15 @@ func blastMail(cfg *Config, logFile string, trackingFile string, audience []stri
if len(group) == cfg.BatchSize {
results, err := sendMail(cfg, group, subject, body)
if err != nil {
fmt.Printf("Error while sending mail: %v\n", err)
return
}
sentToAddresses = append(sentToAddresses, group...)
os.WriteFile(trackingFile, []byte(strings.Join(sentToAddresses, "\n")), 0666)
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)
newReaderCount += len(group)
group = group[0:0]
}
}
@ -463,416 +322,25 @@ func blastMail(cfg *Config, logFile string, trackingFile string, audience []stri
if len(group) > 0 {
results, err := sendMail(cfg, group, subject, body)
if err != nil {
fmt.Printf("Error while sending mail: %v\n", err)
return
}
sentToAddresses = append(sentToAddresses, group...)
os.WriteFile(trackingFile, []byte(strings.Join(sentToAddresses, "\n")), 0666)
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)
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)
if newReaderCount > 0 {
newSubscribers := existingReaderCount > 0
if newSubscribers {
fmt.Printf("Sent to %d new subscribers!\n", newReaderCount)
}
} else {
fmt.Printf("Not sent (no new subscribers)\n")
}
}
var postmarkClient = http.Client{}
@ -901,47 +369,8 @@ type PostmarkBatchResult struct {
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
*/
fmt.Printf("Sending batch [%d recipients]...", len(recipients))
from := cfg.Postmark.SenderEmail
if cfg.Postmark.SenderName != "" {
@ -969,23 +398,15 @@ func sendMail(cfg *Config, recipients []string, subject, html string) ([]Postmar
return nil, err
}
req, err := http.NewRequest(http.MethodPost, cfg.Postmark.ApiUrl, bytes.NewReader(reqBody))
req, err := http.NewRequest(http.MethodPost, "https://api.postmarkapp.com/email/batchWithTemplates", 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
}
@ -993,30 +414,17 @@ func sendMail(cfg *Config, recipients []string, subject, html string) ([]Postmar
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")
fmt.Printf("Batch sent successfully, but failed to parse response from postmark.\n")
return nil, nil
}
close(stop)
<-done
fmt.Printf("\r\033[32m✅ Emailed batch of %d recipients\033[0m\n", len(recipients))
fmt.Printf("Done.\n")
return results, nil
}
@ -1099,13 +507,7 @@ func main() {
* 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: addresses in .track files are skipped
---
(Optional) Chat server invites
* ./meetupinvite2000 chat [email address]
* Emails an invitation to join Handmade Cities chat
* In case of errors, you can always blast again. Addresses already in .track file will be skipped
`)
},
}
@ -1152,41 +554,16 @@ func main() {
cmd.Usage()
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]
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()
}