diff --git a/main.go b/main.go index 477f0d6..e9ac310 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ package main import ( + "bufio" "bytes" "crypto/sha1" "encoding/json" @@ -123,6 +124,7 @@ 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) @@ -351,6 +353,393 @@ func blastMail(cfg *Config, logFile string, trackingFile string, audience []stri } } +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 { @@ -445,7 +834,7 @@ func sendMail(cfg *Config, recipients []string, subject, html string) ([]Postmar 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 { // stop spinner before returning close(stop) @@ -575,7 +964,13 @@ 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 - * In case of errors, you can always blast again. Addresses already in .track file will be skipped + * 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 `) }, } @@ -627,11 +1022,25 @@ func main() { }, } + 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() }