2023-09-12 21:15:40 +00:00
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 .
2023-09-12 21:30:27 +00:00
* 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 } } } .
2023-09-12 21:15:40 +00:00
* 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 ( )
}