chore: import clean Agent Desk snapshot (3/7)

This commit is contained in:
2026-10-16 11:58:36 +00:00
parent 89c5231c4c
commit fe4a99cf4b
127 changed files with 29641 additions and 0 deletions

View 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())
}