242 lines
6.4 KiB
Go
242 lines
6.4 KiB
Go
package webhooks
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"source.offmarket.win/aleagle/eaglecast/models"
|
|
)
|
|
|
|
var reSMTPStatus = regexp.MustCompile(`\b([245]\.\d\.\d)\b`)
|
|
|
|
type azureEvent struct {
|
|
EventType string `json:"eventType"`
|
|
Data azureEventData `json:"data"`
|
|
RawData json.RawMessage `json:"-"`
|
|
}
|
|
|
|
type azureEventData struct {
|
|
Recipient string `json:"recipient"`
|
|
MessageID string `json:"messageId"`
|
|
InternetMessageID string `json:"internetMessageId"`
|
|
Status string `json:"status"`
|
|
DeliveryStatusDetails azureDeliveryStatus `json:"deliveryStatusDetails"`
|
|
DeliveryAttemptTimestamp *time.Time `json:"deliveryAttemptTimestamp"`
|
|
DeliveryAttemptTimeStamp *time.Time `json:"deliveryAttemptTimeStamp"`
|
|
AdditionalData map[string]json.RawMessage `json:"-"`
|
|
}
|
|
|
|
type azureDeliveryStatus struct {
|
|
StatusMessage string `json:"statusMessage"`
|
|
}
|
|
|
|
// Azure handles webhook notifications from Azure Communication Services through Event Grid.
|
|
type Azure struct {
|
|
sharedSecret string
|
|
sharedSecretHeader string
|
|
}
|
|
|
|
// NewAzure returns a new Azure webhook handler.
|
|
func NewAzure(sharedSecret, sharedSecretHeader string) *Azure {
|
|
return &Azure{
|
|
sharedSecret: strings.TrimSpace(sharedSecret),
|
|
sharedSecretHeader: strings.TrimSpace(sharedSecretHeader),
|
|
}
|
|
}
|
|
|
|
// ProcessSubscription processes Event Grid subscription validation requests and
|
|
// returns the response payload that should be written to HTTP response body.
|
|
func (a *Azure) ProcessSubscription(b []byte) (json.RawMessage, error) {
|
|
events, err := parseAzureEvents(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(events) == 0 {
|
|
return nil, errors.New("empty event payload")
|
|
}
|
|
|
|
// Validation code arrives in the first event for subscription validation flow.
|
|
var payload map[string]json.RawMessage
|
|
if err := json.Unmarshal(events[0].RawData, &payload); err != nil {
|
|
return nil, fmt.Errorf("error reading validation payload: %v", err)
|
|
}
|
|
|
|
rawData, ok := payload["data"]
|
|
if !ok {
|
|
return nil, errors.New("missing event data")
|
|
}
|
|
|
|
var data map[string]string
|
|
if err := json.Unmarshal(rawData, &data); err != nil {
|
|
return nil, fmt.Errorf("error reading validation data: %v", err)
|
|
}
|
|
|
|
code := strings.TrimSpace(data["validationCode"])
|
|
if code == "" {
|
|
return nil, errors.New("missing validationCode in subscription payload")
|
|
}
|
|
|
|
res, _ := json.Marshal(map[string]string{
|
|
"validationResponse": code,
|
|
})
|
|
return json.RawMessage(res), nil
|
|
}
|
|
|
|
// ProcessBounce parses Azure Event Grid email delivery events and returns bounce entries.
|
|
func (a *Azure) ProcessBounce(req *http.Request, b []byte) ([]models.Bounce, error) {
|
|
if err := a.verifyAuth(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
events, err := parseAzureEvents(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]models.Bounce, 0, len(events))
|
|
for _, ev := range events {
|
|
// Ignore non-delivery report events.
|
|
if ev.EventType != "Microsoft.Communication.EmailDeliveryReportReceived" {
|
|
continue
|
|
}
|
|
|
|
typ, ok := mapAzureStatus(ev.Data.Status, ev.Data.DeliveryStatusDetails.StatusMessage)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
email := strings.ToLower(strings.TrimSpace(ev.Data.Recipient))
|
|
if email == "" {
|
|
continue
|
|
}
|
|
|
|
tstamp := ev.Data.DeliveryAttemptTimestamp
|
|
if tstamp == nil {
|
|
tstamp = ev.Data.DeliveryAttemptTimeStamp
|
|
}
|
|
|
|
createdAt := time.Now()
|
|
if tstamp != nil && !tstamp.IsZero() {
|
|
createdAt = *tstamp
|
|
}
|
|
|
|
out = append(out, models.Bounce{
|
|
Email: email,
|
|
Type: typ,
|
|
Meta: json.RawMessage(ev.RawData),
|
|
Source: "azure",
|
|
CreatedAt: createdAt,
|
|
})
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func (a *Azure) verifyAuth(req *http.Request) error {
|
|
const (
|
|
defaultSecretHeader = "X-EagleCast-Webhook-Secret"
|
|
querySecretParam = "code"
|
|
)
|
|
|
|
// If no local credential is configured, allow webhook payloads.
|
|
if a.sharedSecret == "" {
|
|
return nil
|
|
}
|
|
|
|
if req == nil {
|
|
return errors.New("missing azure event grid request context")
|
|
}
|
|
|
|
querySecret := strings.TrimSpace(req.URL.Query().Get(querySecretParam))
|
|
if secretsEqual(a.sharedSecret, querySecret) {
|
|
return nil
|
|
}
|
|
|
|
headerName := a.sharedSecretHeader
|
|
if headerName == "" {
|
|
headerName = defaultSecretHeader
|
|
}
|
|
headerSecret := strings.TrimSpace(req.Header.Get(headerName))
|
|
if secretsEqual(a.sharedSecret, headerSecret) {
|
|
return nil
|
|
}
|
|
|
|
return errors.New("invalid azure event grid shared secret")
|
|
}
|
|
|
|
func secretsEqual(expected, given string) bool {
|
|
if expected == "" || given == "" {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(expected), []byte(given)) == 1
|
|
}
|
|
|
|
func parseAzureEvents(b []byte) ([]azureEvent, error) {
|
|
var raws []json.RawMessage
|
|
if err := json.Unmarshal(b, &raws); err != nil {
|
|
return nil, fmt.Errorf("error unmarshalling azure notification array: %v", err)
|
|
}
|
|
|
|
events := make([]azureEvent, 0, len(raws))
|
|
for _, raw := range raws {
|
|
ev := azureEvent{RawData: raw}
|
|
if err := json.Unmarshal(raw, &ev); err != nil {
|
|
return nil, fmt.Errorf("error unmarshalling azure event: %v", err)
|
|
}
|
|
events = append(events, ev)
|
|
}
|
|
|
|
return events, nil
|
|
}
|
|
|
|
func mapAzureStatus(status, details string) (string, bool) {
|
|
s := strings.ToLower(strings.TrimSpace(status))
|
|
d := strings.ToLower(strings.TrimSpace(details))
|
|
|
|
switch s {
|
|
case "bounced", "suppressed":
|
|
return models.BounceTypeHard, true
|
|
case "failed":
|
|
// Infer severity from SMTP-enhanced status if available.
|
|
if m := reSMTPStatus.FindStringSubmatch(d); len(m) > 1 {
|
|
if strings.HasPrefix(m[1], "5.") {
|
|
return models.BounceTypeHard, true
|
|
}
|
|
if strings.HasPrefix(m[1], "4.") {
|
|
return models.BounceTypeSoft, true
|
|
}
|
|
}
|
|
|
|
if strings.Contains(d, "mailbox full") ||
|
|
strings.Contains(d, "temporar") ||
|
|
strings.Contains(d, "timeout") ||
|
|
strings.Contains(d, "rate limit") ||
|
|
strings.Contains(d, "throttle") {
|
|
return models.BounceTypeSoft, true
|
|
}
|
|
if strings.Contains(d, "invalid") ||
|
|
strings.Contains(d, "does not exist") ||
|
|
strings.Contains(d, "unknown user") ||
|
|
strings.Contains(d, "domain") {
|
|
return models.BounceTypeHard, true
|
|
}
|
|
|
|
return models.BounceTypeSoft, true
|
|
case "filteredspam", "quarantined":
|
|
if strings.Contains(d, "spam complaint") ||
|
|
strings.Contains(d, "complaint") ||
|
|
strings.Contains(d, "abuse") {
|
|
return models.BounceTypeComplaint, true
|
|
}
|
|
return models.BounceTypeSoft, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|