Skip to content

Commit

Permalink
ListBounces() now returns a page iterator
Browse files Browse the repository at this point in the history
* API responses with CreatedAt fields are now unmarshalled into RFC2822
* Removed GetCode() from `Bounce` struct. Verified API returns 'string'
and not 'int'
  • Loading branch information
thrawn01 committed Jan 11, 2019
1 parent 7983a79 commit f4ff6d0
Show file tree
Hide file tree
Showing 22 changed files with 252 additions and 230 deletions.
21 changes: 0 additions & 21 deletions acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package mailgun

import (
"bytes"
"crypto/rand"
"fmt"
"io"
"io/ioutil"
Expand All @@ -12,8 +11,6 @@ import (
"net/url"
"os"
"testing"

"github.com/facebookgo/ensure"
)

// Return the variable missing which caused the test to be skipped
Expand All @@ -26,24 +23,6 @@ func SkipNetworkTest() string {
return ""
}

// Many tests require configuration settings unique to the user, passed in via
// environment variables. If these variables aren't set, we need to fail the test early.
func reqEnv(t *testing.T, variableName string) string {
value := os.Getenv(variableName)
ensure.True(t, value != "")
return value
}

func randomDomainURL(n int) string {
const alpha = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
var bytes = make([]byte, n)
rand.Read(bytes)
for i, b := range bytes {
bytes[i] = alpha[b%byte(len(alpha))]
}
return "http://" + string(bytes) + ".com"
}

func spendMoney(t *testing.T, tFunc func()) {
ok := os.Getenv("MG_SPEND_MONEY")
if ok != "" {
Expand Down
142 changes: 106 additions & 36 deletions bounces.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package mailgun
import (
"context"
"strconv"
"time"
)

// Bounce aggregates data relating to undeliverable messages to a specific intended recipient,
Expand All @@ -12,8 +11,8 @@ import (
// while Error provides a human readable reason why.
// CreatedAt provides the time at which Mailgun detected the bounce.
type Bounce struct {
CreatedAt string `json:"created_at"`
Code interface{} `json:"code"`
CreatedAt RFC2822Time `json:"created_at"`
Code string `json:"code"`
Address string `json:"address"`
Error string `json:"error"`
}
Expand All @@ -25,56 +24,127 @@ type Paging struct {
Last string `json:"last,omitempty"`
}

type bounceEnvelope struct {
type bouncesListResponse struct {
Items []Bounce `json:"items"`
Paging Paging `json:"paging"`
}

// GetCreatedAt parses the textual, RFC-822 timestamp into a standard Go-compatible
// Time structure.
func (i Bounce) GetCreatedAt() (t time.Time, err error) {
return parseMailgunTime(i.CreatedAt)
}

// GetCode will return the bounce code for the message, regardless if it was
// returned as a string or as an integer. This method overcomes a protocol
// bug in the Mailgun API.
func (b Bounce) GetCode() (int, error) {
switch c := b.Code.(type) {
case int:
return c, nil
case string:
return strconv.Atoi(c)
default:
return -1, strconv.ErrSyntax
}
}

// ListBounces returns a complete set of bounces logged against the sender's domain, if any.
// The results include the total number of bounces (regardless of skip or limit settings),
// and the slice of bounces specified, if successful.
// Note that the length of the slice may be smaller than the total number of bounces.
func (mg *MailgunImpl) ListBounces(ctx context.Context, opts *ListOptions) ([]Bounce, error) {
func (mg *MailgunImpl) ListBounces(opts *ListOptions) *BouncesIterator {
r := newHTTPRequest(generateApiUrl(mg, bouncesEndpoint))
r.setClient(mg.Client())
r.setBasicAuth(basicAuthUser, mg.APIKey())
if opts != nil {
if opts.Limit != 0 {
r.addParameter("limit", strconv.Itoa(opts.Limit))
}
}
url, err := r.generateUrlWithParameters()
return &BouncesIterator{
mg: mg,
bouncesListResponse: bouncesListResponse{Paging: Paging{Next: url, First: url}},
err: err,
}
}

type BouncesIterator struct {
bouncesListResponse
mg Mailgun
err error
}

if opts != nil && opts.Limit != 0 {
r.addParameter("limit", strconv.Itoa(opts.Limit))
// If an error occurred during iteration `Err()` will return non nil
func (ci *BouncesIterator) Err() error {
return ci.err
}

// Retrieves the next page of items from the api. Returns false when there
// no more pages to retrieve or if there was an error. Use `.Err()` to retrieve
// the error
func (ci *BouncesIterator) Next(ctx context.Context, items *[]Bounce) bool {
if ci.err != nil {
return false
}
ci.err = ci.fetch(ctx, ci.Paging.Next)
if ci.err != nil {
return false
}
cpy := make([]Bounce, len(ci.Items))
copy(cpy, ci.Items)
*items = cpy
if len(ci.Items) == 0 {
return false
}
return true
}

if opts != nil && opts.Skip != 0 {
r.addParameter("skip", strconv.Itoa(opts.Skip))
// Retrieves the first page of items from the api. Returns false if there
// was an error. It also sets the iterator object to the first page.
// Use `.Err()` to retrieve the error.
func (ci *BouncesIterator) First(ctx context.Context, items *[]Bounce) bool {
if ci.err != nil {
return false
}
ci.err = ci.fetch(ctx, ci.Paging.First)
if ci.err != nil {
return false
}
cpy := make([]Bounce, len(ci.Items))
copy(cpy, ci.Items)
*items = cpy
return true
}

r.setClient(mg.Client())
r.setBasicAuth(basicAuthUser, mg.APIKey())
// Retrieves the last page of items from the api.
// Calling Last() is invalid unless you first call First() or Next()
// Returns false if there was an error. It also sets the iterator object
// to the last page. Use `.Err()` to retrieve the error.
func (ci *BouncesIterator) Last(ctx context.Context, items *[]Bounce) bool {
if ci.err != nil {
return false
}
ci.err = ci.fetch(ctx, ci.Paging.Last)
if ci.err != nil {
return false
}
cpy := make([]Bounce, len(ci.Items))
copy(cpy, ci.Items)
*items = cpy
return true
}

var response bounceEnvelope
err := getResponseFromJSON(ctx, r, &response)
if err != nil {
return nil, err
// Retrieves the previous page of items from the api. Returns false when there
// no more pages to retrieve or if there was an error. Use `.Err()` to retrieve
// the error if any
func (ci *BouncesIterator) Previous(ctx context.Context, items *[]Bounce) bool {
if ci.err != nil {
return false
}
if ci.Paging.Previous == "" {
return false
}
ci.err = ci.fetch(ctx, ci.Paging.Previous)
if ci.err != nil {
return false
}
cpy := make([]Bounce, len(ci.Items))
copy(cpy, ci.Items)
*items = cpy
if len(ci.Items) == 0 {
return false
}
return true
}

func (ci *BouncesIterator) fetch(ctx context.Context, url string) error {
r := newHTTPRequest(url)
r.setClient(ci.mg.Client())
r.setBasicAuth(basicAuthUser, ci.mg.APIKey())

return response.Items, nil
return getResponseFromJSON(ctx, r, &ci.bouncesListResponse)
}

// GetBounce retrieves a single bounce record, if any exist, for the given recipient address.
Expand Down
83 changes: 38 additions & 45 deletions bounces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"os"
"strings"
"testing"

Expand All @@ -19,21 +20,28 @@ func TestGetBounces(t *testing.T) {
ensure.Nil(t, err)

ctx := context.Background()
_, err = mg.ListBounces(ctx, nil)
ensure.Nil(t, err)
it := mg.ListBounces(nil)

var page []Bounce
for it.Next(ctx, &page) {
for _, bounce := range page {
t.Logf("Bounce: %+v\n", bounce)
}
}
ensure.Nil(t, it.Err())
}

func TestGetSingleBounce(t *testing.T) {
if reason := SkipNetworkTest(); reason != "" {
t.Skip(reason)
}

domain := reqEnv(t, "MG_DOMAIN")
mg, err := NewMailgunFromEnv()
ensure.Nil(t, err)

ctx := context.Background()
exampleEmail := fmt.Sprintf("%s@%s", strings.ToLower(randomString(64, "")), domain)
exampleEmail := fmt.Sprintf("%s@%s", strings.ToLower(randomString(64, "")),
os.Getenv("MG_DOMAIN"))
_, err = mg.GetBounce(ctx, exampleEmail)
ensure.NotNil(t, err)

Expand All @@ -47,70 +55,55 @@ func TestAddDelBounces(t *testing.T) {
t.Skip(reason)
}

domain := reqEnv(t, "MG_DOMAIN")
domain := os.Getenv("MG_DOMAIN")
mg, err := NewMailgunFromEnv()
ctx := context.Background()
ensure.Nil(t, err)

// Compute an e-mail address for our domain.
exampleEmail := fmt.Sprintf("%s@%s", strings.ToLower(randomString(8, "bounce")), domain)
findBounce := func(address string) bool {
it := mg.ListBounces(nil)
var page []Bounce
for it.Next(ctx, &page) {
ensure.True(t, len(page) != 0)
for _, bounce := range page {
t.Logf("Bounce Address: %s\n", bounce.Address)
if bounce.Address == address {
return true
}
}
}
if it.Err() != nil {
t.Logf("BounceIterator err: %s", it.Err())
}
return false
}

// First, basic sanity check.
// Fail early if we have bounces for a fictitious e-mail address.
// Compute an e-mail address for our Bounce.
exampleEmail := fmt.Sprintf("%s@%s", strings.ToLower(randomString(8, "bounce")), domain)

ctx := context.Background()
_, err = mg.ListBounces(ctx, nil)
ensure.Nil(t, err)
// Add the bounce for our address.

err = mg.AddBounce(ctx, exampleEmail, "550", "TestAddDelBounces-generated error")
ensure.Nil(t, err)

// We should now have one bounce listed when we query the API.

bounces, err := mg.ListBounces(ctx,nil)
ensure.Nil(t, err)
if len(bounces) == 0 {
t.Fatal("Expected at least one bounce for this domain.")
}

found := 0
for _, bounce := range bounces {
t.Logf("Bounce Address: %s\n", bounce.Address)
if bounce.Address == exampleEmail {
found++
}
}

if found == 0 {
if !findBounce(exampleEmail) {
t.Fatalf("Expected bounce for address %s in list of bounces", exampleEmail)
}

bounce, err := mg.GetBounce(ctx, exampleEmail)
ensure.Nil(t, err)
if bounce.CreatedAt == "" {
if bounce.Address != exampleEmail {
t.Fatalf("Expected at least one bounce for %s", exampleEmail)
}
t.Logf("Bounce Created At: %s", bounce.CreatedAt)

// Delete it. This should put us back the way we were.

err = mg.DeleteBounce(ctx, exampleEmail)
ensure.Nil(t, err)

// Make sure we're back to the way we were.

bounces, err = mg.ListBounces(ctx, nil)
ensure.Nil(t, err)

found = 0
for _, bounce := range bounces {
t.Logf("Bounce Address: %s\n", bounce.Address)
if bounce.Address == exampleEmail {
found++
}
}

if found != 0 {
t.Fatalf("Expected no bounce for address %s in list of bounces", exampleEmail)
if findBounce(exampleEmail) {
t.Fatalf("Un-expected bounce for address %s in list of bounces", exampleEmail)
}

_, err = mg.GetBounce(ctx, exampleEmail)
Expand Down
6 changes: 3 additions & 3 deletions credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import (

// A Credential structure describes a principle allowed to send or receive mail at the domain.
type Credential struct {
CreatedAt string `json:"created_at"`
Login string `json:"login"`
Password string `json:"password"`
CreatedAt RFC2822Time `json:"created_at"`
Login string `json:"login"`
Password string `json:"password"`
}

type credentialsListResponse struct {
Expand Down
3 changes: 2 additions & 1 deletion credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package mailgun
import (
"context"
"fmt"
"os"
"strings"
"testing"

Expand Down Expand Up @@ -35,7 +36,7 @@ func TestCreateDeleteCredentials(t *testing.T) {
t.Skip(reason)
}

domain := reqEnv(t, "MG_DOMAIN")
domain := os.Getenv("MG_DOMAIN")
mg, err := NewMailgunFromEnv()
ensure.Nil(t, err)

Expand Down
Loading

0 comments on commit f4ff6d0

Please sign in to comment.