-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwebhook.go
More file actions
224 lines (185 loc) · 6.89 KB
/
Copy pathwebhook.go
File metadata and controls
224 lines (185 loc) · 6.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
package email
import (
"context"
"net/http"
"time"
)
// EventType represents the type of a webhook delivery notification event.
type EventType string
const (
// EventDelivered indicates the message was accepted by the recipient's mail server.
EventDelivered EventType = "delivered"
// EventBounced indicates a hard bounce (permanent delivery failure).
EventBounced EventType = "bounced"
// EventDeferred indicates a soft bounce (temporary delivery failure).
EventDeferred EventType = "deferred"
// EventOpened indicates the recipient opened the email.
EventOpened EventType = "opened"
// EventClicked indicates the recipient clicked a link in the email.
EventClicked EventType = "clicked"
// EventComplained indicates the recipient marked the email as spam.
EventComplained EventType = "complained"
// EventUnsubscribed indicates the recipient unsubscribed.
EventUnsubscribed EventType = "unsubscribed"
// EventDropped indicates the provider rejected the message before sending.
EventDropped EventType = "dropped"
)
// WebhookEvent is the provider-agnostic representation of a delivery
// notification event. Provider-specific parsers (in providers/ submodules)
// normalize raw webhook payloads into this type.
type WebhookEvent struct {
// Type is the normalized event type.
Type EventType
// MessageID is the provider-assigned message identifier.
MessageID string
// Recipient is the email address this event relates to.
Recipient string
// Timestamp is when the event occurred at the provider.
Timestamp time.Time
// Provider identifies the source (e.g. "sendgrid", "mailgun", "ses").
Provider string
// Reason contains detail for bounces, drops, or deferrals.
// Empty for positive events like delivered/opened.
Reason string
// URL is the clicked URL for EventClicked events. Empty otherwise.
URL string
// UserAgent is the user agent for open/click events when available.
UserAgent string
// IP is the IP address associated with the event when available.
IP string
// Tags contains provider-specific tags/categories for the message.
Tags []string
// Metadata holds arbitrary provider-specific key-value data that doesn't
// map to a named field.
Metadata map[string]string
// RawPayload is the original bytes from the provider, preserved for
// debugging or provider-specific processing.
RawPayload []byte
}
// WebhookParser parses provider-specific HTTP requests into WebhookEvents.
// Each provider adapter (providers/webhooksendgrid, etc.) implements this
// interface. Implementations must be safe for concurrent use.
type WebhookParser interface {
// Parse reads the HTTP request and returns zero or more normalized events.
// Providers like SendGrid batch multiple events per request.
Parse(r *http.Request) ([]WebhookEvent, error)
}
// WebhookHandler handles normalized webhook events.
// Implementations must be safe for concurrent use.
type WebhookHandler interface {
// HandleEvent processes a single webhook event.
// Returning an error causes the WebhookReceiver to respond with 500,
// signaling the provider to retry delivery.
HandleEvent(ctx context.Context, event WebhookEvent) error
}
// WebhookHandlerFunc is an adapter to allow ordinary functions to be used
// as WebhookHandlers, following the net/http.HandlerFunc pattern.
type WebhookHandlerFunc func(ctx context.Context, event WebhookEvent) error
// HandleEvent calls f(ctx, event).
func (f WebhookHandlerFunc) HandleEvent(ctx context.Context, event WebhookEvent) error {
return f(ctx, event)
}
// WebhookReceiver is an [http.Handler] that receives webhook POSTs from
// email providers, parses them using a [WebhookParser], and dispatches
// normalized events to a [WebhookHandler].
//
// It is safe for concurrent use.
//
// receiver := email.NewWebhookReceiver(parser, handler)
// http.Handle("/webhooks/email", receiver)
type WebhookReceiver struct {
parser WebhookParser
handler WebhookHandler
logger Logger
filter map[EventType]struct{}
}
// WebhookReceiverOption configures a [WebhookReceiver].
type WebhookReceiverOption func(*WebhookReceiver)
// WithWebhookLogger sets the logger for the webhook receiver.
func WithWebhookLogger(l Logger) WebhookReceiverOption {
return func(wr *WebhookReceiver) {
if l != nil {
wr.logger = l
}
}
}
// WithEventFilter restricts the receiver to only dispatch the listed event
// types. Events not in the filter are silently acknowledged (200 OK) but
// not dispatched to the handler.
func WithEventFilter(types ...EventType) WebhookReceiverOption {
return func(wr *WebhookReceiver) {
wr.filter = make(map[EventType]struct{}, len(types))
for _, t := range types {
wr.filter[t] = struct{}{}
}
}
}
// NewWebhookReceiver creates a new [WebhookReceiver].
func NewWebhookReceiver(parser WebhookParser, handler WebhookHandler, opts ...WebhookReceiverOption) *WebhookReceiver {
wr := &WebhookReceiver{
parser: parser,
handler: handler,
logger: NoOpLogger{},
}
for _, opt := range opts {
opt(wr)
}
return wr
}
// ServeHTTP implements [http.Handler]. It parses the request, dispatches
// each event to the handler, and responds:
// - 200 OK if all events are handled successfully
// - 400 Bad Request if the request cannot be parsed
// - 405 Method Not Allowed if the method is not POST
// - 500 Internal Server Error if any handler returns an error
//
// All events in the batch are dispatched even if one fails — the receiver
// does not short-circuit on the first error. This avoids dropping later
// events in a batch when a transient error occurs in the middle, but it
// means handlers MUST be idempotent: a 500 response causes the provider
// to redeliver the entire batch, including events that already succeeded.
func (wr *WebhookReceiver) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
events, err := wr.parser.Parse(r)
if err != nil {
wr.logger.Error("webhook parse failed", "error", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
wr.logger.Debug("webhook received", "event_count", len(events))
failures := 0
for i := range events {
if err := wr.dispatchEvent(r.Context(), events[i]); err != nil {
failures++
}
}
if failures > 0 {
wr.logger.Error("webhook batch had handler failures",
"failures", failures,
"total", len(events),
)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// dispatchEvent checks the filter and dispatches a single event.
func (wr *WebhookReceiver) dispatchEvent(ctx context.Context, event WebhookEvent) error {
if wr.filter != nil {
if _, ok := wr.filter[event.Type]; !ok {
return nil
}
}
if err := wr.handler.HandleEvent(ctx, event); err != nil {
wr.logger.Error("webhook handler failed",
"event_type", string(event.Type),
"message_id", event.MessageID,
"error", err,
)
return err
}
return nil
}