225 lines
7.1 KiB
Go
225 lines
7.1 KiB
Go
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
|
|
} |