diff --git a/.gitignore b/.gitignore index 210eb8f..b7282fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ **/.*.swp +.vscode + +**/*.test diff --git a/.travis.yml b/.travis.yml index 92823df..469dc83 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,8 @@ language: go +sudo: false go: - - 1.5 + - 1.7 +before_install: + go get github.com/mattn/goveralls +script: + - $HOME/gopath/bin/goveralls -service=travis-ci -ignore 'cmd/*/*.go,examples/*/*.go,helpers/*/*.go' diff --git a/README.rst b/README.rst index 367a60b..5a12a1f 100644 --- a/README.rst +++ b/README.rst @@ -10,9 +10,18 @@ SparkPost Go API client ======================= +.. image:: https://travis-ci.org/SparkPost/gosparkpost.svg?branch=master + :target: https://travis-ci.org/SparkPost/gosparkpost + :alt: Build Status + +.. image:: https://coveralls.io/repos/SparkPost/gosparkpost/badge.svg?branch=master&service=github + :target: https://coveralls.io/github/SparkPost/gosparkpost?branch=master + :alt: Code Coverage + .. image:: http://slack.sparkpost.com/badge.svg :target: http://slack.sparkpost.com :alt: Slack Community + The official Go package for using the SparkPost API. @@ -91,8 +100,12 @@ Documentation ------------- * `SparkPost API Reference`_ +* `Code samples`_ +* `Command-line tool: sparks`_ -.. _SparkPost API Reference: https://www.sparkpost.com/api +.. _SparkPost API Reference: https://developers.sparkpost.com/api +.. _Code samples: examples/README.md +.. _Command-line tool\: sparks: cmd/sparks/README.md Contribute ---------- diff --git a/cmd/README.md b/cmd/README.md new file mode 100644 index 0000000..1da6c44 --- /dev/null +++ b/cmd/README.md @@ -0,0 +1,21 @@ +## Tools for SparkPost and/or Email + +### [fblgen](./fblgen/) + +Generate and optionally send an FBL report in response to an email sent through SparkPost. + +### [mimedump](./mimedump/) + +Extract the HTML part of a saved email message. + +### [oobgen](./oobgen/) + +Generate and optionally send an out-of-band (OOB) bounce from an email with full headers. + +### [qp](./qp/) + +Encode or decode quoted-printable data. Inspired by the `base64` command-line tool, supports the same long options. + +### [sparks](./sparks/) + +Send email through SparkPost from the command line. diff --git a/cmd/fblgen/README.md b/cmd/fblgen/README.md new file mode 100644 index 0000000..d970cc3 --- /dev/null +++ b/cmd/fblgen/README.md @@ -0,0 +1,24 @@ +## fblgen + +Testing your response to FBL reports doesn't have to involve waiting for an angry/lazy recipient to click "This is Spam". +Here's how to send an FBL report in response to a message sent via SparkPost, and saved (with full headers) to a local file: + + $ ./fblgen --file ./test.eml --verbose + Got domain [sparkpostmail.com] from Return-Path + Got MX [smtp.sparkpostmail.com.] for [sparkpostmail.com] + Would send FBL from [test@sp.example.com] to [fbl@sparkpostmail.com] via [smtp.sparkpostmail.com.:smtp] + +Note that this command (once you've added the `--send` flag) will attempt to connect from your local machine to the MX listed above. +It's entirely possible that there will be something blocking that port, for example a firewall, or your residential ISP. +Here are [two](http://nc110.sourceforge.net/) [ways](https://nmap.org/ncat/) to check whether that's the case. +Whichever command you run should return in under a second. +If there's a successful connection, you're good to go. + + $ nc -vz -w 3 smtp.sparkpostmail.com 25 + $ -Date: Thu, 8 Mar 2005 17:40:36 EDT +Date: Mon, 02 Jan 2006 15:04:05 MST Subject: FW: Earn money To: <%s> MIME-Version: 1.0 diff --git a/cmd/fblgen/fblgen.go b/cmd/fblgen/fblgen.go index 7d470a1..9ae087a 100644 --- a/cmd/fblgen/fblgen.go +++ b/cmd/fblgen/fblgen.go @@ -1,117 +1,63 @@ package main import ( - "encoding/base64" "flag" "fmt" "log" "net" - "net/mail" "net/smtp" "os" - "regexp" - "strconv" "strings" -) - -var filename = flag.String("file", "", "path to email with a text/html part") -var dumpArf = flag.Bool("arf", false, "dump out multipart/report message") -var send = flag.Bool("send", false, "send fbl report") -var fblAddress = flag.String("fblto", "", "where to deliver the fbl report") -var verboseOpt = flag.Bool("verbose", false, "print out lots of messages") -var cidPattern *regexp.Regexp = regexp.MustCompile(`"customer_id"\s*:\s*"(\d+)"`) -var toPattern *regexp.Regexp = regexp.MustCompile(`"r"\s*:\s*"([^"\s]+)"`) + "github.com/SparkPost/gosparkpost/helpers/loadmsg" +) func main() { + var filename = flag.String("file", "", "path to raw email") + var dumpArf = flag.Bool("arf", false, "dump out multipart/report message") + var send = flag.Bool("send", false, "send fbl report") + var fblAddress = flag.String("fblto", "", "where to deliver the fbl report") + var verboseOpt = flag.Bool("verbose", false, "print out lots of messages") + flag.Parse() var verbose bool - if verboseOpt != nil && *verboseOpt == true { + if *verboseOpt == true { verbose = true } - if filename == nil || strings.TrimSpace(*filename) == "" { + if *filename == "" { log.Fatal("--file is required") } - fh, err := os.Open(*filename) + msg := loadmsg.Message{Filename: *filename} + err := msg.Load() if err != nil { log.Fatal(err) } - msg, err := mail.ReadMessage(fh) - if err != nil { - log.Fatal(err) - } - - b64hdr := strings.Replace(msg.Header.Get("X-MSFBL"), " ", "", -1) - if verbose == true { - log.Printf("X-MSFBL: %s\n", b64hdr) - } - - var dec []byte - b64 := base64.StdEncoding - if strings.Index(b64hdr, "|") >= 0 { - // Everything before the pipe is an encoded hmac - // TODO: verify contents using hmac - encs := strings.Split(b64hdr, "|") - dec, err = b64.DecodeString(encs[1]) - if err != nil { - log.Fatal(err) - } - } else { - dec, err = b64.DecodeString(b64hdr) - if err != nil { - log.Fatal(err) - } - } - - cidMatches := cidPattern.FindSubmatch(dec) - if cidMatches == nil || len(cidMatches) < 2 { - log.Fatalf("No key \"customer_id\" in X-MSFBL header:\n%s\n", string(dec)) - } - cid, err := strconv.Atoi(string(cidMatches[1])) - if err != nil { - log.Fatal(err) - } - - toMatches := toPattern.FindSubmatch(dec) - if toMatches == nil || len(toMatches) < 2 { - log.Fatalf("No key \"r\" (recipient) in X-MSFBL header:\n%s\n", string(dec)) - } - - if verbose == true { - log.Printf("Decoded FBL (cid=%d): %s\n", cid, string(dec)) - } - - returnPath := msg.Header.Get("Return-Path") - if fblAddress != nil && *fblAddress != "" { - returnPath = *fblAddress - } - fblAddr, err := mail.ParseAddress(returnPath) - if err != nil { - log.Fatal(err) + if *fblAddress != "" { + msg.SetReturnPath(*fblAddress) } - atIdx := strings.Index(fblAddr.Address, "@") + 1 + atIdx := strings.Index(msg.ReturnPath.Address, "@") if atIdx < 0 { - log.Fatalf("Unsupported Return-Path header [%s]\n", returnPath) + log.Fatalf("Unsupported Return-Path header [%s]\n", msg.ReturnPath.Address) } - fblDomain := fblAddr.Address[atIdx:] + fblDomain := msg.ReturnPath.Address[atIdx+1:] fblTo := fmt.Sprintf("fbl@%s", fblDomain) if verbose == true { - if fblAddress != nil && *fblAddress != "" { + if *fblAddress != "" { log.Printf("Got domain [%s] from --fblto\n", fblDomain) } else { - log.Printf("Got domain [%s] from Return-Path header\n", fblDomain) + log.Printf("Got domain [%s] from Return-Path\n", fblDomain) } } // from/to are opposite here, since we're simulating a reply - fblFrom := string(toMatches[1]) - arf := BuildArf(fblFrom, fblTo, b64hdr, cid) + fblFrom := string(msg.Recipient) + arf := BuildArf(fblFrom, fblTo, msg.MSFBL, msg.CustID) - if dumpArf != nil && *dumpArf == true { + if *dumpArf == true { fmt.Fprintf(os.Stdout, "%s", arf) } @@ -127,7 +73,7 @@ func main() { } smtpHost := fmt.Sprintf("%s:smtp", mxs[0].Host) - if send != nil && *send == true { + if *send == true { log.Printf("Sending FBL from [%s] to [%s] via [%s]...\n", fblFrom, fblTo, smtpHost) err = smtp.SendMail(smtpHost, nil, fblFrom, []string{fblTo}, []byte(arf)) @@ -137,7 +83,7 @@ func main() { log.Printf("Sent.\n") } else { if verbose == true { - log.Printf("Would send FBL from [%s] to [%s] via [%s]...\n", + log.Printf("Would send FBL from [%s] to [%s] via [%s]\n", fblFrom, fblTo, smtpHost) } } diff --git a/cmd/mimedump/mimedump.go b/cmd/mimedump/mimedump.go index a1fdbcb..c352653 100644 --- a/cmd/mimedump/mimedump.go +++ b/cmd/mimedump/mimedump.go @@ -12,9 +12,9 @@ import ( mime "github.com/jhillyerd/go.enmime" ) -var filename = flag.String("file", "", "path to email with a text/html part") - func main() { + var filename = flag.String("file", "", "path to email with a text/html part") + flag.Parse() if filename == nil || strings.TrimSpace(*filename) == "" { diff --git a/cmd/oobgen/README.md b/cmd/oobgen/README.md new file mode 100644 index 0000000..1c51d52 --- /dev/null +++ b/cmd/oobgen/README.md @@ -0,0 +1,24 @@ +## oobgen + +Testing your response to out-of-band (OOB) bounces doesn't have to involve waiting for one to be sent to you. +Here's how to send an OOB bounce in response to a message sent via SparkPost, and saved (with full headers) to a local file: + + $ ./oobgen --file ./test.eml --verbose + Got domain [sparkpostmail.com] from Return-Path + Got MX [smtp.sparkpostmail.com.] for [sparkpostmail.com] + Would send OOB from [test@sp.example.com] to [verp@sparkpostmail.com] via [smtp.sparkpostmail.com.:smtp] + +Note that this command (once you've added the `--send` flag) will attempt to connect from your local machine to the MX listed above. +It's entirely possible that there will be something blocking that port, for example a firewall, or your residential ISP. +Here are [two](http://nc110.sourceforge.net/) [ways](https://nmap.org/ncat/) to check whether that's the case. +Whichever command you run should return in under a second. +If there's a successful connection, you're good to go. + + $ nc -vz -w 3 smtp.sparkpostmail.com 25 + $ + (reason: 550 5.0.0 <%s>... User unknown) + + ----- Transcript of session follows ----- +... while talking to %s: +>>> DATA +<<< 550 5.0.0 <%s>... User unknown +550 5.1.1 <%s>... User unknown +<<< 503 5.0.0 Need RCPT (recipient) + +--%s +Content-Type: message/delivery-status + +Reporting-MTA: dns; %s +Received-From-MTA: DNS; %s +Arrival-Date: Mon, 02 Jan 2006 15:04:05 MST + +Final-Recipient: RFC822; %s +Action: failed +Status: 5.0.0 +Remote-MTA: DNS; %s +Diagnostic-Code: SMTP; 550 5.0.0 <%s>... User unknown +Last-Attempt-Date: Mon, 02 Jan 2006 15:04:05 MST + +--%s +Content-Type: message/rfc822 + +%s + +--%s-- +` + +func BuildOob(from, to, rawMsg string) string { + boundary := fmt.Sprintf("_----%d===_61/00-25439-267B0055", time.Now().Unix()) + fromDomain := from[strings.Index(from, "@")+1:] + toDomain := to[strings.Index(to, "@")+1:] + msg := fmt.Sprintf(OobFormat, + from, to, boundary, + boundary, to, to, toDomain, to, to, + boundary, toDomain, fromDomain, to, toDomain, to, + boundary, rawMsg, + boundary) + return msg +} diff --git a/cmd/oobgen/oobgen.go b/cmd/oobgen/oobgen.go new file mode 100644 index 0000000..d256b86 --- /dev/null +++ b/cmd/oobgen/oobgen.go @@ -0,0 +1,85 @@ +package main + +import ( + "flag" + "fmt" + "io/ioutil" + "log" + "net" + "net/mail" + "net/smtp" + "strings" + + "github.com/SparkPost/gosparkpost/helpers/loadmsg" +) + +func main() { + var filename = flag.String("file", "", "path to raw email") + var send = flag.Bool("send", false, "send fbl report") + var verboseOpt = flag.Bool("verbose", false, "print out lots of messages") + + flag.Parse() + var verbose bool + if *verboseOpt == true { + verbose = true + } + + if *filename == "" { + log.Fatal("--file is required") + } + + msg := loadmsg.Message{Filename: *filename} + err := msg.Load() + if err != nil { + log.Fatal(err) + } + + atIdx := strings.Index(msg.ReturnPath.Address, "@") + if atIdx < 0 { + log.Fatalf("Unsupported Return-Path header [%s]\n", msg.ReturnPath.Address) + } + oobDomain := msg.ReturnPath.Address[atIdx+1:] + if verbose == true { + log.Printf("Got domain [%s] from Return-Path\n", oobDomain) + } + + fileBytes, err := ioutil.ReadFile(*filename) + if err != nil { + log.Fatal(err) + } + + // from/to are opposite here, since we're simulating a reply + to := msg.ReturnPath.Address + from, err := mail.ParseAddress(msg.Message.Header.Get("From")) + if err != nil { + log.Fatal(err) + } + oob := BuildOob(to, from.Address, string(fileBytes)) + + mxs, err := net.LookupMX(oobDomain) + if err != nil { + log.Fatal(err) + } + if mxs == nil || len(mxs) <= 0 { + log.Fatal("No MXs for [%s]\n", oobDomain) + } + if verbose == true { + log.Printf("Got MX [%s] for [%s]\n", mxs[0].Host, oobDomain) + } + smtpHost := fmt.Sprintf("%s:smtp", mxs[0].Host) + + if *send == true { + log.Printf("Sending OOB from [%s] to [%s] via [%s]...\n", + from, to, smtpHost) + err = smtp.SendMail(smtpHost, nil, from.Address, []string{to}, []byte(oob)) + if err != nil { + log.Fatal(err) + } + log.Printf("Sent.\n") + } else { + if verbose == true { + log.Printf("Would send OOB from [%s] to [%s] via [%s]\n", + from.Address, to, smtpHost) + } + } +} diff --git a/cmd/sparks/README.md b/cmd/sparks/README.md new file mode 100644 index 0000000..4c70521 --- /dev/null +++ b/cmd/sparks/README.md @@ -0,0 +1,46 @@ +# sparks + +`sparks` is a command-line tool for sending email using the [SparkPost API](https://developers.sparkpost.com/api/). + +### Why does this exist? + +I've found this tool useful for testing and troubleshooting, and for making sure that reported issues with `gosparkpost` get squashed. It also has handy working example code showing how to use the `gosparkpost` library to do various things, like using inline images, adding attachments, and managing cc/bcc recipients. + +### Why is it called `sparks`? + +It's similar in function to [swaks](http://www.jetmore.org/john/code/swaks/), which is a handy SMTP tool: `Swiss Army Knife for SMTP`, and `sparks` sounded better than `swaksp`, `swakapi` or `apisak`, etc. + +### Installation + + $ go get git@github.com:SparkPost/gosparkpost + $ cd $GOPATH/src/github.com/SparkPost/gosparkpost/cmd/sparks + $ go build && go install + +### Config + + $ export SPARKPOST_API_KEY=0000000000000000000000000000000000000000 + +### Usage Examples + +HTML content with inline image, dumping request and response HTTP headers, and response body. + + $ sparks -from img@sp.example.com -html $HOME/test/image.html \ + -img image/jpg:hi:$HOME/test/hi.jpg -subject 'hello!' \ + -to me@example.com.sink.sparkpostmail.com -httpdump + +HTML content with attachment. + + $ sparks -from att@sp.example.com -html 'Did you get that thing I sent you?' \ + -to you@example.com.sink.sparkpostmail.com -subject 'that thing' \ + -attach image/jpg:thing.jpg:$HOME/test/thing.jpg + +Text content with cc and bcc, but don't send it. +The output that would be sent is pretty printed using the handy JSON tool `jq`. + + $ sparks -from cc@sp.example.com -text 'This is an ambiguous notification' \ + -subject 'ambiguous notification' \ + -to me@example.com.sink.sparkpostmail.com \ + -cc you@example.com.sink.sparkpostmail.com \ + -bcc thing1@example.com.sink.sparkpostmail.com \ + -bcc thing2@example.com.sink.sparkpostmail.com \ + -dry-run | jq . diff --git a/cmd/sparks/sparks.go b/cmd/sparks/sparks.go index c12a1ce..177c1d6 100644 --- a/cmd/sparks/sparks.go +++ b/cmd/sparks/sparks.go @@ -27,34 +27,34 @@ func (s *Strings) Set(value string) error { return nil } -var to Strings -var cc Strings -var bcc Strings -var headers Strings -var images Strings -var attachments Strings - -func init() { +func main() { + var to Strings + var cc Strings + var bcc Strings + var headers Strings + var images Strings + var attachments Strings + flag.Var(&to, "to", "where the mail goes to") flag.Var(&cc, "cc", "carbon copy this address") flag.Var(&bcc, "bcc", "blind carbon copy this address") flag.Var(&headers, "header", "custom header for your content") flag.Var(&images, "img", "mimetype:cid:path for image to include") flag.Var(&attachments, "attach", "mimetype:name:path for file to attach") -} -var from = flag.String("from", "default@sparkpostbox.com", "where the mail came from") -var subject = flag.String("subject", "", "email subject") -var htmlFlag = flag.String("html", "", "string/filename containing html content") -var textFlag = flag.String("text", "", "string/filename containing text content") -var subsFlag = flag.String("subs", "", "string/filename containing substitution data (json object)") -var sendDelay = flag.String("send-delay", "", "delay delivery the specified amount of time") -var inline = flag.Bool("inline-css", false, "automatically inline css") -var dryrun = flag.Bool("dry-run", false, "dump json that would be sent to server") -var url = flag.String("url", "", "base url for api requests (optional)") -var help = flag.Bool("help", false, "display a help message") + var from = flag.String("from", "default@sparkpostbox.com", "where the mail came from") + var subject = flag.String("subject", "", "email subject") + var htmlFlag = flag.String("html", "", "string/filename containing html content") + var textFlag = flag.String("text", "", "string/filename containing text content") + var rfc822Flag = flag.String("rfc822", "", "string/filename containing raw message") + var subsFlag = flag.String("subs", "", "string/filename containing substitution data (json object)") + var sendDelay = flag.String("send-delay", "", "delay delivery the specified amount of time") + var inline = flag.Bool("inline-css", false, "automatically inline css") + var dryrun = flag.Bool("dry-run", false, "dump json that would be sent to server") + var url = flag.String("url", "", "base url for api requests (optional)") + var help = flag.Bool("help", false, "display a help message") + var httpDump = flag.Bool("httpdump", false, "dump out http request and response") -func main() { flag.Parse() if *help { @@ -67,15 +67,19 @@ func main() { } apiKey := os.Getenv("SPARKPOST_API_KEY") - if strings.TrimSpace(apiKey) == "" { + if strings.TrimSpace(apiKey) == "" && *dryrun == false { log.Fatal("FATAL: API key not found in environment!\n") } hasHtml := strings.TrimSpace(*htmlFlag) != "" hasText := strings.TrimSpace(*textFlag) != "" + hasRFC822 := strings.TrimSpace(*rfc822Flag) != "" hasSubs := strings.TrimSpace(*subsFlag) != "" - if !hasHtml && !hasText { + // rfc822 must be specified by itself, i.e. no text or html + if hasRFC822 && (hasHtml || hasText) { + log.Fatal("FATAL: --rfc822 cannot be combined with --html or --text!\n") + } else if !hasRFC822 && !hasHtml && !hasText { log.Fatal("FATAL: must specify one of --html or --text!\n") } @@ -86,6 +90,9 @@ func main() { } cfg.BaseUrl = *url } + if *httpDump { + cfg.Verbose = true + } var sparky sp.Client err := sparky.Init(cfg) @@ -98,8 +105,25 @@ func main() { Subject: *subject, } + if hasRFC822 { + // these are pulled from the raw message + content.From = nil + content.Subject = "" + if strings.HasPrefix(*rfc822Flag, "/") || strings.HasPrefix(*rfc822Flag, "./") { + // read file to get raw message + rfc822Bytes, err := ioutil.ReadFile(*rfc822Flag) + if err != nil { + log.Fatal(err) + } + content.EmailRFC822 = string(rfc822Bytes) + } else { + // raw message string passed on command line + content.EmailRFC822 = *rfc822Flag + } + } + if hasHtml { - if strings.Contains(*htmlFlag, "/") { + if strings.HasPrefix(*htmlFlag, "/") || strings.HasPrefix(*htmlFlag, "./") { // read file to get html htmlBytes, err := ioutil.ReadFile(*htmlFlag) if err != nil { @@ -113,7 +137,7 @@ func main() { } if hasText { - if strings.Contains(*textFlag, "/") { + if strings.HasPrefix(*textFlag, "/") || strings.HasPrefix(*textFlag, "./") { // read file to get text textBytes, err := ioutil.ReadFile(*textFlag) if err != nil { @@ -169,7 +193,7 @@ func main() { var subJson *json.RawMessage if hasSubs { var subsBytes []byte - if strings.Contains(*subsFlag, "/") { + if strings.HasPrefix(*subsFlag, "/") || strings.HasPrefix(*subsFlag, "./") { // read file to get substitution data subsBytes, err = ioutil.ReadFile(*subsFlag) if err != nil { @@ -190,18 +214,20 @@ func main() { tx.Recipients = []sp.Recipient{} for _, r := range to { - tx.Recipients = append(tx.Recipients.([]sp.Recipient), sp.Recipient{ - Address: sp.Address{Email: r, HeaderTo: headerTo}, - SubstitutionData: subJson, - }) + var recip sp.Recipient = sp.Recipient{Address: sp.Address{Email: r, HeaderTo: headerTo}} + if hasSubs { + recip.SubstitutionData = subJson + } + tx.Recipients = append(tx.Recipients.([]sp.Recipient), recip) } if len(cc) > 0 { for _, r := range cc { - tx.Recipients = append(tx.Recipients.([]sp.Recipient), sp.Recipient{ - Address: sp.Address{Email: r, HeaderTo: headerTo}, - SubstitutionData: subJson, - }) + var recip sp.Recipient = sp.Recipient{Address: sp.Address{Email: r, HeaderTo: headerTo}} + if hasSubs { + recip.SubstitutionData = subJson + } + tx.Recipients = append(tx.Recipients.([]sp.Recipient), recip) } if content.Headers == nil { content.Headers = map[string]string{} @@ -211,10 +237,11 @@ func main() { if len(bcc) > 0 { for _, r := range bcc { - tx.Recipients = append(tx.Recipients.([]sp.Recipient), sp.Recipient{ - Address: sp.Address{Email: r, HeaderTo: headerTo}, - SubstitutionData: subJson, - }) + var recip sp.Recipient = sp.Recipient{Address: sp.Address{Email: r, HeaderTo: headerTo}} + if hasSubs { + recip.SubstitutionData = subJson + } + tx.Recipients = append(tx.Recipients.([]sp.Recipient), recip) } } @@ -247,7 +274,7 @@ func main() { if tx.Options == nil { tx.Options = &sp.TxOptions{} } - tx.Options.InlineCSS = true + tx.Options.InlineCSS = inline } if *dryrun != false { @@ -264,5 +291,20 @@ func main() { log.Fatal(err) } - log.Printf("HTTP [%s] TX %s\n", req.HTTP.Status, id) + if *httpDump { + if reqDump, ok := req.Verbose["http_requestdump"]; ok { + os.Stdout.Write([]byte(reqDump)) + } else { + os.Stdout.Write([]byte("*** No request dump available! ***\n\n")) + } + + if resDump, ok := req.Verbose["http_responsedump"]; ok { + os.Stdout.Write([]byte(resDump)) + os.Stdout.Write([]byte("\n")) + } else { + os.Stdout.Write([]byte("*** No response dump available! ***\n")) + } + } else { + log.Printf("HTTP [%s] TX %s\n", req.HTTP.Status, id) + } } diff --git a/common.go b/common.go index 820988d..e3f4128 100644 --- a/common.go +++ b/common.go @@ -2,16 +2,17 @@ package gosparkpost import ( "bytes" - "crypto/tls" + "context" "encoding/base64" "encoding/json" - "fmt" "io/ioutil" + "mime" "net/http" + "net/http/httputil" "regexp" "strings" - certifi "github.com/certifi/gocertifi" + "github.com/pkg/errors" ) // Config includes all information necessary to make an API request. @@ -24,12 +25,15 @@ type Config struct { Verbose bool } -// Client contains connection and authentication information. +// Client contains connection, configuration, and authentication information. // Specifying your own http.Client gives you lots of control over how connections are made. +// Clients are safe for concurrent (read-only) reuse by multiple goroutines. +// Headers is useful to set subaccount (X-MSYS-SUBACCOUNT header) and any other custom headers. +// All changes to Headers must happen before Client is exposed to possible concurrent use. type Client struct { Config *Config Client *http.Client - headers map[string]string + Headers *http.Header } var nonDigit *regexp.Regexp = regexp.MustCompile(`\D`) @@ -41,13 +45,13 @@ func NewConfig(m map[string]string) (*Config, error) { if baseurl, ok := m["baseurl"]; ok { c.BaseUrl = baseurl } else { - return nil, fmt.Errorf("BaseUrl is required for api config") + return nil, errors.New("BaseUrl is required for api config") } if apikey, ok := m["apikey"]; ok { c.ApiKey = apikey } else { - return nil, fmt.Errorf("ApiKey is required for api config") + return nil, errors.New("ApiKey is required for api config") } return c, nil @@ -55,15 +59,43 @@ func NewConfig(m map[string]string) (*Config, error) { // Response contains information about the last HTTP response. // Helpful when an error message doesn't necessarily give the complete picture. +// Also contains any messages emitted as a result of the Verbose config option. type Response struct { HTTP *http.Response Body []byte - Results map[string]interface{} `json:"results,omitempty"` - Errors []Error `json:"errors,omitempty"` + Verbose map[string]string + Results interface{} `json:"results,omitempty"` + Errors SPErrors `json:"errors,omitempty"` } -// Error mirrors the error format returned by SparkPost APIs. -type Error struct { +// HTTPError returns nil when the HTTP response code is in the range 200-299. +// If the API has returned a JSON error in the expected format, return that. +// Otherwise, return an error containing the HTTP code and response body. +func (res *Response) HTTPError() error { + if res == nil { + return errors.New("Internal error: Response may not be nil") + } else if res.HTTP == nil { + return errors.New("Internal error: Response.HTTP may not be nil") + } + + if Is2XX(res.HTTP.StatusCode) { + return nil + } else if len(res.Errors) > 0 { + return res.Errors + } + + return SPErrors{{ + Code: res.HTTP.Status, + Message: string(res.Body), + Description: "HTTP/JSON Error", + }} +} + +// SPErrors is the plural of SPError +type SPErrors []SPError + +// SPError mirrors the error format returned by SparkPost APIs. +type SPError struct { Message string `json:"message"` Code string `json:"code"` Description string `json:"description"` @@ -71,144 +103,157 @@ type Error struct { Line int `json:"line,omitempty"` } -func (e Error) Json() (string, error) { - jsonBytes, err := json.Marshal(e) - if err != nil { - return "", err - } - return string(jsonBytes), nil +// Error satisfies the builtin Error interface +func (e SPErrors) Error() string { + // safe to ignore errors when Marshaling a constant type + jsonb, _ := json.Marshal(e) + return string(jsonb) } // Init pulls together everything necessary to make an API request. // Caller may provide their own http.Client by setting it in the provided API object. -func (api *Client) Init(cfg *Config) error { +func (c *Client) Init(cfg *Config) error { // Set default values if cfg.BaseUrl == "" { cfg.BaseUrl = "https://api.sparkpost.com" } else if !strings.HasPrefix(cfg.BaseUrl, "https://") { - return fmt.Errorf("API base url must be https!") + return errors.New("API base url must be https!") } if cfg.ApiVersion == 0 { cfg.ApiVersion = 1 } - api.Config = cfg - api.headers = make(map[string]string) - - if api.Client == nil { - // Ran into an issue where USERTrust was not recognized on OSX. - // The rest of this block was the fix. - - // load Mozilla cert pool - pool, err := certifi.CACerts() - if err != nil { - return err - } - - // configure transport using Mozilla cert pool - transport := &http.Transport{ - TLSClientConfig: &tls.Config{RootCAs: pool}, - } - - // configure http client using transport - api.Client = &http.Client{Transport: transport} + c.Config = cfg + c.Headers = &http.Header{} + if c.Client == nil { + c.Client = http.DefaultClient } return nil } -// SetHeader adds additional HTTP headers for every API request made from client. -// Usefull to set subaccount X-MSYS-SUBACCOUNT header and etc. -func (c *Client) SetHeader(header string, value string) { - c.headers[header] = value -} - -// Removes header set in SetHeader function -func (c *Client) RemoveHeader(header string) { - delete(c.headers, header) -} - // HttpPost sends a Post request with the provided JSON payload to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpPost(url string, data []byte) (*Response, error) { - return c.DoRequest("POST", url, data) +func (c *Client) HttpPost(ctx context.Context, url string, data []byte) (*Response, error) { + return c.DoRequest(ctx, "POST", url, data) } // HttpGet sends a Get request to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpGet(url string) (*Response, error) { - return c.DoRequest("GET", url, nil) +func (c *Client) HttpGet(ctx context.Context, url string) (*Response, error) { + return c.DoRequest(ctx, "GET", url, nil) } // HttpPut sends a Put request with the provided JSON payload to the specified url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpPut(url string, data []byte) (*Response, error) { - return c.DoRequest("PUT", url, data) +func (c *Client) HttpPut(ctx context.Context, url string, data []byte) (*Response, error) { + return c.DoRequest(ctx, "PUT", url, data) } // HttpDelete sends a Delete request to the provided url. // Query params are supported via net/url - roll your own and stringify it. // Authenticate using the configured API key. -func (c *Client) HttpDelete(url string) (*Response, error) { - return c.DoRequest("DELETE", url, nil) +func (c *Client) HttpDelete(ctx context.Context, url string) (*Response, error) { + return c.DoRequest(ctx, "DELETE", url, nil) } -func (c *Client) DoRequest(method, urlStr string, data []byte) (*Response, error) { +func (c *Client) DoRequest(ctx context.Context, method, urlStr string, data []byte) (*Response, error) { + if c == nil { + return nil, errors.New("Client must be non-nil!") + } else if c.Client == nil { + return nil, errors.New("Client.Client (http.Client) must be non-nil!") + } else if c.Config == nil { + return nil, errors.New("Client.Config must be non-nil!") + } req, err := http.NewRequest(method, urlStr, bytes.NewBuffer(data)) if err != nil { - if c.Config.Verbose { - fmt.Println("Error: %s", err) + return nil, errors.Wrap(err, "building request") + } + + ares := &Response{} + if c.Config.Verbose { + if ares.Verbose == nil { + ares.Verbose = map[string]string{} } - return nil, err + ares.Verbose["http_method"] = method + ares.Verbose["http_uri"] = urlStr } if data != nil { req.Header.Set("Content-Type", "application/json") if c.Config.Verbose { - fmt.Printf("URL: %s %s\n", method, urlStr) - fmt.Printf("Will Post: %s\n", string(data)) + ares.Verbose["http_postdata"] = string(data) } } // TODO: set User-Agent based on gosparkpost version and possibly git's short hash req.Header.Set("User-Agent", "GoSparkPost v0.1") - // Forward additional headers set in client to request - for header, value := range c.headers { - req.Header.Set(header, value) - } - if c.Config.ApiKey != "" { req.Header.Set("Authorization", c.Config.ApiKey) - } else { + } else if c.Config.Username != "" { req.Header.Add("Authorization", "Basic "+basicAuth(c.Config.Username, c.Config.Password)) } + // Forward additional headers set in client to request + if c.Headers != nil { + for header, values := range map[string][]string(*c.Headers) { + for _, value := range values { + req.Header.Add(header, value) + } + } + } + + if ctx == nil { + ctx = context.Background() + } + // set any headers provided in context + if header, ok := ctx.Value("http.Header").(http.Header); ok { + for key, vals := range map[string][]string(header) { + req.Header.Del(key) + for _, val := range vals { + req.Header.Add(key, val) + } + } + } + req = req.WithContext(ctx) + if c.Config.Verbose { - fmt.Println("Request: ", req) + reqBytes, err := httputil.DumpRequestOut(req, false) + if err != nil { + return ares, errors.Wrap(err, "saving request") + } + ares.Verbose["http_requestdump"] = string(reqBytes) } res, err := c.Client.Do(req) - ares := &Response{HTTP: res} + ares.HTTP = res if c.Config.Verbose { - if err != nil { - fmt.Println("Error: ", err) + ares.Verbose["http_status"] = ares.HTTP.Status + bodyBytes, dumpErr := httputil.DumpResponse(res, true) + if dumpErr != nil { + ares.Verbose["http_responsedump_err"] = dumpErr.Error() } else { - fmt.Println("Server Response: ", ares.HTTP.Status) - bodyBytes, err := ares.ReadBody() - if err != nil { - fmt.Println("Error: ", err) - } else { - fmt.Println("Body: ", string(bodyBytes)) - } + ares.Verbose["http_responsedump"] = string(bodyBytes) } } - return ares, err + if err != nil { + return ares, errors.Wrap(err, "error response") + } + return ares, nil +} + +// Is2XX returns true if the provided HTTP response code is in the range 200-299. +func Is2XX(code int) bool { + if code < 300 && code >= 200 { + return true + } + return false } func basicAuth(username, password string) string { @@ -228,8 +273,11 @@ func (r *Response) ReadBody() ([]byte, error) { defer r.HTTP.Body.Close() bodyBytes, err := ioutil.ReadAll(r.HTTP.Body) + if err != nil { + return bodyBytes, errors.Wrap(err, "reading http body") + } r.Body = bodyBytes - return bodyBytes, err + return bodyBytes, nil } // ParseResponse pulls info from JSON http responses into api.Response object. @@ -239,10 +287,14 @@ func (r *Response) ParseResponse() error { if err != nil { return err } + // Don't try to unmarshal an empty response + if bytes.Compare(body, []byte("")) == 0 { + return nil + } err = json.Unmarshal(body, r) if err != nil { - return fmt.Errorf("Failed to parse API response: [%s]\n%s", err, string(body)) + return errors.Wrap(err, "parsing api response") } return nil @@ -251,31 +303,25 @@ func (r *Response) ParseResponse() error { // AssertJson returns an error if the provided HTTP response isn't JSON. func (r *Response) AssertJson() error { if r.HTTP == nil { - return fmt.Errorf("AssertJson got nil http.Response") + return errors.New("AssertJson got nil http.Response") } - ctype := strings.ToLower(r.HTTP.Header.Get("Content-Type")) - // allow things like "application/json; charset=utf-8" in addition to the bare content type - if !strings.HasPrefix(ctype, "application/json") { - return fmt.Errorf("Expected json, got [%s] with code %d", ctype, r.HTTP.StatusCode) + body, err := r.ReadBody() + if err != nil { + return err } - return nil -} - -// PrettyError returns a human-readable error message for common http errors returned by the API. -// The string parameters are used to customize the generated error message -// (example: noun=template, verb=create). -func (r *Response) PrettyError(noun, verb string) error { - if r.HTTP == nil { + // Don't fail on an empty response + if bytes.Compare(body, []byte("")) == 0 { return nil } - code := r.HTTP.StatusCode - if code == 404 { - return fmt.Errorf("%s does not exist, %s failed.", noun, verb) - } else if code == 401 { - return fmt.Errorf("%s %s failed, permission denied. Check your API key.", noun, verb) - } else if code == 403 { - // This is what happens if an endpoint URL gets typo'd. - return fmt.Errorf("%s %s failed. Are you using the right API path?", noun, verb) + + ctype := r.HTTP.Header.Get("Content-Type") + mediaType, _, err := mime.ParseMediaType(ctype) + if err != nil { + return errors.Wrap(err, "parsing content-type") + } + // allow things like "application/json; charset=utf-8" in addition to the bare content type + if mediaType != "application/json" { + return errors.Errorf("Expected json, got [%s] with code %d", mediaType, r.HTTP.StatusCode) } return nil } diff --git a/common_test.go b/common_test.go new file mode 100644 index 0000000..2464edb --- /dev/null +++ b/common_test.go @@ -0,0 +1,175 @@ +package gosparkpost_test + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + sp "github.com/SparkPost/gosparkpost" + "github.com/pkg/errors" +) + +var ( + testMux *http.ServeMux + testClient *sp.Client + testServer *httptest.Server +) + +func testSetup(t *testing.T) { + // spin up a test server + testMux = http.NewServeMux() + testServer = httptest.NewTLSServer(testMux) + // our client configured to hit the https test server with self-signed cert + tx := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + testClient = &sp.Client{Client: &http.Client{Transport: tx}} + testClient.Config = &sp.Config{Verbose: true} + testUrl, err := url.Parse(testServer.URL) + if err != nil { + t.Fatalf("Test server url parsing failed: %v", err) + } + testClient.Config.BaseUrl = testUrl.String() + err = testClient.Init(testClient.Config) + if err != nil { + t.Fatalf("Test client init failed: %v", err) + } +} + +func testTeardown() { + testServer.Close() +} + +func testMethod(t *testing.T, r *http.Request, want string) { + if got := r.Method; got != want { + t.Fatalf("Request method: %v, want %v", got, want) + } +} + +func testFailVerbose(t *testing.T, res *sp.Response, fmt string, args ...interface{}) { + if res != nil { + for _, e := range res.Verbose { + t.Error(e) + } + } + t.Fatalf(fmt, args...) +} + +func TestNewConfig(t *testing.T) { + for idx, test := range []struct { + in map[string]string + cfg *sp.Config + err error + }{ + {map[string]string{}, nil, errors.New("BaseUrl is required for api config")}, + {map[string]string{"baseurl": "http://example.com"}, nil, errors.New("ApiKey is required for api config")}, + {map[string]string{"baseurl": "http://example.com", "apikey": "foo"}, &sp.Config{BaseUrl: "http://example.com", ApiKey: "foo"}, nil}, + } { + cfg, err := sp.NewConfig(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("NewConfig[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("NewConfig[%d] => err %q, want %q", idx, err, test.err) + } else if cfg == nil && test.cfg != nil || cfg != nil && test.cfg == nil { + t.Errorf("NewConfig[%d] => cfg %v, want %v", idx, cfg, test.cfg) + } + } +} + +func TestJson(t *testing.T) { + var e = sp.SPErrors([]sp.SPError{{Message: "This is fine."}}) + var exp = `[{"message":"This is fine.","code":"","description":""}]` + str := e.Error() + if str != exp { + t.Errorf("*SPError.Stringify => %q, want %q", str, exp) + } +} + +func TestDoRequest(t *testing.T) { + testSetup(t) + defer testTeardown() + + for idx, test := range []struct { + client *sp.Client + method string + err error + }{ + {nil, "", errors.New("Client must be non-nil!")}, + {&sp.Client{}, "", errors.New("Client.Client (http.Client) must be non-nil!")}, + {&sp.Client{Client: http.DefaultClient}, "", errors.New("Client.Config must be non-nil!")}, + {testClient, "💩", errors.New(`building request: net/http: invalid method "💩"`)}, + } { + _, err := test.client.DoRequest(nil, test.method, "", nil) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("DoRequest[%d] => err %v, want %v", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("DoRequest[%d] => err %v, want %v", idx, err, test.err) + } + } +} + +func TestHTTPError(t *testing.T) { + var res *sp.Response + err := res.HTTPError() + if err == nil { + t.Error("nil response should fail") + } + + res = &sp.Response{} + err = res.HTTPError() + if err == nil { + t.Error("nil http should fail") + } +} + +func TestInit(t *testing.T) { + for idx, test := range []struct { + api *sp.Client + cfg *sp.Config + out *sp.Config + err error + }{ + {&sp.Client{}, &sp.Config{BaseUrl: ""}, &sp.Config{BaseUrl: "https://api.sparkpost.com"}, nil}, + {&sp.Client{}, &sp.Config{BaseUrl: "http://api.sparkpost.com"}, nil, errors.New("API base url must be https!")}, + } { + err := test.api.Init(test.cfg) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("Init[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("Init[%d] => err %q, want %q", idx, err, test.err) + } else if test.out != nil && test.api.Config.BaseUrl != test.out.BaseUrl { + t.Errorf("Init[%d] => BaseUrl %q, want %q", idx, test.api.Config.BaseUrl, test.out.BaseUrl) + } + } +} + +func loadTestFile(t *testing.T, fileToLoad string) string { + b, err := ioutil.ReadFile(fileToLoad) + + if err != nil { + t.Fatalf("Failed to load test data: %v", err) + } + + return string(b) +} + +func AreEqualJSON(s1, s2 string) (bool, error) { + var o1 interface{} + var o2 interface{} + + var err error + err = json.Unmarshal([]byte(s1), &o1) + if err != nil { + return false, fmt.Errorf("Error mashalling string 1 :: %s", err.Error()) + } + err = json.Unmarshal([]byte(s2), &o2) + if err != nil { + return false, fmt.Errorf("Error mashalling string 2 :: %s", err.Error()) + } + + return reflect.DeepEqual(o1, o2), nil +} diff --git a/deliverability-metrics.go b/del_metrics.go similarity index 66% rename from deliverability-metrics.go rename to del_metrics.go index ece5961..bd258d0 100644 --- a/deliverability-metrics.go +++ b/del_metrics.go @@ -1,16 +1,17 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" + "net/url" - URL "net/url" + "github.com/pkg/errors" ) -// https://www.sparkpost.com/api#/reference/message-events -var deliverabilityMetricPathFormat = "/api/v%d/metrics/deliverability" +var MetricsPathFormat = "/api/v%d/metrics/deliverability" -type DeliverabilityMetricItem struct { +type MetricItem struct { CountInjected int `json:"count_injected"` CountBounce int `json:"count_bounce,omitempty"` CountRejected int `json:"count_rejected,omitempty"` @@ -50,77 +51,71 @@ type DeliverabilityMetricItem struct { BindingGroup string `json:"binding_group,omitempty"` } -type DeliverabilityMetricEventsWrapper struct { - Results []*DeliverabilityMetricItem `json:"results,omitempty"` - TotalCount int `json:"total_count,omitempty"` - Links []map[string]string `json:"links,omitempty"` - Errors []interface{} `json:"errors,omitempty"` - //{"errors":[{"param":"from","message":"From must be before to","value":"2014-07-20T09:00"},{"param":"to","message":"To must be in the format YYYY-MM-DDTHH:mm","value":"now"}]} +type Metrics struct { + Results []MetricItem `json:"results,omitempty"` + TotalCount int `json:"total_count,omitempty"` + Links []map[string]string `json:"links,omitempty"` + Errors []interface{} `json:"errors,omitempty"` + + ExtraPath string `json:"-"` + Params map[string]string `json:"-"` } // https://developers.sparkpost.com/api/#/reference/metrics/deliverability-metrics-by-domain -func (c *Client) QueryDeliverabilityMetrics(extraPath string, parameters map[string]string) (*DeliverabilityMetricEventsWrapper, error) { +func (c *Client) QueryMetrics(m *Metrics) (*Response, error) { + return c.QueryMetricsContext(context.Background(), m) +} +func (c *Client) QueryMetricsContext(ctx context.Context, m *Metrics) (*Response, error) { var finalUrl string - path := fmt.Sprintf(deliverabilityMetricPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(MetricsPathFormat, c.Config.ApiVersion) - if extraPath != "" { - path = fmt.Sprintf("%s/%s", path, extraPath) + if m.ExtraPath != "" { + path = fmt.Sprintf("%s/%s", path, m.ExtraPath) } - //log.Printf("Path: %s", path) - - if parameters == nil || len(parameters) == 0 { + if m.Params == nil || len(m.Params) == 0 { finalUrl = fmt.Sprintf("%s%s", c.Config.BaseUrl, path) } else { - params := URL.Values{} - for k, v := range parameters { + params := url.Values{} + for k, v := range m.Params { params.Add(k, v) } finalUrl = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, params.Encode()) } - return doMetricsRequest(c, finalUrl) -} - -func (c *Client) MetricEventAsString(e *DeliverabilityMetricItem) string { - - return fmt.Sprintf("domain: %s, [%v]", e.Domain, e) + return m.doMetricsRequest(ctx, c, finalUrl) } -func doMetricsRequest(c *Client, finalUrl string) (*DeliverabilityMetricEventsWrapper, error) { +func (m *Metrics) doMetricsRequest(ctx context.Context, c *Client, finalUrl string) (*Response, error) { // Send off our request - res, err := c.HttpGet(finalUrl) + res, err := c.HttpGet(ctx, finalUrl) if err != nil { - return nil, err + return res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, err + return res, err } - // Get the Content - bodyBytes, err := res.ReadBody() + err = res.ParseResponse() if err != nil { - return nil, err + return res, err } - /*// DEBUG - err = iou.WriteFile("./events.json", bodyBytes, 0644) + // Get the Content + bodyBytes, err := res.ReadBody() if err != nil { - return nil, err + return res, err } - */ // Parse expected response structure - var resMap DeliverabilityMetricEventsWrapper - err = json.Unmarshal(bodyBytes, &resMap) - + err = json.Unmarshal(bodyBytes, m) if err != nil { - return nil, err + return res, errors.Wrap(err, "unmarshaling response") } - return &resMap, err + return res, nil } diff --git a/del_metrics_test.go b/del_metrics_test.go new file mode 100644 index 0000000..7065390 --- /dev/null +++ b/del_metrics_test.go @@ -0,0 +1,62 @@ +package gosparkpost_test + +import ( + "fmt" + "net/http" + "testing" + + sp "github.com/SparkPost/gosparkpost" +) + +// The "links" section is snipped for brevity +var delMetricsBaseNoArgs string = `{ + "errors": [ + { + "message": "from is required", + "param": "from" + }, + { + "message": "from must be in the format YYYY-MM-DDTHH:MM", + "param": "from" + }, + { + "message": "from must be before to", + "param": "from" + } + ], + "links": [ + { + "href": "/api/v1/metrics/deliverability", + "method": "GET", + "rel": "deliverability" + }, + { + "href": "/api/v1/metrics/deliverability/watched-domain", + "method": "GET", + "rel": "watched-domain" + } + ] +}` + +func TestMetrics_Get_noArgsError(t *testing.T) { + testSetup(t) + defer testTeardown() + + path := fmt.Sprintf(sp.MetricsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(delMetricsBaseNoArgs)) + }) + + m := &sp.Metrics{} + res, err := testClient.QueryMetrics(m) + if err != nil { + testFailVerbose(t, res, "Metrics GET returned error: %+v", err) + } + + if len(m.Errors) != 3 { + testFailVerbose(t, res, "Expected 3 errors, got %d", len(m.Errors)) + } +} diff --git a/event_docs.go b/event_docs.go new file mode 100644 index 0000000..1090bc0 --- /dev/null +++ b/event_docs.go @@ -0,0 +1,68 @@ +package gosparkpost + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/pkg/errors" +) + +var EventDocumentationFormat = "/api/v%d/webhooks/events/documentation" + +type EventGroup struct { + Name string + Events map[string]EventMeta `json:"events"` + Description string `json:"description"` + DisplayName string `json:"display_name"` +} + +type EventMeta struct { + Name string + Fields map[string]EventField `json:"event"` + Description string `json:"description"` + DisplayName string `json:"display_name"` +} + +type EventField struct { + Description string `json:"description"` + SampleValue interface{} `json:"sampleValue"` +} + +func (c *Client) EventDocumentation() (g map[string]*EventGroup, res *Response, err error) { + return c.EventDocumentationContext(context.Background()) +} + +func (c *Client) EventDocumentationContext(ctx context.Context) (groups map[string]*EventGroup, res *Response, err error) { + path := fmt.Sprintf(EventDocumentationFormat, c.Config.ApiVersion) + res, err = c.HttpGet(ctx, c.Config.BaseUrl+path) + if err != nil { + return + } + + if err = res.AssertJson(); err != nil { + return + } + + if Is2XX(res.HTTP.StatusCode) { + var body []byte + var ok bool + body, err = res.ReadBody() + if err != nil { + return + } + + var results map[string]map[string]*EventGroup + if err = json.Unmarshal(body, &results); err != nil { + } else if groups, ok = results["results"]; ok { + // Success! + } else { + err = errors.New("Unexpected response format (results)") + } + } else { + if err = res.ParseResponse(); err == nil { + err = res.HTTPError() + } + } + return +} diff --git a/event_docs_test.go b/event_docs_test.go new file mode 100644 index 0000000..fc347d9 --- /dev/null +++ b/event_docs_test.go @@ -0,0 +1,109 @@ +package gosparkpost_test + +import ( + "fmt" + "io/ioutil" + "net/http" + "testing" + + sp "github.com/SparkPost/gosparkpost" +) + +const eventDocumentationFile = "test/event-docs.json" + +var eventDocumentationBytes []byte +var eventGroups = []string{"track", "gen", "unsubscribe", "relay", "message"} +var eventGroupMap = map[string]map[string]int{ + "track_event": { + "click": 22, + "open": 20, + }, + "gen_event": { + "generation_failure": 21, + "generation_rejection": 23, + }, + "unsubscribe_event": { + "list_unsubscribe": 18, + "link_unsubscribe": 19, + }, + "relay_event": { + "relay_permfail": 15, + "relay_injection": 12, + "relay_rejection": 14, + "relay_delivery": 12, + "relay_tempfail": 15, + }, + "message_event": { + "spam_complaint": 24, + "out_of_band": 21, + "policy_rejection": 23, + "delay": 38, + "bounce": 37, + "delivery": 36, + "injection": 31, + "sms_status": 22, + }, +} + +func init() { + var err error + eventDocumentationBytes, err = ioutil.ReadFile(eventDocumentationFile) + if err != nil { + panic(err) + } +} + +func TestEventDocs_Get_parse(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + path := fmt.Sprintf(sp.EventDocumentationFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.Write(eventDocumentationBytes) + }) + + // hit our local handler + groups, res, err := testClient.EventDocumentation() + if err != nil { + t.Errorf("EventDocumentation GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + // basic content test + if len(groups) == 0 { + testFailVerbose(t, res, "EventDocumentation GET returned 0 EventGroups") + } else { + // check the top level event data + eventGroupsSeen := make(map[string]bool, len(eventGroups)) + for _, etype := range eventGroups { + eventGroupsSeen[etype+"_event"] = false + } + + for gname, v := range groups { + eventGroupsSeen[gname] = true + if _, ok := eventGroupMap[gname]; !ok { + t.Fatalf("expected group [%s] not present in response", gname) + } + for ename, efields := range v.Events { + if fieldCount, ok := eventGroupMap[gname][ename]; !ok { + t.Fatalf("expected event [%s] not present in [%s]", ename, gname) + if fieldCount != len(efields.Fields) { + t.Fatalf("saw %d fields for %s, expected %d", len(efields.Fields), ename, fieldCount) + } + } + } + } + + for gname, seen := range eventGroupsSeen { + if !seen { + t.Fatalf("expected message type [%s] not returned", gname) + } + } + } +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..8cb7aaf --- /dev/null +++ b/examples/README.md @@ -0,0 +1,66 @@ +# Example Code + +Short snippets of code showing how to do various things. +Feel free to submit your own examples! + +### Cc and Bcc + +Mail clients usually set up the details of `Cc` and `Bcc` for you, so thinking about it in terms of individual emails to be sent can be a bit of an adjustment. Here's a snippet that shows how it's done. See also [sparks](cmd/sparks/sparks.go) for an example that will send mail, instead of just printing out JSON. + +[Cc and Bcc example](cc/cc.go) + +### Overview + +Replicating mail clients' `Cc` and `Bcc` behavior with SparkPost is easier to reason about if you focus on the individual recipients - everyone that gets an email when you click send - and what the message needs to look like for them. Everyone who gets a message must currently have their own `Recipient` within the `Transmission` that's sent to SparkPost. + +Here's the output of the example linked above, which generates a message with 2 recipients in the `To`, 2 in the `Cc`, and 2 in the `Bcc`. Notice we're setting `header_to` in each `Recipient`, and it's always the same. We're also setting the `Cc` header w/in `content`, which is also always the same. + + { + "recipients": [ + { + "address": { + "email": "to1@test.com.sink.sparkpostmail.com", + "header_to": "to1@test.com.sink.sparkpostmail.com,to2@test.com.sink.sparkpostmail.com" + } + }, + { + "address": { + "email": "to2@test.com.sink.sparkpostmail.com", + "header_to": "to1@test.com.sink.sparkpostmail.com,to2@test.com.sink.sparkpostmail.com" + } + }, + { + "address": { + "email": "cc1@test.com.sink.sparkpostmail.com", + "header_to": "to1@test.com.sink.sparkpostmail.com,to2@test.com.sink.sparkpostmail.com" + } + }, + { + "address": { + "email": "cc2@test.com.sink.sparkpostmail.com", + "header_to": "to1@test.com.sink.sparkpostmail.com,to2@test.com.sink.sparkpostmail.com" + } + }, + { + "address": { + "email": "bcc1@test.com.sink.sparkpostmail.com", + "header_to": "to1@test.com.sink.sparkpostmail.com,to2@test.com.sink.sparkpostmail.com" + } + }, + { + "address": { + "email": "bcc2@test.com.sink.sparkpostmail.com", + "header_to": "to1@test.com.sink.sparkpostmail.com,to2@test.com.sink.sparkpostmail.com" + } + } + ], + "content": { + "text": "This is a cc/bcc example", + "subject": "cc/bcc example message", + "from": "test@example.com", + "headers": { + "cc": "cc1@test.com.sink.sparkpostmail.com,cc2@test.com.sink.sparkpostmail.com" + } + } + } + diff --git a/examples/cc/cc.go b/examples/cc/cc.go new file mode 100644 index 0000000..e37480f --- /dev/null +++ b/examples/cc/cc.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + sp "github.com/SparkPost/gosparkpost" +) + +func main() { + to := []string{ + "to1@test.com.sink.sparkpostmail.com", + "to2@test.com.sink.sparkpostmail.com", + } + headerTo := strings.Join(to, ",") + + cc := []string{ + "cc1@test.com.sink.sparkpostmail.com", + "cc2@test.com.sink.sparkpostmail.com", + } + + bcc := []string{ + "bcc1@test.com.sink.sparkpostmail.com", + "bcc2@test.com.sink.sparkpostmail.com", + } + + content := sp.Content{ + From: "test@example.com", + Subject: "cc/bcc example message", + Text: "This is a cc/bcc example", + } + + tx := &sp.Transmission{ + Recipients: []sp.Recipient{}, + } + + if len(to) > 0 { + for _, t := range to { + tx.Recipients = append(tx.Recipients.([]sp.Recipient), sp.Recipient{ + Address: sp.Address{Email: t, HeaderTo: headerTo}, + }) + } + } + + if len(cc) > 0 { + for _, c := range cc { + tx.Recipients = append(tx.Recipients.([]sp.Recipient), sp.Recipient{ + Address: sp.Address{Email: c, HeaderTo: headerTo}, + }) + } + // add cc header to content + if content.Headers == nil { + content.Headers = map[string]string{} + } + content.Headers["cc"] = strings.Join(cc, ",") + } + + if len(bcc) > 0 { + for _, b := range bcc { + tx.Recipients = append(tx.Recipients.([]sp.Recipient), sp.Recipient{ + Address: sp.Address{Email: b, HeaderTo: headerTo}, + }) + } + } + + tx.Content = content + + jsonBytes, err := json.Marshal(tx) + if err != nil { + panic(err) + } + fmt.Fprintln(os.Stdout, string(jsonBytes)) + +} diff --git a/helpers/loadmsg/loadmsg.go b/helpers/loadmsg/loadmsg.go new file mode 100644 index 0000000..0c7dd6d --- /dev/null +++ b/helpers/loadmsg/loadmsg.go @@ -0,0 +1,90 @@ +package loadmsg + +import ( + "encoding/base64" + "net/mail" + "os" + "strconv" + "strings" + + "github.com/buger/jsonparser" + "github.com/pkg/errors" +) + +type Message struct { + Filename string + File *os.File + Message *mail.Message + MSFBL string + Json []byte + CustID int + Recipient []byte + ReturnPath *mail.Address +} + +func (m *Message) Load() error { + var err error + + m.File, err = os.Open(m.Filename) + if err != nil { + return errors.Wrap(err, "opening file") + } + + m.Message, err = mail.ReadMessage(m.File) + if err != nil { + return errors.Wrap(err, "parsing message") + } + + if m.ReturnPath == nil { + err = m.SetReturnPath(m.Message.Header.Get("Return-Path")) + if err != nil { + return errors.Wrap(err, "setting return path") + } + } + + m.MSFBL = strings.Replace(m.Message.Header.Get("X-MSFBL"), " ", "", -1) + + if m.MSFBL == "" { + // early return if there isn't a MSFBL header + return nil + } + + if strings.Index(m.MSFBL, "|") >= 0 { + // Everything before the pipe is an encoded HMAC + // TODO: verify contents using HMAC + m.MSFBL = strings.Split(m.MSFBL, "|")[1] + } + + m.Json, err = base64.StdEncoding.DecodeString(m.MSFBL) + if err != nil { + return errors.Wrap(err, "decoding fbl") + } + + var cid []byte + cid, _, _, err = jsonparser.Get(m.Json, "customer_id") + if err != nil { + return errors.Wrap(err, "getting customer_id") + } + m.CustID, err = strconv.Atoi(string(cid)) + if err != nil { + return errors.Wrap(err, "int-ifying customer_id") + } + + m.Recipient, _, _, err = jsonparser.Get(m.Json, "r") + if err != nil { + return errors.Wrap(err, "getting recipient") + } + + return nil +} + +func (m *Message) SetReturnPath(addr string) (err error) { + if !strings.Contains(addr, "@") { + return errors.Errorf("Unsupported Return-Path header: no @") + } + m.ReturnPath, err = mail.ParseAddress(addr) + if err != nil { + return errors.Wrap(err, "parsing return path") + } + return nil +} diff --git a/message_events.go b/message_events.go index 4215c88..47b5a43 100644 --- a/message_events.go +++ b/message_events.go @@ -1,8 +1,8 @@ package gosparkpost import ( + "context" "encoding/json" - "errors" "fmt" "net/url" "strings" @@ -12,9 +12,8 @@ import ( // https://www.sparkpost.com/api#/reference/message-events var ( - ErrEmptyPage = errors.New("empty page") - messageEventsPathFormat = "%s/api/v%d/message-events" - messageEventsSamplesPathFormat = "%s/api/v%d/message-events/events/samples" + MessageEventsPathFormat = "/api/v%d/message-events" + MessageEventsSamplesPathFormat = "/api/v%d/message-events/events/samples" ) type EventsPage struct { @@ -22,86 +21,101 @@ type EventsPage struct { Events events.Events TotalCount int - nextPage string - prevPage string - firstPage string - lastPage string + Errors []interface{} + + NextPage string + PrevPage string + FirstPage string + LastPage string + + Params map[string]string `json:"-"` } // https://developers.sparkpost.com/api/#/reference/message-events/events-samples/search-for-message-events -func (c *Client) MessageEvents(params map[string]string) (*EventsPage, error) { - url, err := url.Parse(fmt.Sprintf(messageEventsPathFormat, c.Config.BaseUrl, c.Config.ApiVersion)) +func (c *Client) MessageEventsSearch(ep *EventsPage) (*Response, error) { + return c.MessageEventsSearchContext(context.Background(), ep) +} + +// MessageEventsSearchContext is the same as MessageEventsSearch, and it accepts a context.Context +func (c *Client) MessageEventsSearchContext(ctx context.Context, ep *EventsPage) (*Response, error) { + path := fmt.Sprintf(MessageEventsPathFormat, c.Config.ApiVersion) + url, err := url.Parse(c.Config.BaseUrl + path) if err != nil { return nil, err } - if len(params) > 0 { + if len(ep.Params) > 0 { q := url.Query() - for k, v := range params { + for k, v := range ep.Params { q.Add(k, v) } url.RawQuery = q.Encode() } // Send off our request - res, err := c.HttpGet(url.String()) + res, err := c.HttpGet(ctx, url.String()) if err != nil { - return nil, err + return res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, err + return res, err } // Get the Content bodyBytes, err := res.ReadBody() if err != nil { - return nil, err + return res, err } - var eventsPage EventsPage - err = json.Unmarshal(bodyBytes, &eventsPage) + err = json.Unmarshal(bodyBytes, ep) if err != nil { - return nil, err + return res, err } - eventsPage.client = c + ep.client = c + + return res, nil +} - return &eventsPage, nil +// Next returns the next page of results from a previous MessageEventsSearch call +func (ep *EventsPage) Next() (*EventsPage, *Response, error) { + return ep.NextContext(context.Background()) } -func (events *EventsPage) Next() (*EventsPage, error) { - if events.nextPage == "" { - return nil, ErrEmptyPage +// NextContext is the same as Next, and it accepts a context.Context +func (ep *EventsPage) NextContext(ctx context.Context) (*EventsPage, *Response, error) { + if ep.NextPage == "" { + return nil, nil, nil } // Send off our request - res, err := events.client.HttpGet(events.client.Config.BaseUrl + events.nextPage) + res, err := ep.client.HttpGet(ctx, ep.client.Config.BaseUrl+ep.NextPage) if err != nil { - return nil, err + return nil, res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, err + return nil, res, err } // Get the Content bodyBytes, err := res.ReadBody() if err != nil { - return nil, err + return nil, res, err } var eventsPage EventsPage err = json.Unmarshal(bodyBytes, &eventsPage) if err != nil { - return nil, err + return nil, res, err } - eventsPage.client = events.client + eventsPage.client = ep.client - return &eventsPage, nil + return &eventsPage, res, nil } func (ep *EventsPage) UnmarshalJSON(data []byte) error { @@ -116,6 +130,7 @@ func (ep *EventsPage) UnmarshalJSON(data []byte) error { Href string `json:"href"` Rel string `json:"rel"` } `json:"links,omitempty"` + Errors []interface{} `json:"errors,omitempty"` } err := json.Unmarshal(data, &resultsWrapper) if err != nil { @@ -127,29 +142,36 @@ func (ep *EventsPage) UnmarshalJSON(data []byte) error { return err } + ep.Errors = resultsWrapper.Errors ep.TotalCount = resultsWrapper.TotalCount for _, link := range resultsWrapper.Links { switch link.Rel { case "next": - ep.nextPage = link.Href + ep.NextPage = link.Href case "previous": - ep.prevPage = link.Href + ep.PrevPage = link.Href case "first": - ep.firstPage = link.Href + ep.FirstPage = link.Href case "last": - ep.lastPage = link.Href + ep.LastPage = link.Href } } return nil } -// Samples requests a list of example event data. -func (c *Client) EventSamples(types *[]string) (*events.Events, error) { - url, err := url.Parse(fmt.Sprintf(messageEventsSamplesPathFormat, c.Config.BaseUrl, c.Config.ApiVersion)) +// EventSamples requests a list of example event data. +func (c *Client) EventSamples(types *[]string) (*events.Events, *Response, error) { + return c.EventSamplesContext(context.Background(), types) +} + +// EventSamplesContext is the same as EventSamples, and it accepts a context.Context +func (c *Client) EventSamplesContext(ctx context.Context, types *[]string) (*events.Events, *Response, error) { + path := fmt.Sprintf(MessageEventsSamplesPathFormat, c.Config.ApiVersion) + url, err := url.Parse(c.Config.BaseUrl + path) if err != nil { - return nil, err + return nil, nil, err } // Filter out types. @@ -157,7 +179,7 @@ func (c *Client) EventSamples(types *[]string) (*events.Events, error) { // validate types for _, etype := range *types { if !events.ValidEventType(etype) { - return nil, fmt.Errorf("Invalid event type [%s]", etype) + return nil, nil, fmt.Errorf("Invalid event type [%s]", etype) } } @@ -169,29 +191,29 @@ func (c *Client) EventSamples(types *[]string) (*events.Events, error) { } // Send off our request - res, err := c.HttpGet(url.String()) + res, err := c.HttpGet(ctx, url.String()) if err != nil { - return nil, err + return nil, res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, err + return nil, res, err } // Get the Content bodyBytes, err := res.ReadBody() if err != nil { - return nil, err + return nil, res, err } var events events.Events err = json.Unmarshal(bodyBytes, &events) if err != nil { - return nil, err + return nil, res, err } - return &events, nil + return &events, res, nil } // ParseEvents function is left only for backward-compatibility. Events are parsed by events pkg. diff --git a/message_events_test.go b/message_events_test.go index f910d6c..d489180 100644 --- a/message_events_test.go +++ b/message_events_test.go @@ -2,6 +2,7 @@ package gosparkpost_test import ( "fmt" + "net/http" "testing" sp "github.com/SparkPost/gosparkpost" @@ -9,6 +10,34 @@ import ( "github.com/SparkPost/gosparkpost/test" ) +var msgEventsEmpty string = `{ + "links": [], + "results": [], + "total_count": 0 +}` + +func TestMsgEvents_Get_Empty(t *testing.T) { + testSetup(t) + defer testTeardown() + + path := fmt.Sprintf(sp.MessageEventsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(msgEventsEmpty)) + }) + + ep := &sp.EventsPage{Params: map[string]string{ + "from": "1970-01-01T00:00", + "events": "injection", + }} + res, err := testClient.MessageEventsSearch(ep) + if err != nil { + testFailVerbose(t, res, "Message Events GET returned error: %v", err) + } +} + func TestMessageEvents(t *testing.T) { if true { // Temporarily disable test so TravisCI reports build success instead of test failure. @@ -33,20 +62,20 @@ func TestMessageEvents(t *testing.T) { return } - params := map[string]string{ + ep := &sp.EventsPage{Params: map[string]string{ "per_page": "10", - } - eventsPage, err := client.MessageEvents(params) + }} + _, err = client.MessageEventsSearch(ep) if err != nil { t.Error(err) return } - if len(eventsPage.Events) == 0 { + if len(ep.Events) == 0 { t.Error("expected non-empty result") } - for _, ev := range eventsPage.Events { + for _, ev := range ep.Events { switch event := ev.(type) { case *events.Click, *events.Open, *events.GenerationFailure, *events.GenerationRejection, *events.ListUnsubscribe, *events.LinkUnsubscribe, *events.PolicyRejection, @@ -69,11 +98,11 @@ func TestMessageEvents(t *testing.T) { } } - eventsPage, err = eventsPage.Next() - if err != nil && err != sp.ErrEmptyPage { + ep, _, err = ep.Next() + if err != nil { t.Error(err) - } else { - if len(eventsPage.Events) == 0 { + } else if ep != nil { + if len(ep.Events) == 0 { t.Error("expected non-empty result") } } @@ -103,7 +132,7 @@ func TestAllEventsSamples(t *testing.T) { return } - e, err := client.EventSamples(nil) + e, _, err := client.EventSamples(nil) if err != nil { t.Error(err) return @@ -162,7 +191,7 @@ func TestFilteredEventsSamples(t *testing.T) { } types := []string{"open", "click", "bounce"} - e, err := client.EventSamples(&types) + e, _, err := client.EventSamples(&types) if err != nil { t.Error(err) return diff --git a/recipient_lists.go b/recipient_lists.go index ddb8640..1ce780f 100644 --- a/recipient_lists.go +++ b/recipient_lists.go @@ -1,38 +1,29 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" - "reflect" "strings" + + "github.com/pkg/errors" ) // https://www.sparkpost.com/api#/reference/recipient-lists -var recipListsPathFormat = "/api/v%d/recipient-lists" +var RecipientListsPathFormat = "/api/v%d/recipient-lists" // RecipientList is the JSON structure accepted by and returned from the SparkPost Recipient Lists API. // It's mostly metadata at this level - see Recipients for more detail. type RecipientList struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Attributes interface{} `json:"attributes,omitempty"` - Recipients *[]Recipient `json:"recipients"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Attributes interface{} `json:"attributes,omitempty"` + Recipients []Recipient `json:"recipients"` Accepted *int `json:"total_accepted_recipients,omitempty"` } -func (rl *RecipientList) String() string { - n := 0 - if rl.Recipients != nil { - n = len(*rl.Recipients) - } else if rl.Accepted != nil { - n = *rl.Accepted - } - return fmt.Sprintf("ID:\t%s\nName:\t%s\nDesc:\t%s\nCount:\t%d\n", - rl.ID, rl.Name, rl.Description, n) -} - // Recipient represents one email (you guessed it) recipient. type Recipient struct { Address interface{} `json:"address"` @@ -56,7 +47,7 @@ func ParseAddress(addr interface{}) (a Address, err error) { switch addrVal := addr.(type) { case string: // simple string value if addrVal == "" { - err = fmt.Errorf("Recipient.Address may not be empty") + err = errors.New("Recipient.Address may not be empty") } else { a.Email = addrVal } @@ -77,7 +68,7 @@ func ParseAddress(addr interface{}) (a Address, err error) { a.HeaderTo = vVal } default: - err = fmt.Errorf("strings are required for all Recipient.Address values") + err = errors.New("strings are required for all Recipient.Address values") break } } @@ -95,7 +86,7 @@ func ParseAddress(addr interface{}) (a Address, err error) { } default: - err = fmt.Errorf("unsupported Recipient.Address value type [%s]", reflect.TypeOf(addrVal)) + err = errors.Errorf("unsupported Recipient.Address value type [%T]", addrVal) } return @@ -104,22 +95,26 @@ func ParseAddress(addr interface{}) (a Address, err error) { // Validate runs sanity checks on a RecipientList struct. This should // catch most errors before attempting a doomed API call. func (rl *RecipientList) Validate() error { + if rl == nil { + return errors.New("Can't validate a nil RecipientList") + } + // enforce required parameters - if rl.Recipients == nil || len(*rl.Recipients) <= 0 { - return fmt.Errorf("RecipientList requires at least one Recipient") + if rl.Recipients == nil || len(rl.Recipients) <= 0 { + return errors.New("RecipientList requires at least one Recipient") } // enforce max lengths if len(rl.ID) > 64 { - return fmt.Errorf("RecipientList id may not be longer than 64 bytes") + return errors.New("RecipientList id may not be longer than 64 bytes") } else if len(rl.Name) > 64 { - return fmt.Errorf("RecipientList name may not be longer than 64 bytes") + return errors.New("RecipientList name may not be longer than 64 bytes") } else if len(rl.Description) > 1024 { - return fmt.Errorf("RecipientList description may not be longer than 1024 bytes") + return errors.New("RecipientList description may not be longer than 1024 bytes") } var err error - for _, r := range *rl.Recipients { + for _, r := range rl.Recipients { err = r.Validate() if err != nil { return err @@ -139,11 +134,16 @@ func (r Recipient) Validate() error { return nil } -// Create accepts a populated RecipientList object, validates it, +// RecipientListCreate accepts a populated RecipientList object, validates it, // and performs an API call against the configured endpoint. func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Response, err error) { + return c.RecipientListCreateContext(context.Background(), rl) +} + +// RecipientListCreateContext is the same as RecipientListCreate, and it accepts a context.Context +func (c *Client) RecipientListCreateContext(ctx context.Context, rl *RecipientList) (id string, res *Response, err error) { if rl == nil { - err = fmt.Errorf("Create called with nil RecipientList") + err = errors.New("Create called with nil RecipientList") return } @@ -157,9 +157,9 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons return } - path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(RecipientListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes) + res, err = c.HttpPost(ctx, url, jsonBytes) if err != nil { return } @@ -168,41 +168,35 @@ func (c *Client) RecipientListCreate(rl *RecipientList) (id string, res *Respons return } - err = res.ParseResponse() - if err != nil { + if err = res.ParseResponse(); err != nil { return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var ok bool - id, ok = res.Results["id"].(string) - if !ok { - err = fmt.Errorf("Unexpected response to Recipient List creation") - } - - } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("RecipientList", "create") - if err != nil { - return - } - - code := res.HTTP.StatusCode - if code == 400 || code == 422 { - eobj := res.Errors[0] - err = fmt.Errorf("%s: %s\n%s", eobj.Code, eobj.Message, eobj.Description) - } else { // everything else - err = fmt.Errorf("%d: %s", code, string(res.Body)) + var results map[string]interface{} + if results, ok = res.Results.(map[string]interface{}); !ok { + err = errors.New("Unexpected response to Recipient List creation (results)") + } else if id, ok = results["id"].(string); !ok { + err = errors.New("Unexpected response to Recipient List creation (id)") } + } else { + err = res.HTTPError() } return } -func (c *Client) RecipientLists() (*[]RecipientList, *Response, error) { - path := fmt.Sprintf(recipListsPathFormat, c.Config.ApiVersion) +// RecipientLists returns all recipient lists +func (c *Client) RecipientLists() ([]RecipientList, *Response, error) { + return c.RecipientListsContext(context.Background()) +} + +// RecipientListsContext is the same as RecipientLists, and it accepts a context.Context +func (c *Client) RecipientListsContext(ctx context.Context) ([]RecipientList, *Response, error) { + path := fmt.Sprintf(RecipientListsPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err := c.HttpGet(url) + res, err := c.HttpGet(ctx, url) if err != nil { return nil, nil, err } @@ -211,7 +205,7 @@ func (c *Client) RecipientLists() (*[]RecipientList, *Response, error) { return nil, res, err } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var body []byte body, err = res.ReadBody() if err != nil { @@ -219,24 +213,16 @@ func (c *Client) RecipientLists() (*[]RecipientList, *Response, error) { } rllist := map[string][]RecipientList{} if err = json.Unmarshal(body, &rllist); err != nil { - return nil, res, err } else if list, ok := rllist["results"]; ok { - return &list, res, nil + return list, res, nil + } else { + err = errors.New("Unexpected response to RecipientList list") } - return nil, res, fmt.Errorf("Unexpected response to RecipientList list") } else { - err = res.ParseResponse() - if err != nil { - return nil, res, err - } - if len(res.Errors) > 0 { - err = res.PrettyError("RecipientList", "list") - if err != nil { - return nil, res, err - } + if err = res.ParseResponse(); err == nil { + err = res.HTTPError() } - return nil, res, fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } return nil, res, err diff --git a/recipient_lists_test.go b/recipient_lists_test.go index c2606d0..f16df19 100644 --- a/recipient_lists_test.go +++ b/recipient_lists_test.go @@ -1,46 +1,173 @@ package gosparkpost_test import ( + "reflect" "strings" "testing" sp "github.com/SparkPost/gosparkpost" - "github.com/SparkPost/gosparkpost/test" + "github.com/pkg/errors" ) -func TestRecipients(t *testing.T) { - if true { - // Temporarily disable test so TravisCI reports build success instead of test failure. - return +func TestAddressValidation(t *testing.T) { + for idx, test := range []struct { + in interface{} + err error + out sp.Address + }{ + {nil, errors.New("unsupported Recipient.Address value type []"), sp.Address{}}, + {"", errors.New("Recipient.Address may not be empty"), sp.Address{}}, + {"a@b.com", nil, sp.Address{Email: "a@b.com"}}, + {sp.Address{"a@b.com", "A B", "c@d.com"}, nil, sp.Address{"a@b.com", "A B", "c@d.com"}}, + {map[string]interface{}{"foo": 42}, errors.New("strings are required for all Recipient.Address values"), sp.Address{}}, + {map[string]interface{}{"Name": "A B", "email": "a@b.com", "header_To": "c@d.com"}, nil, sp.Address{"a@b.com", "A B", "c@d.com"}}, + {map[string]string{"Name": "A B", "email": "a@b.com", "header_To": "c@d.com"}, nil, sp.Address{"a@b.com", "A B", "c@d.com"}}, + } { + a, err := sp.ParseAddress(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("ParseAddress[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("ParseAddress[%d] => err %q, want %q", idx, err, test.err) + } else if !reflect.DeepEqual(a, test.out) { + t.Errorf("ParseAddress[%d] => got/want:\n%q\n%q", idx, a, test.out) + } } +} - cfgMap, err := test.LoadConfig() - if err != nil { - t.Error(err) - return - } - cfg, err := sp.NewConfig(cfgMap) - if err != nil { - t.Error(err) - return +func TestRecipientValidation(t *testing.T) { + for idx, test := range []struct { + in sp.Recipient + err error + }{ + {sp.Recipient{}, errors.New("unsupported Recipient.Address value type []")}, + {sp.Recipient{Address: "a@b.com"}, nil}, + } { + err := test.in.Validate() + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("Recipient.Validate[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("Recipient.Validate[%d] => err %q, want %q", idx, err, test.err) + } } +} - var client sp.Client - err = client.Init(cfg) - if err != nil { - t.Error(err) - return +func TestRecipientListValidation(t *testing.T) { + for idx, test := range []struct { + in *sp.RecipientList + err error + }{ + {nil, errors.New("Can't validate a nil RecipientList")}, + {&sp.RecipientList{}, errors.New("RecipientList requires at least one Recipient")}, + + {&sp.RecipientList{ID: strings.Repeat("id", 33), + Recipients: []sp.Recipient{{}}}, errors.New("RecipientList id may not be longer than 64 bytes")}, + {&sp.RecipientList{ID: "id", Name: strings.Repeat("name", 17), + Recipients: []sp.Recipient{{}}}, errors.New("RecipientList name may not be longer than 64 bytes")}, + {&sp.RecipientList{ID: "id", Name: "name", Description: strings.Repeat("desc", 257), + Recipients: []sp.Recipient{{}}}, errors.New("RecipientList description may not be longer than 1024 bytes")}, + + {&sp.RecipientList{ID: "id", Name: "name", Description: "desc", + Recipients: []sp.Recipient{{}}}, errors.New("unsupported Recipient.Address value type []")}, + {&sp.RecipientList{ID: "id", Name: "name", Description: "desc", + Recipients: []sp.Recipient{{Address: "a@b.com"}}}, nil}, + } { + err := test.in.Validate() + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("RecipientList.Validate[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("RecipientList.Validate[%d] => err %q, want %q", idx, err, test.err) + } } +} - list, _, err := client.RecipientLists() - if err != nil { - t.Error(err) - return +func TestRecipientListCreate(t *testing.T) { + for idx, test := range []struct { + in *sp.RecipientList + err error + status int + json string + id string + }{ + {nil, errors.New("Create called with nil RecipientList"), 0, "", ""}, + {&sp.RecipientList{}, errors.New("RecipientList requires at least one Recipient"), 0, "", ""}, + {&sp.RecipientList{ID: "id", Recipients: []sp.Recipient{{Address: "a@b.com"}}}, + errors.New("Unexpected response to Recipient List creation (results)"), 200, `{"foo":{"id":"id"}}`, ""}, + {&sp.RecipientList{ID: "id", Recipients: []sp.Recipient{{Address: "a@b.com"}}}, + errors.New("Unexpected response to Recipient List creation (id)"), 200, `{"results":{"ID":"id"}}`, ""}, + {&sp.RecipientList{ID: "id", Attributes: func() { return }, Recipients: []sp.Recipient{{Address: "a@b.com"}}}, + errors.New("json: unsupported type: func()"), 200, `{"results":{"ID":"id"}}`, ""}, + {&sp.RecipientList{ID: "id", Recipients: []sp.Recipient{{Address: "a@b.com"}}}, + errors.New("parsing api response: unexpected end of JSON input"), 200, `{"results":{"ID":"id"}`, ""}, + + {&sp.RecipientList{ID: "id", Recipients: []sp.Recipient{{Address: "a@b.com"}}}, + errors.New(`[{"message":"List already exists","code":"5001","description":"List 'id' already exists"}]`), 400, + `{"errors":[{"message":"List already exists","code":"5001","description":"List 'id' already exists"}]}`, ""}, + + {&sp.RecipientList{ID: "id", Recipients: []sp.Recipient{{Address: "a@b.com"}}}, nil, 200, + `{"results":{"total_rejected_recipients": 0,"total_accepted_recipients":1,"id":"id"}}`, "id"}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "POST", test.status, sp.RecipientListsPathFormat, test.json) + + id, _, err := testClient.RecipientListCreate(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("RecipientListCreate[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("RecipientListCreate[%d] => err %q want %q", idx, err, test.err) + } else if id != test.id { + t.Errorf("RecipientListCreate[%d] => id %q want %q", idx, id, test.id) + } } +} + +func TestRecipientLists(t *testing.T) { + var res200 = loadTestFile(t, "test/json/recipient_lists_200.json") + var acc200 = []int{3, 8} + var rl200 = []sp.RecipientList{{ + ID: "unique_id_4_graduate_students_list", + Name: "graduate_students", + Description: "An email list of graduate students at UMBC", + Attributes: map[string]interface{}{ + "internal_id": float64(112), + "list_group_id": float64(12321), + }, + Accepted: &acc200[0], + }, { + ID: "unique_id_4_undergraduates", + Name: "undergraduate_students", + Description: "An email list of undergraduate students at UMBC", + Attributes: map[string]interface{}{ + "internal_id": float64(111), + "list_group_id": float64(11321), + }, + Accepted: &acc200[1], + }} + + for idx, test := range []struct { + err error + status int + json string + out []sp.RecipientList + }{ + {nil, 200, `{"results":[{}]}`, []sp.RecipientList{{}}}, + {errors.New("Unexpected response to RecipientList list"), 200, `{"foo":[{}]}`, nil}, + {errors.New("unexpected end of JSON input"), 200, `{"results":[{}]`, nil}, + {errors.New(`[{"message":"No RecipientList for you!","code":"","description":""}]`), 401, `{"errors":[{"message":"No RecipientList for you!"}]}`, nil}, + {errors.New("parsing api response: unexpected end of JSON input"), 401, `{"errors":[]`, nil}, + {nil, 200, res200, rl200}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "GET", test.status, sp.RecipientListsPathFormat, test.json) - strs := make([]string, len(*list)) - for idx, rl := range *list { - strs[idx] = rl.String() + lists, _, err := testClient.RecipientLists() + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("RecipientLists[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("RecipientLists[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil && !reflect.DeepEqual(lists, test.out) { + t.Errorf("RecipientLists[%d] => got/want:\n%q\n%q", idx, lists, test.out) + } } - t.Errorf("%s\n", strings.Join(strs, "\n")) } diff --git a/subaccounts.go b/subaccounts.go index 9d188de..4e498de 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -1,21 +1,30 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" + + "github.com/pkg/errors" ) -// https://www.sparkpost.com/api#/reference/subaccounts -var subaccountsPathFormat = "/api/v%d/subaccounts" -var availableGrants = []string{ +// SubaccountsPathFormat provides an easy way to fill out the path including the version. +var SubaccountsPathFormat = "/api/v%d/subaccounts" + +// SubaccountGrants contains the grants that will be given to new subaccounts by default. +var SubaccountGrants = []string{ "smtp/inject", "sending_domains/manage", "message_events/view", "suppression_lists/manage", + "tracking_domains/view", + "tracking_domains/manage", "transmissions/view", "transmissions/modify", } -var validStatuses = []string{ + +// SubaccountStatuses contains valid subaccount statuses. +var SubaccountStatuses = []string{ "active", "suspended", "terminated", @@ -23,7 +32,7 @@ var validStatuses = []string{ // Subaccount is the JSON structure accepted by and returned from the SparkPost Subaccounts API. type Subaccount struct { - ID int `json:"subaccount_id,omitempty"` + ID int `json:"id,omitempty"` Name string `json:"name,omitempty"` Key string `json:"key,omitempty"` KeyLabel string `json:"key_label,omitempty"` @@ -31,41 +40,32 @@ type Subaccount struct { ShortKey string `json:"short_key,omitempty"` Status string `json:"status,omitempty"` ComplianceStatus string `json:"compliance_status,omitempty"` + IPPool string `json:"ip_pool,omitempty"` } -// Create accepts a populated Subaccount object, validates it, -// and performs an API call against the configured endpoint. +// SubaccountCreate attempts to create a subaccount using the provided object func (c *Client) SubaccountCreate(s *Subaccount) (res *Response, err error) { + return c.SubaccountCreateContext(context.Background(), s) +} + +// SubaccountCreateContext is the same as SubaccountCreate, and it allows the caller to pass in a context +// New subaccounts will have all grants in SubaccountGrants, unless s.Grants is non-nil. +func (c *Client) SubaccountCreateContext(ctx context.Context, s *Subaccount) (res *Response, err error) { // enforce required parameters if s == nil { - err = fmt.Errorf("Create called with nil Subaccount") - } else if s.Name == "" { - err = fmt.Errorf("Subaccount requires a non-empty Name") - } else if s.KeyLabel == "" { - err = fmt.Errorf("Subaccount requires a non-empty Key Label") - } else - // enforce max lengths - if len(s.Name) > 1024 { - err = fmt.Errorf("Subaccount name may not be longer than 1024 bytes") - } else if len(s.KeyLabel) > 1024 { - err = fmt.Errorf("Subaccount key label may not be longer than 1024 bytes") - } - if err != nil { + err = errors.New("Create called with nil Subaccount") return } if len(s.Grants) == 0 { - s.Grants = availableGrants + s.Grants = SubaccountGrants } - jsonBytes, err := json.Marshal(s) - if err != nil { - return - } + // Marshaling a static type won't fail + jsonBytes, _ := json.Marshal(s) - path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) - url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes) + path := fmt.Sprintf(SubaccountsPathFormat, c.Config.ApiVersion) + res, err = c.HttpPost(ctx, c.Config.BaseUrl+path, jsonBytes) if err != nil { return } @@ -79,53 +79,45 @@ func (c *Client) SubaccountCreate(s *Subaccount) (res *Response, err error) { return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var ok bool - f, ok := res.Results["subaccount_id"].(float64) - if !ok { - err = fmt.Errorf("Unexpected response to Subaccount creation") - } - s.ID = int(f) - s.ShortKey, ok = res.Results["short_key"].(string) - if !ok { - err = fmt.Errorf("Unexpected response to Subaccount creation") - } - - } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Subaccount", "create") - if err != nil { - return - } - - if res.HTTP.StatusCode == 422 { // subaccount syntax error - eobj := res.Errors[0] - err = fmt.Errorf("%s: %s\n%s", eobj.Code, eobj.Message, eobj.Description) - } else { // everything else - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + var results map[string]interface{} + if results, ok = res.Results.(map[string]interface{}); !ok { + err = errors.New("Unexpected response to Subaccount creation (results)") + } else if f, ok := results["subaccount_id"].(float64); !ok { + err = errors.New("Unexpected response to Subaccount creation (subaccount_id)") + } else { + s.ID = int(f) + if s.ShortKey, ok = results["short_key"].(string); !ok { + err = errors.New("Unexpected response to Subaccount creation (short_key)") + } } + } else { + err = res.HTTPError() } return } -// Update updates a subaccount with the specified id. -// Actually it will marshal and send all the subaccount fields, but that must not be a problem, -// as fields not supposed for update will be omitted +// SubaccountUpdate updates a subaccount with the specified id. +// It marshals and sends all the subaccount fields, ignoring the read-only ones. func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { - if s.ID == 0 { - err = fmt.Errorf("Subaccount Update called with zero id") - } else if len(s.Name) > 1024 { - err = fmt.Errorf("Subaccount name may not be longer than 1024 bytes") + return c.SubaccountUpdateContext(context.Background(), s) +} + +// SubaccountUpdateContext is the same as SubaccountUpdate, and it allows the caller to provide a context +func (c *Client) SubaccountUpdateContext(ctx context.Context, s *Subaccount) (res *Response, err error) { + if s == nil { + err = errors.New("Subaccount Update called with nil Subaccount") } else if s.Status != "" { found := false - for _, v := range validStatuses { + for _, v := range SubaccountStatuses { if s.Status == v { found = true } } if !found { - err = fmt.Errorf("Not a valid subaccount status") + err = errors.New("Not a valid subaccount status") } } @@ -133,15 +125,13 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { return } - jsonBytes, err := json.Marshal(s) - if err != nil { - return - } + // Marshaling a static type won't fail + jsonBytes, _ := json.Marshal(s) - path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) - url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, s.ID) + path := fmt.Sprintf(SubaccountsPathFormat, c.Config.ApiVersion) + url := fmt.Sprintf("%s%s/%d", c.Config.BaseUrl, path, s.ID) - res, err = c.HttpPut(url, jsonBytes) + res, err = c.HttpPut(ctx, url, jsonBytes) if err != nil { return } @@ -155,32 +145,19 @@ func (c *Client) SubaccountUpdate(s *Subaccount) (res *Response, err error) { return } - if res.HTTP.StatusCode == 200 { - return - - } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Subaccount", "update") - if err != nil { - return - } - - // handle template-specific ones - if res.HTTP.StatusCode == 409 { - err = fmt.Errorf("Subaccount with id [%s] is in use by msg generation", s.ID) - } else { // everything else - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - } - } - + err = res.HTTPError() return } -// List returns metadata for all Templates in the system. +// Subaccounts returns metadata for all Subaccounts in the system. func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err error) { - path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) - url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpGet(url) + return c.SubaccountsContext(context.Background()) +} + +// SubaccountsContext is the same as Subaccounts, and it allows the caller to provide a context +func (c *Client) SubaccountsContext(ctx context.Context) (subaccounts []Subaccount, res *Response, err error) { + path := fmt.Sprintf(SubaccountsPathFormat, c.Config.ApiVersion) + res, err = c.HttpGet(ctx, c.Config.BaseUrl+path) if err != nil { return } @@ -190,7 +167,7 @@ func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err err return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var body []byte body, err = res.ReadBody() if err != nil { @@ -199,36 +176,32 @@ func (c *Client) Subaccounts() (subaccounts []Subaccount, res *Response, err err slist := map[string][]Subaccount{} err = json.Unmarshal(body, &slist) if err != nil { - return } else if list, ok := slist["results"]; ok { subaccounts = list - return + } else { + err = errors.New("Unexpected response to Subaccount list") } - err = fmt.Errorf("Unexpected response to Subaccount list") - return } else { err = res.ParseResponse() - if err != nil { - return + if err == nil { + err = res.HTTPError() } - if len(res.Errors) > 0 { - err = res.PrettyError("Subaccount", "list") - if err != nil { - return - } - } - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - return } return } +// Subaccount looks up a subaccount using the provided id func (c *Client) Subaccount(id int) (subaccount *Subaccount, res *Response, err error) { - path := fmt.Sprintf(subaccountsPathFormat, c.Config.ApiVersion) + return c.SubaccountContext(context.Background(), id) +} + +// SubaccountContext is the same as Subaccount, and it accepts a context.Context +func (c *Client) SubaccountContext(ctx context.Context, id int) (subaccount *Subaccount, res *Response, err error) { + path := fmt.Sprintf(SubaccountsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s/%d", c.Config.BaseUrl, path, id) - res, err = c.HttpGet(u) + res, err = c.HttpGet(ctx, u) if err != nil { return } @@ -238,37 +211,25 @@ func (c *Client) Subaccount(id int) (subaccount *Subaccount, res *Response, err return } - if res.HTTP.StatusCode == 200 { - if res.HTTP.StatusCode == 200 { - var body []byte - body, err = res.ReadBody() - if err != nil { - return - } - slist := map[string]Subaccount{} - err = json.Unmarshal(body, &slist) - if err != nil { - return - } else if s, ok := slist["results"]; ok { - subaccount = &s - return - } - err = fmt.Errorf("Unexpected response to Subaccount") + if Is2XX(res.HTTP.StatusCode) { + var body []byte + body, err = res.ReadBody() + if err != nil { return } - } else { - err = res.ParseResponse() + slist := map[string]Subaccount{} + err = json.Unmarshal(body, &slist) if err != nil { - return + } else if s, ok := slist["results"]; ok { + subaccount = &s + } else { + err = errors.New("Unexpected response to Subaccount") } - if len(res.Errors) > 0 { - err = res.PrettyError("Subaccount", "retrieve") - if err != nil { - return - } + } else { + err = res.ParseResponse() + if err == nil { + err = res.HTTPError() } - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - return } return diff --git a/subaccounts_test.go b/subaccounts_test.go new file mode 100644 index 0000000..35d5ffb --- /dev/null +++ b/subaccounts_test.go @@ -0,0 +1,193 @@ +package gosparkpost_test + +import ( + "reflect" + "strconv" + "strings" + "testing" + + sp "github.com/SparkPost/gosparkpost" + "github.com/pkg/errors" +) + +func TestSubaccountCreate(t *testing.T) { + var res200 = loadTestFile(t, "test/json/subaccount_create_200.json") + + for idx, test := range []struct { + in *sp.Subaccount + err error + status int + json string + out *sp.Subaccount + }{ + {nil, errors.New("Create called with nil Subaccount"), 0, "", nil}, + + {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, + errors.New("Unexpected response to Subaccount creation (results)"), 200, + strings.Replace(res200, `"results"`, `"foo"`, 1), nil}, + {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, + errors.New("parsing api response: unexpected end of JSON input"), 200, + res200[:len(res200)/2], nil}, + {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, + errors.New("Unexpected response to Subaccount creation (subaccount_id)"), 200, + strings.Replace(res200, `"subaccount_id"`, `"foo"`, 1), nil}, + {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, + errors.New("Unexpected response to Subaccount creation (short_key)"), 200, + strings.Replace(res200, `"short_key"`, `"foo"`, 1), nil}, + + {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, + errors.New(`[{"message":"error","code":"","description":""}]`), 400, + `{"errors":[{"message":"error"}]}`, nil}, + + {&sp.Subaccount{Name: "n", KeyLabel: "kl"}, nil, 200, res200, + &sp.Subaccount{ + ID: 888, + Name: "n", + KeyLabel: "kl", + ShortKey: "cf80", + Grants: sp.SubaccountGrants, + }}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "POST", test.status, sp.SubaccountsPathFormat, test.json) + + _, err := testClient.SubaccountCreate(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("SubaccountCreate[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("SubaccountCreate[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil && !reflect.DeepEqual(test.in, test.out) { + t.Errorf("SubaccountCreate[%d] => got/want:\n%q\n%q", idx, test.in, test.out) + } + } +} + +func TestSubaccountUpdate(t *testing.T) { + for idx, test := range []struct { + in *sp.Subaccount + err error + status int + json string + }{ + {nil, errors.New("Subaccount Update called with nil Subaccount"), 0, ""}, + {&sp.Subaccount{ID: 42, Name: "n", Status: "super"}, + errors.New("Not a valid subaccount status"), 0, ""}, + + {&sp.Subaccount{ID: 42, Name: "n"}, + errors.New("parsing api response: unexpected end of JSON input"), 200, + `{"foo":{"message":"syntax error"}`}, + {&sp.Subaccount{ID: 42, Name: "n"}, + errors.New(`[{"message":"error","code":"","description":""}]`), 400, + `{"errors":[{"message":"error"}]}`}, + + {&sp.Subaccount{ID: 42, Name: "n", Status: "active"}, nil, 200, + `{"results":{"message":"Successfully updated subaccount information"}}`}, + } { + testSetup(t) + defer testTeardown() + + id := "0" + if test.in != nil { + id = strconv.Itoa(test.in.ID) + } + mockRestResponseBuilderFormat(t, "PUT", test.status, sp.SubaccountsPathFormat+"/"+id, test.json) + + _, err := testClient.SubaccountUpdate(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("SubaccountUpdate[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("SubaccountUpdate[%d] => err %q want %q", idx, err, test.err) + } + } +} + +func TestSubaccounts(t *testing.T) { + var res200 = loadTestFile(t, "test/json/subaccounts_200.json") + var sa200 = []sp.Subaccount{{ + ID: 123, + Name: "Joe's Garage", + Status: "active", + IPPool: "my_ip_pool", + ComplianceStatus: "active", + }, { + ID: 456, + Name: "SharkPost", + Status: "active", + ComplianceStatus: "active", + }, { + ID: 789, + Name: "Dev Avocado", + Status: "suspended", + ComplianceStatus: "active", + }} + + for idx, test := range []struct { + err error + status int + json string + out []sp.Subaccount + }{ + {errors.New("unexpected end of JSON input"), 200, `{"foo":[]`, nil}, + {errors.New("Unexpected response to Subaccount list"), 200, `{"foo":[]}`, nil}, + + {errors.New(`[{"message":"error","code":"","description":""}]`), 400, + `{"errors":[{"message":"error"}]}`, nil}, + + {nil, 200, res200, sa200}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "GET", test.status, sp.SubaccountsPathFormat, test.json) + + subs, _, err := testClient.Subaccounts() + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("Subaccounts[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("Subaccounts[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil && !reflect.DeepEqual(subs, test.out) { + t.Errorf("Subaccounts[%d] => got/want:\n%q\n%q", idx, subs, test.out) + } + } +} + +func TestSubaccount(t *testing.T) { + var res200 = loadTestFile(t, "test/json/subaccount_200.json") + var sub200 = &sp.Subaccount{ + ID: 123, + Name: "Joe's Garage", + Status: "active", + IPPool: "assigned_ip_pool", + ComplianceStatus: "active", + } + + for idx, test := range []struct { + in int + err error + status int + json string + out *sp.Subaccount + }{ + {42, errors.New("unexpected end of JSON input"), 200, "{", nil}, + {42, errors.New("Unexpected response to Subaccount"), 200, `{"foo":{}}`, nil}, + {42, errors.New(`[{"message":"error","code":"","description":""}]`), 400, + `{"errors":[{"message":"error"}]}`, nil}, + + {123, nil, 200, res200, sub200}, + } { + testSetup(t) + defer testTeardown() + + id := strconv.Itoa(test.in) + mockRestResponseBuilderFormat(t, "GET", test.status, sp.SubaccountsPathFormat+"/"+id, test.json) + + sub, _, err := testClient.Subaccount(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("SubaccountCreate[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("SubaccountCreate[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil && !reflect.DeepEqual(sub, test.out) { + t.Errorf("SubaccountCreate[%d] => got/want:\n%q\n%q", idx, sub, test.out) + } + } +} diff --git a/suppression_list.go b/suppression_list.go index 36e6a33..b4e6d84 100644 --- a/suppression_list.go +++ b/suppression_list.go @@ -1,14 +1,17 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" - URL "net/url" + "net/url" ) -// https://developers.sparkpost.com/api/#/reference/suppression-list -var suppressionListsPathFormat = "/api/v%d/suppression-list" +// SuppressionListsPathFormat https://developers.sparkpost.com/api/#/reference/suppression-list +var SuppressionListsPathFormat = "/api/v%d/suppression-list" +// SuppressionEntry stores a recipient’s opt-out preferences. It is a list of recipient email addresses to which you do NOT want to send email. +// https://developers.sparkpost.com/api/suppression-list.html#header-list-entry-attributes type SuppressionEntry struct { // Email is used when list is stored Email string `json:"email,omitempty"` @@ -19,148 +22,238 @@ type SuppressionEntry struct { Transactional bool `json:"transactional,omitempty"` NonTransactional bool `json:"non_transactional,omitempty"` Source string `json:"source,omitempty"` + Type string `json:"type,omitempty"` Description string `json:"description,omitempty"` Updated string `json:"updated,omitempty"` Created string `json:"created,omitempty"` } -type SuppressionListWrapper struct { +// WritableSuppressionEntry stores a recipient’s opt-out preferences. It is a list of recipient email addresses to which you do NOT want to send email. +// https://developers.sparkpost.com/api/suppression-list.html#suppression-list-bulk-insert-update-put +type WritableSuppressionEntry struct { + // Recipient is used when a list is returned + Recipient string `json:"recipient,omitempty"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` +} + +// SuppressionPage wraps suppression entries and response meta information +type SuppressionPage struct { + client *Client + Results []*SuppressionEntry `json:"results,omitempty"` Recipients []SuppressionEntry `json:"recipients,omitempty"` + Errors []struct { + Message string `json:"message,omitempty"` + } `json:"errors,omitempty"` + + TotalCount int `json:"total_count,omitempty"` + + NextPage string + PrevPage string + FirstPage string + LastPage string + + Links []struct { + Href string `json:"href"` + Rel string `json:"rel"` + } `json:"links,omitempty"` + + Params map[string]string `json:"-"` +} + +// SuppressionList retrieves the account's suppression list. +// Suppression lists larger than 10,000 entries will need to use cursor to retrieve more results. +// See https://developers.sparkpost.com/api/suppression-list.html#suppression-list-search-get +func (c *Client) SuppressionList(sp *SuppressionPage) (*Response, error) { + return c.SuppressionListContext(context.Background(), sp) } -func (c *Client) SuppressionList() (*SuppressionListWrapper, error) { - path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) - finalUrl := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) +// SuppressionListContext retrieves the account's suppression list +func (c *Client) SuppressionListContext(ctx context.Context, sp *SuppressionPage) (*Response, error) { + path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) + return c.suppressionGet(ctx, c.Config.BaseUrl+path, sp) +} - return doSuppressionRequest(c, finalUrl) +// SuppressionRetrieve retrieves the suppression status for a specific recipient by specifying the recipient’s email address +// // https://developers.sparkpost.com/api/suppression-list.html#suppression-list-retrieve,-delete,-insert-or-update-get +func (c *Client) SuppressionRetrieve(email string, sp *SuppressionPage) (*Response, error) { + return c.SuppressionRetrieveContext(context.Background(), email, sp) } -func (c *Client) SuppressionRetrieve(recipientEmail string) (*SuppressionListWrapper, error) { - path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) - finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, recipientEmail) +//SuppressionRetrieveContext retrieves the suppression status for a specific recipient by specifying the recipient’s email address +// // https://developers.sparkpost.com/api/suppression-list.html#suppression-list-retrieve,-delete,-insert-or-update-get +func (c *Client) SuppressionRetrieveContext(ctx context.Context, email string, sp *SuppressionPage) (*Response, error) { + path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) + finalURL := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, email) - return doSuppressionRequest(c, finalUrl) + return c.suppressionGet(ctx, finalURL, sp) } -func (c *Client) SuppressionSearch(parameters map[string]string) (*SuppressionListWrapper, error) { - var finalUrl string - path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) +// SuppressionSearch search for suppression entries. For a list of parameters see +// https://developers.sparkpost.com/api/suppression-list.html#suppression-list-search-get +func (c *Client) SuppressionSearch(sp *SuppressionPage) (*Response, error) { + return c.SuppressionSearchContext(context.Background(), sp) +} + +// SuppressionSearchContext search for suppression entries. For a list of parameters see +// https://developers.sparkpost.com/api/suppression-list.html#suppression-list-search-get +func (c *Client) SuppressionSearchContext(ctx context.Context, sp *SuppressionPage) (*Response, error) { + var finalURL string + path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - if parameters == nil || len(parameters) == 0 { - finalUrl = fmt.Sprintf("%s%s", c.Config.BaseUrl, path) + if sp.Params == nil || len(sp.Params) == 0 { + finalURL = fmt.Sprintf("%s%s", c.Config.BaseUrl, path) } else { - params := URL.Values{} - for k, v := range parameters { - params.Add(k, v) + args := url.Values{} + for k, v := range sp.Params { + args.Add(k, v) } - finalUrl = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, params.Encode()) + finalURL = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, args.Encode()) } - return doSuppressionRequest(c, finalUrl) + return c.suppressionGet(ctx, finalURL, sp) } -func (c *Client) SuppressionDelete(recipientEmail string) (res *Response, err error) { - path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) - finalUrl := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, recipientEmail) +// Next returns the next page of results from a previous MessageEventsSearch call +func (sp *SuppressionPage) Next() (*SuppressionPage, *Response, error) { + return sp.NextContext(context.Background()) +} - res, err = c.HttpDelete(finalUrl) - if err != nil { +// NextContext is the same as Next, and it accepts a context.Context +func (sp *SuppressionPage) NextContext(ctx context.Context) (*SuppressionPage, *Response, error) { + if sp.NextPage == "" { + return nil, nil, nil + } + + suppressionPage := &SuppressionPage{} + suppressionPage.client = sp.client + finalURL := fmt.Sprintf("%s", sp.client.Config.BaseUrl+sp.NextPage) + res, err := sp.client.suppressionGet(ctx, finalURL, suppressionPage) + + return suppressionPage, res, err +} + +// SuppressionDelete deletes an entry from the suppression list +func (c *Client) SuppressionDelete(email string) (res *Response, err error) { + return c.SuppressionDeleteContext(context.Background(), email) +} + +// SuppressionDeleteContext deletes an entry from the suppression list +func (c *Client) SuppressionDeleteContext(ctx context.Context, email string) (res *Response, err error) { + if email == "" { + err = fmt.Errorf("Deleting a suppression entry requires an email address") return nil, err } - if res.HTTP.StatusCode >= 200 && res.HTTP.StatusCode <= 299 { - return + path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) + finalURL := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, email) + + res, err = c.HttpDelete(ctx, finalURL) + if err != nil { + return res, err + } - } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("SuppressionEntry", "delete") + // We get an empty response on success. If there are errors we get JSON. + if res.AssertJson() == nil { + err = res.ParseResponse() if err != nil { - return nil, err + return res, err } - - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } - return + return res, res.HTTPError() +} + +// SuppressionUpsert adds an entry to the suppression, or updates the existing entry +func (c *Client) SuppressionUpsert(entries []WritableSuppressionEntry) (*Response, error) { + return c.SuppressionUpsertContext(context.Background(), entries) } -func (c *Client) SuppressionInsertOrUpdate(entries []SuppressionEntry) (err error) { +// SuppressionUpsertContext is the same as SuppressionUpsert, and it accepts a context.Context +func (c *Client) SuppressionUpsertContext(ctx context.Context, entries []WritableSuppressionEntry) (*Response, error) { if entries == nil { - err = fmt.Errorf("send `entries` cannot be nil here") - return + return nil, fmt.Errorf("`entries` cannot be nil") } - path := fmt.Sprintf(suppressionListsPathFormat, c.Config.ApiVersion) - finalUrl := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) + path := fmt.Sprintf(SuppressionListsPathFormat, c.Config.ApiVersion) - list := SuppressionListWrapper{nil, entries} + type EntriesWrapper struct { + Recipients []WritableSuppressionEntry `json:"recipients,omitempty"` + } - return c.send(finalUrl, list) + entriesWrapper := EntriesWrapper{entries} -} + // Marshaling a static type won't fail + jsonBytes, _ := json.Marshal(entriesWrapper) -func (c *Client) send(finalUrl string, recipients SuppressionListWrapper) (err error) { - jsonBytes, err := json.Marshal(recipients) + finalURL := c.Config.BaseUrl + path + res, err := c.HttpPut(ctx, finalURL, jsonBytes) if err != nil { - return - } - - res, err := c.HttpPut(finalUrl, jsonBytes) - if err != nil { - return + return res, err } if err = res.AssertJson(); err != nil { - return + return res, err } err = res.ParseResponse() if err != nil { - return - } - - if res.HTTP.StatusCode == 200 { - - } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Transmission", "create") - if err != nil { - return - } - - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + return res, err } - return + return res, res.HTTPError() } -func doSuppressionRequest(c *Client, finalUrl string) (*SuppressionListWrapper, error) { +// Wraps call to server and unmarshals response +func (c *Client) suppressionGet(ctx context.Context, finalURL string, sp *SuppressionPage) (*Response, error) { + // Send off our request - res, err := c.HttpGet(finalUrl) + res, err := c.HttpGet(ctx, finalURL) if err != nil { - return nil, err + return res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, err + return res, err + } + + err = res.ParseResponse() + if err != nil { + return res, err } // Get the Content bodyBytes, err := res.ReadBody() if err != nil { - return nil, err + return res, err } // Parse expected response structure - var resMap SuppressionListWrapper - err = json.Unmarshal(bodyBytes, &resMap) - + err = json.Unmarshal(bodyBytes, sp) if err != nil { - return nil, err + return res, err + } + + // For usage convenience parse out common links + for _, link := range sp.Links { + switch link.Rel { + case "next": + sp.NextPage = link.Href + case "previous": + sp.PrevPage = link.Href + case "first": + sp.FirstPage = link.Href + case "last": + sp.LastPage = link.Href + } + } + + if sp.client == nil { + sp.client = c } - return &resMap, err + return res, nil } diff --git a/suppression_list_test.go b/suppression_list_test.go new file mode 100644 index 0000000..4f64297 --- /dev/null +++ b/suppression_list_test.go @@ -0,0 +1,605 @@ +package gosparkpost_test + +import ( + "fmt" + "io/ioutil" + "net/http" + "testing" + + "encoding/json" + + sp "github.com/SparkPost/gosparkpost" +) + +func TestUnmarshal_SupressionEvent(t *testing.T) { + testSetup(t) + defer testTeardown() + + var suppressionEventString = loadTestFile(t, "test/json/suppression_entry_simple.json") + + suppressionEntry := &sp.SuppressionEntry{} + err := json.Unmarshal([]byte(suppressionEventString), suppressionEntry) + if err != nil { + testFailVerbose(t, nil, "Unmarshal SuppressionEntry returned error: %v", err) + } + + verifySuppressionEnty(t, suppressionEntry) +} + +// Test parsing of "not found" case +func TestSuppression_Get_notFound(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile(t, "test/json/suppression_not_found_error.json") + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionList(suppressionPage) + if err != nil { + testFailVerbose(t, res, "SuppressionList GET returned error: %v", err) + } + + // basic content test + if suppressionPage.Results != nil { + testFailVerbose(t, res, "SuppressionList GET returned non-nil Results (error expected)") + } else if len(suppressionPage.Results) != 0 { + testFailVerbose(t, res, "SuppressionList GET returned %d results, expected %d", len(suppressionPage.Results), 0) + } else if len(suppressionPage.Errors) != 1 { + testFailVerbose(t, res, "SuppressionList GET returned %d errors, expected %d", len(suppressionPage.Errors), 1) + } else if suppressionPage.Errors[0].Message != "Recipient could not be found" { + testFailVerbose(t, res, "SuppressionList GET Unmarshal error; saw [%v] expected [%v]", + res.Errors[0].Message, "Recipient could not be found") + } +} + +func TestSuppression_Retrieve(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile(t, "test/json/suppression_retrieve.json") + status := http.StatusOK + email := "john.doe@domain.com" + mockRestResponseBuilderFormat(t, "GET", status, sp.SuppressionListsPathFormat+"/"+email, mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionRetrieve(email, suppressionPage) + if err != nil { + testFailVerbose(t, res, "SuppressionList retrieve returned error: %v", err) + } else if res == nil { + testFailVerbose(t, res, "SuppressionList retrieve expected an HTTP response") + } + + if len(suppressionPage.Results) != 1 { + testFailVerbose(t, res, "SuppressionList retrieve expected 1 result: %v", suppressionPage) + } else if suppressionPage.TotalCount != 1 { + testFailVerbose(t, res, "SuppressionList retrieve expected 1 result: %v", suppressionPage) + } + + verifySuppressionEnty(t, suppressionPage.Results[0]) + +} + +func TestSuppression_Error_Bad_Path(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile(t, "test/json/suppression_not_found_error.json") + mockRestBuilderFormat(t, "GET", "/bad/path", mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionList(suppressionPage) + if err.Error() != "Expected json, got [text/plain] with code 404" { + testFailVerbose(t, res, "SuppressionList GET returned error: %v", err) + } else if res.HTTP.StatusCode != 404 { + testFailVerbose(t, res, "Expected a 404 error: %v", res) + } + +} + +func TestSuppression_Error_Bad_JSON(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, "ThisIsBadJSON") + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + + // Bad JSON should generate an Error + res, err := testClient.SuppressionList(suppressionPage) + if err == nil { + testFailVerbose(t, res, "Expected an error due to bad JSON: %v", err) + } + +} + +func TestSuppression_Error_Wrong_JSON(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, "{\"errors\":\"\"") + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + + // Bad JSON should generate an Error + res, err := testClient.SuppressionList(suppressionPage) + if err == nil { + testFailVerbose(t, res, "Expected an error due to bad JSON: %v", err) + } + +} + +// Test parsing of combined suppression list results +func TestSuppression_Get_combinedList(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile(t, "test/json/suppression_combined.json") + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionList(suppressionPage) + if err != nil { + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + // basic content test + if suppressionPage.Results == nil { + t.Error("SuppressionList GET returned nil Results") + } else if len(suppressionPage.Results) != 1 { + t.Errorf("SuppressionList GET returned %d results, expected %d", len(suppressionPage.Results), 1) + } else if suppressionPage.Results[0].Recipient != "rcpt_1@example.com" { + t.Errorf("SuppressionList GET Unmarshal error; saw [%v] expected [rcpt_1@example.com]", suppressionPage.Results[0].Recipient) + } +} + +// Test parsing of separate suppression list results +func TestSuppression_Get_separateList(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile(t, "test/json/suppression_seperate_lists.json") + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionList(suppressionPage) + if err != nil { + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + // basic content test + if suppressionPage.Results == nil { + t.Error("SuppressionList GET returned nil Results") + } else if len(suppressionPage.Results) != 2 { + t.Errorf("SuppressionList GET returned %d results, expected %d", len(suppressionPage.Results), 2) + } else if suppressionPage.Results[0].Recipient != "rcpt_1@example.com" { + t.Errorf("SuppressionList GET Unmarshal error; saw [%v] expected [rcpt_1@example.com]", suppressionPage.Results[0].Recipient) + } +} + +// Tests that links are generally parsed properly +func TestSuppression_links(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile(t, "test/json/suppression_cursor.json") + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionList(suppressionPage) + if err != nil { + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + // basic content test + if suppressionPage.Results == nil { + t.Error("SuppressionList GET returned nil Results") + } else if suppressionPage.TotalCount != 44 { + t.Errorf("SuppressionList GET returned %d results, expected %d", suppressionPage.TotalCount, 44) + } else if len(suppressionPage.Links) != 4 { + t.Errorf("SuppressionList GET returned %d results, expected %d", len(suppressionPage.Links), 2) + } else if suppressionPage.Links[0].Href != "The_HREF_first" { + t.Error("SuppressionList GET returned invalid link[0].Href") + } else if suppressionPage.Links[1].Href != "The_HREF_next" { + t.Error("SuppressionList GET returned invalid link[1].Href") + } else if suppressionPage.Links[0].Rel != "first" { + t.Error("SuppressionList GET returned invalid s.Links[0].Rel") + } else if suppressionPage.Links[1].Rel != "next" { + t.Error("SuppressionList GET returned invalid s.Links[1].Rel") + } + + // Check convenience links + if suppressionPage.FirstPage != "The_HREF_first" { + t.Errorf("Unexpected FirstPage value: %s", suppressionPage.FirstPage) + } else if suppressionPage.LastPage != "The_HREF_last" { + t.Errorf("Unexpected LastPage value: %s", suppressionPage.LastPage) + } else if suppressionPage.PrevPage != "The_HREF_previous" { + t.Errorf("Unexpected PrevPage value: %s", suppressionPage.PrevPage) + } else if suppressionPage.NextPage != "The_HREF_next" { + t.Errorf("Unexpected NextPage value: %s", suppressionPage.NextPage) + } + +} + +func TestSuppression_Empty_NextPage(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile(t, "test/json/suppression_single_page.json") + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionList(suppressionPage) + if err != nil { + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + nextResponse, res, err := suppressionPage.Next() + + if nextResponse != nil { + t.Errorf("nextResponse should be nil but was: %v", nextResponse) + } else if res != nil { + t.Errorf("Response should be nil but was: %v", res) + } else if err != nil { + t.Errorf("Error should be nil but was: %v", err) + } +} + +// +func TestSuppression_NextPage(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile(t, "test/json/suppression_page1.json") + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) + + mockResponse = loadTestFile(t, "test/json/suppression_page2.json") + mockRestBuilder(t, "GET", "/test/json/suppression_page2.json", mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionList(suppressionPage) + if err != nil { + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + if suppressionPage.NextPage != "/test/json/suppression_page2.json" { + t.Errorf("Unexpected NextPage value: %s", suppressionPage.NextPage) + } + + nextResponse, res, err := suppressionPage.Next() + + if nextResponse.NextPage != "/test/json/suppression_pageLast.json" { + t.Errorf("Unexpected NextPage value: %s", nextResponse.NextPage) + } +} + +// Test parsing of combined suppression list results +func TestSuppression_Search_combinedList(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile(t, "test/json/suppression_combined.json") + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + res, err := testClient.SuppressionSearch(suppressionPage) + if err != nil { + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + // basic content test + if suppressionPage.Results == nil { + t.Error("SuppressionList GET returned nil Results") + } else if len(suppressionPage.Results) != 1 { + t.Errorf("SuppressionList GET returned %d results, expected %d", len(suppressionPage.Results), 1) + } else if suppressionPage.Results[0].Recipient != "rcpt_1@example.com" { + t.Errorf("SuppressionList GET Unmarshal error; saw [%v] expected [rcpt_1@example.com]", suppressionPage.Results[0].Recipient) + } +} + +// Test parsing of combined suppression list results +func TestSuppression_Search_params(t *testing.T) { + testSetup(t) + defer testTeardown() + + // set up the response handler + var mockResponse = loadTestFile(t, "test/json/suppression_combined.json") + mockRestBuilderFormat(t, "GET", sp.SuppressionListsPathFormat, mockResponse) + + // hit our local handler + suppressionPage := &sp.SuppressionPage{} + parameters := map[string]string{ + "from": "1970-01-01T00:00", + } + suppressionPage.Params = parameters + + res, err := testClient.SuppressionSearch(suppressionPage) + if err != nil { + t.Errorf("SuppressionList GET returned error: %v", err) + for _, e := range res.Verbose { + t.Error(e) + } + return + } + + // basic content test + if suppressionPage.Results == nil { + t.Error("SuppressionList GET returned nil Results") + } else if len(suppressionPage.Results) != 1 { + t.Errorf("SuppressionList GET returned %d results, expected %d", len(suppressionPage.Results), 1) + } else if suppressionPage.Results[0].Recipient != "rcpt_1@example.com" { + t.Errorf("SuppressionList GET Unmarshal error; saw [%v] expected [rcpt_1@example.com]", suppressionPage.Results[0].Recipient) + } +} + +func TestClient_SuppressionUpsert_nil_entry(t *testing.T) { + testSetup(t) + defer testTeardown() + + response, err := testClient.SuppressionUpsert(nil) + if response != nil { + t.Errorf("Expected nil response object but got: %v", response) + } else if err == nil { + t.Errorf("Expected an error") + } +} + +func TestClient_SuppressionUpsert_bad_json(t *testing.T) { + testSetup(t) + defer testTeardown() + + var mockResponse = "{bad json}" + mockRestBuilderFormat(t, "PUT", sp.SuppressionListsPathFormat, mockResponse) + + entry := sp.WritableSuppressionEntry{ + Recipient: "john.doe@domain.com", + Description: "entry description", + Type: "non_transactional", + } + + entries := []sp.WritableSuppressionEntry{ + entry, + } + + response, err := testClient.SuppressionUpsert(entries) + if response == nil { + t.Errorf("Expected a response") + } else if err == nil { + t.Errorf("Expected an error") + } +} + +func TestClient_SuppressionUpsert_1_entry(t *testing.T) { + testSetup(t) + defer testTeardown() + + var expectedRequest = loadTestFile(t, "test/json/suppression_entry_simple_request.json") + var mockResponse = "{}" + mockRestRequestResponseBuilderFormat(t, "PUT", http.StatusOK, sp.SuppressionListsPathFormat, expectedRequest, mockResponse) + + entry := sp.WritableSuppressionEntry{ + Recipient: "john.doe@domain.com", + Description: "entry description", + Type: "non_transactional", + } + + entries := []sp.WritableSuppressionEntry{ + entry, + } + + response, err := testClient.SuppressionUpsert(entries) + if response == nil { + t.Errorf("Expected a response") + } else if err != nil { + t.Errorf("Did not expect an error: %v", err) + } +} + +func TestClient_SuppressionUpsert_error_response(t *testing.T) { + testSetup(t) + defer testTeardown() + + var mockResponse = loadTestFile(t, "test/json/suppression_not_found_error.json") + status := http.StatusBadRequest + mockRestResponseBuilderFormat(t, "PUT", status, sp.SuppressionListsPathFormat, mockResponse) + + entry := sp.WritableSuppressionEntry{ + Recipient: "john.doe@domain.com", + Description: "entry description", + Type: "non_transactional", + } + + entries := []sp.WritableSuppressionEntry{ + entry, + } + + response, err := testClient.SuppressionUpsert(entries) + if response == nil { + t.Errorf("Expected a response") + } else if err == nil { + t.Errorf("Expected an error with the HTTP status code") + } + + if response.HTTP.StatusCode != status { + testFailVerbose(t, response, "Expected HTTP status code %d but got %d", status, response.HTTP.StatusCode) + } else if len(response.Errors) != 1 { + testFailVerbose(t, response, "SuppressionUpsert PUT returned %d errors, expected %d", len(response.Errors), 1) + } else if response.Errors[0].Message != "Recipient could not be found" { + testFailVerbose(t, response, "SuppressionUpsert PUT Unmarshal error; saw [%v] expected [%v]", + response.Errors[0].Message, "Recipient could not be found") + } +} + +func TestClient_Suppression_Delete_nil_email(t *testing.T) { + testSetup(t) + defer testTeardown() + + status := http.StatusNotFound + mockRestResponseBuilderFormat(t, "DELETE", status, sp.SuppressionListsPathFormat+"/", "") + + _, err := testClient.SuppressionDelete("") + if err == nil { + t.Errorf("Expected an error indicating an email address is required") + } +} + +func TestClient_Suppression_Delete(t *testing.T) { + testSetup(t) + defer testTeardown() + + email := "test@test.com" + status := http.StatusNoContent + mockRestResponseBuilderFormat(t, "DELETE", status, sp.SuppressionListsPathFormat+"/"+email, "") + + response, err := testClient.SuppressionDelete(email) + if err != nil { + t.Errorf("Did not expect an error") + } + + if response.HTTP.StatusCode != status { + testFailVerbose(t, response, "Expected HTTP status code %d but got %d", status, response.HTTP.StatusCode) + } +} + +func TestClient_Suppression_Delete_Errors(t *testing.T) { + testSetup(t) + defer testTeardown() + + email := "test@test.com" + status := http.StatusBadRequest + var mockResponse = loadTestFile(t, "test/json/suppression_not_found_error.json") + mockRestResponseBuilderFormat(t, "DELETE", status, sp.SuppressionListsPathFormat+"/"+email, mockResponse) + + response, err := testClient.SuppressionDelete(email) + if err == nil { + t.Errorf("Expected an error") + } + + if response.HTTP.StatusCode != status { + testFailVerbose(t, response, "Expected HTTP status code %d but got %d", status, response.HTTP.StatusCode) + } else if len(response.Errors) != 1 { + testFailVerbose(t, response, "SuppressionDelete DELETE returned %d errors, expected %d", len(response.Errors), 1) + } else if response.Errors[0].Message != "Recipient could not be found" { + testFailVerbose(t, response, "SuppressionDelete DELETE Unmarshal error; saw [%v] expected [%v]", + response.Errors[0].Message, "Recipient could not be found") + } +} + +///////////////////// +// Internal Helpers +///////////////////// + +func verifySuppressionEnty(t *testing.T, suppressionEntry *sp.SuppressionEntry) { + if suppressionEntry.Recipient != "john.doe@domain.com" { + testFailVerbose(t, nil, "Unexpected Recipient: %s", suppressionEntry.Recipient) + } else if suppressionEntry.Description != "entry description" { + testFailVerbose(t, nil, "Unexpected Description: %s", suppressionEntry.Description) + } else if suppressionEntry.Source != "manually created" { + testFailVerbose(t, nil, "Unexpected Source: %s", suppressionEntry.Source) + } else if suppressionEntry.Type != "non_transactional" { + testFailVerbose(t, nil, "Unexpected Type: %s", suppressionEntry.Type) + } else if suppressionEntry.Created != "2016-05-02T16:29:56+00:00" { + testFailVerbose(t, nil, "Unexpected Created: %s", suppressionEntry.Created) + } else if suppressionEntry.Updated != "2016-05-02T17:20:50+00:00" { + testFailVerbose(t, nil, "Unexpected Updated: %s", suppressionEntry.Updated) + } else if suppressionEntry.NonTransactional != true { + testFailVerbose(t, nil, "Unexpected NonTransactional value") + } +} + +func mockRestBuilderFormat(t *testing.T, method string, pathFormat string, mockResponse string) { + mockRestResponseBuilderFormat(t, method, http.StatusOK, pathFormat, mockResponse) +} + +func mockRestBuilder(t *testing.T, method string, path string, mockResponse string) { + mockRestResponseBuilder(t, method, http.StatusOK, path, mockResponse) +} + +func mockRestResponseBuilderFormat(t *testing.T, method string, status int, pathFormat string, mockResponse string) { + path := fmt.Sprintf(pathFormat, testClient.Config.ApiVersion) + mockRestResponseBuilder(t, method, status, path, mockResponse) +} + +func mockRestResponseBuilder(t *testing.T, method string, status int, path string, mockResponse string) { + mockRestRequestResponseBuilder(t, method, status, path, "", mockResponse) +} + +func mockRestRequestResponseBuilderFormat(t *testing.T, method string, status int, pathFormat string, expectedBody string, mockResponse string) { + path := fmt.Sprintf(pathFormat, testClient.Config.ApiVersion) + mockRestRequestResponseBuilder(t, method, status, path, expectedBody, mockResponse) +} + +func mockRestRequestResponseBuilder(t *testing.T, method string, status int, path string, expectedBody string, mockResponse string) { + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + if expectedBody != "" { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + testFailVerbose(t, nil, "error: %v", err) + } + + ok, err := AreEqualJSON(expectedBody, string(body[:])) + if err != nil { + testFailVerbose(t, nil, "error: %v", err) + } + + if !ok { + testFailVerbose(t, nil, "Request did not match expected. \nExpected: \n%s\n\nActual:\n%s\n\n", err) + } + } + + testMethod(t, r, method) + if mockResponse != "" { + w.Header().Set("Content-Type", "application/json; charset=utf8") + } + w.WriteHeader(status) + if mockResponse != "" { + w.Write([]byte(mockResponse)) + } + }) +} diff --git a/template_from_json_test.go b/template_from_json_test.go deleted file mode 100644 index dd2f86e..0000000 --- a/template_from_json_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package gosparkpost_test - -import ( - "encoding/json" - "log" - - sp "github.com/SparkPost/gosparkpost" -) - -// Build a native Go Template structure from a JSON string -func ExampleTemplate() { - template := &sp.Template{} - jsonStr := `{ - "name": "testy template", - "content": { - "html": "this is a test email!", - "subject": "test email", - "from": { - "name": "tester", - "email": "tester@example.com" - }, - "reply_to": "tester@example.com" - } - }` - err := json.Unmarshal([]byte(jsonStr), template) - if err != nil { - log.Fatal(err) - } -} diff --git a/templates.go b/templates.go index f7cddc5..696055c 100644 --- a/templates.go +++ b/templates.go @@ -1,15 +1,18 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" "reflect" "strings" "time" + + "github.com/pkg/errors" ) // https://www.sparkpost.com/api#/reference/templates -var templatesPathFormat = "/api/v%d/templates" +var TemplatesPathFormat = "/api/v%d/templates" // Template is the JSON structure accepted by and returned from the SparkPost Templates API. // It's mostly metadata at this level - see Content and Options for more detail. @@ -56,15 +59,15 @@ type From struct { Name string } -// Options specifies settings to apply to this Template. +// TmplOptions specifies settings to apply to this Template. // These settings may be overridden in the Transmission API call. type TmplOptions struct { - OpenTracking bool `json:"open_tracking,omitempty"` - ClickTracking bool `json:"click_tracking,omitempty"` - Transactional bool `json:"transactional,omitempty"` + OpenTracking *bool `json:"open_tracking,omitempty"` + ClickTracking *bool `json:"click_tracking,omitempty"` + Transactional *bool `json:"transactional,omitempty"` } -// Preview options contains the required subsitution_data object to +// PreviewOptions contains the required subsitution_data object to // preview a template type PreviewOptions struct { SubstitutionData map[string]interface{} `json:"substitution_data"` @@ -74,6 +77,13 @@ type PreviewOptions struct { func ParseFrom(from interface{}) (f From, err error) { // handle the allowed types switch fromVal := from.(type) { + case From: + f = fromVal + + case Address: + f.Email = fromVal.Email + f.Name = fromVal.Name + case string: // simple string value if fromVal == "" { err = fmt.Errorf("Content.From may not be empty") @@ -171,14 +181,14 @@ func (t *Template) Validate() error { return nil } -// SetHeaders is a convenience method which sets Template.Content.Headers to the provided map. -func (t *Template) SetHeaders(headers map[string]string) { - t.Content.Headers = headers -} - -// Create accepts a populated Template object, validates its Contents, +// TemplateCreate accepts a populated Template object, validates its Contents, // and performs an API call against the configured endpoint. func (c *Client) TemplateCreate(t *Template) (id string, res *Response, err error) { + return c.TemplateCreateContext(context.Background(), t) +} + +// TemplateCreateContext is the same as TemplateCreate, and it allows the caller to provide a context. +func (c *Client) TemplateCreateContext(ctx context.Context, t *Template) (id string, res *Response, err error) { if t == nil { err = fmt.Errorf("Create called with nil Template") return @@ -189,54 +199,96 @@ func (c *Client) TemplateCreate(t *Template) (id string, res *Response, err erro return } - jsonBytes, err := json.Marshal(t) - if err != nil { - return - } + // A Template that makes it past Validate() will always Marshal + jsonBytes, _ := json.Marshal(t) - path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(url, jsonBytes) + res, err = c.HttpPost(ctx, url, jsonBytes) if err != nil { return } - if err = res.AssertJson(); err != nil { + if err = res.ParseResponse(); err != nil { return } - err = res.ParseResponse() + if Is2XX(res.HTTP.StatusCode) { + var ok bool + var results map[string]interface{} + if results, ok = res.Results.(map[string]interface{}); !ok { + err = fmt.Errorf("Unexpected response to Template creation (results)") + } else if id, ok = results["id"].(string); !ok { + err = fmt.Errorf("Unexpected response to Template creation (id)") + } + } else { + err = res.HTTPError() + } + return +} + +// TemplateGet fills out the provided template, using the specified id. +func (c *Client) TemplateGet(t *Template, draft bool) (*Response, error) { + return c.TemplateGetContext(context.Background(), t, draft) +} + +// TemplateGetContext is the same as TemplateGet, and it allows the caller to provide a context +func (c *Client) TemplateGetContext(ctx context.Context, t *Template, draft bool) (*Response, error) { + if t == nil { + return nil, errors.New("TemplateGet called with nil Template") + } + + if t.ID == "" { + return nil, errors.New("TemplateGet called with blank id") + } + + path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) + url := fmt.Sprintf("%s%s/%s?draft=%t", c.Config.BaseUrl, path, t.ID, draft) + + res, err := c.HttpGet(ctx, url) if err != nil { - return + return nil, err } - if res.HTTP.StatusCode == 200 { - var ok bool - id, ok = res.Results["id"].(string) - if !ok { - err = fmt.Errorf("Unexpected response to Template creation") - } + var body []byte + body, err = res.ReadBody() + if err != nil { + return res, err + } - } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Template", "create") - if err != nil { - return - } + if err = res.ParseResponse(); err != nil { + return res, err + } - if res.HTTP.StatusCode == 422 { // template syntax error - eobj := res.Errors[0] - err = fmt.Errorf("%s: %s\n%s", eobj.Code, eobj.Message, eobj.Description) - } else { // everything else - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + if Is2XX(res.HTTP.StatusCode) { + // Unwrap the returned Template + tmp := map[string]*json.RawMessage{} + if err = json.Unmarshal(body, &tmp); err != nil { + } else if results, ok := tmp["results"]; ok { + err = json.Unmarshal(*results, t) + } else { + err = errors.New("Unexpected response to TemplateGet") } + } else { + err = res.HTTPError() } - return + return res, err } -// Update updates a draft/published template with the specified id -func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { +// TemplateUpdate updates a draft/published template with the specified id +// The `updatePublished` parameter controls which version (draft/false vs published/true) of the template will be updated. +func (c *Client) TemplateUpdate(t *Template, updatePublished bool) (res *Response, err error) { + return c.TemplateUpdateContext(context.Background(), t, updatePublished) +} + +// TemplateUpdateContext is the same as TemplateUpdate, and it allows the caller to provide a context +func (c *Client) TemplateUpdateContext(ctx context.Context, t *Template, updatePublished bool) (res *Response, err error) { + if t == nil { + err = fmt.Errorf("Update called with nil Template") + return + } + if t.ID == "" { err = fmt.Errorf("Update called with blank id") return @@ -247,15 +299,13 @@ func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { return } - jsonBytes, err := json.Marshal(t) - if err != nil { - return - } + // A Template that makes it past Validate() will always Marshal + jsonBytes, _ := json.Marshal(t) - path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) - url := fmt.Sprintf("%s%s/%s?update_published=%t", c.Config.BaseUrl, path, t.ID, t.Published) + path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) + url := fmt.Sprintf("%s%s/%s?update_published=%t", c.Config.BaseUrl, path, t.ID, updatePublished) - res, err = c.HttpPut(url, jsonBytes) + res, err = c.HttpPut(ctx, url, jsonBytes) if err != nil { return } @@ -264,86 +314,70 @@ func (c *Client) TemplateUpdate(t *Template) (res *Response, err error) { return } - err = res.ParseResponse() - if err != nil { + if Is2XX(res.HTTP.StatusCode) { return } - if res.HTTP.StatusCode == 200 { - return - - } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Template", "update") - if err != nil { - return - } - - // handle template-specific ones - if res.HTTP.StatusCode == 409 { - err = fmt.Errorf("Template with id [%s] is in use by msg generation", t.ID) - } else { // everything else - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - } + if err = res.ParseResponse(); err == nil { + err = res.HTTPError() } return } -// List returns metadata for all Templates in the system. +// Templates returns metadata for all Templates in the system. func (c *Client) Templates() ([]Template, *Response, error) { - path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) - url := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err := c.HttpGet(url) + return c.TemplatesContext(context.Background()) +} + +// TemplatesContext is the same as Templates, and it allows the caller to provide a context +func (c *Client) TemplatesContext(ctx context.Context) (tl []Template, res *Response, err error) { + path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) + url := c.Config.BaseUrl + path + res, err = c.HttpGet(ctx, url) if err != nil { - return nil, nil, err + return } if err = res.AssertJson(); err != nil { - return nil, res, err + return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var body []byte body, err = res.ReadBody() if err != nil { - return nil, res, err + return } tlist := map[string][]Template{} if err = json.Unmarshal(body, &tlist); err != nil { - return nil, res, err - } else if list, ok := tlist["results"]; ok { - return list, res, nil + return } - return nil, res, fmt.Errorf("Unexpected response to Template list") + return tlist["results"], res, nil + } - } else { - err = res.ParseResponse() - if err != nil { - return nil, res, err - } - if len(res.Errors) > 0 { - err = res.PrettyError("Template", "list") - if err != nil { - return nil, res, err - } - } - return nil, res, fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + if err = res.ParseResponse(); err == nil { + err = res.HTTPError() } - return nil, res, err + return } -// Delete removes the Template with the specified id. +// TemplateDelete removes the Template with the specified id. func (c *Client) TemplateDelete(id string) (res *Response, err error) { + return c.TemplateDeleteContext(context.Background(), id) +} + +// TemplateDeleteContext is the same as TemplateDelete, and it allows the caller to provide a context +func (c *Client) TemplateDeleteContext(ctx context.Context, id string) (res *Response, err error) { if id == "" { err = fmt.Errorf("Delete called with blank id") return } - path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, id) - res, err = c.HttpDelete(url) + res, err = c.HttpDelete(ctx, url) if err != nil { return } @@ -352,33 +386,20 @@ func (c *Client) TemplateDelete(id string) (res *Response, err error) { return } - err = res.ParseResponse() - if err != nil { - return - } - - if res.HTTP.StatusCode == 200 { - return - - } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Template", "delete") - if err != nil { - return - } - - // handle template-specific ones - if res.HTTP.StatusCode == 409 { - err = fmt.Errorf("Template with id [%s] is in use by msg generation", id) - } else { // everything else - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - } + if err = res.ParseResponse(); err == nil { + err = res.HTTPError() } return } +// TemplatePreview renders and returns the output of a template using the provided substitution data. func (c *Client) TemplatePreview(id string, payload *PreviewOptions) (res *Response, err error) { + return c.TemplatePreviewContext(context.Background(), id, payload) +} + +// TemplatePreviewContext is the same as TemplatePreview, and it allows the caller to provide a context +func (c *Client) TemplatePreviewContext(ctx context.Context, id string, payload *PreviewOptions) (res *Response, err error) { if id == "" { err = fmt.Errorf("Preview called with blank id") return @@ -393,9 +414,9 @@ func (c *Client) TemplatePreview(id string, payload *PreviewOptions) (res *Respo return } - path := fmt.Sprintf(templatesPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TemplatesPathFormat, c.Config.ApiVersion) url := fmt.Sprintf("%s%s/%s/preview", c.Config.BaseUrl, path, id) - res, err = c.HttpPost(url, jsonBytes) + res, err = c.HttpPost(ctx, url, jsonBytes) if err != nil { return } @@ -404,24 +425,8 @@ func (c *Client) TemplatePreview(id string, payload *PreviewOptions) (res *Respo return } - err = res.ParseResponse() - if err != nil { - return - } - - if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Template", "preview") - if err != nil { - return - } - - if res.HTTP.StatusCode == 422 { // preview payload error - eobj := res.Errors[0] - err = fmt.Errorf("%s: %s\n%s", eobj.Code, eobj.Message, eobj.Description) - } else { // everything else - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - } + if err = res.ParseResponse(); err == nil { + err = res.HTTPError() } return diff --git a/templates_test.go b/templates_test.go index 028dc43..5131371 100644 --- a/templates_test.go +++ b/templates_test.go @@ -1,76 +1,365 @@ package gosparkpost_test import ( - "fmt" + "bytes" + "encoding/json" + "reflect" + "strings" "testing" sp "github.com/SparkPost/gosparkpost" - "github.com/SparkPost/gosparkpost/test" + "github.com/pkg/errors" ) -func TestTemplates(t *testing.T) { - if true { - // Temporarily disable test so TravisCI reports build success instead of test failure. - // NOTE: need travis to set sparkpost base urls etc, or mock http request - return +// ExampleTemplate builds a native Go Template structure from a JSON string +func ExampleTemplate() { + template := &sp.Template{} + jsonStr := `{ + "name": "testy template", + "content": { + "html": "this is a test email!", + "subject": "test email", + "from": { + "name": "tester", + "email": "tester@example.com" + }, + "reply_to": "tester@example.com" + } + }` + err := json.Unmarshal([]byte(jsonStr), template) + if err != nil { + panic(err) } +} - cfgMap, err := test.LoadConfig() - if err != nil { - t.Error(err) - return +func TestTemplateFromValidation(t *testing.T) { + for idx, test := range []struct { + in interface{} + err error + out sp.From + }{ + {sp.From{"a@b.com", "A B"}, nil, sp.From{"a@b.com", "A B"}}, + {sp.Address{"a@b.com", "A B", "c@d.com"}, nil, sp.From{"a@b.com", "A B"}}, + {"a@b.com", nil, sp.From{"a@b.com", ""}}, + {nil, errors.New("unsupported Content.From value type [%!s()]"), sp.From{"", ""}}, + {[]byte("a@b.com"), errors.New("unsupported Content.From value type [[]uint8]"), sp.From{"", ""}}, + {"", errors.New("Content.From may not be empty"), sp.From{"", ""}}, + {map[string]interface{}{"name": "A B", "email": "a@b.com"}, nil, sp.From{"a@b.com", "A B"}}, + {map[string]interface{}{"name": 1, "email": "a@b.com"}, errors.New("strings are required for all Content.From values"), + sp.From{"a@b.com", ""}}, + {map[string]string{"name": "A B", "email": "a@b.com"}, nil, sp.From{"a@b.com", "A B"}}, + } { + f, err := sp.ParseFrom(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("ParseFrom[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("ParseFrom[%d] => err %q, want %q", idx, err, test.err) + } else if f.Email != test.out.Email { + t.Errorf("ParseFrom[%d] => Email %q, want %q", idx, f.Email, test.out.Email) + } else if f.Name != test.out.Name { + t.Errorf("ParseFrom[%d] => Name %q, want %q", idx, f.Name, test.out.Name) + } } - cfg, err := sp.NewConfig(cfgMap) +} + +// Assert that options are actually ... optional, +// and that unspecified options don't default to their zero values. +func TestTemplateOptions(t *testing.T) { + var jsonb []byte + var err error + var opt bool + + te := &sp.Template{} + to := &sp.TmplOptions{Transactional: &opt} + te.Options = to + + jsonb, err = json.Marshal(te) if err != nil { - t.Error(err) - return + t.Fatal(err) } - var client sp.Client - err = client.Init(cfg) - if err != nil { - t.Error(err) - return + if !bytes.Contains(jsonb, []byte(`"options":{"transactional":false}`)) { + t.Fatal("expected transactional option to be false") } - tlist, _, err := client.Templates() + opt = true + jsonb, err = json.Marshal(te) if err != nil { - t.Error(err) - return + t.Fatal(err) } - t.Logf("templates listed: %+v", tlist) - - content := sp.Content{ - Subject: "this is a test template", - // NB: deliberate syntax error - //Text: "text part of the test template {{a}", - Text: "text part of the test template", - From: map[string]string{ - "name": "test name", - "email": "test@email.com", - }, + + if !bytes.Contains(jsonb, []byte(`"options":{"transactional":true}`)) { + t.Fatalf("expected transactional option to be true:\n%s", string(jsonb)) } - template := &sp.Template{Content: content, Name: "test template"} +} - id, _, err := client.TemplateCreate(template) - if err != nil { - t.Error(err) - return +func TestTemplateValidation(t *testing.T) { + for idx, test := range []struct { + in *sp.Template + err error + out *sp.Template + }{ + {nil, errors.New("Can't Validate a nil Template"), nil}, + {&sp.Template{}, errors.New("Template requires a non-empty Content.Subject"), nil}, + {&sp.Template{Content: sp.Content{Subject: "s"}}, errors.New("Template requires either Content.HTML or Content.Text"), nil}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: ""}}, + errors.New("Content.From may not be empty"), nil}, + + {&sp.Template{ID: strings.Repeat("id", 33), Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Template id may not be longer than 64 bytes"), nil}, + {&sp.Template{Name: strings.Repeat("name", 257), Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Template name may not be longer than 1024 bytes"), nil}, + {&sp.Template{Description: strings.Repeat("desc", 257), Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Template description may not be longer than 1024 bytes"), nil}, + + {&sp.Template{ + Content: sp.Content{ + Subject: "s", HTML: "h", From: "f", + Attachments: []sp.Attachment{{Filename: strings.Repeat("f", 256)}}, + }}, + errors.Errorf("Attachment name length must be <= 255: [%s]", strings.Repeat("f", 256)), nil}, + {&sp.Template{ + Content: sp.Content{ + Subject: "s", HTML: "h", From: "f", + Attachments: []sp.Attachment{{B64Data: "\r\n"}}, + }}, + errors.New("Attachment data may not contain line breaks [\\r\\n]"), nil}, + + {&sp.Template{ + Content: sp.Content{ + Subject: "s", HTML: "h", From: "f", + InlineImages: []sp.InlineImage{{Filename: strings.Repeat("f", 256)}}, + }}, + errors.Errorf("InlineImage name length must be <= 255: [%s]", strings.Repeat("f", 256)), nil}, + {&sp.Template{ + Content: sp.Content{ + Subject: "s", HTML: "h", From: "f", + InlineImages: []sp.InlineImage{{B64Data: "\r\n"}}, + }}, + errors.New("InlineImage data may not contain line breaks [\\r\\n]"), nil}, + + {&sp.Template{Content: sp.Content{EmailRFC822: "From:foo@example.com\r\n", Subject: "removeme"}}, + nil, &sp.Template{Content: sp.Content{EmailRFC822: "From:foo@example.com\r\n"}}}, + } { + err := test.in.Validate() + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("Template.Validate[%d] => err %q, want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("Template.Validate[%d] => err %q, want %q", idx, err, test.err) + } else if test.out != nil && !reflect.DeepEqual(test.in, test.out) { + t.Errorf("Template.Validate[%d] => failed post-condition check for %q", test.in) + } } - fmt.Printf("Created Template with id=%s\n", id) +} - d := map[string]interface{}{} - res, err := client.TemplatePreview(id, &sp.PreviewOptions{d}) - if err != nil { - t.Error(err) - return +func TestTemplateCreate(t *testing.T) { + for idx, test := range []struct { + in *sp.Template + err error + status int + json string + id string + }{ + {nil, errors.New("Create called with nil Template"), 0, "", ""}, + {&sp.Template{}, errors.New("Template requires a non-empty Content.Subject"), 0, "", ""}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Unexpected response to Template creation (results)"), + 200, `{"foo":{"id":"new-template"}}`, ""}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("Unexpected response to Template creation (id)"), + 200, `{"results":{"ID":"new-template"}}`, ""}, + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New("parsing api response: unexpected end of JSON input"), + 200, `{"truncated":{}`, ""}, + + {&sp.Template{Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, + sp.SPErrors([]sp.SPError{{ + Message: "substitution language syntax error in template content", + Description: "Error while compiling header Subject: substitution statement missing ending '}}'", + Code: "3000", + Part: "Header:Subject", + }}), + 422, `{ "errors": [ { "message": "substitution language syntax error in template content", "description": "Error while compiling header Subject: substitution statement missing ending '}}'", "code": "3000", "part": "Header:Subject" } ] }`, ""}, + + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, + errors.New(`parsing api response: invalid character 'B' looking for beginning of value`), + 503, `Bad Gateway`, ""}, + + {&sp.Template{Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, nil, + 200, `{"results":{"id":"new-template"}}`, "new-template"}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "POST", test.status, sp.TemplatesPathFormat, test.json) + + id, _, err := testClient.TemplateCreate(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("TemplateCreate[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("TemplateCreate[%d] => err %q want %q", idx, err, test.err) + } else if id != test.id { + t.Errorf("TemplateCreate[%d] => id %q want %q", idx, id, test.id) + } } - fmt.Printf("Preview Template with id=%s and response %+v\n", id, res) +} - _, err = client.TemplateDelete(id) - if err != nil { - t.Error(err) - return +func TestTemplateGet(t *testing.T) { + for idx, test := range []struct { + in *sp.Template + draft bool + err error + status int + json string + out *sp.Template + }{ + {nil, false, errors.New("TemplateGet called with nil Template"), 200, "", nil}, + {&sp.Template{ID: ""}, false, errors.New("TemplateGet called with blank id"), 200, "", nil}, + {&sp.Template{ID: "nope"}, false, errors.New(`[{"message":"Resource could not be found","code":"","description":""}]`), 404, `{ "errors": [ { "message": "Resource could not be found" } ] }`, nil}, + {&sp.Template{ID: "nope"}, false, errors.New(`parsing api response: unexpected end of JSON input`), 400, `{`, nil}, + {&sp.Template{ID: "id"}, false, errors.New("Unexpected response to TemplateGet"), 200, `{"foo":{}}`, nil}, + {&sp.Template{ID: "id"}, false, errors.New("parsing api response: unexpected end of JSON input"), 200, `{"foo":{}`, nil}, + + {&sp.Template{ID: "id"}, false, errors.New(`parsing api response: invalid character 'B' looking for beginning of value`), 503, `Bad Gateway`, nil}, + + {&sp.Template{ID: "id"}, false, nil, 200, `{"results":{"content":{"from":{"email":"a@b.com","name": "a b"},"html":"hi!","subject":"blink","text":"no blink ;_;"},"id":"id"}}`, &sp.Template{ID: "id", Content: sp.Content{From: map[string]interface{}{"email": "a@b.com", "name": "a b"}, HTML: "hi!", Text: "no blink ;_;", Subject: "blink"}}}, + } { + testSetup(t) + defer testTeardown() + + id := "" + if test.in != nil { + id = test.in.ID + } + mockRestResponseBuilderFormat(t, "GET", test.status, sp.TemplatesPathFormat+"/"+id, test.json) + + _, err := testClient.TemplateGet(test.in, test.draft) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("TemplateGet[%d] => err %v want %v", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("TemplateGet[%d] => err %v want %v", idx, err, test.err) + } else if test.out != nil { + if !reflect.DeepEqual(test.out, test.in) { + t.Errorf("TemplateGet[%d] => template got/want:\n%q\n%q", idx, test.in, test.out) + } + } + } +} + +func TestTemplateUpdate(t *testing.T) { + for idx, test := range []struct { + in *sp.Template + pub bool + err error + status int + json string + }{ + {nil, false, errors.New("Update called with nil Template"), 0, ""}, + {&sp.Template{ID: ""}, false, errors.New("Update called with blank id"), 0, ""}, + {&sp.Template{ID: "id", Content: sp.Content{}}, false, errors.New("Template requires a non-empty Content.Subject"), 0, ""}, + {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, false, errors.New("parsing api response: unexpected end of JSON input"), 0, `{ "errors": [ { "message": "truncated json" }`}, + + {&sp.Template{ID: "id", Content: sp.Content{Subject: "s{{", HTML: "h", From: "f"}}, false, + sp.SPErrors([]sp.SPError{{ + Message: "substitution language syntax error in template content", + Description: "Error while compiling header Subject: substitution statement missing ending '}}'", + Code: "3000", + Part: "Header:Subject", + }}), + 422, `{ "errors": [ { "message": "substitution language syntax error in template content", "description": "Error while compiling header Subject: substitution statement missing ending '}}'", "code": "3000", "part": "Header:Subject" } ] }`}, + + {&sp.Template{ID: "id", Content: sp.Content{Subject: "s", HTML: "h", From: "f"}}, false, nil, 200, ""}, + } { + testSetup(t) + defer testTeardown() + + id := "" + if test.in != nil { + id = test.in.ID + } + mockRestResponseBuilderFormat(t, "PUT", test.status, sp.TemplatesPathFormat+"/"+id, test.json) + + _, err := testClient.TemplateUpdate(test.in, test.pub) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("TemplateUpdate[%d] => err %q want %q", idx, err, test.err) + } + } +} + +func TestTemplates(t *testing.T) { + for idx, test := range []struct { + err error + status int + json string + }{ + {errors.New("parsing api response: unexpected end of JSON input"), 0, `{ "errors": [ { "message": "truncated json" }`}, + {errors.New("[{\"message\":\"truncated json\",\"code\":\"\",\"description\":\"\"}]"), 0, `{ "errors": [ { "message": "truncated json" } ] }`}, + {nil, 200, `{ "results": [ { "description": "A test message from SparkPost.com", "id": "my-first-email", "last_update_time": "2006-01-02T15:04:05+00:00", "name": "My First Email", "published": false } ] }`}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "GET", test.status, sp.TemplatesPathFormat, test.json) + + _, _, err := testClient.Templates() + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("Templates[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("Templates[%d] => err %q want %q", idx, err, test.err) + } + } +} + +func TestTemplateDelete(t *testing.T) { + for idx, test := range []struct { + id string + err error + status int + json string + }{ + {"", errors.New("Delete called with blank id"), 0, ""}, + {"nope", errors.New(`[{"message":"Resource could not be found","code":"","description":""}]`), 404, `{ "errors": [ { "message": "Resource could not be found" } ] }`}, + {"id", nil, 200, "{}"}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "DELETE", test.status, sp.TemplatesPathFormat+"/"+test.id, test.json) + + _, err := testClient.TemplateDelete(test.id) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("TemplateDelete[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("TemplateDelete[%d] => err %q want %q", idx, err, test.err) + } + } +} + +func TestTemplatePreview(t *testing.T) { + for idx, test := range []struct { + id string + opts *sp.PreviewOptions + err error + status int + json string + }{ + {"", nil, errors.New("Preview called with blank id"), 200, ""}, + {"id", &sp.PreviewOptions{map[string]interface{}{ + "func": func() { return }}, + }, errors.New("json: unsupported type: func()"), 200, ""}, + {"id", nil, errors.New("parsing api response: unexpected end of JSON input"), 200, "{"}, + {"nope", nil, errors.New(`[{"message":"Resource could not be found","code":"","description":""}]`), 404, `{ "errors": [ { "message": "Resource could not be found" } ] }`}, + + {"id", nil, nil, 200, ""}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "POST", test.status, sp.TemplatesPathFormat+"/"+test.id+"/preview", test.json) + + _, err := testClient.TemplatePreview(test.id, test.opts) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("TemplatePreview[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("TemplatePreview[%d] => err %q want %q", idx, err, test.err) + } } - fmt.Printf("Deleted Template with id=%s\n", id) } diff --git a/test/event-docs.json b/test/event-docs.json new file mode 100644 index 0000000..cf02b82 --- /dev/null +++ b/test/event-docs.json @@ -0,0 +1,1930 @@ +{ + "results": { + "message_event": { + "events": { + "bounce": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "bounce" + }, + "bounce_class": { + "description": "Classification code for a given message (see [Bounce Classification Codes](https://support.sparkpost.com/customer/portal/articles/1929896))", + "sampleValue": "1" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "device_token": { + "description": "Token of the device / application targeted by this PUSH notification message. Applies only when delv_method is gcm or apn.", + "sampleValue": "45c19189783f867973f6e6a5cca60061ffe4fa77c547150563a1192fa9847f8a" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_address": { + "description": "IP address of the host to which SparkPost delivered this message; in engagement events, the IP address of the host where the HTTP request originated", + "sampleValue": "127.0.0.1" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "msg_size": { + "description": "Message's size in bytes", + "sampleValue": "1337" + }, + "num_retries": { + "description": "Number of failed attempts before this message was successfully delivered; when the first attempt succeeds, zero", + "sampleValue": "2" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "sms_coding": { + "description": "Data encoding used in the SMS message", + "sampleValue": "ASCII" + }, + "sms_dst": { + "description": "SMS destination address", + "sampleValue": "7876712656" + }, + "sms_dst_npi": { + "description": "Destination numbering plan identification", + "sampleValue": "E164" + }, + "sms_dst_ton": { + "description": "Type of number for the destination address", + "sampleValue": "International" + }, + "sms_src": { + "description": "SMS source address", + "sampleValue": "1234" + }, + "sms_src_npi": { + "description": "Source numbering plan identification", + "sampleValue": "E164" + }, + "sms_src_ton": { + "description": "Type of number for the source address", + "sampleValue": "Unknown" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "subject": { + "description": "Subject line from the email header", + "sampleValue": "Summer deals are here!" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + } + }, + "description": "Remote MTA has permanently rejected a message.", + "display_name": "Bounce" + }, + "delivery": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "delivery" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "device_token": { + "description": "Token of the device / application targeted by this PUSH notification message. Applies only when delv_method is gcm or apn.", + "sampleValue": "45c19189783f867973f6e6a5cca60061ffe4fa77c547150563a1192fa9847f8a" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_address": { + "description": "IP address of the host to which SparkPost delivered this message; in engagement events, the IP address of the host where the HTTP request originated", + "sampleValue": "127.0.0.1" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "msg_size": { + "description": "Message's size in bytes", + "sampleValue": "1337" + }, + "num_retries": { + "description": "Number of failed attempts before this message was successfully delivered; when the first attempt succeeds, zero", + "sampleValue": "2" + }, + "queue_time": { + "description": "Delay, expressed in milliseconds, between this message's injection into SparkPost and its delivery to the receiving domain; that is, the length of time this message spent in the outgoing queue", + "sampleValue": "12" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "subject": { + "description": "Subject line from the email header", + "sampleValue": "Summer deals are here!" + }, + "sms_coding": { + "description": "Data encoding used in the SMS message", + "sampleValue": "ASCII" + }, + "sms_dst": { + "description": "SMS destination address", + "sampleValue": "7876712656" + }, + "sms_dst_npi": { + "description": "Destination numbering plan identification", + "sampleValue": "E164" + }, + "sms_dst_ton": { + "description": "Type of number for the destination address", + "sampleValue": "International" + }, + "sms_remoteids": { + "description": "The message ID(s) in the response, assigned by the remote server/SMSC", + "sampleValue": [ + "0000", + "0001", + "0002", + "0003", + "0004" + ] + }, + "sms_segments": { + "description": "Segment number of the log line for large messages sent through multiple SMSes", + "sampleValue": 5 + }, + "sms_src": { + "description": "SMS source address", + "sampleValue": "1234" + }, + "sms_src_npi": { + "description": "Source numbering plan identification", + "sampleValue": "E164" + }, + "sms_src_ton": { + "description": "Type of number for the source address", + "sampleValue": "Unknown" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + } + }, + "description": "Remote MTA acknowledged receipt of a message.", + "display_name": "Delivery" + }, + "injection": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "injection" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "msg_size": { + "description": "Message's size in bytes", + "sampleValue": "1337" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "sms_coding": { + "description": "Data encoding used in the SMS message", + "sampleValue": "ASCII" + }, + "sms_dst": { + "description": "SMS destination address", + "sampleValue": "7876712656" + }, + "sms_dst_npi": { + "description": "Destination numbering plan identification", + "sampleValue": "E164" + }, + "sms_dst_ton": { + "description": "Type of number for the destination address", + "sampleValue": "International" + }, + "sms_segments": { + "description": "Segment number of the log line for large messages sent through multiple SMSes", + "sampleValue": 5 + }, + "sms_src": { + "description": "SMS source address", + "sampleValue": "1234" + }, + "sms_src_npi": { + "description": "Source numbering plan identification", + "sampleValue": "E164" + }, + "sms_src_ton": { + "description": "Type of number for the source address", + "sampleValue": "Unknown" + }, + "sms_text": { + "description": "The SMS message payload (in the character set specified in sms_coding)", + "sampleValue": "lol" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "subject": { + "description": "Subject line from the email header", + "sampleValue": "Summer deals are here!" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + } + }, + "description": "Message is received by or injected into SparkPost.", + "display_name": "Injection" + }, + "sms_status": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "sms_status" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "dr_latency": { + "description": "Delivery report latency; interval between message submission and receipt", + "sampleValue": "0.02" + }, + "ip_address": { + "description": "IP address of the host to which SparkPost delivered this message; in engagement events, the IP address of the host where the HTTP request originated", + "sampleValue": "127.0.0.1" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "sms_dst": { + "description": "SMS destination address", + "sampleValue": "7876712656" + }, + "sms_dst_npi": { + "description": "Destination numbering plan identification", + "sampleValue": "E164" + }, + "sms_dst_ton": { + "description": "Type of number for the destination address", + "sampleValue": "International" + }, + "sms_remoteids": { + "description": "The message ID(s) in the response, assigned by the remote server/SMSC", + "sampleValue": [ + "0000", + "0001", + "0002", + "0003", + "0004" + ] + }, + "sms_src": { + "description": "SMS source address", + "sampleValue": "1234" + }, + "sms_src_npi": { + "description": "Source numbering plan identification", + "sampleValue": "E164" + }, + "sms_src_ton": { + "description": "Type of number for the source address", + "sampleValue": "Unknown" + }, + "sms_text": { + "description": "The SMS message payload (in the character set specified in sms_coding)", + "sampleValue": "lol" + }, + "stat_type": { + "description": "Status type in an SMS status event", + "sampleValue": "SMSC Delivery" + }, + "stat_state": { + "description": "Status value in an SMS status event", + "sampleValue": "Delivered" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + } + }, + "description": "SMPP/SMS message produced a status log output", + "display_name": "SMS Status" + }, + "spam_complaint": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "spam_complaint" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "fbtype": { + "description": "Type of spam report entered against this message (see [RFC 5965 § 7.3](http://tools.ietf.org/html/rfc5965#section-7.3))", + "sampleValue": "abuse" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "report_by": { + "description": "Address of the entity reporting this message as spam", + "sampleValue": "server.email.com" + }, + "report_to": { + "description": "Address to which this spam report is to be delivered", + "sampleValue": "abuse.example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "subject": { + "description": "Subject line from the email header", + "sampleValue": "Summer deals are here!" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "user_str": { + "description": "If configured, additional message log information, in user-defined format", + "sampleValue": "Additional Example Information" + } + }, + "description": "Message was classified as spam by the recipient.", + "display_name": "Spam Complaint" + }, + "out_of_band": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "out_of_band" + }, + "bounce_class": { + "description": "Classification code for a given message (see [Bounce Classification Codes](https://support.sparkpost.com/customer/portal/articles/1929896))", + "sampleValue": "1" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "device_token": { + "description": "Token of the device / application targeted by this PUSH notification message. Applies only when delv_method is gcm or apn.", + "sampleValue": "45c19189783f867973f6e6a5cca60061ffe4fa77c547150563a1192fa9847f8a" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + } + }, + "description": "Remote MTA initially reported acceptance of a message, but it has since asynchronously reported that the message was not delivered.", + "display_name": "Out of Band" + }, + "policy_rejection": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "policy_rejection" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "remote_addr": { + "description": "IP address of the host from which SparkPost received this message", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "bounce_class": { + "description": "Classification code for a given message (see [Bounce Classification Codes](https://support.sparkpost.com/customer/portal/articles/1929896))", + "sampleValue": "1" + } + }, + "description": "Due to policy, SparkPost rejected a message or failed to generate a message.", + "display_name": "Policy Rejection" + }, + "delay": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "delay" + }, + "bounce_class": { + "description": "Classification code for a given message (see [Bounce Classification Codes](https://support.sparkpost.com/customer/portal/articles/1929896))", + "sampleValue": "1" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "device_token": { + "description": "Token of the device / application targeted by this PUSH notification message. Applies only when delv_method is gcm or apn.", + "sampleValue": "45c19189783f867973f6e6a5cca60061ffe4fa77c547150563a1192fa9847f8a" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_address": { + "description": "IP address of the host to which SparkPost delivered this message; in engagement events, the IP address of the host where the HTTP request originated", + "sampleValue": "127.0.0.1" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "msg_size": { + "description": "Message's size in bytes", + "sampleValue": "1337" + }, + "num_retries": { + "description": "Number of failed attempts before this message was successfully delivered; when the first attempt succeeds, zero", + "sampleValue": "2" + }, + "queue_time": { + "description": "Delay, expressed in milliseconds, between this message's injection into SparkPost and its delivery to the receiving domain; that is, the length of time this message spent in the outgoing queue", + "sampleValue": "12" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "sms_coding": { + "description": "Data encoding used in the SMS message", + "sampleValue": "ASCII" + }, + "sms_dst": { + "description": "SMS destination address", + "sampleValue": "7876712656" + }, + "sms_dst_npi": { + "description": "Destination numbering plan identification", + "sampleValue": "E164" + }, + "sms_dst_ton": { + "description": "Type of number for the destination address", + "sampleValue": "International" + }, + "sms_src": { + "description": "SMS source address", + "sampleValue": "1234" + }, + "sms_src_npi": { + "description": "Source numbering plan identification", + "sampleValue": "E164" + }, + "sms_src_ton": { + "description": "Type of number for the source address", + "sampleValue": "Unknown" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "subject": { + "description": "Subject line from the email header", + "sampleValue": "Summer deals are here!" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + } + }, + "description": "Remote MTA has temporarily rejected a message.", + "display_name": "Delay" + } + }, + "description": "Message events describe the life cycle of a message including injection, delivery, and disposition.", + "display_name": "Message Events" + }, + "track_event": { + "events": { + "click": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "click" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "ip_address": { + "description": "IP address of the host to which SparkPost delivered this message; in engagement events, the IP address of the host where the HTTP request originated", + "sampleValue": "127.0.0.1" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "target_link_name": { + "description": "Name of the link for which a click event was generated", + "sampleValue": "Example Link Name" + }, + "target_link_url": { + "description": "URL of the link for which a click event was generated", + "sampleValue": "http://example.com" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "user_agent": { + "description": "Value of the browser's User-Agent header", + "sampleValue": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36" + }, + "geo_ip": { + "description": "Geographic location based on the IP address, including latitude, longitude, city, country, and region", + "sampleValue": { + "country": "US", + "region": "MD", + "city": "Columbia", + "latitude": 39.1749, + "longitude": -76.8375 + } + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + } + }, + "description": "Recipient clicked a tracked link in a message, thus prompting a redirect through the SparkPost click-tracking server to the link's destination.", + "display_name": "Click" + }, + "open": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "open" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "ip_address": { + "description": "IP address of the host to which SparkPost delivered this message; in engagement events, the IP address of the host where the HTTP request originated", + "sampleValue": "127.0.0.1" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "user_agent": { + "description": "Value of the browser's User-Agent header", + "sampleValue": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36" + }, + "geo_ip": { + "description": "Geographic location based on the IP address, including latitude, longitude, city, country, and region", + "sampleValue": { + "country": "US", + "region": "MD", + "city": "Columbia", + "latitude": 39.1749, + "longitude": -76.8375 + } + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + } + }, + "description": "Recipient opened a message in a mail client, thus rendering a tracking pixel.", + "display_name": "Open" + } + }, + "description": "Engagement events describe the behavior of a recipient with respect to the message sent.", + "display_name": "Engagement Events" + }, + "gen_event": { + "events": { + "generation_failure": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "generation_failure" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_subs": { + "description": "Substitutions applied to the template to construct this message", + "sampleValue": { + "country": "US", + "gender": "Female" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + } + }, + "description": "Message generation failed for an intended recipient.", + "display_name": "Generation Failure" + }, + "generation_rejection": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "generation_rejection" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_subs": { + "description": "Substitutions applied to the template to construct this message", + "sampleValue": { + "country": "US", + "gender": "Female" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "subject": { + "description": "Subject line from the email header", + "sampleValue": "Summer deals are here!" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "bounce_class": { + "description": "Classification code for a given message (see [Bounce Classification Codes](https://support.sparkpost.com/customer/portal/articles/1929896))", + "sampleValue": "1" + } + }, + "description": "SparkPost rejected message generation due to policy.", + "display_name": "Generation Rejection" + } + }, + "description": "Generation events provide insight into message generation failures or rejections.", + "display_name": "Generation Events" + }, + "unsubscribe_event": { + "events": { + "list_unsubscribe": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "list_unsubscribe" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "mailfrom": { + "description": "Envelope mailfrom of the original email", + "sampleValue": "recipient@example.com" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + } + }, + "description": "User clicked the 'unsubscribe' button on an email client.", + "display_name": "List Unsubscribe" + }, + "link_unsubscribe": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "link_unsubscribe" + }, + "campaign_id": { + "description": "Campaign of which this message was a part", + "sampleValue": "Example Campaign Name" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "friendly_from": { + "description": "Friendly sender or \"From\" header in the original email", + "sampleValue": "sender@example.com" + }, + "mailfrom": { + "description": "Envelope mailfrom of the original email", + "sampleValue": "recipient@example.com" + }, + "message_id": { + "description": "SparkPost-cluster-wide unique identifier for this message", + "sampleValue": "000443ee14578172be22" + }, + "rcpt_meta": { + "description": "Metadata describing the message recipient", + "sampleValue": { + "customKey": "customValue" + } + }, + "rcpt_tags": { + "description": "Tags applied to the message which generated this event", + "sampleValue": [ + "male", + "US" + ] + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "rcpt_type": { + "description": "Indicates that a recipient address appeared in the Cc or Bcc header or the archive JSON array", + "sampleValue": "cc" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "template_id": { + "description": "Slug of the template used to construct this message", + "sampleValue": "templ-1234" + }, + "template_version": { + "description": "Version of the template used to construct this message", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "transmission_id": { + "description": "Transmission which originated this message", + "sampleValue": "65832150921904138" + }, + "user_agent": { + "description": "Value of the browser's User-Agent header", + "sampleValue": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + } + }, + "description": "User clicked a hyperlink in a received email.", + "display_name": "Link Unsubscribe" + } + }, + "description": "Unsubscribe events provide insight into the action the user performed to become unsubscribed.", + "display_name": "Unsubscribe Events" + }, + "relay_event": { + "events": { + "relay_injection": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "relay_injection" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "msg_size": { + "description": "Message's size in bytes", + "sampleValue": "1337" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + } + }, + "description": "Relayed message is received by or injected into SparkPost.", + "display_name": "Relay Injection" + }, + "relay_rejection": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "relay_rejection" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "rcpt_to": { + "description": "Lowercase version of recipient address used on this message's SMTP envelope", + "sampleValue": "recipient@example.com" + }, + "raw_rcpt_to": { + "description": "Actual recipient address used on this message's SMTP envelope", + "sampleValue": "Recipient@example.com" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "remote_addr": { + "description": "IP address of the host from which SparkPost received this message", + "sampleValue": "127.0.0.1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "bounce_class": { + "description": "Classification code for a given message (see [Bounce Classification Codes](https://support.sparkpost.com/customer/portal/articles/1929896))", + "sampleValue": "1" + } + }, + "description": "SparkPost rejected a relayed message or failed to generate a relayed message.", + "display_name": "Relay Rejection" + }, + "relay_delivery": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "relay_delivery" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "queue_time": { + "description": "Delay, expressed in milliseconds, between this message's injection into SparkPost and its delivery to the receiving domain; that is, the length of time this message spent in the outgoing queue", + "sampleValue": "12" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "num_retries": { + "description": "Number of failed attempts before this message was successfully delivered; when the first attempt succeeds, zero", + "sampleValue": "2" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + } + }, + "description": "Remote HTTP Endpoint acknowledged receipt of a relayed message.", + "display_name": "Relay Delivery" + }, + "relay_tempfail": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "relay_tempfail" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "queue_time": { + "description": "Delay, expressed in milliseconds, between this message's injection into SparkPost and its delivery to the receiving domain; that is, the length of time this message spent in the outgoing queue", + "sampleValue": "12" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "num_retries": { + "description": "Number of failed attempts before this message was successfully delivered; when the first attempt succeeds, zero", + "sampleValue": "2" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + } + }, + "description": "Remote HTTP Endpoint has failed to accept a relayed message.", + "display_name": "Relay Temporary Failure" + }, + "relay_permfail": { + "event": { + "type": { + "description": "Type of event this record describes", + "sampleValue": "relay_permfail" + }, + "event_id": { + "description": "Unique event identifier", + "sampleValue": "92356927693813856" + }, + "routing_domain": { + "description": "Domain receiving this message", + "sampleValue": "example.com" + }, + "msg_from": { + "description": "Sender address used on this message's SMTP envelope", + "sampleValue": "sender@example.com" + }, + "ip_pool": { + "description": "IP pool through which this message was sent", + "sampleValue": "Example-Ip-Pool" + }, + "queue_time": { + "description": "Delay, expressed in milliseconds, between this message's injection into SparkPost and its delivery to the receiving domain; that is, the length of time this message spent in the outgoing queue", + "sampleValue": "12" + }, + "subaccount_id": { + "description": "Unique subaccount identifier.", + "sampleValue": "101" + }, + "customer_id": { + "description": "SparkPost-customer identifier through which this message was sent", + "sampleValue": "1" + }, + "timestamp": { + "description": "Event date and time, in Unix timestamp format (integer seconds since 00:00:00 GMT 1970-01-01)", + "sampleValue": "1454442600" + }, + "num_retries": { + "description": "Number of failed attempts before this message was successfully delivered; when the first attempt succeeds, zero", + "sampleValue": "2" + }, + "delv_method": { + "description": "Protocol by which SparkPost delivered this message", + "sampleValue": "esmtp" + }, + "raw_reason": { + "description": "Unmodified, exact response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (17.99.99.99) is in black list" + }, + "reason": { + "description": "Canonicalized text of the response returned by the remote server due to a failed delivery attempt", + "sampleValue": "MAIL REFUSED - IP (a.b.c.d) is in black list" + }, + "sending_ip": { + "description": "IP address through which this message was sent", + "sampleValue": "127.0.0.1" + }, + "error_code": { + "description": "Error code by which the remote server described a failed delivery attempt", + "sampleValue": "554" + } + }, + "description": "Relayed message has reached the maximum retry threshold and will be removed from the system.", + "display_name": "Relay Permanent Failure" + } + }, + "description": "Relay events describe the life cycle of an inbound message including injection, delivery, and disposition.", + "display_name": "Relay Events" + } + } +} diff --git a/test/json/recipient_lists_200.json b/test/json/recipient_lists_200.json new file mode 100644 index 0000000..d90559b --- /dev/null +++ b/test/json/recipient_lists_200.json @@ -0,0 +1,24 @@ +{ + "results": [ + { + "id": "unique_id_4_graduate_students_list", + "name": "graduate_students", + "description": "An email list of graduate students at UMBC", + "attributes": { + "internal_id": 112, + "list_group_id": 12321 + }, + "total_accepted_recipients": 3 + }, + { + "id": "unique_id_4_undergraduates", + "name": "undergraduate_students", + "description": "An email list of undergraduate students at UMBC", + "attributes": { + "internal_id": 111, + "list_group_id": 11321 + }, + "total_accepted_recipients": 8 + } + ] +} \ No newline at end of file diff --git a/test/json/subaccount_200.json b/test/json/subaccount_200.json new file mode 100644 index 0000000..342c8e5 --- /dev/null +++ b/test/json/subaccount_200.json @@ -0,0 +1,9 @@ +{ + "results": { + "id": 123, + "name": "Joe's Garage", + "status": "active", + "compliance_status": "active", + "ip_pool": "assigned_ip_pool" + } +} diff --git a/test/json/subaccount_create_200.json b/test/json/subaccount_create_200.json new file mode 100644 index 0000000..76cfac0 --- /dev/null +++ b/test/json/subaccount_create_200.json @@ -0,0 +1,8 @@ +{ + "results": { + "subaccount_id": 888, + "key": "cf806c8c472562ab98ad5acac1d1b06cbd1fb438", + "label": "API Key for Sparkle Ponies Subaccount", + "short_key": "cf80" + } +} diff --git a/test/json/subaccounts_200.json b/test/json/subaccounts_200.json new file mode 100644 index 0000000..6381720 --- /dev/null +++ b/test/json/subaccounts_200.json @@ -0,0 +1,23 @@ +{ + "results": [ + { + "id": 123, + "name": "Joe's Garage", + "status": "active", + "ip_pool": "my_ip_pool", + "compliance_status": "active" + }, + { + "id": 456, + "name": "SharkPost", + "status": "active", + "compliance_status": "active" + }, + { + "id": 789, + "name": "Dev Avocado", + "status": "suspended", + "compliance_status": "active" + } + ] +} diff --git a/test/json/suppression_combined.json b/test/json/suppression_combined.json new file mode 100644 index 0000000..f2df11b --- /dev/null +++ b/test/json/suppression_combined.json @@ -0,0 +1,13 @@ +{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ] +} \ No newline at end of file diff --git a/test/json/suppression_cursor.json b/test/json/suppression_cursor.json new file mode 100644 index 0000000..0e79b7d --- /dev/null +++ b/test/json/suppression_cursor.json @@ -0,0 +1,31 @@ +{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ], + "links": [ + { + "href": "The_HREF_first", + "rel": "first" + }, + { + "href": "The_HREF_next", + "rel": "next" + },{ + "href": "The_HREF_previous", + "rel": "previous" + }, + { + "href": "The_HREF_last", + "rel": "last" + } + ], + "total_count": 44 +} \ No newline at end of file diff --git a/test/json/suppression_delete_no_email.json b/test/json/suppression_delete_no_email.json new file mode 100644 index 0000000..160ba98 --- /dev/null +++ b/test/json/suppression_delete_no_email.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "message": "Resource could not be found" + } + ] +} diff --git a/test/json/suppression_entry_simple.json b/test/json/suppression_entry_simple.json new file mode 100644 index 0000000..6c60713 --- /dev/null +++ b/test/json/suppression_entry_simple.json @@ -0,0 +1,9 @@ + { + "recipient": "john.doe@domain.com", + "description": "entry description", + "source": "manually created", + "type": "non_transactional", + "created": "2016-05-02T16:29:56+00:00", + "updated": "2016-05-02T17:20:50+00:00", + "non_transactional": true +} \ No newline at end of file diff --git a/test/json/suppression_entry_simple_request.json b/test/json/suppression_entry_simple_request.json new file mode 100644 index 0000000..ea570a1 --- /dev/null +++ b/test/json/suppression_entry_simple_request.json @@ -0,0 +1,9 @@ +{ + "recipients": [ + { + "recipient": "john.doe@domain.com", + "type": "non_transactional", + "description": "entry description" + } + ] +} diff --git a/test/json/suppression_not_found_error.json b/test/json/suppression_not_found_error.json new file mode 100644 index 0000000..61951a1 --- /dev/null +++ b/test/json/suppression_not_found_error.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "message": "Recipient could not be found" + } + ] +} \ No newline at end of file diff --git a/test/json/suppression_page1.json b/test/json/suppression_page1.json new file mode 100644 index 0000000..b87c9b5 --- /dev/null +++ b/test/json/suppression_page1.json @@ -0,0 +1,28 @@ +{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ], + "links": [ + { + "href": "/test/json/suppression_page1.json", + "rel": "first" + }, + { + "href": "/test/json/suppression_page2.json", + "rel": "next" + }, + { + "href": "/test/json/suppression_pageLast.json", + "rel": "last" + } + ], + "total_count": 3 +} diff --git a/test/json/suppression_page2.json b/test/json/suppression_page2.json new file mode 100644 index 0000000..753aac7 --- /dev/null +++ b/test/json/suppression_page2.json @@ -0,0 +1,32 @@ +{ + "results": [ + { + "recipient": "rcpt_2@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ], + "links": [ + { + "href": "/test/json/suppression_page1.json", + "rel": "first" + }, + { + "href": "/test/json/suppression_pageLast.json", + "rel": "next" + }, + { + "href": "/test/json/suppression_page1.json", + "rel": "previous" + }, + { + "href": "/test/json/suppression_pageLast.json", + "rel": "last" + } + ], + "total_count": 3 +} diff --git a/test/json/suppression_pageLast.json b/test/json/suppression_pageLast.json new file mode 100644 index 0000000..591df6e --- /dev/null +++ b/test/json/suppression_pageLast.json @@ -0,0 +1,28 @@ +{ + "results": [ + { + "recipient": "rcpt_3@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ], + "links": [ + { + "href": "/test/json/suppression_page1.json", + "rel": "first" + }, + { + "href": "/test/json/suppression_page2.json", + "rel": "previous" + }, + { + "href": "/test/json/suppression_pageLast.json", + "rel": "last" + } + ], + "total_count": 3 +} diff --git a/test/json/suppression_retrieve.json b/test/json/suppression_retrieve.json new file mode 100644 index 0000000..3b63fd2 --- /dev/null +++ b/test/json/suppression_retrieve.json @@ -0,0 +1,15 @@ +{ + "results": [ + { + "recipient": "john.doe@domain.com", + "description": "entry description", + "source": "manually created", + "type": "non_transactional", + "created": "2016-05-02T16:29:56+00:00", + "updated": "2016-05-02T17:20:50+00:00", + "non_transactional": true + } + ], + "links": [], + "total_count": 1 +} \ No newline at end of file diff --git a/test/json/suppression_seperate_lists.json b/test/json/suppression_seperate_lists.json new file mode 100644 index 0000000..f9f78be --- /dev/null +++ b/test/json/suppression_seperate_lists.json @@ -0,0 +1,24 @@ +{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "non_transactional": true, + "source": "Manually Added", + "type": "non_transactional", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + }, + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "source": "Bounce Rule", + "type": "transactional", + "description": "550: 550-5.1.1 Invalid Recipient", + "created": "2015-10-15T12:00:00+00:00", + "updated": "2015-10-15T12:00:00+00:00" + } + ], + "links": [], + "total_count": 2 +} \ No newline at end of file diff --git a/test/json/suppression_single_page.json b/test/json/suppression_single_page.json new file mode 100644 index 0000000..8ee9c21 --- /dev/null +++ b/test/json/suppression_single_page.json @@ -0,0 +1,17 @@ +{ + "results": [ + { + "recipient": "rcpt_1@example.com", + "transactional": true, + "non_transactional": true, + "source": "Manually Added", + "description": "User requested to not receive any non-transactional emails.", + "created": "2016-01-01T12:00:00+00:00", + "updated": "2016-01-01T12:00:00+00:00" + } + ], + "links": [ + + ], + "total_count": 1 +} \ No newline at end of file diff --git a/test/json/webhook_detail_200.json b/test/json/webhook_detail_200.json new file mode 100644 index 0000000..49faedd --- /dev/null +++ b/test/json/webhook_detail_200.json @@ -0,0 +1,42 @@ +{ + "results": { + "name": "Example webhook", + "target": "http://client.example.com/example-webhook", + "events": [ + "delivery", + "injection", + "open", + "click" + ], + "auth_type": "oauth2", + "auth_request_details": { + "url": "https://oauth.myurl.com/tokens", + "body": { + "client_id": "", + "client_secret": "" + } + }, + "auth_credentials": { + "access_token": "", + "expires_in": 3600 + }, + "auth_token": "", + "active": true, + "links": [ + { + "href": "http://www.messagesystems-api-url.com/api/v1/webhooks/12affc24-f183-11e3-9234-3c15c2c818c2/validate", + "rel": "urn.msys.webhooks.validate", + "method": [ + "POST" + ] + }, + { + "href": "http://www.messagesystems-api-url.com/api/v1/webhooks/12affc24-f183-11e3-9234-3c15c2c818c2/batch-status", + "rel": "urn.msys.webhooks.batches", + "method": [ + "GET" + ] + } + ] + } +} diff --git a/test/json/webhook_status_200.json b/test/json/webhook_status_200.json new file mode 100644 index 0000000..528ed1a --- /dev/null +++ b/test/json/webhook_status_200.json @@ -0,0 +1,17 @@ +{ + "results": [ + { + "batch_id": "032d330540298f54f0e8bcc1373f3cfd", + "ts": "2014-07-30T21:38:08.000Z", + "attempts": 7, + "response_code": "200" + }, + { + "batch_id": "13c6764994a8f6b4e29906d5712ca7d", + "ts": "2014-07-30T20:38:08.000Z", + "attempts": 2, + "failure_code": "400", + "response_code": "400" + } + ] +} diff --git a/test/json/webhooks_200.json b/test/json/webhooks_200.json new file mode 100644 index 0000000..e1354f6 --- /dev/null +++ b/test/json/webhooks_200.json @@ -0,0 +1,96 @@ +{ + "results": [ + { + "id": "a2b83490-10df-11e4-b670-c1ffa86371ff", + "name": "Some webhook", + "target": "http://client.example.com/some-webhook", + "events": [ + "delivery", + "injection", + "open", + "click" + ], + "auth_type": "basic", + "auth_request_details": {}, + "auth_credentials": { + "username": "basicuser", + "password": "somepass" + }, + "auth_token": "", + "last_successful": "2014-08-01 16:09:15", + "last_failure": "2014-06-01 15:15:45", + "active": true, + "links": [ + { + "href": "http://www.messagesystems-api-url.com/api/v1/webhooks/a2b83490-10df-11e4-b670-c1ffa86371ff", + "rel": "urn.msys.webhooks.webhook", + "method": [ + "GET", + "PUT" + ] + } + ] + }, + { + "id": "12affc24-f183-11e3-9234-3c15c2c818c2", + "name": "Example webhook", + "target": "http://client.example.com/example-webhook", + "events": [ + "delivery", + "injection", + "open", + "click" + ], + "auth_type": "oauth2", + "auth_request_details": { + "url": "https://oauth.myurl.com/tokens", + "body": { + "client_id": "", + "client_secret": "" + } + }, + "auth_credentials": { + "access_token": "", + "expires_in": 3600 + }, + "auth_token": "", + "last_successful": "2014-07-01 16:09:15", + "last_failure": "2014-08-01 15:15:45", + "active": true, + "links": [ + { + "href": "http://www.messagesystems-api-url.com/api/v1/webhooks/12affc24-f183-11e3-9234-3c15c2c818c2", + "rel": "urn.msys.webhooks.webhook", + "method": [ + "GET", + "PUT" + ] + } + ] + }, + { + "id": "123456-abcd-efgh-7890-123445566778", + "name": "Another webhook", + "target": "http://client.example.com/another-example", + "events": [ + "generation_rejection", + "generation_failure" + ], + "auth_type": "none", + "auth_request_details": {}, + "auth_credentials": {}, + "auth_token": "5ebe2294ecd0e0f08eab7690d2a6ee69", + "active": true, + "links": [ + { + "href": "http://www.messagesystems-api-url.com/api/v1/webhooks/123456-abcd-efgh-7890-123445566778", + "rel": "urn.msys.webhooks.webhook", + "method": [ + "GET", + "PUT" + ] + } + ] + } + ] +} diff --git a/transmissions.go b/transmissions.go index b58607c..fead186 100644 --- a/transmissions.go +++ b/transmissions.go @@ -1,6 +1,7 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" "net/url" @@ -9,8 +10,8 @@ import ( "time" ) -// https://www.sparkpost.com/api#/reference/transmissions -var transmissionsPathFormat = "/api/v%d/transmissions" +// TransmissionsPathFormat https://www.sparkpost.com/api#/reference/transmissions +var TransmissionsPathFormat = "/api/v%d/transmissions" // Transmission is the JSON structure accepted by and returned from the SparkPost Transmissions API. type Transmission struct { @@ -31,8 +32,10 @@ type Transmission struct { NumInvalidRecipients *int `json:"num_invalid_recipients,omitempty"` } +// RFC3339 formats time.Time values as expected by the SparkPost API type RFC3339 time.Time +// MarshalJSON applies RFC3339 formatting func (r *RFC3339) MarshalJSON() ([]byte, error) { if r == nil { return json.Marshal(nil) @@ -40,15 +43,17 @@ func (r *RFC3339) MarshalJSON() ([]byte, error) { return json.Marshal(time.Time(*r).Format(time.RFC3339)) } -// Options specifies settings to apply to this Transmission. +// TxOptions specifies settings to apply to this Transmission. // If not specified, and present in TmplOptions, those values will be used. type TxOptions struct { TmplOptions StartTime *RFC3339 `json:"start_time,omitempty"` - Sandbox string `json:"sandbox,omitempty"` - SkipSuppression string `json:"skip_suppression,omitempty"` - InlineCSS bool `json:"inline_css,omitempty"` + Transactional *bool `json:"transactional,omitempty"` + Sandbox *bool `json:"sandbox,omitempty"` + SkipSuppression *bool `json:"skip_suppression,omitempty"` + IPPool string `json:"ip_pool,omitempty"` + InlineCSS *bool `json:"inline_css,omitempty"` } // ParseRecipients asserts that Transmission.Recipients is valid. @@ -71,7 +76,7 @@ func ParseRecipients(recips interface{}) (ra *[]Recipient, err error) { return case map[string]string: - for k, _ := range rVal { + for k := range rVal { if strings.EqualFold(k, "list_id") { return } @@ -136,7 +141,7 @@ func ParseContent(content interface{}) (err error) { return fmt.Errorf("Transmission.Content objects must contain a key `template_id`") case map[string]string: - for k, _ := range rVal { + for k := range rVal { if strings.EqualFold(k, "template_id") { return nil } @@ -193,10 +198,15 @@ func (t *Transmission) Validate() error { return nil } -// Create accepts a populated Transmission object, performs basic sanity +// Send accepts a populated Transmission object, performs basic sanity // checks on it, and performs an API call against the configured endpoint. // Calling this function can cause email to be sent, if used correctly. func (c *Client) Send(t *Transmission) (id string, res *Response, err error) { + return c.SendContext(context.Background(), t) +} + +// SendContext does the same thing as Send, and in addition it accepts a context from the caller. +func (c *Client) SendContext(ctx context.Context, t *Transmission) (id string, res *Response, err error) { if t == nil { err = fmt.Errorf("Create called with nil Transmission") return @@ -212,9 +222,9 @@ func (c *Client) Send(t *Transmission) (id string, res *Response, err error) { return } - path := fmt.Sprintf(transmissionsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) u := fmt.Sprintf("%s%s", c.Config.BaseUrl, path) - res, err = c.HttpPost(u, jsonBytes) + res, err = c.HttpPost(ctx, u, jsonBytes) if err != nil { return } @@ -228,92 +238,93 @@ func (c *Client) Send(t *Transmission) (id string, res *Response, err error) { return } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var ok bool - id, ok = res.Results["id"].(string) - if !ok { - err = fmt.Errorf("Unexpected response to Transmission creation") - } - - } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Transmission", "create") - if err != nil { - return + var results map[string]interface{} + if results, ok = res.Results.(map[string]interface{}); !ok { + err = fmt.Errorf("Unexpected response to Transmission creation (results)") + } else if id, ok = results["id"].(string); !ok { + err = fmt.Errorf("Unexpected response to Transmission creation (id)") } - - err = fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) + } else { + err = res.HTTPError() } return } -// Retrieve accepts a Transmission.ID and retrieves the corresponding object. -func (c *Client) Transmission(id string) (*Transmission, *Response, error) { - if nonDigit.MatchString(id) { - return nil, nil, fmt.Errorf("id may only contain digits") +// Transmission accepts a Transmission, looks up the record using its ID, and fills out the provided object. +func (c *Client) Transmission(t *Transmission) (*Response, error) { + return c.TransmissionContext(context.Background(), t) +} + +// TransmissionContext is the same as Transmission, and it allows the caller to pass in a context. +func (c *Client) TransmissionContext(ctx context.Context, t *Transmission) (*Response, error) { + if nonDigit.MatchString(t.ID) { + return nil, fmt.Errorf("id may only contain digits") } - path := fmt.Sprintf(transmissionsPathFormat, c.Config.ApiVersion) - u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, id) - res, err := c.HttpGet(u) + path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) + u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) + res, err := c.HttpGet(ctx, u) if err != nil { - return nil, nil, err + return nil, err } if err = res.AssertJson(); err != nil { - return nil, res, err + return res, err } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var body []byte body, err = res.ReadBody() if err != nil { - return nil, res, err + return res, err } // Unwrap the returned Transmission - tmp := map[string]map[string]Transmission{} + tmp := map[string]map[string]json.RawMessage{} if err = json.Unmarshal(body, &tmp); err != nil { - return nil, res, err } else if results, ok := tmp["results"]; ok { - if tr, ok := results["transmission"]; ok { - return &tr, res, nil + if raw, ok := results["transmission"]; ok { + err = json.Unmarshal(raw, t) } else { - return nil, res, fmt.Errorf("Unexpected results structure in response") + err = fmt.Errorf("Unexpected response to Transmission (transmission)") } + } else { + err = fmt.Errorf("Unexpected response to Transmission (results)") } - return nil, res, fmt.Errorf("Unexpected response to Transmission.Retrieve") } else { err = res.ParseResponse() - if err != nil { - return nil, res, err - } - if len(res.Errors) > 0 { - err = res.PrettyError("Transmission", "retrieve") - if err != nil { - return nil, res, err - } + if err == nil { + err = res.HTTPError() } - return nil, res, fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } - return nil, res, err + return res, err } -// Delete attempts to remove the Transmission with the specified id. +// TransmissionDelete attempts to remove the Transmission with the specified id. // Only Transmissions which are scheduled for future generation may be deleted. -func (c *Client) TransmissionDelete(id string) (*Response, error) { - if id == "" { +func (c *Client) TransmissionDelete(t *Transmission) (*Response, error) { + return c.TransmissionDeleteContext(context.Background(), t) +} + +// TransmissionDeleteContext is the same as TransmissionDelete, and it allows the caller to provide a context. +func (c *Client) TransmissionDeleteContext(ctx context.Context, t *Transmission) (*Response, error) { + if t == nil { + return nil, nil + } + if t.ID == "" { return nil, fmt.Errorf("Delete called with blank id") } - if nonDigit.MatchString(id) { + if nonDigit.MatchString(t.ID) { return nil, fmt.Errorf("Transmissions.Delete: id may only contain digits") } - path := fmt.Sprintf(transmissionsPathFormat, c.Config.ApiVersion) - u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, id) - res, err := c.HttpDelete(u) + path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) + u := fmt.Sprintf("%s%s/%s", c.Config.BaseUrl, path, t.ID) + res, err := c.HttpDelete(ctx, u) if err != nil { return nil, err } @@ -326,43 +337,36 @@ func (c *Client) TransmissionDelete(id string) (*Response, error) { return res, err } - if res.HTTP.StatusCode == 200 { - return res, nil - - } else if len(res.Errors) > 0 { - // handle common errors - err = res.PrettyError("Transmission", "delete") - if err != nil { - return res, err - } - - return res, fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) - } + return res, res.HTTPError() +} - return res, nil +// Transmissions returns Transmission summary information for matching Transmissions. +// Filtering by CampaignID (t.CampaignID) and TemplateID (t.ID) is supported. +func (c *Client) Transmissions(t *Transmission) ([]Transmission, *Response, error) { + return c.TransmissionsContext(context.Background(), t) } -// List returns Transmission summary information for matching Transmissions. -// To skip filtering by campaign or template id, use a nil param. -func (c *Client) Transmissions(campaignID, templateID *string) ([]Transmission, *Response, error) { +// TransmissionsContext is the same as Transmissions, and it allows the caller to provide a context. +func (c *Client) TransmissionsContext(ctx context.Context, t *Transmission) ([]Transmission, *Response, error) { // If a query parameter is present and empty, that searches for blank IDs, as opposed // to when it is omitted entirely, which returns everything. qp := make([]string, 0, 2) - if campaignID != nil { - qp = append(qp, fmt.Sprintf("campaign_id=%s", url.QueryEscape(*campaignID))) + if t.CampaignID != "" { + qp = append(qp, fmt.Sprintf("campaign_id=%s", url.QueryEscape(t.CampaignID))) } - if templateID != nil { - qp = append(qp, fmt.Sprintf("template_id=%s", url.QueryEscape(*templateID))) + if t.ID != "" { + qp = append(qp, fmt.Sprintf("template_id=%s", url.QueryEscape(t.ID))) } qstr := "" if len(qp) > 0 { qstr = strings.Join(qp, "&") } - path := fmt.Sprintf(transmissionsPathFormat, c.Config.ApiVersion) + path := fmt.Sprintf(TransmissionsPathFormat, c.Config.ApiVersion) + // FIXME: redo this using net/url u := fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, qstr) - res, err := c.HttpGet(u) + res, err := c.HttpGet(ctx, u) if err != nil { return nil, nil, err } @@ -371,7 +375,7 @@ func (c *Client) Transmissions(campaignID, templateID *string) ([]Transmission, return nil, res, err } - if res.HTTP.StatusCode == 200 { + if Is2XX(res.HTTP.StatusCode) { var body []byte body, err = res.ReadBody() if err != nil { @@ -379,23 +383,18 @@ func (c *Client) Transmissions(campaignID, templateID *string) ([]Transmission, } tlist := map[string][]Transmission{} if err = json.Unmarshal(body, &tlist); err != nil { - return nil, res, err } else if list, ok := tlist["results"]; ok { return list, res, nil + } else { + err = fmt.Errorf("Unexpected response to Transmission list (results)") } - return nil, res, fmt.Errorf("Unexpected response to Transmission list") } else { err = res.ParseResponse() - if err != nil { - return nil, res, err + if err == nil { + err = res.HTTPError() } - if len(res.Errors) > 0 { - err = res.PrettyError("Transmission", "list") - if err != nil { - return nil, res, err - } - } - return nil, res, fmt.Errorf("%d: %s", res.HTTP.StatusCode, string(res.Body)) } + + return nil, res, err } diff --git a/transmissions_test.go b/transmissions_test.go index c0c20c6..74e7968 100644 --- a/transmissions_test.go +++ b/transmissions_test.go @@ -1,110 +1,193 @@ package gosparkpost_test import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" "testing" sp "github.com/SparkPost/gosparkpost" - "github.com/SparkPost/gosparkpost/test" ) -func TestTransmissions(t *testing.T) { - if true { - // Temporarily disable test so TravisCI reports build success instead of test failure. - return - } +var transmissionSuccess string = `{ + "results": { + "total_rejected_recipients": 0, + "total_accepted_recipients": 1, + "id": "11111111111111111" + } +}` - cfgMap, err := test.LoadConfig() - if err != nil { - t.Error(err) - return +func TestTransmissions_Post_Success(t *testing.T) { + testSetup(t) + defer testTeardown() + + path := fmt.Sprintf(sp.TransmissionsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(transmissionSuccess)) + }) + + testClient.Config.ApiKey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + // set some headers on the client + testClient.Headers.Add("X-Foo", "foo") + testClient.Headers.Add("X-Foo", "baz") + testClient.Headers.Add("X-Bar", "bar") + testClient.Headers.Add("X-Baz", "baz") + testClient.Headers.Del("X-Baz") + // override one of the headers using a context + header := http.Header{} + header.Add("X-Foo", "bar") + ctx := context.WithValue(context.Background(), "http.Header", header) + tx := &sp.Transmission{ + CampaignID: "Post_Success", + ReturnPath: "returnpath@example.com", + Recipients: []string{"recipient1@example.com"}, + Content: sp.Content{ + Subject: "this is a test message", + HTML: "

TEST

", + From: map[string]string{"name": "test", "email": "from@example.com"}, + }, + Metadata: map[string]interface{}{"shoe_size": 9}, } - cfg, err := sp.NewConfig(cfgMap) + // send using the client and the context + id, res, err := testClient.SendContext(ctx, tx) if err != nil { - t.Error(err) - return + testFailVerbose(t, res, "Transmission POST returned error: %v", err) } - var client sp.Client - err = client.Init(cfg) - if err != nil { - t.Error(err) - return + if id != "11111111111111111" { + testFailVerbose(t, res, "Unexpected value for id! (expected: 11111111111111111, saw: %s)", id) + } + + var reqDump string + var ok bool + if reqDump, ok = res.Verbose["http_requestdump"]; !ok { + testFailVerbose(t, res, "HTTP Request unavailable") + } + + if !strings.Contains(reqDump, "X-Foo: bar") { + testFailVerbose(t, res, "Header set on Client not sent") } + if !strings.Contains(reqDump, "X-Bar: bar") { + testFailVerbose(t, res, "Header set on Client not sent") + } + if strings.Contains(reqDump, "X-Baz: baz") { + testFailVerbose(t, res, "Header set on Client should not have been sent") + } +} + +func TestTransmissions_Delete_Headers(t *testing.T) { + testSetup(t) + defer testTeardown() + + path := fmt.Sprintf(sp.TransmissionsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path+"/42", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(http.StatusOK) + w.Write([]byte("{}")) + }) - campaignID := "msys_smoke" - tlist, res, err := client.Transmissions(&campaignID, nil) + testClient.Config.Username = "testuser" + testClient.Config.Password = "testpass" + + header := http.Header{} + header.Add("X-Foo", "bar") + ctx := context.WithValue(context.Background(), "http.Header", header) + tx := &sp.Transmission{ID: "42"} + res, err := testClient.TransmissionDeleteContext(ctx, tx) if err != nil { - t.Error(err) - return + testFailVerbose(t, res, "Transmission DELETE failed") } - t.Errorf("List: %d, %d entries", res.HTTP.StatusCode, len(tlist)) - for _, tr := range tlist { - t.Errorf("%s: %s", tr.ID, tr.CampaignID) + + var reqDump string + var ok bool + if reqDump, ok = res.Verbose["http_requestdump"]; !ok { + testFailVerbose(t, res, "HTTP Request unavailable") + } + + if !strings.Contains(reqDump, "X-Foo: bar") { + testFailVerbose(t, res, "Header set on Transmission not sent") } +} - // TODO: 404 from Transmission Create could mean either - // Recipient List or Content wasn't found - open doc ticket - // to make error message more specific +func TestTransmissions_ByID_Success(t *testing.T) { + testSetup(t) + defer testTeardown() - T := &sp.Transmission{ - CampaignID: "msys_smoke", - ReturnPath: "dgray@messagesystems.com", - Recipients: []string{"dgray@messagesystems.com", "dgray@sparkpost.com"}, - // Single-recipient Transmissions are transient - Retrieve will 404 - //Recipients: []string{"dgray@messagesystems.com"}, + tx := &sp.Transmission{ + CampaignID: "Post_Success", + ReturnPath: "returnpath@example.com", + Recipients: []string{"recipient1@example.com"}, Content: sp.Content{ Subject: "this is a test message", - HTML: "this is the HTML body of the test message", - From: map[string]string{ - "name": "Dave Gray", - "email": "dgray@messagesystems.com", - }, - }, - Metadata: map[string]interface{}{ - "binding": "example", + HTML: "

TEST

", + From: map[string]string{"name": "test", "email": "from@example.com"}, }, + Metadata: map[string]interface{}{"shoe_size": 9}, } - err = T.Validate() + txBody := map[string]map[string]*sp.Transmission{"results": {"transmission": tx}} + txBytes, err := json.Marshal(txBody) if err != nil { t.Error(err) - return } - id, _, err := client.Send(T) + path := fmt.Sprintf(sp.TransmissionsPathFormat, testClient.Config.ApiVersion) + testMux.HandleFunc(path+"/42", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json; charset=utf8") + w.WriteHeader(http.StatusOK) + w.Write(txBytes) + }) + + tx1 := &sp.Transmission{ID: "42"} + res, err := testClient.Transmission(tx1) if err != nil { - t.Error(err) - return + testFailVerbose(t, res, "Transmission GET failed") } - t.Errorf("Transmission created with id [%s]", id) - - tr, res, err := client.Transmission(id) + res, err = testClient.TransmissionContext(nil, tx1) if err != nil { - t.Error(err) - return + testFailVerbose(t, res, "Transmission GET failed") } - if res != nil { - t.Errorf("Retrieve returned HTTP %s\n", res.HTTP.Status) - if len(res.Errors) > 0 { - for _, e := range res.Errors { - json, err := e.Json() - if err != nil { - t.Error(err) - } - t.Errorf("%s\n", json) - } - } else { - t.Errorf("Transmission retrieved: %s=%s\n", tr.ID, tr.State) - } + if tx1.CampaignID != tx.CampaignID { + testFailVerbose(t, res, "CampaignIDs do not match") } +} + +// Assert that options are actually ... optional, +// and that unspecified options don't default to their zero values. +func TestTransmissionOptions(t *testing.T) { + var jsonb []byte + var err error + var opt bool - res, err = client.TransmissionDelete(id) + tx := &sp.Transmission{} + to := &sp.TxOptions{InlineCSS: &opt} + tx.Options = to + + jsonb, err = json.Marshal(tx) if err != nil { - t.Error(err) - return + t.Fatal(err) } - t.Errorf("Delete returned HTTP %s\n%s\n", res.HTTP.Status, res.Body) + if !bytes.Contains(jsonb, []byte(`"options":{"inline_css":false}`)) { + t.Fatalf("expected inline_css option to be false:\n%s", string(jsonb)) + } + opt = true + jsonb, err = json.Marshal(tx) + if err != nil { + t.Fatal(err) + } + + if !bytes.Contains(jsonb, []byte(`"options":{"inline_css":true}`)) { + t.Fatalf("expected inline_css option to be true:\n%s", string(jsonb)) + } } diff --git a/webhooks.go b/webhooks.go index 9470a7c..3f98ec3 100644 --- a/webhooks.go +++ b/webhooks.go @@ -1,17 +1,18 @@ package gosparkpost import ( + "context" "encoding/json" "fmt" + "net/url" - URL "net/url" + "github.com/pkg/errors" ) -// https://www.sparkpost.com/api#/reference/message-events -var webhookListPathFormat = "/api/v%d/webhooks" -var webhookQueryPathFormat = "/api/v%d/webhooks/%s" -var webhookStatusPathFormat = "/api/v%d/webhooks/%s/batch-status" +// WebhooksPathFormat is the path prefix used for webhook-related requests, with a format string for the API version. +var WebhooksPathFormat = "/api/v%d/webhooks" +// WebhookItem defines how webhook objects will be returned, as well as how they must be sent. type WebhookItem struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` @@ -45,143 +46,157 @@ type WebhookItem struct { } `json:"links,omitempty"` } +// WebhookStatus defines how the status of a webhook will be returned. type WebhookStatus struct { BatchID string `json:"batch_id,omitempty"` - Ts string `json:"ts,omitempty"` + Timestamp string `json:"ts,omitempty"` Attempts int `json:"attempts,omitempty"` ResponseCode string `json:"response_code,omitempty"` + FailureCode string `json:"failure_code,omitempty"` } +// WebhookCommon contains fields common to all response types. +type WebhookCommon struct { + Errors []interface{} `json:"errors,omitempty"` + Params map[string]string `json:"-"` +} + +// WebhookListWrapper is passed into and updated by the Webhooks method, using results returned from the API. type WebhookListWrapper struct { - Results []*WebhookItem `json:"results,omitempty"` - Errors []interface{} `json:"errors,omitempty"` - //{"errors":[{"param":"from","message":"From must be before to","value":"2014-07-20T09:00"},{"param":"to","message":"To must be in the format YYYY-MM-DDTHH:mm","value":"now"}]} + Results []WebhookItem `json:"results,omitempty"` + WebhookCommon } -type WebhookQueryWrapper struct { - Results *WebhookItem `json:"results,omitempty"` - Errors []interface{} `json:"errors,omitempty"` - //{"errors":[{"param":"from","message":"From must be before to","value":"2014-07-20T09:00"},{"param":"to","message":"To must be in the format YYYY-MM-DDTHH:mm","value":"now"}]} +// WebhookDetailWrapper is passed into and updated by the WebhookDetail method, using results returned from the API. +type WebhookDetailWrapper struct { + ID string `json:"-"` + Results *WebhookItem `json:"results,omitempty"` + WebhookCommon } +// WebhookStatusWrapper is passed into and updated by the WebhookStatus method, using results returned from the API. type WebhookStatusWrapper struct { - Results []*WebhookStatus `json:"results,omitempty"` - Errors []interface{} `json:"errors,omitempty"` - //{"errors":[{"param":"from","message":"From must be before to","value":"2014-07-20T09:00"},{"param":"to","message":"To must be in the format YYYY-MM-DDTHH:mm","value":"now"}]} + ID string `json:"-"` + Results []WebhookStatus `json:"results,omitempty"` + WebhookCommon } -func buildUrl(c *Client, url string, parameters map[string]string) string { - +func buildUrl(c *Client, path string, parameters map[string]string) string { if parameters == nil || len(parameters) == 0 { - url = fmt.Sprintf("%s%s", c.Config.BaseUrl, url) + path = fmt.Sprintf("%s%s", c.Config.BaseUrl, path) } else { - params := URL.Values{} + params := url.Values{} for k, v := range parameters { params.Add(k, v) } - url = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, url, params.Encode()) + path = fmt.Sprintf("%s%s?%s", c.Config.BaseUrl, path, params.Encode()) } - return url + return path } +// WebhookStatus updates its argument with details of batch delivery for the specified webhook. // https://developers.sparkpost.com/api/#/reference/webhooks/batch-status/retrieve-status-information -func (c *Client) WebhookStatus(id string, parameters map[string]string) (*WebhookStatusWrapper, error) { - - var finalUrl string - path := fmt.Sprintf(webhookStatusPathFormat, c.Config.ApiVersion, id) - - finalUrl = buildUrl(c, path, parameters) - - return doWebhookStatusRequest(c, finalUrl) +func (c *Client) WebhookStatus(s *WebhookStatusWrapper) (*Response, error) { + return c.WebhookStatusContext(context.Background(), s) } -// https://developers.sparkpost.com/api/#/reference/webhooks/retrieve/retrieve-webhook-details -func (c *Client) QueryWebhook(id string, parameters map[string]string) (*WebhookQueryWrapper, error) { - - var finalUrl string - path := fmt.Sprintf(webhookQueryPathFormat, c.Config.ApiVersion, id) - - finalUrl = buildUrl(c, path, parameters) +// WebhookStatusContext is the same as WebhookStatus, and allows the caller to specify their own context. +func (c *Client) WebhookStatusContext(ctx context.Context, s *WebhookStatusWrapper) (*Response, error) { + if s == nil { + return nil, errors.New("WebhookStatus called with nil WebhookStatusWrapper") + } - return doWebhooksQueryRequest(c, finalUrl) -} + path := fmt.Sprintf(WebhooksPathFormat, c.Config.ApiVersion) + finalUrl := buildUrl(c, path+"/"+s.ID+"/batch-status", s.Params) -// https://developers.sparkpost.com/api/#/reference/webhooks/list/list-all-webhooks -func (c *Client) ListWebhooks(parameters map[string]string) (*WebhookListWrapper, error) { + bodyBytes, res, err := doRequest(c, finalUrl, ctx) + if err != nil { + return res, err + } - var finalUrl string - path := fmt.Sprintf(webhookListPathFormat, c.Config.ApiVersion) + err = json.Unmarshal(bodyBytes, s) + if err != nil { + return res, errors.Wrap(err, "parsing api response") + } - finalUrl = buildUrl(c, path, parameters) + return res, err +} - return doWebhooksListRequest(c, finalUrl) +// WebhookDetail updates its argument with details for the specified webhook. +// https://developers.sparkpost.com/api/#/reference/webhooks/retrieve/retrieve-webhook-details +func (c *Client) WebhookDetail(q *WebhookDetailWrapper) (*Response, error) { + return c.WebhookDetailContext(context.Background(), q) } -func doWebhooksListRequest(c *Client, finalUrl string) (*WebhookListWrapper, error) { +// WebhookDetailContext is the same as WebhookDetail, and allows the caller to specify their own context. +func (c *Client) WebhookDetailContext(ctx context.Context, d *WebhookDetailWrapper) (*Response, error) { + if d == nil { + return nil, errors.New("WebhookDetail called with nil WebhookDetailWrapper") + } - bodyBytes, err := doRequest(c, finalUrl) + path := fmt.Sprintf(WebhooksPathFormat, c.Config.ApiVersion) + finalUrl := buildUrl(c, path+"/"+d.ID, d.Params) + + bodyBytes, res, err := doRequest(c, finalUrl, ctx) if err != nil { - return nil, err + return res, err } - // Parse expected response structure - var resMap WebhookListWrapper - err = json.Unmarshal(bodyBytes, &resMap) - + err = json.Unmarshal(bodyBytes, d) if err != nil { - return nil, err + return res, errors.Wrap(err, "parsing api response") } - return &resMap, err + return res, err } -func doWebhooksQueryRequest(c *Client, finalUrl string) (*WebhookQueryWrapper, error) { - bodyBytes, err := doRequest(c, finalUrl) - - // Parse expected response structure - var resMap WebhookQueryWrapper - err = json.Unmarshal(bodyBytes, &resMap) +// Webhooks updates its argument with a list of all configured webhooks. +// https://developers.sparkpost.com/api/#/reference/webhooks/list/list-all-webhooks +func (c *Client) Webhooks(l *WebhookListWrapper) (*Response, error) { + return c.WebhooksContext(context.Background(), l) +} - if err != nil { - return nil, err +// WebhooksContext is the same as Webhooks, and allows the caller to specify their own context. +func (c *Client) WebhooksContext(ctx context.Context, l *WebhookListWrapper) (*Response, error) { + if l == nil { + return nil, errors.New("Webhooks called with nil WebhookListWrapper") } - return &resMap, err -} + path := fmt.Sprintf(WebhooksPathFormat, c.Config.ApiVersion) + finalUrl := buildUrl(c, path, l.Params) -func doWebhookStatusRequest(c *Client, finalUrl string) (*WebhookStatusWrapper, error) { - bodyBytes, err := doRequest(c, finalUrl) - - // Parse expected response structure - var resMap WebhookStatusWrapper - err = json.Unmarshal(bodyBytes, &resMap) + bodyBytes, res, err := doRequest(c, finalUrl, ctx) + if err != nil { + return res, err + } + err = json.Unmarshal(bodyBytes, l) if err != nil { - return nil, err + return res, errors.Wrap(err, "parsing api response") } - return &resMap, err + return res, err } -func doRequest(c *Client, finalUrl string) ([]byte, error) { +func doRequest(c *Client, finalUrl string, ctx context.Context) ([]byte, *Response, error) { // Send off our request - res, err := c.HttpGet(finalUrl) + res, err := c.HttpGet(ctx, finalUrl) if err != nil { - return nil, err + return nil, res, err } // Assert that we got a JSON Content-Type back if err = res.AssertJson(); err != nil { - return nil, err + return nil, res, err } // Get the Content bodyBytes, err := res.ReadBody() if err != nil { - return nil, err + return nil, res, err } - return bodyBytes, err + return bodyBytes, res, err } diff --git a/webhooks_test.go b/webhooks_test.go new file mode 100644 index 0000000..389b415 --- /dev/null +++ b/webhooks_test.go @@ -0,0 +1,255 @@ +package gosparkpost_test + +import ( + "reflect" + "testing" + + sp "github.com/SparkPost/gosparkpost" + "github.com/pkg/errors" +) + +func TestWebhookBadHost(t *testing.T) { + testSetup(t) + defer testTeardown() + // Get the request to fail by mangling the HTTP host + testClient.Config.BaseUrl = "%zz" + + _, err := testClient.Webhooks(&sp.WebhookListWrapper{}) + if err == nil || err.Error() != `building request: parse %zz/api/v1/webhooks: invalid URL escape "%zz"` { + t.Errorf("error: %#v", err) + } + + _, err = testClient.WebhookStatus(&sp.WebhookStatusWrapper{ID: "id"}) + if err == nil || err.Error() != `building request: parse %zz/api/v1/webhooks/id/batch-status: invalid URL escape "%zz"` { + t.Errorf("error: %#v", err) + } + + _, err = testClient.WebhookDetail(&sp.WebhookDetailWrapper{ID: "id"}) + if err == nil || err.Error() != `building request: parse %zz/api/v1/webhooks/id: invalid URL escape "%zz"` { + t.Errorf("error: %#v", err) + } + +} + +func TestWebhookStatus(t *testing.T) { + var res200 = loadTestFile(t, "test/json/webhook_status_200.json") + var params = map[string]string{"timezone": "UTC"} + + for idx, test := range []struct { + in *sp.WebhookStatusWrapper + err error + status int + json string + out *sp.WebhookStatusWrapper + }{ + {nil, errors.New("WebhookStatus called with nil WebhookStatusWrapper"), 400, `{}`, nil}, + {&sp.WebhookStatusWrapper{ID: "id"}, errors.New("parsing api response: unexpected end of JSON input"), 200, res200[:len(res200)-2], nil}, + + {&sp.WebhookStatusWrapper{ID: "id", WebhookCommon: sp.WebhookCommon{Params: params}}, + nil, 200, res200, &sp.WebhookStatusWrapper{ + ID: "id", + Results: []sp.WebhookStatus{ + sp.WebhookStatus{ + BatchID: "032d330540298f54f0e8bcc1373f3cfd", + Timestamp: "2014-07-30T21:38:08.000Z", + Attempts: 7, + ResponseCode: "200", + FailureCode: "", + }, + sp.WebhookStatus{ + BatchID: "13c6764994a8f6b4e29906d5712ca7d", + Timestamp: "2014-07-30T20:38:08.000Z", + Attempts: 2, + ResponseCode: "400", + FailureCode: "400"}, + }, + }}, + } { + testSetup(t) + defer testTeardown() + + id := "" + if test.in != nil { + id = test.in.ID + } + mockRestResponseBuilderFormat(t, "GET", test.status, sp.WebhooksPathFormat+"/"+id+"/batch-status", test.json) + + _, err := testClient.WebhookStatus(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("WebhookStatus[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("WebhookStatus[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil { + if !reflect.DeepEqual(test.out.Results, test.in.Results) { + t.Errorf("WebhookStatus[%d] => webhook got/want:\n%#v\n%#v", idx, test.in.Results, test.out.Results) + } + } + } +} + +func TestWebhookDetail(t *testing.T) { + var res200 = loadTestFile(t, "test/json/webhook_detail_200.json") + + for idx, test := range []struct { + in *sp.WebhookDetailWrapper + err error + status int + json string + out *sp.WebhookDetailWrapper + }{ + {nil, errors.New("WebhookDetail called with nil WebhookDetailWrapper"), 400, `{}`, nil}, + {&sp.WebhookDetailWrapper{ID: "id"}, errors.New("parsing api response: unexpected end of JSON input"), + 200, res200[:len(res200)-2], nil}, + + {&sp.WebhookDetailWrapper{ID: "id"}, nil, 200, res200, &sp.WebhookDetailWrapper{ + Results: &sp.WebhookItem{ + ID: "", Name: "Example webhook", Target: "http://client.example.com/example-webhook", Events: []string{"delivery", "injection", "open", "click"}, AuthType: "oauth2", AuthRequestDetails: struct { + URL string "json:\"url,omitempty\"" + Body struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + } "json:\"body,omitempty\"" + }{URL: "https://oauth.myurl.com/tokens", Body: struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + }{ClientID: "", ClientSecret: ""}}, AuthCredentials: struct { + Username string "json:\"username,omitempty\"" + Password string "json:\"password,omitempty\"" + AccessToken string "json:\"access_token,omitempty\"" + ExpiresIn int "json:\"expires_in,omitempty\"" + }{Username: "", Password: "", AccessToken: "", ExpiresIn: 3600}, AuthToken: "", LastSuccessful: "", LastFailure: "", Links: []struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{Href: "http://www.messagesystems-api-url.com/api/v1/webhooks/12affc24-f183-11e3-9234-3c15c2c818c2/validate", Rel: "urn.msys.webhooks.validate", Method: []string{"POST"}}, struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{Href: "http://www.messagesystems-api-url.com/api/v1/webhooks/12affc24-f183-11e3-9234-3c15c2c818c2/batch-status", Rel: "urn.msys.webhooks.batches", Method: []string{"GET"}}}}}}, + } { + testSetup(t) + defer testTeardown() + + id := "foo" + if test.in != nil { + id = test.in.ID + } + mockRestResponseBuilderFormat(t, "GET", test.status, sp.WebhooksPathFormat+"/"+id, test.json) + + _, err := testClient.WebhookDetail(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("WebhookDetail[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("WebhookDetail[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil { + if !reflect.DeepEqual(test.out.Results, test.in.Results) { + t.Errorf("WebhookDetail[%d] => webhook got/want:\n%#v\n%#v", idx, test.in.Results, test.out.Results) + } + } + } +} + +func TestWebhooks(t *testing.T) { + var res200 = loadTestFile(t, "test/json/webhooks_200.json") + + for idx, test := range []struct { + in *sp.WebhookListWrapper + err error + status int + json string + out *sp.WebhookListWrapper + }{ + {nil, errors.New("Webhooks called with nil WebhookListWrapper"), 400, `{}`, nil}, + {&sp.WebhookListWrapper{}, errors.New("parsing api response: unexpected end of JSON input"), 200, res200[:len(res200)-2], nil}, + + {&sp.WebhookListWrapper{}, nil, 200, res200, &sp.WebhookListWrapper{Results: []sp.WebhookItem{ + sp.WebhookItem{ID: "a2b83490-10df-11e4-b670-c1ffa86371ff", Name: "Some webhook", Target: "http://client.example.com/some-webhook", Events: []string{"delivery", "injection", "open", "click"}, AuthType: "basic", AuthRequestDetails: struct { + URL string "json:\"url,omitempty\"" + Body struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + } "json:\"body,omitempty\"" + }{URL: "", Body: struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + }{ClientID: "", ClientSecret: ""}}, AuthCredentials: struct { + Username string "json:\"username,omitempty\"" + Password string "json:\"password,omitempty\"" + AccessToken string "json:\"access_token,omitempty\"" + ExpiresIn int "json:\"expires_in,omitempty\"" + }{Username: "basicuser", Password: "somepass", AccessToken: "", ExpiresIn: 0}, AuthToken: "", LastSuccessful: "2014-08-01 16:09:15", LastFailure: "2014-06-01 15:15:45", Links: []struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{Href: "http://www.messagesystems-api-url.com/api/v1/webhooks/a2b83490-10df-11e4-b670-c1ffa86371ff", Rel: "urn.msys.webhooks.webhook", Method: []string{"GET", "PUT"}}}}, + sp.WebhookItem{ID: "12affc24-f183-11e3-9234-3c15c2c818c2", Name: "Example webhook", Target: "http://client.example.com/example-webhook", Events: []string{"delivery", "injection", "open", "click"}, AuthType: "oauth2", AuthRequestDetails: struct { + URL string "json:\"url,omitempty\"" + Body struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + } "json:\"body,omitempty\"" + }{URL: "https://oauth.myurl.com/tokens", Body: struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + }{ClientID: "", ClientSecret: ""}}, AuthCredentials: struct { + Username string "json:\"username,omitempty\"" + Password string "json:\"password,omitempty\"" + AccessToken string "json:\"access_token,omitempty\"" + ExpiresIn int "json:\"expires_in,omitempty\"" + }{Username: "", Password: "", AccessToken: "", ExpiresIn: 3600}, AuthToken: "", LastSuccessful: "2014-07-01 16:09:15", LastFailure: "2014-08-01 15:15:45", Links: []struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{Href: "http://www.messagesystems-api-url.com/api/v1/webhooks/12affc24-f183-11e3-9234-3c15c2c818c2", Rel: "urn.msys.webhooks.webhook", Method: []string{"GET", "PUT"}}}}, + sp.WebhookItem{ID: "123456-abcd-efgh-7890-123445566778", Name: "Another webhook", Target: "http://client.example.com/another-example", Events: []string{"generation_rejection", "generation_failure"}, AuthType: "none", AuthRequestDetails: struct { + URL string "json:\"url,omitempty\"" + Body struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + } "json:\"body,omitempty\"" + }{URL: "", Body: struct { + ClientID string "json:\"client_id,omitempty\"" + ClientSecret string "json:\"client_secret,omitempty\"" + }{ClientID: "", ClientSecret: ""}}, AuthCredentials: struct { + Username string "json:\"username,omitempty\"" + Password string "json:\"password,omitempty\"" + AccessToken string "json:\"access_token,omitempty\"" + ExpiresIn int "json:\"expires_in,omitempty\"" + }{Username: "", Password: "", AccessToken: "", ExpiresIn: 0}, AuthToken: "5ebe2294ecd0e0f08eab7690d2a6ee69", LastSuccessful: "", LastFailure: "", Links: []struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{struct { + Href string "json:\"href,omitempty\"" + Rel string "json:\"rel,omitempty\"" + Method []string "json:\"method,omitempty\"" + }{Href: "http://www.messagesystems-api-url.com/api/v1/webhooks/123456-abcd-efgh-7890-123445566778", Rel: "urn.msys.webhooks.webhook", Method: []string{"GET", "PUT"}}}}}}}, + } { + testSetup(t) + defer testTeardown() + mockRestResponseBuilderFormat(t, "GET", test.status, sp.WebhooksPathFormat, test.json) + + _, err := testClient.Webhooks(test.in) + if err == nil && test.err != nil || err != nil && test.err == nil { + t.Errorf("Webhooks[%d] => err %q want %q", idx, err, test.err) + } else if err != nil && err.Error() != test.err.Error() { + t.Errorf("Webhooks[%d] => err %q want %q", idx, err, test.err) + } else if test.out != nil { + if !reflect.DeepEqual(test.out.Results, test.in.Results) { + t.Errorf("Webhooks[%d] => webhook got/want:\n%#v\n%#v", idx, test.in.Results, test.out.Results) + } + } + } +}