Repository: mailgun/mailgun-go
Pull request #151 (2019-01-21 22:37:40)

Added support for webhooks 2.0
* Deprecated mg.VerifyWebhookRequest()
* Added mailgun.ParseEvent()
* Added mailgun.ParseEvents()
* Added mg.VerifyWebhookSignature()


Patch:
diff --git a/CHANGELOG b/CHANGELOG
index fee57dc..d347781 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -4,6 +4,16 @@ 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).
 
+## [3.2.0] - 2019-01-21
+### Changes
+* Deprecated mg.VerifyWebhookRequest()
+
+### Added
+* Added mailgun.ParseEvent()
+* Added mailgun.ParseEvents()
+* Added mg.VerifyWebhookSignature()
+
+
 ## [3.1.0] - 2019-01-16
 ### Changes
 * Removed context.Context from ListDomains() signature
diff --git a/README.md b/README.md
index b63ec5f..7b54920 100644
--- a/README.md
+++ b/README.md
@@ -184,6 +184,71 @@ func main() {
 }
 ```
 
+## Webhook Handling
+```go
+package main
+
+import (
+    "context"
+    "encoding/json"
+    "fmt"
+    "net/http"
+    "os"
+    "time"
+
+    "github.com/mailgun/mailgun-go/v3"
+    "github.com/mailgun/mailgun-go/v3/events"
+)
+
+func main() {
+    mg := mailgun.NewMailgun("your-domain.com", "your-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)
+	}
+}
+```
 The official mailgun documentation includes examples using this library. Go
 [here](https://documentation.mailgun.com/en/latest/api_reference.html#api-reference)
 and click on the "Go" button at the top of the page.
diff --git a/events.go b/events.go
index 0db9f67..73a5f7b 100644
--- a/events.go
+++ b/events.go
@@ -86,7 +86,7 @@ func (ei *EventIterator) Next(ctx context.Context, events *[]Event) bool {
 	if ei.err != nil {
 		return false
 	}
-	*events, ei.err = parseEvents(ei.Items)
+	*events, ei.err = ParseEvents(ei.Items)
 	if len(ei.Items) == 0 {
 		return false
 	}
@@ -104,7 +104,7 @@ func (ei *EventIterator) First(ctx context.Context, events *[]Event) bool {
 	if ei.err != nil {
 		return false
 	}
-	*events, ei.err = parseEvents(ei.Items)
+	*events, ei.err = ParseEvents(ei.Items)
 	return true
 }
 
@@ -120,7 +120,7 @@ func (ei *EventIterator) Last(ctx context.Context, events *[]Event) bool {
 	if ei.err != nil {
 		return false
 	}
-	*events, ei.err = parseEvents(ei.Items)
+	*events, ei.err = ParseEvents(ei.Items)
 	return true
 }
 
@@ -138,7 +138,7 @@ func (ei *EventIterator) Previous(ctx context.Context, events *[]Event) bool {
 	if ei.err != nil {
 		return false
 	}
-	*events, ei.err = parseEvents(ei.Items)
+	*events, ei.err = ParseEvents(ei.Items)
 	if len(ei.Items) == 0 {
 		return false
 	}
diff --git a/examples/examples.go b/examples/examples.go
index 7b58709..e4deb00 100644
--- a/examples/examples.go
+++ b/examples/examples.go
@@ -884,3 +884,13 @@ func UpdateWebhook(domain, apiKey string) error {
 
 	return mg.UpdateWebhook(ctx, "clicked", []string{"https://your_domain.com/clicked"})
 }
+
+func VerifyWebhookSignature(domain, apiKey, timestamp, token, signature string) (bool, error) {
+	mg := mailgun.NewMailgun(domain, apiKey)
+
+	return mg.VerifyWebhookSignature(mailgun.Signature{
+		TimeStamp: timestamp,
+		Token:     token,
+		Signature: signature,
+	})
+}
diff --git a/parse.go b/parse.go
index 97304f7..c83efcc 100644
--- a/parse.go
+++ b/parse.go
@@ -45,10 +45,15 @@ func new_(e interface{}) func() Event {
 	}
 }
 
-func parseEvents(raw []events.RawJSON) ([]Event, error) {
+func parseResponse(raw []byte) ([]Event, error) {
+	var resp events.Response
+	if err := easyjson.Unmarshal(raw, &resp); err != nil {
+		return nil, fmt.Errorf("failed to un-marshall event.Response: %s", err)
+	}
+
 	var result []Event
-	for _, value := range raw {
-		event, err := parse(value)
+	for _, value := range resp.Items {
+		event, err := ParseEvent(value)
 		if err != nil {
 			return nil, fmt.Errorf("while parsing event: %s", err)
 		}
@@ -57,15 +62,11 @@ func parseEvents(raw []events.RawJSON) ([]Event, error) {
 	return result, nil
 }
 
-func parseResponse(raw []byte) ([]Event, error) {
-	var resp events.Response
-	if err := easyjson.Unmarshal(raw, &resp); err != nil {
-		return nil, fmt.Errorf("failed to un-marshall event.Response: %s", err)
-	}
-
+// Given a slice of events.RawJSON events return a slice of Event for each parsed event
+func ParseEvents(raw []events.RawJSON) ([]Event, error) {
 	var result []Event
-	for _, value := range resp.Items {
-		event, err := parse(value)
+	for _, value := range raw {
+		event, err := ParseEvent(value)
 		if err != nil {
 			return nil, fmt.Errorf("while parsing event: %s", err)
 		}
@@ -74,8 +75,8 @@ func parseResponse(raw []byte) ([]Event, error) {
 	return result, nil
 }
 
-// Parse converts raw bytes data into an event struct.
-func parse(raw []byte) (Event, error) {
+// Parse converts raw bytes data into an event struct. Can accept events.RawJSON as input
+func ParseEvent(raw []byte) (Event, error) {
 	// Try to recognize the event first.
 	var e events.EventName
 	if err := easyjson.Unmarshal(raw, &e); err != nil {
diff --git a/webhooks.go b/webhooks.go
index 291fb39..68f3488 100644
--- a/webhooks.go
+++ b/webhooks.go
@@ -8,6 +8,8 @@ import (
 	"encoding/hex"
 	"io"
 	"net/http"
+
+	"github.com/mailgun/mailgun-go/v3/events"
 )
 
 // ListWebhooks returns the complete set of webhooks configured for your domain.
@@ -82,6 +84,39 @@ func (mg *MailgunImpl) UpdateWebhook(ctx context.Context, t string, urls []strin
 	return err
 }
 
+// Represents the signature portion of the webhook POST body
+type Signature struct {
+	TimeStamp string `json:"timestamp"`
+	Token     string `json:"token"`
+	Signature string `json:"signature"`
+}
+
+// Represents the JSON payload provided when a Webhook is called by mailgun
+type WebhookPayload struct {
+	Signature Signature      `json:"signature"`
+	EventData events.RawJSON `json:"event-data"`
+}
+
+// Use this method to parse the webhook signature given as JSON in the webhook response
+func (mg *MailgunImpl) VerifyWebhookSignature(sig Signature) (verified bool, err error) {
+	h := hmac.New(sha256.New, []byte(mg.APIKey()))
+	io.WriteString(h, sig.TimeStamp)
+	io.WriteString(h, sig.Token)
+
+	calculatedSignature := h.Sum(nil)
+	signature, err := hex.DecodeString(sig.Signature)
+	if err != nil {
+		return false, err
+	}
+	if len(calculatedSignature) != len(signature) {
+		return false, nil
+	}
+
+	return subtle.ConstantTimeCompare(signature, calculatedSignature) == 1, nil
+}
+
+// Deprecated: Please use the VerifyWebhookSignature() to parse the latest
+// version of WebHooks from mailgun
 func (mg *MailgunImpl) VerifyWebhookRequest(req *http.Request) (verified bool, err error) {
 	h := hmac.New(sha256.New, []byte(mg.APIKey()))
 	io.WriteString(h, req.FormValue("timestamp"))


Test patch:
diff --git a/examples_test.go b/examples_test.go
index d97d2e6..7d6e78b 100644
--- a/examples_test.go
+++ b/examples_test.go
@@ -2,12 +2,17 @@ package mailgun_test
 
 import (
 	"context"
+	"encoding/json"
+	"fmt"
 	"io/ioutil"
 	"log"
+	"net/http"
+	"os"
 	"strings"
 	"time"
 
 	"github.com/mailgun/mailgun-go/v3"
+	"github.com/mailgun/mailgun-go/v3/events"
 )
 
 func ExampleMailgunImpl_ValidateEmail() {
@@ -132,14 +137,57 @@ func ExampleMailgunImpl_GetRoutes() {
 	}
 }
 
-func ExampleMailgunImpl_UpdateRoute() {
-	mg := mailgun.NewMailgun("example.com", "my_api_key")
-	ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
-	defer cancel()
-	_, err := mg.UpdateRoute(ctx, "route-id-here", mailgun.Route{
-		Priority: 2,
-	})
+func ExampleMailgunImpl_VerifyWebhookSignature() {
+	// Create an instance of the Mailgun Client
+	mg, err := mailgun.NewMailgunFromEnv()
 	if err != nil {
-		log.Fatal(err)
+		fmt.Printf("mailgun 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 raw event to extract the
+
+		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("Running...")
+	if err := http.ListenAndServe(":9090", nil); err != nil {
+		fmt.Printf("serve error: %s\n", err)
+		os.Exit(1)
 	}
 }
diff --git a/parse_test.go b/parse_test.go
index 52cad17..356d239 100644
--- a/parse_test.go
+++ b/parse_test.go
@@ -11,13 +11,13 @@ import (
 )
 
 func TestParseErrors(t *testing.T) {
-	_, err := parse([]byte(""))
+	_, err := ParseEvent([]byte(""))
 	ensure.DeepEqual(t, err.Error(), "failed to recognize event: EOF")
 
-	_, err = parse([]byte(`{"event": "unknown_event"}`))
+	_, err = ParseEvent([]byte(`{"event": "unknown_event"}`))
 	ensure.DeepEqual(t, err.Error(), "unsupported event: 'unknown_event'")
 
-	_, err = parse([]byte(`{
+	_, err = ParseEvent([]byte(`{
 		"event": "accepted",
 		"timestamp": "1420255392.850187"
 	}`))
@@ -26,7 +26,7 @@ func TestParseErrors(t *testing.T) {
 }
 
 func TestParseSuccess(t *testing.T) {
-	event, err := parse([]byte(`{
+	event, err := ParseEvent([]byte(`{
 		"event": "accepted",
 		"timestamp": 1420255392.850187,
 		"user-variables": "{}",
@@ -75,7 +75,7 @@ func TestParseSuccess(t *testing.T) {
 	ensure.DeepEqual(t, subject, "Test message going through the bus.")
 
 	// Make sure the next event parsing attempt will zero the fields.
-	event2, err := parse([]byte(`{
+	event2, err := ParseEvent([]byte(`{
 		"event": "accepted",
 		"timestamp": 1533922516.538978,
 		"recipient": "someone@example.com"
@@ -133,7 +133,7 @@ func TestTimeStamp(t *testing.T) {
 
 func TestEventNames(t *testing.T) {
 	for name := range EventNames {
-		event, err := parse([]byte(fmt.Sprintf(`{"event": "%s"}`, name)))
+		event, err := ParseEvent([]byte(fmt.Sprintf(`{"event": "%s"}`, name)))
 		ensure.Nil(t, err)
 		ensure.DeepEqual(t, event.GetName(), name)
 	}
@@ -152,7 +152,7 @@ func TestEventMessageWithAttachment(t *testing.T) {
                                  "content-type": "application/pdf",
                                  "size": 139214}],
                 "size": 142698}}`)
-	event, err := parse(body)
+	event, err := ParseEvent(body)
 	ensure.Nil(t, err)
 	ensure.DeepEqual(t, event.(*events.Delivered).Message.Attachments[0].FileName, "doc.pdf")
 }
@@ -166,7 +166,7 @@ func TestStored(t *testing.T) {
             "key": "%s",
             "url": "%s"
         }}`, key, url))
-	event, err := parse(body)
+	event, err := ParseEvent(body)
 	ensure.Nil(t, err)
 	ensure.DeepEqual(t, event.(*events.Stored).Storage.Key, key)
 	ensure.DeepEqual(t, event.(*events.Stored).Storage.URL, url)
diff --git a/webhooks_test.go b/webhooks_test.go
index 64e730e..bd17032 100644
--- a/webhooks_test.go
+++ b/webhooks_test.go
@@ -62,13 +62,28 @@ var signedTests = []bool{
 	false,
 }
 
-func TestVerifyWebhookRequest_Form(t *testing.T) {
-	if reason := SkipNetworkTest(); reason != "" {
-		t.Skip(reason)
+func TestVerifyWebhookSignature(t *testing.T) {
+	mg := NewMailgun(exampleDomain, exampleAPIKey)
+
+	for _, v := range signedTests {
+		fields := getSignatureFields(mg.APIKey(), v)
+		sig := Signature{
+			TimeStamp: fields["timestamp"],
+			Token:     fields["token"],
+			Signature: fields["signature"],
+		}
+
+		verified, err := mg.VerifyWebhookSignature(sig)
+		ensure.Nil(t, err)
+
+		if v != verified {
+			t.Errorf("VerifyWebhookSignature should return '%v' but got '%v'", v, verified)
+		}
 	}
+}
 
-	mg, err := NewMailgunFromEnv()
-	ensure.Nil(t, err)
+func TestVerifyWebhookRequest_Form(t *testing.T) {
+	mg := NewMailgun(exampleDomain, exampleAPIKey)
 
 	for _, v := range signedTests {
 		fields := getSignatureFields(mg.APIKey(), v)
@@ -84,12 +99,7 @@ func TestVerifyWebhookRequest_Form(t *testing.T) {
 }
 
 func TestVerifyWebhookRequest_MultipartForm(t *testing.T) {
-	if reason := SkipNetworkTest(); reason != "" {
-		t.Skip(reason)
-	}
-
-	mg, err := NewMailgunFromEnv()
-	ensure.Nil(t, err)
+	mg := NewMailgun(exampleDomain, exampleAPIKey)
 
 	for _, v := range signedTests {
 		fields := getSignatureFields(mg.APIKey(), v)