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