Files
gochat/backend/internal/channel/email/webhook_handler.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

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
}