Newsblaster using the Postmark template batch API
This commit is contained in:
commit
8c5f152de8
|
@ -0,0 +1,2 @@
|
||||||
|
config.json
|
||||||
|
data/
|
|
@ -0,0 +1,10 @@
|
||||||
|
module git.handmade.network/hmn/newsblaster2000
|
||||||
|
|
||||||
|
go 1.18
|
||||||
|
|
||||||
|
require github.com/spf13/cobra v1.7.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
)
|
|
@ -0,0 +1,10 @@
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||||
|
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
@ -0,0 +1,393 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
PostmarkAPIToken string
|
||||||
|
PostmarkTemplateAlias string
|
||||||
|
PostmarkMessageStream string
|
||||||
|
TestEmailAddress string
|
||||||
|
FromAddress string
|
||||||
|
FromName string
|
||||||
|
BatchSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyConfig() (Config, bool) {
|
||||||
|
defaultConfig := Config{BatchSize: 500} // NOTE(asaf): Max 500 messages per batch: https://postmarkapp.com/developer/api/templates-api#send-batch-with-templates
|
||||||
|
defaultContents, err := json.MarshalIndent(&defaultConfig, "", " ")
|
||||||
|
contents, err := os.ReadFile("config.json")
|
||||||
|
if err != nil {
|
||||||
|
contents = defaultContents
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
os.WriteFile("config.json", contents, 0666)
|
||||||
|
fmt.Printf("Created config.json file.\n")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Error while reading config.json: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var config Config
|
||||||
|
err = json.Unmarshal(contents, &config)
|
||||||
|
|
||||||
|
valid := true
|
||||||
|
if config.PostmarkAPIToken == "" {
|
||||||
|
fmt.Printf("Postmark API token missing in config.\n")
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
if config.PostmarkTemplateAlias == "" {
|
||||||
|
fmt.Printf("Postmark template alias missing in config.\n")
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
if config.PostmarkMessageStream == "" {
|
||||||
|
fmt.Printf("Postmark message stream missing in config.\n")
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
if config.TestEmailAddress == "" {
|
||||||
|
fmt.Printf("Test email address is missing in config.\n")
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
if config.FromAddress == "" {
|
||||||
|
fmt.Printf("FromAddress is missing in config. That's the return address that will appear on the email.\n")
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
if config.FromName == "" {
|
||||||
|
fmt.Printf("FromName is missing in config. That's the name that will appear next to the 'from' field in the email.\n")
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
fmt.Printf("Please edit config.json and rerun.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, valid
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMailFile(contents string) (subject string, body string, valid bool) {
|
||||||
|
valid = false
|
||||||
|
parts := strings.SplitN(string(contents), "\n", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
fmt.Printf("File does not contain subject and body\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subject = strings.TrimSpace(parts[0])
|
||||||
|
rawBody := strings.Trim(parts[1], "\n")
|
||||||
|
if subject == "" {
|
||||||
|
fmt.Printf("Subject is empty\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body = rawBody
|
||||||
|
valid = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendTest(newsletterFile string) {
|
||||||
|
cfg, valid := verifyConfig()
|
||||||
|
if !valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audience := []string{cfg.TestEmailAddress}
|
||||||
|
contents, err := os.ReadFile(newsletterFile)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
fmt.Printf("Can't find file %s\n", newsletterFile)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Error reading file %s: %v\n", newsletterFile, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subject, body, valid := parseMailFile(string(contents))
|
||||||
|
if !valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trackingFile := newsletterFile + ".test.track"
|
||||||
|
logFile := newsletterFile + "." + time.Now().Format("20060102T150405") + ".test.log"
|
||||||
|
os.Truncate(trackingFile, 0)
|
||||||
|
|
||||||
|
blastMail(cfg, logFile, trackingFile, audience, subject, body)
|
||||||
|
|
||||||
|
sha1File := newsletterFile + ".sha1"
|
||||||
|
sum := sha1.Sum(contents)
|
||||||
|
sumString := fmt.Sprintf("%x", sum)
|
||||||
|
os.WriteFile(sha1File, []byte(sumString), 0666)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendNews(audienceFile string, newsletterFile string) {
|
||||||
|
cfg, valid := verifyConfig()
|
||||||
|
if !valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(newsletterFile)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
fmt.Printf("Can't find file %s\n", newsletterFile)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Error reading file %s: %v\n", newsletterFile, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subject, body, valid := parseMailFile(string(contents))
|
||||||
|
if !valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audienceContents, err := os.ReadFile(audienceFile)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
fmt.Printf("Can't find file %s\n", audienceFile)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Error reading file %s: %v\n", audienceFile, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audience := strings.Split(string(audienceContents), "\n")
|
||||||
|
for i, a := range audience {
|
||||||
|
audience[i] = strings.TrimSpace(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
sha1File := newsletterFile + ".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", audienceFile, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
trackingFile := newsletterFile + ".track"
|
||||||
|
logFile := newsletterFile + "." + time.Now().Format("20060102T150405") + ".log"
|
||||||
|
|
||||||
|
blastMail(cfg, logFile, trackingFile, audience, subject, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func blastMail(cfg Config, logFile string, trackingFile string, audience []string, subject string, 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 {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Body string `json:"content_body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostmarkTemplateMessage struct {
|
||||||
|
From string `json:"From"`
|
||||||
|
To string `json:"To"`
|
||||||
|
TemplateAlias string `json:"TemplateAlias"`
|
||||||
|
TemplateModel PostmarkTemplateModel `json:"TemplateModel"`
|
||||||
|
TrackOpens bool `json:"TrackOpens"`
|
||||||
|
TrackLinks string `json:"TrackLinks"`
|
||||||
|
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, contentHtml string) ([]PostmarkBatchResult, error) {
|
||||||
|
from := cfg.FromAddress
|
||||||
|
if cfg.FromName != "" {
|
||||||
|
from = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.FromAddress)
|
||||||
|
}
|
||||||
|
body := PostmarkBatchWithTemplateBody{}
|
||||||
|
for _, r := range recipients {
|
||||||
|
body.Messages = append(body.Messages, PostmarkTemplateMessage{
|
||||||
|
From: from,
|
||||||
|
To: r,
|
||||||
|
TemplateAlias: cfg.PostmarkTemplateAlias,
|
||||||
|
TemplateModel: PostmarkTemplateModel{
|
||||||
|
Subject: subject,
|
||||||
|
Body: contentHtml,
|
||||||
|
},
|
||||||
|
TrackOpens: false,
|
||||||
|
TrackLinks: "None",
|
||||||
|
MessageStream: cfg.PostmarkMessageStream,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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.PostmarkAPIToken)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "newsblaster2000",
|
||||||
|
Short: "NewsBlaster2000",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
_, valid := verifyConfig()
|
||||||
|
if valid {
|
||||||
|
fmt.Printf(`Instructions:
|
||||||
|
1. Create an audience file
|
||||||
|
* Name it whatever you want.
|
||||||
|
* The file should be a list of email addresses. One email per line.
|
||||||
|
|
||||||
|
2. Create an email file
|
||||||
|
* Name it whatever you want.
|
||||||
|
* The first line of the file will be used as the subject.
|
||||||
|
* The rest of the file will be used as the body.
|
||||||
|
* Newlines between the subject and body will be removed.
|
||||||
|
|
||||||
|
3. Do a test run
|
||||||
|
* go run . test [email filename]
|
||||||
|
* You must send the test email before blasting it to everyone.
|
||||||
|
* If you modify the email file after testing, you must test again. Otherwise NewsBlaster2000 will complain.
|
||||||
|
|
||||||
|
4. Start blasting
|
||||||
|
* go run . blast [audience file] [email file]
|
||||||
|
* Will batch send using the Postmark batch API.
|
||||||
|
* 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.
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
testCmd := &cobra.Command{
|
||||||
|
Use: "test [email file]",
|
||||||
|
Short: "Send a test mail to the test address specified in the config",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if len(args) < 1 {
|
||||||
|
cmd.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
newsletterFile := args[0]
|
||||||
|
sendTest(newsletterFile)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
blastCmd := &cobra.Command{
|
||||||
|
Use: "blast [audience file] [email file]",
|
||||||
|
Short: "Blast your newsletter to your followers",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if len(args) < 2 {
|
||||||
|
cmd.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
audienceFile := args[0]
|
||||||
|
newsletterFile := args[1]
|
||||||
|
sendNews(audienceFile, newsletterFile)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(testCmd)
|
||||||
|
cmd.AddCommand(blastCmd)
|
||||||
|
|
||||||
|
cmd.Execute()
|
||||||
|
}
|
Loading…
Reference in New Issue