Skip to content

Commit

Permalink
Merge pull request #151 from thrawn01/webhooks-2.0
Browse files Browse the repository at this point in the history
Added support for webhooks 2.0
  • Loading branch information
thrawn01 authored Jan 23, 2019
2 parents 0e832c8 + 95e5134 commit 89aabae
Show file tree
Hide file tree
Showing 9 changed files with 223 additions and 44 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
}
Expand Down
10 changes: 10 additions & 0 deletions examples/examples.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
64 changes: 56 additions & 8 deletions examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
}
}
27 changes: 14 additions & 13 deletions parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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 {
Expand Down
16 changes: 8 additions & 8 deletions parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}`))
Expand All @@ -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": "{}",
Expand Down Expand Up @@ -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": "[email protected]"
Expand Down Expand Up @@ -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)
}
Expand All @@ -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")
}
Expand All @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions webhooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"))
Expand Down
Loading

0 comments on commit 89aabae

Please sign in to comment.