Newsblaster using the Postmark template batch API

This commit is contained in:
Asaf Gartner 2023-09-13 00:15:40 +03:00
commit 8c5f152de8
4 changed files with 415 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
config.json
data/

10
go.mod Normal file
View File

@ -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
)

10
go.sum Normal file
View File

@ -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=

393
main.go Normal file
View File

@ -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()
}