Compare commits
1 Commits
v1.2.0
...
dad080aa50
| Author | SHA1 | Date | |
|---|---|---|---|
| dad080aa50 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -46,8 +46,4 @@ tags
|
|||||||
# Persistent undo
|
# Persistent undo
|
||||||
[._]*.un~
|
[._]*.un~
|
||||||
|
|
||||||
# Misc
|
|
||||||
.DS_Store
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/go,vim
|
# End of https://www.toptal.com/developers/gitignore/api/go,vim
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
gitea_urls:
|
|
||||||
api: https://git.handmadecities.com/api/v1
|
|
||||||
download: https://git.handmadecities.com/
|
|
||||||
# set to true if you use a self-signed certificate
|
|
||||||
skip_tls_verify: false
|
|
||||||
18
config.toml
Normal file
18
config.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 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"
|
||||||
142
main.go
142
main.go
@@ -1,20 +1,20 @@
|
|||||||
// Meetup Invite 2000
|
// Meetup Invite 2000
|
||||||
// @author: Abner Coimbre <abner@handmadecities.com>
|
// @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
|
// 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 (@AsafGartner)
|
// Asaf Gartner
|
||||||
// Joshua Barnett (@jshbrntt)
|
|
||||||
// Jack Punter (@TarriestPython)
|
|
||||||
//
|
//
|
||||||
// Emotional Support
|
// Emotional Support
|
||||||
// Cucui Ganon Rosario: https://www.youtube.com/watch?v=bvCsTca0uBc
|
// Cucui Ganon Rosario
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
|
// WARNING: This program requires a companion config.toml file provided by Abner. Without it we will crash!
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -44,7 +44,7 @@ type Config struct {
|
|||||||
ApiUrl string `toml:"api_url"`
|
ApiUrl string `toml:"api_url"`
|
||||||
SharedSecret string `toml:"shared_secret"`
|
SharedSecret string `toml:"shared_secret"`
|
||||||
City string `toml:"city"`
|
City string `toml:"city"`
|
||||||
} `toml:"hmc"`
|
}
|
||||||
Postmark struct {
|
Postmark struct {
|
||||||
ServerToken string `toml:"server_token"`
|
ServerToken string `toml:"server_token"`
|
||||||
ApiUrl string `toml:"api_url"`
|
ApiUrl string `toml:"api_url"`
|
||||||
@@ -113,21 +113,15 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
/* De-duplicate emails using a map */
|
/* Convert emails to []string */
|
||||||
/* *** */
|
/* *** */
|
||||||
emailMap := make(map[string]bool)
|
var subscribers []string
|
||||||
for _, email := range emails {
|
for _, email := range emails {
|
||||||
if emailStr, ok := email.(string); ok {
|
if emailStr, ok := email.(string); ok {
|
||||||
emailMap[emailStr] = true
|
subscribers = append(subscribers, emailStr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,13 +276,6 @@ 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 == "" {
|
||||||
@@ -306,19 +293,14 @@ 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]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,28 +308,14 @@ 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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,47 +345,8 @@ 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) {
|
||||||
/* Start a tiny spinner while the HTTP call runs.
|
fmt.Printf("Sending batch [%d recipients]...", len(recipients))
|
||||||
* 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 != "" {
|
||||||
@@ -447,21 +376,13 @@ func sendMail(cfg *Config, recipients []string, subject, html string) ([]Postmar
|
|||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, "https://api.postmarkapp.com/email/batchWithTemplates", bytes.NewReader(reqBody))
|
req, err := http.NewRequest(http.MethodPost, "https://api.postmarkapp.com/email/batchWithTemplates", 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,30 +390,17 @@ 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 {
|
||||||
close(stop)
|
fmt.Printf("Batch sent successfully, but failed to parse response from postmark.\n")
|
||||||
<-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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,10 +463,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
|
||||||
* Call it whatever you want. Markdown format is expected
|
* Name it whatever you want. Markdown is expected
|
||||||
* Newlines at the beginning and end will be trimmed
|
* Any newlines at the start or end of the file will be removed
|
||||||
* The first line is your email subject. It MUST start with the # symbol
|
* The first line of the file will be used as {{ subject }} in the postmark template. It must start with the # symbol
|
||||||
* The rest of the file is your email's body
|
* The rest of the file will be used as {{{ content_body }}}
|
||||||
|
|
||||||
2. See who's subscribed
|
2. See who's subscribed
|
||||||
* ./meetupinvite2000 dump
|
* ./meetupinvite2000 dump
|
||||||
@@ -567,15 +475,15 @@ 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 before blasting to everyone (please update config.toml to change test recipient)
|
* You must send a test email before blasting it to everyone (update config.toml to change test recipient)
|
||||||
* If you modify the email after testing, you must test again. Otherwise we will complain
|
* If you modify the email file after testing, you must test again. Otherwise MeetupInvite2000 will complain
|
||||||
|
|
||||||
4. Start blasting!
|
4. Start blasting!
|
||||||
* ./meetupinvite2000 blast [email file]
|
* ./meetupinvite2000 blast [email file]
|
||||||
* Batch sends your invite using our Email API (Postmark)
|
* Will batch send your invite using our Email API (Postmark)
|
||||||
* Produces .track file that tracks email addresses we attempted to send to
|
* Will produce a .track file that will list all email addresses that we attempted to send to
|
||||||
* Produces .log file with information received back from Postmark
|
* In case of error, you can blast again. All emails listed in the .track file will be skipped
|
||||||
* In case of errors, you can always blast again. Addresses already in .track file will be skipped
|
* Will produce a .log file with information received back from Postmark
|
||||||
`)
|
`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -597,7 +505,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/%s\033[0m\n", config.Postmark.MessageStream)
|
fmt.Printf("\nMailing list grows automatically as more subscribe at \033[1mhandmadecities.com/meetups\033[0m\n")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user