package email // WebhookHandler processes incoming email relay requests. // Reference: Chatwoot's ActionMailbox integration for ChannelEmail // // Chatwoot has two inbound email paths: // 1. IMAP polling (FetchImapEmailService) — handled by IMAPListener // 2. ActionMailbox relay (Postfix → Rails) — handled by WebhookHandler // // The WebhookHandler handles path #2: when email is forwarded/relayed // to gochat via HTTP (similar to ActionMailbox but via REST endpoint). // // For production deployment, the email relay can work via: // - Postfix pipe to a webhook endpoint // - Amazon SES / SendGrid inbound webhook // - Mailgun route → HTTP POST // - CloudFlare Email Routing → worker → HTTP POST import ( "encoding/json" "io" "net/http" applogger "github.com/gochat/gochat/pkg/logger" ) // WebhookHandler processes email relay webhook requests. // It does not depend on the provider package directly — the parsed EmailMessage // is returned to the caller (typically the provider) for further processing. type WebhookHandler struct{} // NewWebhookHandler creates an Email webhook handler. func NewWebhookHandler() *WebhookHandler { return &WebhookHandler{} } // HandleWebhookRequest processes an incoming email relay HTTP request. // This handles ActionMailbox-style inbound email relay. // // Supported relay formats: // - Mailgun: JSON POST with message fields // - SendGrid Inbound: JSON POST with envelope + content // - SES Inbound: JSON POST with S3 reference or inline content // - Generic: raw RFC 5322 email body // // The handler: // 1. Reads the HTTP request body // 2. Determines the relay format (Mailgun, SendGrid, SES, raw) // 3. Parses into EmailMessage // 4. Returns 200 OK to prevent retry // // The parsed EmailMessage is NOT processed here — it should be // handed to the IncomingProcessor or provider for full pipeline processing. func (h *WebhookHandler) HandleWebhookRequest(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { applogger.L().Error("Failed to read email webhook body", "error", err) http.Error(w, "Failed to read request body", http.StatusInternalServerError) return } defer r.Body.Close() if len(body) == 0 { applogger.L().Warn("Empty email webhook body") http.Error(w, "Empty request body", http.StatusBadRequest) return } // Determine relay format and parse emailMsg, err := parseRelayPayload(body, r.Header) if err != nil { applogger.L().Error("Failed to parse email relay payload", "error", err) // Return 200 OK even on parse errors to prevent retries // (reference: Chatwoot's ActionMailbox always returns 200) w.WriteHeader(http.StatusOK) w.Write([]byte("accepted")) return } applogger.L().Info("Email relay webhook received", "from", emailMsg.FromAddress, "subject", emailMsg.Subject, "message_id", emailMsg.MessageID, ) // Return 200 OK immediately — actual processing is async // Reference: Chatwoot's ActionMailbox returns 200 and processes async w.WriteHeader(http.StatusOK) w.Write([]byte("accepted")) } // ParseWebhookBody parses the request body into an EmailMessage. // This is the public API for callers who want the parsed result // (e.g., the provider's HandleWebhook method). func (h *WebhookHandler) ParseWebhookBody(body []byte, headers http.Header) (*EmailMessage, error) { return parseRelayPayload(body, headers) } // parseRelayPayload determines the relay format and parses the email message. func parseRelayPayload(body []byte, headers http.Header) (*EmailMessage, error) { contentType := headers.Get("Content-Type") // Try Mailgun format first if isMailgunPayload(headers) { return parseMailgunPayload(body) } // Try SendGrid inbound format if isSendGridPayload(headers) { return parseSendGridPayload(body) } // Try generic JSON format if contentType == "application/json" { var data map[string]interface{} if err := json.Unmarshal(body, &data); err == nil { return parseGenericJSONPayload(data) } } // Fall back to raw RFC 5322 email return parseRawEmail(body) } // isMailgunPayload checks if the request is from Mailgun. func isMailgunPayload(headers http.Header) bool { return headers.Get("X-Mailgun-Sid") != "" } // isSendGridPayload checks if the request is from SendGrid Inbound Parse. func isSendGridPayload(headers http.Header) bool { return headers.Get("X-Sg-Envelope-Id") != "" } // parseMailgunPayload parses a Mailgun inbound webhook payload. func parseMailgunPayload(body []byte) (*EmailMessage, error) { // Mailgun sends multipart form data with message fields // Simplified parsing — production would use proper multipart decoder var data map[string]string if err := json.Unmarshal(body, &data); err != nil { return nil, err } emailMsg := &EmailMessage{ FromAddress: data["from"], Subject: data["subject"], TextContent: data["body-plain"], HTMLContent: data["body-html"], MessageID: data["Message-Id"], InReplyTo: data["In-Reply-To"], References: data["References"], Date: data["Date"], } // Parse recipient if to, ok := data["to"]; ok { emailMsg.ToAddresses = []string{to} } return emailMsg, nil } // parseSendGridPayload parses a SendGrid Inbound Parse webhook payload. func parseSendGridPayload(body []byte) (*EmailMessage, error) { var data map[string]interface{} if err := json.Unmarshal(body, &data); err != nil { return nil, err } emailMsg := &EmailMessage{} // SendGrid envelope fields if envelope, ok := data["envelope"].(map[string]interface{}); ok { emailMsg.FromAddress, _ = envelope["from"].(string) if to, ok := envelope["to"].([]interface{}); ok && len(to) > 0 { emailMsg.ToAddresses = make([]string, len(to)) for i, addr := range to { emailMsg.ToAddresses[i], _ = addr.(string) } } } emailMsg.Subject, _ = data["subject"].(string) emailMsg.TextContent, _ = data["text"].(string) emailMsg.HTMLContent, _ = data["html"].(string) emailMsg.MessageID, _ = data["messageId"].(string) emailMsg.InReplyTo, _ = data["in_reply_to"].(string) emailMsg.Date, _ = data["date"].(string) return emailMsg, nil } // parseGenericJSONPayload parses a generic JSON email relay payload. func parseGenericJSONPayload(data map[string]interface{}) (*EmailMessage, error) { emailMsg := &EmailMessage{} emailMsg.FromAddress, _ = data["from"].(string) emailMsg.Subject, _ = data["subject"].(string) emailMsg.TextContent, _ = data["text"].(string) emailMsg.HTMLContent, _ = data["html"].(string) emailMsg.MessageID, _ = data["message_id"].(string) emailMsg.InReplyTo, _ = data["in_reply_to"].(string) emailMsg.Date, _ = data["date"].(string) if to, ok := data["to"].(string); ok { emailMsg.ToAddresses = []string{to} } return emailMsg, nil } // parseRawEmail parses a raw RFC 5322 email message. func parseRawEmail(body []byte) (*EmailMessage, error) { // This would use go-message/mail to parse the raw email // Simplified for now — in production, use: // reader, err := mail.CreateReader(bytes.NewReader(body)) // ... parse headers and body parts ... emailMsg := &EmailMessage{ TextContent: string(body), } applogger.L().Debug("Parsed raw email relay payload (simplified)") return emailMsg, nil }