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":"","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: "", 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)
+ }
+ }
+ }
+}