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.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user