Repository: mailgun/mailgun-go (go)

Issue:
Failed unmarshall EmailVerification struct
https://documentation.mailgun.com/en/latest/api-email-validation.html#example

based on this documentation, reason in validation response is array of string, but reason type in EmailVerification struct is string, so we cannot unmarshall it. we need to update EmailVerification struct to the latest of email validation response
```
json: cannot unmarshal array into Go struct field EmailVerification.reason of type string
```
- golang version: 1.14.1
- mailgun-go version: 4.0.1

Pull request:
ValidateEmail() now supports v3 and v4
## Purpose
To use the `/v4` version of validations define MG_URL in the environment
as `https://api.mailgun.net/v4` or set `v.SetAPIBase("https://api.mailgun.net/v4")`

## Implementation
This change is intentionally backward compatible with current code, setting the APIBase suffix to `/v3` or `/v4` will determine which version will be used. If using `/v3`then `EmailVerification.Reason` will be populated by the API. If using `/v4` then `EmailVerification.Reasons` will be populated by the API

Fixes #217 

Patch:
diff --git a/CHANGELOG b/CHANGELOG
index 914b0a2..5bc2f38 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [4.1.0] - 2020-04-23
+### Changed
+* Added EmailVerification.reason is now a []string (Fixes #217)
+
 ## [4.0.1] - 2020-03-10
 ### Added
 * Added SetTemplateVersion and SetTemplateRenderText methods to Message
diff --git a/README.md b/README.md
index ec21b26..9cb2119 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ func main() {
     ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
     defer cancel()
 
-    // Send the message	with a 10 second timeout
+    // Send the message with a 10 second timeout
     resp, id, err := mg.Send(ctx, message)
 
     if err != nil {
@@ -71,41 +71,41 @@ func main() {
     // (https://app.mailgun.com/app/account/security)
     mg := mailgun.NewMailgun("your-domain.com", "your-private-key")
 
-	it := mg.ListEvents(&mailgun.ListEventOptions{Limit: 100})
-
-	var page []mailgun.Event
-
-	// The entire operation should not take longer than 30 seconds
-	ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
-	defer cancel()
-
-	// For each page of 100 events
-	for it.Next(ctx, &page) {
-		for _, e := range page {
-			// You can access some fields via the interface
-			fmt.Printf("Event: '%s' TimeStamp: '%s'\n", e.GetName(), e.GetTimestamp())
-
-			// and you can act upon each event by type
-			switch event := e.(type) {
-			case *events.Accepted:
-				fmt.Printf("Accepted: auth: %t\n", event.Flags.IsAuthenticated)
-			case *events.Delivered:
-				fmt.Printf("Delivered transport: %s\n", event.Envelope.Transport)
-			case *events.Failed:
-				fmt.Printf("Failed reason: %s\n", event.Reason)
-			case *events.Clicked:
-				fmt.Printf("Clicked GeoLocation: %s\n", event.GeoLocation.Country)
-			case *events.Opened:
-				fmt.Printf("Opened GeoLocation: %s\n", event.GeoLocation.Country)
-			case *events.Rejected:
-				fmt.Printf("Rejected reason: %s\n", event.Reject.Reason)
-			case *events.Stored:
-				fmt.Printf("Stored URL: %s\n", event.Storage.URL)
-			case *events.Unsubscribed:
-				fmt.Printf("Unsubscribed client OS: %s\n", event.ClientInfo.ClientOS)
-			}
-		}
-	}
+    it := mg.ListEvents(&mailgun.ListEventOptions{Limit: 100})
+
+    var page []mailgun.Event
+
+    // The entire operation should not take longer than 30 seconds
+    ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
+    defer cancel()
+
+    // For each page of 100 events
+    for it.Next(ctx, &page) {
+        for _, e := range page {
+            // You can access some fields via the interface
+            fmt.Printf("Event: '%s' TimeStamp: '%s'\n", e.GetName(), e.GetTimestamp())
+
+            // and you can act upon each event by type
+            switch event := e.(type) {
+            case *events.Accepted:
+                fmt.Printf("Accepted: auth: %t\n", event.Flags.IsAuthenticated)
+            case *events.Delivered:
+                fmt.Printf("Delivered transport: %s\n", event.Envelope.Transport)
+            case *events.Failed:
+                fmt.Printf("Failed reason: %s\n", event.Reason)
+            case *events.Clicked:
+                fmt.Printf("Clicked GeoLocation: %s\n", event.GeoLocation.Country)
+            case *events.Opened:
+                fmt.Printf("Opened GeoLocation: %s\n", event.GeoLocation.Country)
+            case *events.Rejected:
+                fmt.Printf("Rejected reason: %s\n", event.Reject.Reason)
+            case *events.Stored:
+                fmt.Printf("Stored URL: %s\n", event.Storage.URL)
+            case *events.Unsubscribed:
+                fmt.Printf("Unsubscribed client OS: %s\n", event.ClientInfo.ClientOS)
+            }
+        }
+    }
 }
 ```
 
@@ -126,18 +126,18 @@ func main() {
     // (https://app.mailgun.com/app/account/security)
     mg := mailgun.NewMailgun("your-domain.com", "your-private-key")
 
-	begin := time.Now().Add(time.Second * -3)
+    begin := time.Now().Add(time.Second * -3)
 
-	// Very short poll interval
-	it := mg.PollEvents(&mailgun.ListEventOptions{
-		// Only events with a timestamp after this date/time will be returned
-		Begin: &begin,
-		// How often we poll the api for new events
-		PollInterval: time.Second * 30,
-	})
+    // Very short poll interval
+    it := mg.PollEvents(&mailgun.ListEventOptions{
+        // Only events with a timestamp after this date/time will be returned
+        Begin: &begin,
+        // How often we poll the api for new events
+        PollInterval: time.Second * 30,
+    })
 
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
+    ctx, cancel := context.WithCancel(context.Background())
+    defer cancel()
 
     // Poll until our email event arrives
     var page []mailgun.Event
@@ -170,11 +170,14 @@ import (
 var apiKey string = "your-api-key"
 
 func main() {
+    // To use the /v4 version of validations define MG_URL in the envronment
+    // as `https://api.mailgun.net/v4` or set `v.SetAPIBase("https://api.mailgun.net/v4")`
+
     // Create an instance of the Validator
     v := mailgun.NewEmailValidator(apiKey)
 
-	ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
-	defer cancel()
+    ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
+    defer cancel()
 
     email, err := v.ValidateEmail(ctx, "recipient@example.com", false)
     if err != nil {
@@ -206,50 +209,50 @@ func main() {
     // (https://app.mailgun.com/app/account/security)
     mg := mailgun.NewMailgun("your-domain.com", "private-api-key")
 
-	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
-
-		var payload mailgun.WebhookPayload
-		if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
-			fmt.Printf("decode JSON error: %s", err)
-			w.WriteHeader(http.StatusNotAcceptable)
-			return
-		}
-
-		verified, err := mg.VerifyWebhookSignature(payload.Signature)
-		if err != nil {
-			fmt.Printf("verify error: %s\n", err)
-			w.WriteHeader(http.StatusNotAcceptable)
-			return
-		}
-
-		if !verified {
-			w.WriteHeader(http.StatusNotAcceptable)
-			fmt.Printf("failed verification %+v\n", payload.Signature)
-			return
-		}
-
-		fmt.Printf("Verified Signature\n")
-
-		// Parse the event provided by the webhook payload
-		e, err := mailgun.ParseEvent(payload.EventData)
-		if err != nil {
-			fmt.Printf("parse event error: %s\n", err)
-			return
-		}
-
-		switch event := e.(type) {
-		case *events.Accepted:
-			fmt.Printf("Accepted: auth: %t\n", event.Flags.IsAuthenticated)
-		case *events.Delivered:
-			fmt.Printf("Delivered transport: %s\n", event.Envelope.Transport)
-		}
-	})
-
-	fmt.Println("Serve on :9090...")
-	if err := http.ListenAndServe(":9090", nil); err != nil {
-		fmt.Printf("serve error: %s\n", err)
-		os.Exit(1)
-	}
+    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+
+        var payload mailgun.WebhookPayload
+        if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+            fmt.Printf("decode JSON error: %s", err)
+            w.WriteHeader(http.StatusNotAcceptable)
+            return
+        }
+
+        verified, err := mg.VerifyWebhookSignature(payload.Signature)
+        if err != nil {
+            fmt.Printf("verify error: %s\n", err)
+            w.WriteHeader(http.StatusNotAcceptable)
+            return
+        }
+
+        if !verified {
+            w.WriteHeader(http.StatusNotAcceptable)
+            fmt.Printf("failed verification %+v\n", payload.Signature)
+            return
+        }
+
+        fmt.Printf("Verified Signature\n")
+
+        // Parse the event provided by the webhook payload
+        e, err := mailgun.ParseEvent(payload.EventData)
+        if err != nil {
+            fmt.Printf("parse event error: %s\n", err)
+            return
+        }
+
+        switch event := e.(type) {
+        case *events.Accepted:
+            fmt.Printf("Accepted: auth: %t\n", event.Flags.IsAuthenticated)
+        case *events.Delivered:
+            fmt.Printf("Delivered transport: %s\n", event.Envelope.Transport)
+        }
+    })
+
+    fmt.Println("Serve on :9090...")
+    if err := http.ListenAndServe(":9090", nil); err != nil {
+        fmt.Printf("serve error: %s\n", err)
+        os.Exit(1)
+    }
 }
 ```
 
@@ -288,14 +291,14 @@ func main() {
     recipient := "recipient@example.com"
 
     // The message object allows you to add attachments and Bcc recipients
-		message := mg.NewMessage(sender, subject, body, recipient)
-		message.SetTemplate("passwordReset")
-		message.AddTemplateVariable("passwordResetLink", "some link to your site unique to your user")
+        message := mg.NewMessage(sender, subject, body, recipient)
+        message.SetTemplate("passwordReset")
+        message.AddTemplateVariable("passwordResetLink", "some link to your site unique to your user")
 
     ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
     defer cancel()
 
-    // Send the message	with a 10 second timeout
+    // Send the message with a 10 second timeout
     resp, id, err := mg.Send(ctx, message)
 
     if err != nil {
diff --git a/email_validation.go b/email_validation.go
index b9f0474..c3d80e4 100644
--- a/email_validation.go
+++ b/email_validation.go
@@ -41,8 +41,21 @@ type EmailVerification struct {
 	IsDisposableAddress bool `json:"is_disposable_address"`
 	// Indicates whether Mailgun thinks the address is an email distribution list.
 	IsRoleAddress bool `json:"is_role_address"`
-	// A human readable reason the address is reported as invalid
+	// The reason why a specific validation may be unsuccessful. (Available in the V3 response)
 	Reason string `json:"reason"`
+	// A list of potential reasons why a specific validation may be unsuccessful. (Available in the v4 response)
+	Reasons []string
+}
+
+type v4EmailValidationResp struct {
+	IsValid             bool                   `json:"is_valid"`
+	MailboxVerification string                 `json:"mailbox_verification"`
+	Parts               EmailVerificationParts `json:"parts"`
+	Address             string                 `json:"address"`
+	DidYouMean          string                 `json:"did_you_mean"`
+	IsDisposableAddress bool                   `json:"is_disposable_address"`
+	IsRoleAddress       bool                   `json:"is_role_address"`
+	Reason              []string               `json:"reason"`
 }
 
 type addressParseResult struct {
@@ -134,8 +147,15 @@ func (m *EmailValidatorImpl) getAddressURL(endpoint string) string {
 }
 
 // ValidateEmail performs various checks on the email address provided to ensure it's correctly formatted.
-// It may also be used to break an email address into its sub-components.  (See example.)
+// It may also be used to break an email address into its sub-components. If user has set the
 func (m *EmailValidatorImpl) ValidateEmail(ctx context.Context, email string, mailBoxVerify bool) (EmailVerification, error) {
+	if strings.HasSuffix(m.APIBase(), "/v4") {
+		return m.validateV4(ctx, email, mailBoxVerify)
+	}
+	return m.validateV3(ctx, email, mailBoxVerify)
+}
+
+func (m *EmailValidatorImpl) validateV3(ctx context.Context, email string, mailBoxVerify bool) (EmailVerification, error) {
 	r := newHTTPRequest(m.getAddressURL("validate"))
 	r.setClient(m.Client())
 	r.addParameter("address", email)
@@ -144,13 +164,37 @@ func (m *EmailValidatorImpl) ValidateEmail(ctx context.Context, email string, ma
 	}
 	r.setBasicAuth(basicAuthUser, m.APIKey())
 
-	var response EmailVerification
-	err := getResponseFromJSON(ctx, r, &response)
+	var res EmailVerification
+	err := getResponseFromJSON(ctx, r, &res)
 	if err != nil {
 		return EmailVerification{}, err
 	}
+	return res, nil
+}
 
-	return response, nil
+func (m *EmailValidatorImpl) validateV4(ctx context.Context, email string, mailBoxVerify bool) (EmailVerification, error) {
+	r := newHTTPRequest(fmt.Sprintf("%s/address/validate", m.APIBase()))
+	r.setClient(m.Client())
+	r.addParameter("address", email)
+	if mailBoxVerify {
+		r.addParameter("mailbox_verification", "true")
+	}
+	r.setBasicAuth(basicAuthUser, m.APIKey())
+
+	var res v4EmailValidationResp
+	err := getResponseFromJSON(ctx, r, &res)
+	if err != nil {
+		return EmailVerification{}, err
+	}
+	return EmailVerification{
+		IsValid:             res.IsValid,
+		MailboxVerification: res.MailboxVerification,
+		Parts:               res.Parts,
+		Address:             res.Address,
+		DidYouMean:          res.DidYouMean,
+		IsDisposableAddress: res.IsDisposableAddress,
+		IsRoleAddress:       res.IsRoleAddress,
+		Reasons:             res.Reason}, nil
 }
 
 // ParseAddresses takes a list of addresses and sorts them into valid and invalid address categories.
diff --git a/mock.go b/mock.go
index f9cf455..8183852 100644
--- a/mock.go
+++ b/mock.go
@@ -41,10 +41,10 @@ func NewMockServer() MockServer {
 		ms.addMailingListRoutes(r)
 		ms.addEventRoutes(r)
 		ms.addMessagesRoutes(r)
-		ms.addValidationRoutes(r)
 		ms.addRoutes(r)
 		ms.addWebhookRoutes(r)
 	})
+	ms.addValidationRoutes(r)
 
 	// Start the server
 	ms.srv = httptest.NewServer(r)
@@ -56,6 +56,10 @@ func (ms *MockServer) Stop() {
 	ms.srv.Close()
 }
 
+func (ms *MockServer) URL4() string {
+	return ms.srv.URL + "/v4"
+}
+
 // URL returns the URL used to connect to the mock server
 func (ms *MockServer) URL() string {
 	return ms.srv.URL + "/v3"
diff --git a/mock_validation.go b/mock_validation.go
index ae4f345..152a09c 100644
--- a/mock_validation.go
+++ b/mock_validation.go
@@ -9,10 +9,30 @@ import (
 )
 
 func (ms *MockServer) addValidationRoutes(r chi.Router) {
-	r.Get("/address/validate", ms.validateEmail)
-	r.Get("/address/parse", ms.parseEmail)
-	r.Get("/address/private/validate", ms.validateEmail)
-	r.Get("/address/private/parse", ms.parseEmail)
+	r.Get("/v3/address/validate", ms.validateEmail)
+	r.Get("/v3/address/parse", ms.parseEmail)
+	r.Get("/v3/address/private/validate", ms.validateEmail)
+	r.Get("/v3/address/private/parse", ms.parseEmail)
+	r.Get("/v4/address/validate", ms.validateEmailV4)
+}
+
+func (ms *MockServer) validateEmailV4(w http.ResponseWriter, r *http.Request) {
+	if r.FormValue("address") == "" {
+		w.WriteHeader(http.StatusBadRequest)
+		toJSON(w, okResp{Message: "'address' parameter is required"})
+		return
+	}
+
+	var results v4EmailValidationResp
+	parts, err := mail.ParseAddress(r.FormValue("address"))
+	if err == nil {
+		results.IsValid = true
+		results.Parts.Domain = strings.Split(parts.Address, "@")[1]
+		results.Parts.LocalPart = strings.Split(parts.Address, "@")[0]
+		results.Parts.DisplayName = parts.Name
+	}
+	results.Reason = []string{"no-reason"}
+	toJSON(w, results)
 }
 
 func (ms *MockServer) validateEmail(w http.ResponseWriter, r *http.Request) {
@@ -30,6 +50,7 @@ func (ms *MockServer) validateEmail(w http.ResponseWriter, r *http.Request) {
 		results.Parts.LocalPart = strings.Split(parts.Address, "@")[0]
 		results.Parts.DisplayName = parts.Name
 	}
+	results.Reason = "no-reason"
 	toJSON(w, results)
 }
 


Test patch:
diff --git a/email_validation_test.go b/email_validation_test.go
index 486f4b1..1c3ae0a 100644
--- a/email_validation_test.go
+++ b/email_validation_test.go
@@ -9,8 +9,9 @@ import (
 	"github.com/mailgun/mailgun-go/v4"
 )
 
-func TestEmailValidation(t *testing.T) {
+func TestEmailValidationV3(t *testing.T) {
 	v := mailgun.NewEmailValidator(testKey)
+	// API Base is set to `http://server/v3`
 	v.SetAPIBase(server.URL())
 	ctx := context.Background()
 
@@ -24,7 +25,29 @@ func TestEmailValidation(t *testing.T) {
 	ensure.True(t, ev.Parts.DisplayName == "")
 	ensure.DeepEqual(t, ev.Parts.LocalPart, "foo")
 	ensure.DeepEqual(t, ev.Parts.Domain, "mailgun.com")
-	ensure.True(t, ev.Reason == "")
+	ensure.DeepEqual(t, ev.Reason, "no-reason")
+	ensure.True(t, len(ev.Reasons) == 0)
+}
+
+func TestEmailValidationV4(t *testing.T) {
+	v := mailgun.NewEmailValidator(testKey)
+	// API Base is set to `http://server/v4`
+	v.SetAPIBase(server.URL4())
+	ctx := context.Background()
+
+	ev, err := v.ValidateEmail(ctx, "foo@mailgun.com", false)
+	ensure.Nil(t, err)
+
+	ensure.True(t, ev.IsValid)
+	ensure.DeepEqual(t, ev.MailboxVerification, "")
+	ensure.False(t, ev.IsDisposableAddress)
+	ensure.False(t, ev.IsRoleAddress)
+	ensure.True(t, ev.Parts.DisplayName == "")
+	ensure.DeepEqual(t, ev.Parts.LocalPart, "foo")
+	ensure.DeepEqual(t, ev.Parts.Domain, "mailgun.com")
+	ensure.DeepEqual(t, ev.Reason, "")
+	ensure.True(t, len(ev.Reasons) != 0)
+	ensure.DeepEqual(t, ev.Reasons[0], "no-reason")
 }
 
 func TestParseAddresses(t *testing.T) {
@@ -61,7 +84,7 @@ func TestUnmarshallResponse(t *testing.T) {
 			"domain": "aol.com",
 			"local_part": "some_email"
 		},
-		"reason": null
+		"reason": "no-reason"
 	}`)
 	var ev mailgun.EmailVerification
 	err := json.Unmarshal(payload, &ev)
@@ -74,5 +97,5 @@ func TestUnmarshallResponse(t *testing.T) {
 	ensure.True(t, ev.Parts.DisplayName == "")
 	ensure.DeepEqual(t, ev.Parts.LocalPart, "some_email")
 	ensure.DeepEqual(t, ev.Parts.Domain, "aol.com")
-	ensure.True(t, ev.Reason == "")
+	ensure.DeepEqual(t, ev.Reason, "no-reason")
 }