package email import ( "encoding/json" "fmt" "net/url" "strings" ) // UniversalInboundWebhook captures all possible field names across providers. type UniversalInboundWebhook struct { // From fields From string `json:"from"` FromCaps string `json:"From"` Sender string `json:"sender"` FromName string `json:"from_name"` // To fields To string `json:"to"` ToCaps string `json:"To"` Recipient string `json:"recipient"` ToName string `json:"to_name"` // Subject Subject string `json:"subject"` SubjectCaps string `json:"Subject"` // Body fields Text string `json:"text"` Body string `json:"body"` BodyPlain string `json:"body-plain"` TextBody string `json:"TextBody"` RawTextBody string `json:"RawTextBody"` HTML string `json:"html"` BodyHTML string `json:"body-html"` HtmlBody string `json:"HtmlBody"` RawHtmlBody string `json:"RawHtmlBody"` // Message IDs & Threading MessageID string `json:"message_id"` MessageIDCaps string `json:"MessageID"` InReplyTo string `json:"in_reply_to"` References string `json:"references"` MailboxHash string `json:"MailboxHash"` // Headers can be a map or an array of objects Headers any `json:"headers"` HeadersCaps any `json:"Headers"` // Brevo items array Items []BrevoInboundItem `json:"items"` } // ParseInboundWebhook parses raw webhook payload from various email providers into a slice of normalized InboundEmailPayloads. func ParseInboundWebhook(contentType string, rawBody []byte, form url.Values) ([]InboundEmailPayload, error) { contentType = strings.ToLower(contentType) // 1. Form-data (Mailgun, SendGrid Inbound Parse) if len(form) > 0 { fromRaw := form.Get("from") if fromRaw == "" { fromRaw = form.Get("sender") } fromEmail, fromName := ParseAddress(fromRaw) toRaw := form.Get("to") if toRaw == "" { toRaw = form.Get("recipient") } if toRaw == "" { toRaw = form.Get("To") } toEmail, toName := ParseAddress(toRaw) bodyText := form.Get("body-plain") if bodyText == "" { bodyText = form.Get("stripped-text") } if bodyText == "" { bodyText = form.Get("text") } bodyHTML := form.Get("body-html") if bodyHTML == "" { bodyHTML = form.Get("stripped-html") } if bodyHTML == "" { bodyHTML = form.Get("html") } return []InboundEmailPayload{ { FromEmail: fromEmail, FromName: fromName, ToEmail: toEmail, ToName: toName, Subject: strings.TrimSpace(form.Get("subject")), BodyText: strings.TrimSpace(bodyText), BodyHTML: strings.TrimSpace(bodyHTML), MessageID: strings.TrimSpace(form.Get("Message-Id")), InReplyTo: strings.TrimSpace(form.Get("In-Reply-To")), References: strings.TrimSpace(form.Get("References")), }, }, nil } rawStr := strings.TrimSpace(string(rawBody)) if rawStr == "" { return nil, nil } // 2. Parse JSON var u UniversalInboundWebhook if err := json.Unmarshal(rawBody, &u); err != nil { return nil, fmt.Errorf("unmarshal email json failed: %w", err) } // Check Brevo items format if len(u.Items) > 0 { var results []InboundEmailPayload for _, item := range u.Items { fromEmail, fromName := ParseAddress(item.Sender) toEmail, toName := ParseAddress(item.Recipient) msgID := "" if len(item.UUID) > 0 { msgID = item.UUID[0] } results = append(results, InboundEmailPayload{ FromEmail: fromEmail, FromName: fromName, ToEmail: toEmail, ToName: toName, Subject: strings.TrimSpace(item.Subject), BodyText: strings.TrimSpace(item.RawTextBody), BodyHTML: strings.TrimSpace(item.RawHTMLBody), MessageID: msgID, Headers: item.Headers, }) } return results, nil } // Resolve From fromRaw := firstNonEmpty(u.From, u.FromCaps, u.Sender) fromEmail, fromName := ParseAddress(fromRaw) if u.FromName != "" { fromName = u.FromName } // Resolve To toRaw := firstNonEmpty(u.To, u.ToCaps, u.Recipient) toEmail, toName := ParseAddress(toRaw) if u.ToName != "" { toName = u.ToName } // Resolve Subject subject := firstNonEmpty(u.Subject, u.SubjectCaps) // Resolve Body Text bodyText := firstNonEmpty(u.Text, u.Body, u.BodyPlain, u.TextBody, u.RawTextBody) // Resolve Body HTML bodyHTML := firstNonEmpty(u.HTML, u.BodyHTML, u.HtmlBody, u.RawHtmlBody) // Resolve Message ID messageID := firstNonEmpty(u.MessageID, u.MessageIDCaps) // Extract headers & In-Reply-To / References headersMap, inReplyTo, references := extractHeadersAndThreading(u.Headers, u.HeadersCaps, u.InReplyTo, u.References) if fromEmail == "" && toEmail == "" && subject == "" && bodyText == "" && bodyHTML == "" { return nil, fmt.Errorf("unrecognized email webhook format") } return []InboundEmailPayload{ { FromEmail: fromEmail, FromName: fromName, ToEmail: toEmail, ToName: toName, Subject: strings.TrimSpace(subject), BodyText: strings.TrimSpace(bodyText), BodyHTML: strings.TrimSpace(bodyHTML), MessageID: strings.TrimSpace(messageID), InReplyTo: strings.TrimSpace(inReplyTo), References: strings.TrimSpace(references), Headers: headersMap, }, }, nil } func extractHeadersAndThreading(headers1, headers2 any, fallbackInReply, fallbackRef string) (map[string]string, string, string) { headersMap := make(map[string]string) inReplyTo := fallbackInReply references := fallbackRef for _, h := range []any{headers1, headers2} { if h == nil { continue } switch val := h.(type) { case map[string]any: for k, v := range val { s := fmt.Sprintf("%v", v) headersMap[k] = s if strings.EqualFold(k, "In-Reply-To") && inReplyTo == "" { inReplyTo = s } if strings.EqualFold(k, "References") && references == "" { references = s } } case map[string]string: for k, v := range val { headersMap[k] = v if strings.EqualFold(k, "In-Reply-To") && inReplyTo == "" { inReplyTo = v } if strings.EqualFold(k, "References") && references == "" { references = v } } case []any: for _, item := range val { if itemMap, ok := item.(map[string]any); ok { name := fmt.Sprintf("%v", itemMap["Name"]) if name == "" { name = fmt.Sprintf("%v", itemMap["name"]) } value := fmt.Sprintf("%v", itemMap["Value"]) if value == "" { value = fmt.Sprintf("%v", itemMap["value"]) } if name != "" { headersMap[name] = value if strings.EqualFold(name, "In-Reply-To") && inReplyTo == "" { inReplyTo = value } if strings.EqualFold(name, "References") && references == "" { references = value } } } } } } return headersMap, inReplyTo, references } func firstNonEmpty(strs ...string) string { for _, s := range strs { if trimmed := strings.TrimSpace(s); trimmed != "" { return trimmed } } return "" }