396 lines
11 KiB
Go
396 lines
11 KiB
Go
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) {
|
|
fmt.Printf("Sending batch [%d recipients]...", len(recipients))
|
|
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
|
|
}
|
|
fmt.Printf("Done.\n")
|
|
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 {{ subject }} in the postmark template.
|
|
* The rest of the file will be used as {{{ content_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()
|
|
}
|