From 22af66c44daccce8a63a3df72ca274be82b30120 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 17 Dec 2025 01:26:04 +0530 Subject: [PATCH] Feat: match incoming messages to conversations using reference number in the subject. refactor: update GetConversation method to include reference number parameter --- cmd/conversation.go | 4 +- cmd/csat.go | 2 +- cmd/macro.go | 2 +- internal/automation/automation.go | 6 +-- internal/automation/evaluator_test.go | 4 +- internal/conversation/conversation.go | 18 ++++---- internal/conversation/message.go | 53 ++++++++++++++++------- internal/conversation/models/models.go | 7 ++-- internal/conversation/queries.sql | 2 + internal/inbox/channel/email/smtp.go | 10 ++++- internal/stringutil/stringutil.go | 20 ++++++++- internal/stringutil/stringutil_test.go | 58 ++++++++++++++++++++++++++ 12 files changed, 148 insertions(+), 38 deletions(-) diff --git a/cmd/conversation.go b/cmd/conversation.go index 1dc0ed0f..1d0a3cec 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -597,7 +597,7 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error { // enforceConversationAccess fetches the conversation and checks if the user has access to it. func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmodels.Conversation, error) { - conversation, err := app.conversation.GetConversation(0, uuid) + conversation, err := app.conversation.GetConversation(0, uuid, "") if err != nil { return nil, err } @@ -759,7 +759,7 @@ func handleCreateConversation(r *fastglue.Request) error { } // Trigger webhook event for conversation created. - conversation, err := app.conversation.GetConversation(conversationID, "") + conversation, err := app.conversation.GetConversation(conversationID, "", "") if err == nil { app.webhook.TriggerEvent(wmodels.EventConversationCreated, conversation) } diff --git a/cmd/csat.go b/cmd/csat.go index e60fba31..0c2e8a7d 100644 --- a/cmd/csat.go +++ b/cmd/csat.go @@ -35,7 +35,7 @@ func handleShowCSAT(r *fastglue.Request) error { }) } - conversation, err := app.conversation.GetConversation(csat.ConversationID, "") + conversation, err := app.conversation.GetConversation(csat.ConversationID, "", "") if err != nil { return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ "Data": map[string]interface{}{ diff --git a/cmd/macro.go b/cmd/macro.go index 6d1b15ab..1ede93c9 100644 --- a/cmd/macro.go +++ b/cmd/macro.go @@ -146,7 +146,7 @@ func handleApplyMacro(r *fastglue.Request) error { } // Enforce conversation access. - conversation, err := app.conversation.GetConversation(0, conversationUUID) + conversation, err := app.conversation.GetConversation(0, conversationUUID, "") if err != nil { return sendErrorEnvelope(r, err) } diff --git a/internal/automation/automation.go b/internal/automation/automation.go index d2aa7ee5..5a31c9e5 100644 --- a/internal/automation/automation.go +++ b/internal/automation/automation.go @@ -66,7 +66,7 @@ type Opts struct { type conversationStore interface { ApplyAction(action models.RuleAction, conversation cmodels.Conversation, user umodels.User) error - GetConversation(teamID int, uuid string) (cmodels.Conversation, error) + GetConversation(teamID int, uuid, refNum string) (cmodels.Conversation, error) GetConversationsCreatedAfter(time.Time) ([]cmodels.Conversation, error) } @@ -318,7 +318,7 @@ func (e *Engine) EvaluateConversationUpdateRules(conversation cmodels.Conversati // EvaluateConversationUpdateRulesByID fetches conversation by ID and enqueues for rule evaluation, // This function is useful when callers want to fresh fetch the conversation from the database instead of passing it directly as they might have a stale copy. func (e *Engine) EvaluateConversationUpdateRulesByID(conversationID int, conversationUUID, eventType string) { - conversation, err := e.conversationStore.GetConversation(conversationID, conversationUUID) + conversation, err := e.conversationStore.GetConversation(conversationID, conversationUUID, "") if err != nil { e.lo.Error("error fetching conversation", "conversation_id", conversationID, "error", err) return @@ -365,7 +365,7 @@ func (e *Engine) handleTimeTrigger() { e.lo.Info("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules)) for _, c := range conversations { // Fetch entire conversation. - conversation, err := e.conversationStore.GetConversation(0, c.UUID) + conversation, err := e.conversationStore.GetConversation(0, c.UUID, "") if err != nil { e.lo.Error("error fetching conversation for time trigger", "uuid", c.UUID, "error", err) continue diff --git a/internal/automation/evaluator_test.go b/internal/automation/evaluator_test.go index 24b65437..bbfbf65b 100644 --- a/internal/automation/evaluator_test.go +++ b/internal/automation/evaluator_test.go @@ -28,8 +28,8 @@ func (m *mockConversationStore) ApplyAction(action models.RuleAction, conversati return args.Error(0) } -func (m *mockConversationStore) GetConversation(teamID int, uuid string) (cmodels.Conversation, error) { - args := m.Called(teamID, uuid) +func (m *mockConversationStore) GetConversation(teamID int, uuid, refNum string) (cmodels.Conversation, error) { + args := m.Called(teamID, uuid, refNum) return args.Get(0).(cmodels.Conversation), args.Error(1) } diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 0688c647..8a1b4e69 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -259,16 +259,16 @@ func (c *Manager) CreateConversation(contactID, contactChannelID, inboxID int, l } // GetConversation retrieves a conversation by its ID or UUID. -func (c *Manager) GetConversation(id int, uuid string) (models.Conversation, error) { +func (c *Manager) GetConversation(id int, uuid, refNum string) (models.Conversation, error) { var conversation models.Conversation var uuidParam any if uuid != "" { uuidParam = uuid } - if err := c.q.GetConversation.Get(&conversation, id, uuidParam); err != nil { + if err := c.q.GetConversation.Get(&conversation, id, uuidParam, refNum); err != nil { if err == sql.ErrNoRows { - return conversation, envelope.NewError(envelope.InputError, + return conversation, envelope.NewError(envelope.NotFoundError, c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.conversation}"), nil) } c.lo.Error("error fetching conversation", "error", err) @@ -505,7 +505,7 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac }) // Refetch the conversation to get the updated details. - conversation, err := c.GetConversation(0, uuid) + conversation, err := c.GetConversation(0, uuid, "") if err != nil { return err } @@ -528,7 +528,7 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac // UpdateConversationTeamAssignee sets the assignee of a conversation to a specific team and sets the assigned user id to NULL. func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor umodels.User) error { // Store previously assigned team ID to apply SLA policy if team has changed. - conversation, err := c.GetConversation(0, uuid) + conversation, err := c.GetConversation(0, uuid, "") if err != nil { return err } @@ -554,7 +554,7 @@ func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor return nil } // Fetch the conversation again to get the updated details. - conversation, err := c.GetConversation(0, uuid) + conversation, err := c.GetConversation(0, uuid, "") if err != nil { return nil } @@ -614,7 +614,7 @@ func (c *Manager) UpdateConversationPriority(uuid string, priorityID int, priori } // Evaluate automation rules for conversation priority change. - conversation, err := c.GetConversation(0, uuid) + conversation, err := c.GetConversation(0, uuid, "") if err == nil { c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationPriorityChange) } @@ -653,7 +653,7 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn snoozeUntil = time.Now().Add(duration) } - conversationBeforeChange, err := c.GetConversation(0, uuid) + conversationBeforeChange, err := c.GetConversation(0, uuid, "") if err != nil { c.lo.Error("error fetching conversation before status change", "uuid", uuid, "error", err) return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil) @@ -694,7 +694,7 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn c.BroadcastConversationUpdate(uuid, "status", status) // Evaluate automation rules. - conversation, err := c.GetConversation(0, uuid) + conversation, err := c.GetConversation(0, uuid, "") if err != nil { c.lo.Error("error fetching conversation after status change", "uuid", uuid, "error", err) } else { diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 855cd4cd..acd8cc92 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -192,7 +192,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { return } if message.SenderID != systemUser.ID { - conversation, err := m.GetConversation(message.ConversationID, "") + conversation, err := m.GetConversation(message.ConversationID, "", "") if err != nil { m.lo.Error("error fetching conversation", "conversation_id", message.ConversationID, "error", err) return @@ -224,7 +224,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { func (m *Manager) RenderMessageInTemplate(channel string, message *models.Message) error { switch channel { case inbox.ChannelEmail: - conversation, err := m.GetConversation(0, message.ConversationUUID) + conversation, err := m.GetConversation(0, message.ConversationUUID, "") if err != nil { m.lo.Error("error fetching conversation", "uuid", message.ConversationUUID, "error", err) return fmt.Errorf("fetching conversation: %w", err) @@ -639,8 +639,8 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { } in.Message.SenderID = in.Contact.ID - // Conversation already exists for this message? Skip if it does. - conversationID, err := m.findConversationID([]string{in.Message.SourceID.String}) + // Message exists by source ID? + conversationID, err := m.messageExistsBySourceID([]string{in.Message.SourceID.String}) if err != nil && err != errConversationNotFound { return err } @@ -648,10 +648,33 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { return nil } - // Find or create new conversation. - isNewConversation, err := m.findOrCreateConversation(&in.Message, in.InboxID, in.Contact.ContactChannelID, in.Contact.ID) - if err != nil { - return err + var isNewConversation bool + + // Try to match conversation by reference number in subject (e.g., "RE: Test - #392"). + if refNum := stringutil.ExtractReferenceNumber(in.Message.Subject); refNum != "" { + conversation, err := m.GetConversation(0, "", refNum) + if err != nil { + envErr, ok := err.(envelope.Error) + if !ok || envErr.ErrorType != envelope.NotFoundError { + return fmt.Errorf("fetching conversation: %w", err) + } + } + if conversation.Contact.Email.String != "" && strings.EqualFold(conversation.Contact.Email.String, in.Contact.Email.String) { + // Conversation found and contact email matches, use this conversation. + in.Message.ConversationID = conversation.ID + in.Message.ConversationUUID = conversation.UUID + m.lo.Debug("matched conversation by reference number in subject", "reference_number", refNum, "contact_email", in.Contact.Email.String) + } else { + m.lo.Debug("reference number found in subject but contact email did not match, skipping conversation match", "reference_number", refNum, "conversation_contact_email", conversation.Contact.Email.String, "message_contact_email", in.Contact.Email.String) + } + } + + // If conversation not matched via reference number, find conversation using references and in-reply-to headers else create a new one. + if in.Message.ConversationID == 0 { + isNewConversation, err = m.findOrCreateConversation(&in.Message, in.InboxID, in.Contact.ContactChannelID, in.Contact.ID) + if err != nil { + return err + } } // Upload message attachments, on failure delete the conversation if it was just created for this message. @@ -673,7 +696,7 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { // Evaluate automation rules & send webhook events. if isNewConversation { - conversation, err := m.GetConversation(in.Message.ConversationID, "") + conversation, err := m.GetConversation(in.Message.ConversationID, "", "") if err == nil { m.webhookStore.TriggerEvent(wmodels.EventConversationCreated, conversation) m.automation.EvaluateNewConversationRules(conversation) @@ -697,7 +720,7 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { // Create SLA event for next response if a SLA is applied and has next response time set, subsequent agent replies will mark this event as met. // This cycle continues for next response time SLA metric. - conversation, err := m.GetConversation(in.Message.ConversationID, "") + conversation, err := m.GetConversation(in.Message.ConversationID, "", "") if err != nil { m.lo.Error("error fetching conversation", "conversation_id", in.Message.ConversationID, "error", err) } else { @@ -722,7 +745,7 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { // MessageExists checks if a message with the given messageID exists. func (m *Manager) MessageExists(messageID string) (bool, error) { - _, err := m.findConversationID([]string{messageID}) + _, err := m.messageExistsBySourceID([]string{messageID}) if err != nil { if errors.Is(err, errConversationNotFound) { return false, nil @@ -864,8 +887,10 @@ func (m *Manager) findOrCreateConversation(in *models.Message, inboxID, contactC ) // Search for existing conversation using the in-reply-to and references. + m.lo.Debug("searching conversation using in-reply-to and references", "in_reply_to", in.InReplyTo, "references", in.References) + sourceIDs := append([]string{in.InReplyTo}, in.References...) - conversationID, err = m.findConversationID(sourceIDs) + conversationID, err = m.messageExistsBySourceID(sourceIDs) if err != nil && err != errConversationNotFound { return new, err } @@ -895,8 +920,8 @@ func (m *Manager) findOrCreateConversation(in *models.Message, inboxID, contactC return new, nil } -// findConversationID finds the conversation ID from the message source ID. -func (m *Manager) findConversationID(messageSourceIDs []string) (int, error) { +// messageExistsBySourceID returns conversation ID if a message with any of the given source IDs exists. +func (m *Manager) messageExistsBySourceID(messageSourceIDs []string) (int, error) { if len(messageSourceIDs) == 0 { return 0, errConversationNotFound } diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go index df8c2910..06c005c6 100644 --- a/internal/conversation/models/models.go +++ b/internal/conversation/models/models.go @@ -251,9 +251,10 @@ func (m *Message) HasCSAT() bool { // IncomingMessage links a message with the contact information and inbox id. type IncomingMessage struct { - Message Message - Contact umodels.User - InboxID int + ConversationUUID string + Message Message + Contact umodels.User + InboxID int } type Status struct { diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index e578665a..2271248a 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -178,6 +178,8 @@ WHERE ($1 > 0 AND c.id = $1) OR ($2::uuid IS NOT NULL AND c.uuid = $2::uuid) + OR + ($3::TEXT != '' AND c.reference_number = $3::TEXT) -- name: get-conversations-created-after diff --git a/internal/inbox/channel/email/smtp.go b/internal/inbox/channel/email/smtp.go index 8d915d61..26fc4da5 100644 --- a/internal/inbox/channel/email/smtp.go +++ b/internal/inbox/channel/email/smtp.go @@ -20,6 +20,7 @@ const ( headerReferences = "References" headerInReplyTo = "In-Reply-To" headerLibredeskLoopPrevention = "X-Libredesk-Loop-Prevention" + headerLibredeskConversationID = "X-Libredesk-Conversation-UUID" headerAutoreply = "X-Autoreply" headerAutoSubmitted = "Auto-Submitted" @@ -205,9 +206,16 @@ func (e *Email) Send(m models.Message) error { for _, ref := range m.References { references += "<" + ref + "> " } - e.lo.Debug("References header set", "references", references) email.Headers.Set(headerReferences, references) + e.lo.Debug("References header set", "references", references) + + // Set conversation uuid header + if m.ConversationUUID != "" { + email.Headers.Set(headerLibredeskConversationID, m.ConversationUUID) + e.lo.Debug("Conversation UUID header set", "conversation_uuid", m.ConversationUUID) + } + // Set email content switch m.ContentType { case "plain": diff --git a/internal/stringutil/stringutil.go b/internal/stringutil/stringutil.go index b28ab803..cd4179dd 100644 --- a/internal/stringutil/stringutil.go +++ b/internal/stringutil/stringutil.go @@ -21,8 +21,9 @@ const ( ) var ( - regexpNonAlNum = regexp.MustCompile(`[^a-zA-Z0-9\-_\.]+`) - regexpSpaces = regexp.MustCompile(`[\s]+`) + regexpNonAlNum = regexp.MustCompile(`[^a-zA-Z0-9\-_\.]+`) + regexpSpaces = regexp.MustCompile(`[\s]+`) + regexpRefNumber = regexp.MustCompile(`#(\d+)`) ) // HTML2Text converts HTML to text. @@ -249,3 +250,18 @@ func ComputeRecipients( return } + +// ExtractReferenceNumber extracts the last reference number from a subject line. +// For example, "RE: Test - #392" returns "392". +// If multiple numbers exist (e.g., "Order #123 - #392"), returns the last one ("392"). +func ExtractReferenceNumber(subject string) string { + matches := regexpRefNumber.FindAllStringSubmatch(subject, -1) + if len(matches) > 0 { + // Return the last match's captured group. + lastMatch := matches[len(matches)-1] + if len(lastMatch) >= 2 { + return lastMatch[1] + } + } + return "" +} diff --git a/internal/stringutil/stringutil_test.go b/internal/stringutil/stringutil_test.go index 45a18152..133ae378 100644 --- a/internal/stringutil/stringutil_test.go +++ b/internal/stringutil/stringutil_test.go @@ -101,3 +101,61 @@ func TestFormatDuration(t *testing.T) { }) } } + +func TestExtractReferenceNumber(t *testing.T) { + tests := []struct { + name string + subject string + expected string + }{ + { + name: "simple reference number", + subject: "Test - #392", + expected: "392", + }, + { + name: "with RE prefix", + subject: "RE: Test - #392", + expected: "392", + }, + { + name: "multiple hashes picks last", + subject: "Order #123 - #392", + expected: "392", + }, + { + name: "no reference number", + subject: "Just a regular subject", + expected: "", + }, + { + name: "hash without number", + subject: "Test #abc", + expected: "", + }, + { + name: "empty string", + subject: "", + expected: "", + }, + { + name: "number without hash", + subject: "Test 392", + expected: "", + }, + { + name: "multiple RE prefixes", + subject: "RE: RE: Test - #100", + expected: "100", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractReferenceNumber(tt.subject) + if result != tt.expected { + t.Errorf("ExtractReferenceNumber(%q) = %q, want %q", tt.subject, result, tt.expected) + } + }) + } +}