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 } }