Initial commit
commit
b3fa772152
|
@ -0,0 +1,49 @@
|
||||||
|
# Created by https://www.toptal.com/developers/gitignore/api/go,vim
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=go,vim
|
||||||
|
|
||||||
|
### Go ###
|
||||||
|
# If you prefer the allow list template instead of the deny list, see community template:
|
||||||
|
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||||
|
#
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
vendor/
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
### Vim ###
|
||||||
|
# Swap
|
||||||
|
[._]*.s[a-v][a-z]
|
||||||
|
!*.svg # comment out if you don't need vector files
|
||||||
|
[._]*.sw[a-p]
|
||||||
|
[._]s[a-rt-v][a-z]
|
||||||
|
[._]ss[a-gi-z]
|
||||||
|
[._]sw[a-p]
|
||||||
|
|
||||||
|
# Session
|
||||||
|
Session.vim
|
||||||
|
Sessionx.vim
|
||||||
|
|
||||||
|
# Temporary
|
||||||
|
.netrwhist
|
||||||
|
*~
|
||||||
|
# Auto-generated tag files
|
||||||
|
tags
|
||||||
|
# Persistent undo
|
||||||
|
[._]*.un~
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/go,vim
|
|
@ -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"
|
|
@ -0,0 +1,11 @@
|
||||||
|
module git.handmadecities.com/abner/meetupinvite2000
|
||||||
|
|
||||||
|
go 1.21.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/spf13/cobra v1.8.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
)
|
|
@ -0,0 +1,23 @@
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
@ -0,0 +1,512 @@
|
||||||
|
// Meetup Invite 2000
|
||||||
|
// @author: Abner Coimbre <abner@handmadecities.com>
|
||||||
|
// Copyright (c) 2024 Handmade Cities
|
||||||
|
|
||||||
|
// Based off News Blaster 2000 from the Handmade Network: https://git.handmade.network/hmn/newsblaster2000
|
||||||
|
|
||||||
|
// ============================ Contributors =========================
|
||||||
|
// Bug & warning fixes
|
||||||
|
// Jacob Bell (@MysteriousJ)
|
||||||
|
// Asaf Gartner
|
||||||
|
//
|
||||||
|
// Emotional Support
|
||||||
|
// Cucui Ganon Rosario
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// WARNING: This program requires a companion config.toml file provided by Abner. Without it we will crash!
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"`
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Convert emails to []string */
|
||||||
|
/* *** */
|
||||||
|
var subscribers []string
|
||||||
|
for _, email := range emails {
|
||||||
|
if emailStr, ok := email.(string); ok {
|
||||||
|
subscribers = append(subscribers, emailStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
fmt.Printf("Error while sending mail: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sentToAddresses = append(sentToAddresses, group...)
|
||||||
|
os.WriteFile(trackingFile, []byte(strings.Join(sentToAddresses, "\n")), 0666)
|
||||||
|
for i, res := range results {
|
||||||
|
log.WriteString(fmt.Sprintf("%s: %s\n", group[i], res.Message))
|
||||||
|
}
|
||||||
|
group = group[0:0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
for i, res := range results {
|
||||||
|
log.WriteString(fmt.Sprintf("%s: %s\n", group[i], res.Message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 sendMail(cfg *Config, recipients []string, subject, html string) ([]PostmarkBatchResult, error) {
|
||||||
|
fmt.Printf("Sending batch [%d recipients]...", len(recipients))
|
||||||
|
|
||||||
|
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, "https://api.postmarkapp.com/email/batchWithTemplates", bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []PostmarkBatchResult
|
||||||
|
resBody, err := io.ReadAll(res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("Bad response from postmark: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(resBody, &results)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Batch sent successfully, but failed to parse response from postmark.\n")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Done.\n")
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Setup Cobra */
|
||||||
|
/* *** */
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "meetupinvite2000",
|
||||||
|
Short: "MeetupInvite2000",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
fmt.Printf(`Instructions:
|
||||||
|
1. Create an email file
|
||||||
|
* Name it whatever you want. Markdown is expected
|
||||||
|
* Any newlines at the start or end of the file will be removed
|
||||||
|
* 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 will be used as {{{ content_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 email before blasting it to everyone (update config.toml to change test recipient)
|
||||||
|
* If you modify the email file after testing, you must test again. Otherwise MeetupInvite2000 will complain
|
||||||
|
|
||||||
|
4. Start blasting!
|
||||||
|
* ./meetupinvite2000 blast [email file]
|
||||||
|
* Will batch send your invite using our Email API (Postmark)
|
||||||
|
* Will produce a .track file that will list all email addresses that we attempted to send to
|
||||||
|
* In case of error, you can blast again. All emails listed in the .track file will be skipped
|
||||||
|
* Will produce a .log file with information received back from Postmark
|
||||||
|
`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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\033[0m\n")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Install commands and serve them to the user */
|
||||||
|
/* *** */
|
||||||
|
cmd.AddCommand(dumpCmd)
|
||||||
|
cmd.AddCommand(testCmd)
|
||||||
|
cmd.AddCommand(blastCmd)
|
||||||
|
|
||||||
|
cmd.Execute()
|
||||||
|
}
|
Loading…
Reference in New Issue