Skip to content

Commit 89aabae

Browse files
authored
Merge pull request #151 from thrawn01/webhooks-2.0
Added support for webhooks 2.0
2 parents 0e832c8 + 95e5134 commit 89aabae

9 files changed

Lines changed: 223 additions & 44 deletions

File tree

CHANGELOG

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [3.2.0] - 2019-01-21
8+
### Changes
9+
* Deprecated mg.VerifyWebhookRequest()
10+
11+
### Added
12+
* Added mailgun.ParseEvent()
13+
* Added mailgun.ParseEvents()
14+
* Added mg.VerifyWebhookSignature()
15+
16+
717
## [3.1.0] - 2019-01-16
818
### Changes
919
* Removed context.Context from ListDomains() signature

README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,71 @@ func main() {
184184
}
185185
```
186186

187+
## Webhook Handling
188+
```go
189+
package main
190+
191+
import (
192+
"context"
193+
"encoding/json"
194+
"fmt"
195+
"net/http"
196+
"os"
197+
"time"
198+
199+
"github.com/mailgun/mailgun-go/v3"
200+
"github.com/mailgun/mailgun-go/v3/events"
201+
)
202+
203+
func main() {
204+
mg := mailgun.NewMailgun("your-domain.com", "your-api-key")
205+
206+
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
207+
208+
var payload mailgun.WebhookPayload
209+
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
210+
fmt.Printf("decode JSON error: %s", err)
211+
w.WriteHeader(http.StatusNotAcceptable)
212+
return
213+
}
214+
215+
verified, err := mg.VerifyWebhookSignature(payload.Signature)
216+
if err != nil {
217+
fmt.Printf("verify error: %s\n", err)
218+
w.WriteHeader(http.StatusNotAcceptable)
219+
return
220+
}
221+
222+
if !verified {
223+
w.WriteHeader(http.StatusNotAcceptable)
224+
fmt.Printf("failed verification %+v\n", payload.Signature)
225+
return
226+
}
227+
228+
fmt.Printf("Verified Signature\n")
229+
230+
// Parse the event provided by the webhook payload
231+
e, err := mailgun.ParseEvent(payload.EventData)
232+
if err != nil {
233+
fmt.Printf("parse event error: %s\n", err)
234+
return
235+
}
236+
237+
switch event := e.(type) {
238+
case *events.Accepted:
239+
fmt.Printf("Accepted: auth: %t\n", event.Flags.IsAuthenticated)
240+
case *events.Delivered:
241+
fmt.Printf("Delivered transport: %s\n", event.Envelope.Transport)
242+
}
243+
})
244+
245+
fmt.Println("Serve on :9090...")
246+
if err := http.ListenAndServe(":9090", nil); err != nil {
247+
fmt.Printf("serve error: %s\n", err)
248+
os.Exit(1)
249+
}
250+
}
251+
```
187252
The official mailgun documentation includes examples using this library. Go
188253
[here](https://documentation.mailgun.com/en/latest/api_reference.html#api-reference)
189254
and click on the "Go" button at the top of the page.

events.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func (ei *EventIterator) Next(ctx context.Context, events *[]Event) bool {
8686
if ei.err != nil {
8787
return false
8888
}
89-
*events, ei.err = parseEvents(ei.Items)
89+
*events, ei.err = ParseEvents(ei.Items)
9090
if len(ei.Items) == 0 {
9191
return false
9292
}
@@ -104,7 +104,7 @@ func (ei *EventIterator) First(ctx context.Context, events *[]Event) bool {
104104
if ei.err != nil {
105105
return false
106106
}
107-
*events, ei.err = parseEvents(ei.Items)
107+
*events, ei.err = ParseEvents(ei.Items)
108108
return true
109109
}
110110

@@ -120,7 +120,7 @@ func (ei *EventIterator) Last(ctx context.Context, events *[]Event) bool {
120120
if ei.err != nil {
121121
return false
122122
}
123-
*events, ei.err = parseEvents(ei.Items)
123+
*events, ei.err = ParseEvents(ei.Items)
124124
return true
125125
}
126126

@@ -138,7 +138,7 @@ func (ei *EventIterator) Previous(ctx context.Context, events *[]Event) bool {
138138
if ei.err != nil {
139139
return false
140140
}
141-
*events, ei.err = parseEvents(ei.Items)
141+
*events, ei.err = ParseEvents(ei.Items)
142142
if len(ei.Items) == 0 {
143143
return false
144144
}

examples/examples.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,3 +884,13 @@ func UpdateWebhook(domain, apiKey string) error {
884884

885885
return mg.UpdateWebhook(ctx, "clicked", []string{"https://your_domain.com/clicked"})
886886
}
887+
888+
func VerifyWebhookSignature(domain, apiKey, timestamp, token, signature string) (bool, error) {
889+
mg := mailgun.NewMailgun(domain, apiKey)
890+
891+
return mg.VerifyWebhookSignature(mailgun.Signature{
892+
TimeStamp: timestamp,
893+
Token: token,
894+
Signature: signature,
895+
})
896+
}

examples_test.go

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ package mailgun_test
22

33
import (
44
"context"
5+
"encoding/json"
6+
"fmt"
57
"io/ioutil"
68
"log"
9+
"net/http"
10+
"os"
711
"strings"
812
"time"
913

1014
"github.com/mailgun/mailgun-go/v3"
15+
"github.com/mailgun/mailgun-go/v3/events"
1116
)
1217

1318
func ExampleMailgunImpl_ValidateEmail() {
@@ -132,14 +137,57 @@ func ExampleMailgunImpl_GetRoutes() {
132137
}
133138
}
134139

135-
func ExampleMailgunImpl_UpdateRoute() {
136-
mg := mailgun.NewMailgun("example.com", "my_api_key")
137-
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
138-
defer cancel()
139-
_, err := mg.UpdateRoute(ctx, "route-id-here", mailgun.Route{
140-
Priority: 2,
141-
})
140+
func ExampleMailgunImpl_VerifyWebhookSignature() {
141+
// Create an instance of the Mailgun Client
142+
mg, err := mailgun.NewMailgunFromEnv()
142143
if err != nil {
143-
log.Fatal(err)
144+
fmt.Printf("mailgun error: %s\n", err)
145+
os.Exit(1)
146+
}
147+
148+
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
149+
150+
var payload mailgun.WebhookPayload
151+
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
152+
fmt.Printf("decode JSON error: %s", err)
153+
w.WriteHeader(http.StatusNotAcceptable)
154+
return
155+
}
156+
157+
verified, err := mg.VerifyWebhookSignature(payload.Signature)
158+
if err != nil {
159+
fmt.Printf("verify error: %s\n", err)
160+
w.WriteHeader(http.StatusNotAcceptable)
161+
return
162+
}
163+
164+
if !verified {
165+
w.WriteHeader(http.StatusNotAcceptable)
166+
fmt.Printf("failed verification %+v\n", payload.Signature)
167+
return
168+
}
169+
170+
fmt.Printf("Verified Signature\n")
171+
172+
// Parse the raw event to extract the
173+
174+
e, err := mailgun.ParseEvent(payload.EventData)
175+
if err != nil {
176+
fmt.Printf("parse event error: %s\n", err)
177+
return
178+
}
179+
180+
switch event := e.(type) {
181+
case *events.Accepted:
182+
fmt.Printf("Accepted: auth: %t\n", event.Flags.IsAuthenticated)
183+
case *events.Delivered:
184+
fmt.Printf("Delivered transport: %s\n", event.Envelope.Transport)
185+
}
186+
})
187+
188+
fmt.Println("Running...")
189+
if err := http.ListenAndServe(":9090", nil); err != nil {
190+
fmt.Printf("serve error: %s\n", err)
191+
os.Exit(1)
144192
}
145193
}

parse.go

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,15 @@ func new_(e interface{}) func() Event {
4545
}
4646
}
4747

48-
func parseEvents(raw []events.RawJSON) ([]Event, error) {
48+
func parseResponse(raw []byte) ([]Event, error) {
49+
var resp events.Response
50+
if err := easyjson.Unmarshal(raw, &resp); err != nil {
51+
return nil, fmt.Errorf("failed to un-marshall event.Response: %s", err)
52+
}
53+
4954
var result []Event
50-
for _, value := range raw {
51-
event, err := parse(value)
55+
for _, value := range resp.Items {
56+
event, err := ParseEvent(value)
5257
if err != nil {
5358
return nil, fmt.Errorf("while parsing event: %s", err)
5459
}
@@ -57,15 +62,11 @@ func parseEvents(raw []events.RawJSON) ([]Event, error) {
5762
return result, nil
5863
}
5964

60-
func parseResponse(raw []byte) ([]Event, error) {
61-
var resp events.Response
62-
if err := easyjson.Unmarshal(raw, &resp); err != nil {
63-
return nil, fmt.Errorf("failed to un-marshall event.Response: %s", err)
64-
}
65-
65+
// Given a slice of events.RawJSON events return a slice of Event for each parsed event
66+
func ParseEvents(raw []events.RawJSON) ([]Event, error) {
6667
var result []Event
67-
for _, value := range resp.Items {
68-
event, err := parse(value)
68+
for _, value := range raw {
69+
event, err := ParseEvent(value)
6970
if err != nil {
7071
return nil, fmt.Errorf("while parsing event: %s", err)
7172
}
@@ -74,8 +75,8 @@ func parseResponse(raw []byte) ([]Event, error) {
7475
return result, nil
7576
}
7677

77-
// Parse converts raw bytes data into an event struct.
78-
func parse(raw []byte) (Event, error) {
78+
// Parse converts raw bytes data into an event struct. Can accept events.RawJSON as input
79+
func ParseEvent(raw []byte) (Event, error) {
7980
// Try to recognize the event first.
8081
var e events.EventName
8182
if err := easyjson.Unmarshal(raw, &e); err != nil {

parse_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ import (
1111
)
1212

1313
func TestParseErrors(t *testing.T) {
14-
_, err := parse([]byte(""))
14+
_, err := ParseEvent([]byte(""))
1515
ensure.DeepEqual(t, err.Error(), "failed to recognize event: EOF")
1616

17-
_, err = parse([]byte(`{"event": "unknown_event"}`))
17+
_, err = ParseEvent([]byte(`{"event": "unknown_event"}`))
1818
ensure.DeepEqual(t, err.Error(), "unsupported event: 'unknown_event'")
1919

20-
_, err = parse([]byte(`{
20+
_, err = ParseEvent([]byte(`{
2121
"event": "accepted",
2222
"timestamp": "1420255392.850187"
2323
}`))
@@ -26,7 +26,7 @@ func TestParseErrors(t *testing.T) {
2626
}
2727

2828
func TestParseSuccess(t *testing.T) {
29-
event, err := parse([]byte(`{
29+
event, err := ParseEvent([]byte(`{
3030
"event": "accepted",
3131
"timestamp": 1420255392.850187,
3232
"user-variables": "{}",
@@ -75,7 +75,7 @@ func TestParseSuccess(t *testing.T) {
7575
ensure.DeepEqual(t, subject, "Test message going through the bus.")
7676

7777
// Make sure the next event parsing attempt will zero the fields.
78-
event2, err := parse([]byte(`{
78+
event2, err := ParseEvent([]byte(`{
7979
"event": "accepted",
8080
"timestamp": 1533922516.538978,
8181
"recipient": "someone@example.com"
@@ -133,7 +133,7 @@ func TestTimeStamp(t *testing.T) {
133133

134134
func TestEventNames(t *testing.T) {
135135
for name := range EventNames {
136-
event, err := parse([]byte(fmt.Sprintf(`{"event": "%s"}`, name)))
136+
event, err := ParseEvent([]byte(fmt.Sprintf(`{"event": "%s"}`, name)))
137137
ensure.Nil(t, err)
138138
ensure.DeepEqual(t, event.GetName(), name)
139139
}
@@ -152,7 +152,7 @@ func TestEventMessageWithAttachment(t *testing.T) {
152152
"content-type": "application/pdf",
153153
"size": 139214}],
154154
"size": 142698}}`)
155-
event, err := parse(body)
155+
event, err := ParseEvent(body)
156156
ensure.Nil(t, err)
157157
ensure.DeepEqual(t, event.(*events.Delivered).Message.Attachments[0].FileName, "doc.pdf")
158158
}
@@ -166,7 +166,7 @@ func TestStored(t *testing.T) {
166166
"key": "%s",
167167
"url": "%s"
168168
}}`, key, url))
169-
event, err := parse(body)
169+
event, err := ParseEvent(body)
170170
ensure.Nil(t, err)
171171
ensure.DeepEqual(t, event.(*events.Stored).Storage.Key, key)
172172
ensure.DeepEqual(t, event.(*events.Stored).Storage.URL, url)

webhooks.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"encoding/hex"
99
"io"
1010
"net/http"
11+
12+
"github.com/mailgun/mailgun-go/v3/events"
1113
)
1214

1315
// 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
8284
return err
8385
}
8486

87+
// Represents the signature portion of the webhook POST body
88+
type Signature struct {
89+
TimeStamp string `json:"timestamp"`
90+
Token string `json:"token"`
91+
Signature string `json:"signature"`
92+
}
93+
94+
// Represents the JSON payload provided when a Webhook is called by mailgun
95+
type WebhookPayload struct {
96+
Signature Signature `json:"signature"`
97+
EventData events.RawJSON `json:"event-data"`
98+
}
99+
100+
// Use this method to parse the webhook signature given as JSON in the webhook response
101+
func (mg *MailgunImpl) VerifyWebhookSignature(sig Signature) (verified bool, err error) {
102+
h := hmac.New(sha256.New, []byte(mg.APIKey()))
103+
io.WriteString(h, sig.TimeStamp)
104+
io.WriteString(h, sig.Token)
105+
106+
calculatedSignature := h.Sum(nil)
107+
signature, err := hex.DecodeString(sig.Signature)
108+
if err != nil {
109+
return false, err
110+
}
111+
if len(calculatedSignature) != len(signature) {
112+
return false, nil
113+
}
114+
115+
return subtle.ConstantTimeCompare(signature, calculatedSignature) == 1, nil
116+
}
117+
118+
// Deprecated: Please use the VerifyWebhookSignature() to parse the latest
119+
// version of WebHooks from mailgun
85120
func (mg *MailgunImpl) VerifyWebhookRequest(req *http.Request) (verified bool, err error) {
86121
h := hmac.New(sha256.New, []byte(mg.APIKey()))
87122
io.WriteString(h, req.FormValue("timestamp"))

0 commit comments

Comments
 (0)