EagleCast
publish-github-pages / deploy (push) Has been cancelled

This commit is contained in:
h202-wq
2026-07-09 10:03:32 -04:00
commit 66d9a033c9
446 changed files with 162542 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
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
}
}