chore: import clean Agent Desk snapshot (3/7)
This commit is contained in:
243
internal/services/email_inbound_service.go
Normal file
243
internal/services/email_inbound_service.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/email"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/config"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/openidentity"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var (
|
||||
EmailInboundService = newEmailInboundService()
|
||||
ticketIDRegex = regexp.MustCompile(`(?i)(?:\[(?:#|Ticket\s*#?)|(?:#|Ticket\s*#))\s*(\d+)`)
|
||||
)
|
||||
|
||||
func newEmailInboundService() *emailInboundService {
|
||||
return &emailInboundService{}
|
||||
}
|
||||
|
||||
type emailInboundService struct{}
|
||||
|
||||
// HandleWebhook processes an incoming email webhook from any supported provider (Cloudflare, Brevo, SendGrid, Postmark, Mailgun, Resend).
|
||||
func (s *emailInboundService) HandleWebhook(ctx context.Context, channelID string, secretHeader string, contentType string, rawPayload []byte, form url.Values) error {
|
||||
channelID = strings.TrimSpace(channelID)
|
||||
var channel *models.Channel
|
||||
if channelID != "" {
|
||||
channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeEmail, enums.StatusOk)
|
||||
}
|
||||
|
||||
// 1. Parse inbound email items
|
||||
inboundItems, err := email.ParseInboundWebhook(contentType, rawPayload, form)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse email webhook failed: %w", err)
|
||||
}
|
||||
if len(inboundItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
systemSecret := ""
|
||||
if cfg := config.GetCurrent(); cfg != nil {
|
||||
systemSecret = cfg.Email.InboundSecret
|
||||
}
|
||||
|
||||
for _, item := range inboundItems {
|
||||
targetChannel := channel
|
||||
if targetChannel == nil {
|
||||
targetChannel = ChannelService.GetEnabledEmailChannelByAddress(item.ToEmail)
|
||||
}
|
||||
if targetChannel == nil {
|
||||
targetChannel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeEmail, enums.StatusOk)
|
||||
}
|
||||
if targetChannel == nil {
|
||||
slog.Warn("no active email channel found for recipient", "to", item.ToEmail)
|
||||
return errorsx.InvalidParam("email channel not found or disabled")
|
||||
}
|
||||
|
||||
cfg, err := ChannelService.ParseEmailChannelConfig(targetChannel.ConfigJSON)
|
||||
if err != nil || cfg == nil {
|
||||
return errorsx.InvalidParam("email channel config invalid")
|
||||
}
|
||||
|
||||
expectedSecret := cfg.WebhookSecret
|
||||
if expectedSecret == "" {
|
||||
expectedSecret = systemSecret
|
||||
}
|
||||
|
||||
// Constant-time so the comparison cannot be measured to recover the secret
|
||||
// one byte at a time. Discord and Slack already do this; email did not.
|
||||
if expectedSecret != "" && subtle.ConstantTimeCompare(
|
||||
[]byte(strings.TrimSpace(secretHeader)), []byte(expectedSecret),
|
||||
) != 1 {
|
||||
return errorsx.UnauthorizedI18n("error.auth.invalidSignature")
|
||||
}
|
||||
|
||||
if err := s.processInboundItem(ctx, targetChannel, item); err != nil {
|
||||
slog.Error("process inbound email item failed", "from", item.FromEmail, "to", item.ToEmail, "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *emailInboundService) processInboundItem(ctx context.Context, channel *models.Channel, item email.InboundEmailPayload) error {
|
||||
fromEmail := strings.ToLower(strings.TrimSpace(item.FromEmail))
|
||||
if fromEmail == "" {
|
||||
return nil
|
||||
}
|
||||
fromName := strings.TrimSpace(item.FromName)
|
||||
if fromName == "" {
|
||||
parts := strings.Split(fromEmail, "@")
|
||||
fromName = parts[0]
|
||||
}
|
||||
|
||||
bodyText := strings.TrimSpace(item.BodyText)
|
||||
if bodyText == "" && item.BodyHTML != "" {
|
||||
bodyText = stripHTMLTags(item.BodyHTML)
|
||||
}
|
||||
if bodyText == "" {
|
||||
bodyText = "(Empty email body)"
|
||||
}
|
||||
|
||||
// Format content with subject if provided
|
||||
content := bodyText
|
||||
if item.Subject != "" {
|
||||
content = fmt.Sprintf("[%s]\n\n%s", item.Subject, bodyText)
|
||||
}
|
||||
|
||||
// 1. Resolve customer identity
|
||||
externalUser := openidentity.ExternalUser{
|
||||
ExternalSource: enums.ExternalSourceEmail,
|
||||
ExternalID: fromEmail,
|
||||
ExternalName: fromName,
|
||||
}
|
||||
|
||||
// 2. Threading Resolution: Find existing conversation by Ticket ID or In-Reply-To header
|
||||
var conversation *models.Conversation
|
||||
ticketID := s.extractTicketID(item.Subject)
|
||||
if ticketID > 0 {
|
||||
existing := ConversationService.Get(ticketID)
|
||||
if existing != nil && existing.Status != enums.IMConversationStatusClosed {
|
||||
conversation = existing
|
||||
}
|
||||
}
|
||||
|
||||
if conversation == nil && item.InReplyTo != "" {
|
||||
conversation = s.findConversationByInReplyTo(item.InReplyTo)
|
||||
}
|
||||
|
||||
// If no existing thread matched, create or match via standard ConversationService
|
||||
if conversation == nil {
|
||||
var err error
|
||||
conversation, err = ConversationService.Create(externalUser, channel.ID, channel.AIAgentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create email conversation failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure customer primary_email is populated
|
||||
if conversation.CustomerID > 0 {
|
||||
customer := repositories.CustomerRepository.Get(sqls.DB(), conversation.CustomerID)
|
||||
if customer != nil && customer.PrimaryEmail == "" {
|
||||
_ = repositories.CustomerRepository.UpdateColumn(sqls.DB(), customer.ID, "primary_email", fromEmail)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Send message through MessageService (triggers AI response loop or agent notification)
|
||||
msgHash := strs.UUID()
|
||||
if item.MessageID != "" {
|
||||
msgHash = fmt.Sprintf("email_%s", strings.Trim(item.MessageID, "<>"))
|
||||
}
|
||||
clientMsgID := fmt.Sprintf("mail_%s", msgHash)
|
||||
|
||||
payloadMap := map[string]any{
|
||||
"email_from": fromEmail,
|
||||
"email_from_name": fromName,
|
||||
"email_to": item.ToEmail,
|
||||
"email_subject": item.Subject,
|
||||
"email_message_id": item.MessageID,
|
||||
"email_in_reply": item.InReplyTo,
|
||||
"email_references": item.References,
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payloadMap)
|
||||
|
||||
_, err := MessageService.SendCustomerMessage(
|
||||
conversation.ID,
|
||||
clientMsgID,
|
||||
enums.IMMessageTypeText,
|
||||
content,
|
||||
string(payloadBytes),
|
||||
externalUser,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send customer message failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("inbound email successfully processed",
|
||||
"from", fromEmail,
|
||||
"channel_id", channel.ChannelID,
|
||||
"conversation_id", conversation.ID,
|
||||
"subject", item.Subject,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *emailInboundService) extractTicketID(subject string) int64 {
|
||||
matches := ticketIDRegex.FindStringSubmatch(subject)
|
||||
if len(matches) > 1 {
|
||||
if id, err := strconv.ParseInt(matches[1], 10, 64); err == nil && id > 0 {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *emailInboundService) findConversationByInReplyTo(inReplyTo string) *models.Conversation {
|
||||
inReplyTo = strings.TrimSpace(inReplyTo)
|
||||
if inReplyTo == "" {
|
||||
return nil
|
||||
}
|
||||
// Look up recent message containing this email message ID
|
||||
likePattern := "%" + inReplyTo + "%"
|
||||
msg := repositories.MessageRepository.FindOne(sqls.DB(), sqls.NewCnd().
|
||||
Where("payload LIKE ?", likePattern).
|
||||
Desc("id"))
|
||||
if msg != nil && msg.ConversationID > 0 {
|
||||
conv := ConversationService.Get(msg.ConversationID)
|
||||
if conv != nil && conv.Status != enums.IMConversationStatusClosed {
|
||||
return conv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stripHTMLTags(s string) string {
|
||||
var builder strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
} else if r == '>' {
|
||||
inTag = false
|
||||
} else if !inTag {
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
Reference in New Issue
Block a user