diff --git a/frontend/apps/main/src/stores/conversation.js b/frontend/apps/main/src/stores/conversation.js index 4a091181..e6eb2bd0 100644 --- a/frontend/apps/main/src/stores/conversation.js +++ b/frontend/apps/main/src/stores/conversation.js @@ -325,7 +325,9 @@ export const useConversationStore = defineStore('conversation', () => { if (!conv || !msgData || !inboxEmail) return - const latestMessage = msgData.getLatestMessage(conv.uuid, ['incoming', 'outgoing'], true) + // Skip automated messages (auto-replies, CSAT) so the reply-box prefill + // reflects the last human-driven recipients, not a system-generated one. + const latestMessage = msgData.getLatestMessage(conv.uuid, ['incoming', 'outgoing'], true, true) if (!latestMessage) { // Reset recipients if no latest message is found. currentTo.value = [] diff --git a/frontend/apps/main/src/utils/conversation-message-cache.js b/frontend/apps/main/src/utils/conversation-message-cache.js index 944559df..4283caee 100644 --- a/frontend/apps/main/src/utils/conversation-message-cache.js +++ b/frontend/apps/main/src/utils/conversation-message-cache.js @@ -84,10 +84,11 @@ export default class MessageCache { * @param {string} convId - Conversation ID * @param {string[]} type - Array of message types to filter - outgoing, incoming, etc. * @param {boolean} excludePrivate - Exclude private messages - * + * @param {boolean} excludeAutomated - Exclude automated messages (like CSAT surveys, system messages, etc.) + * * @returns {object} - Latest message object or null if not found */ - getLatestMessage (convId, type = [], excludePrivate = false) { + getLatestMessage (convId, type = [], excludePrivate = false, excludeAutomated = false) { const conv = this.cache.get(convId) if (!conv) return null @@ -101,6 +102,9 @@ export default class MessageCache { if (excludePrivate) { allMessages = allMessages.filter(msg => !msg.private) } + if (excludeAutomated) { + allMessages = allMessages.filter(msg => !msg.meta?.is_automated) + } // Sort messages by created_at in descending order (newest first) allMessages.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 8f6b0c2d..cfcf9303 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -289,7 +289,6 @@ type queries struct { UnsnoozeAll *sqlx.Stmt `query:"unsnooze-all"` DeleteConversation *sqlx.Stmt `query:"delete-conversation"` RemoveConversationAssignee *sqlx.Stmt `query:"remove-conversation-assignee"` - GetLatestMessage *sqlx.Stmt `query:"get-latest-message"` // Draft queries. UpsertConversationDraft *sqlx.Stmt `query:"upsert-conversation-draft"` @@ -1286,22 +1285,22 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio return fmt.Errorf("sending private note: %w", err) } case amodels.ActionReply: - // Make recipient list. - to, cc, bcc, err := m.makeRecipients(conv.ID, conv.Contact.Email.String, conv.InboxMail, conv.InboxReplyTo) - if err != nil { - return fmt.Errorf("making recipients for reply action: %w", err) + // Automated replies always go to the contact only. CCs from the + // conversation history are deliberately not carried forward. + if conv.Contact.Email.String == "" { + return fmt.Errorf("auto-reply skipped: contact has no email for conversation: %s", conv.UUID) } - _, err = m.QueueReply( + _, err := m.QueueReply( []mmodels.Media{}, conv.InboxID, user.ID, conv.ContactID, conv.UUID, action.Value[0], - to, - cc, - bcc, - map[string]any{}, /**meta**/ + []string{conv.Contact.Email.String}, + nil, + nil, + map[string]any{"is_automated": true}, ) if err != nil { return fmt.Errorf("sending reply: %w", err) @@ -1360,8 +1359,12 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.Use return nil } -// SendCSATReply sends a CSAT reply message to a conversation. No-op if one was already sent. +// SendCSATReply sends a CSAT reply message to a conversation. No-op if one was already sent or contact has no email. func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversation) error { + if conversation.Contact.Email.String == "" { + m.lo.Info("CSAT reply skipped: contact has no email for conversation: %s", "conversation_uuid", conversation.UUID) + return nil + } csatResp, err := m.csatStore.Create(conversation.ID) if err != nil { if errors.Is(err, csat.ErrCSATAlreadyExists) { @@ -1389,20 +1392,14 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } - // Store `is_csat` meta to identify and filter CSAT public url from the message. - meta := map[string]interface{}{ - "is_csat": true, - "csat_uuid": csatResp.UUID, + meta := map[string]any{ + "is_csat": true, + "is_automated": true, + "csat_uuid": csatResp.UUID, } - // Make recipient list. - to, cc, bcc, err := m.makeRecipients(conversation.ID, conversation.Contact.Email.String, conversation.InboxMail, conversation.InboxReplyTo) - if err != nil { - return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) - } - - // Send CSAT reply. - _, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, message, to, cc, bcc, meta) + // Only send CSAT to contact. + _, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, message, []string{conversation.Contact.Email.String}, nil, nil, meta) if err != nil { m.lo.Error("error sending CSAT reply", "conversation_uuid", conversation.UUID, "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 9a24922a..bad258a2 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -1311,19 +1311,6 @@ func (m *Manager) uploadThumbnailForMedia(media mmodels.Media, content []byte) e return nil } -// getLatestMessage returns the latest message in a conversation. -func (m *Manager) getLatestMessage(conversationID int, typ []string, status []string, excludePrivate bool) (models.Message, error) { - var message models.Message - if err := m.q.GetLatestMessage.Get(&message, conversationID, pq.Array(typ), pq.Array(status), excludePrivate); err != nil { - if err == sql.ErrNoRows { - return message, sql.ErrNoRows - } - m.lo.Error("error fetching latest message from DB", "error", err) - return message, fmt.Errorf("fetching latest message: %w", err) - } - return message, nil -} - // ProcessIncomingMessageHooks handles automation rules, webhooks, SLA events, and other post-processing // for incoming messages. This allows other channels to insert messages first and then call this // function to trigger the necessary hooks. diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index b3ab3377..72f08d9d 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -702,26 +702,6 @@ WHERE source_id = ANY($1::text []); -- name: update-message-status update conversation_messages set status = $1, updated_at = NOW() where uuid = $2; --- name: get-latest-message -SELECT - m.created_at, - m.updated_at, - m.status, - m.type, - m.content, - m.uuid, - m.private, - m.sender_id, - m.sender_type, - m.meta -FROM conversation_messages m -WHERE m.conversation_id = $1 -AND m.type = ANY($2) -AND m.status = ANY($3) -AND m.private = NOT $4 -ORDER BY m.created_at DESC -LIMIT 1; - -- name: update-message-source-id UPDATE conversation_messages SET source_id = $1 WHERE id = $2; diff --git a/internal/conversation/recipient.go b/internal/conversation/recipient.go deleted file mode 100644 index b0440052..00000000 --- a/internal/conversation/recipient.go +++ /dev/null @@ -1,33 +0,0 @@ -package conversation - -import ( - "encoding/json" - "fmt" - - "github.com/abhinavxd/libredesk/internal/conversation/models" - "github.com/abhinavxd/libredesk/internal/stringutil" -) - -// makeRecipients computes the recipients for a given conversation ID using the last message in the conversation. -func (m *Manager) makeRecipients(conversationID int, contactEmail, inboxEmail, inboxReplyTo string) (to, cc, bcc []string, err error) { - lastMessage, err := m.getLatestMessage(conversationID, []string{models.MessageIncoming, models.MessageOutgoing}, []string{models.MessageStatusReceived, models.MessageStatusSent}, true) - if err != nil { - return nil, nil, nil, fmt.Errorf("fetching message for makeRecipients: %w", err) - } - - var meta struct { - From []string `json:"from"` - To []string `json:"to"` - CC []string `json:"cc"` - BCC []string `json:"bcc"` - } - if err = json.Unmarshal(lastMessage.Meta, &meta); err != nil { - return nil, nil, nil, err - } - - isIncoming := lastMessage.Type == models.MessageIncoming - to, cc, bcc = stringutil.ComputeRecipients( - meta.From, meta.To, meta.CC, meta.BCC, contactEmail, inboxEmail, inboxReplyTo, isIncoming, - ) - return -} diff --git a/internal/stringutil/stringutil.go b/internal/stringutil/stringutil.go index 87d888ab..4cf02e1b 100644 --- a/internal/stringutil/stringutil.go +++ b/internal/stringutil/stringutil.go @@ -7,7 +7,6 @@ import ( "net/mail" "path/filepath" "regexp" - "slices" "strings" "time" @@ -193,13 +192,6 @@ func DedupAndExcludeString(list []string, exclude string) []string { return cleaned } -// StripConvUUID removes +conv-{uuid-v4} from an email address if present. -// Only matches strict UUID v4 format (36 chars). -// e.g., support+conv-13216cf7-6626-4b0d-a938-46ce65a20701@domain.com -> support@domain.com -func StripConvUUID(email string) string { - return regexpConvUUID.ReplaceAllString(email, "@") -} - // ExtractConvUUID extracts the conversation UUID from a plus-addressed email. // e.g., support+conv-abc12345-1234-4123-1234-123456789abc@domain.com -> abc12345-1234-4123-1234-123456789abc // Returns empty string if no valid UUIDv4 found. @@ -212,70 +204,6 @@ func ExtractConvUUID(email string) string { return match[6 : len(match)-1] } -// DedupAndExcludePlusVariants deduplicates and excludes any of the given inbox addresses and their plus-addressed variants. -func DedupAndExcludePlusVariants(list []string, excludeAddresses ...string) []string { - exclude := make(map[string]struct{}, len(excludeAddresses)) - for _, addr := range excludeAddresses { - if addr != "" { - exclude[strings.ToLower(addr)] = struct{}{} - } - } - seen := make(map[string]struct{}, len(list)) - cleaned := make([]string, 0, len(list)) - for _, s := range list { - if s == "" { - continue - } - if _, skip := exclude[strings.ToLower(StripConvUUID(s))]; skip { - continue - } - if _, ok := seen[s]; !ok { - seen[s] = struct{}{} - cleaned = append(cleaned, s) - } - } - return cleaned -} - -// ComputeRecipients computes new recipients using last message's recipients and direction. -func ComputeRecipients( - from, to, cc, bcc []string, - contactEmail, inboxEmail, inboxReplyTo string, - lastMessageIncoming bool, -) (finalTo, finalCC, finalBCC []string) { - if lastMessageIncoming { - if len(from) > 0 { - finalTo = from - } else if contactEmail != "" { - finalTo = []string{contactEmail} - } - } else { - if len(to) > 0 { - finalTo = to - } else if contactEmail != "" { - finalTo = []string{contactEmail} - } - } - - finalCC = append([]string{}, cc...) - - if lastMessageIncoming { - if len(to) > 0 { - finalCC = append(finalCC, to...) - } - if contactEmail != "" && !slices.Contains(finalTo, contactEmail) && !slices.Contains(finalCC, contactEmail) { - finalCC = append(finalCC, contactEmail) - } - } - - finalTo = DedupAndExcludePlusVariants(finalTo, inboxEmail, inboxReplyTo) - finalCC = DedupAndExcludePlusVariants(finalCC, inboxEmail, inboxReplyTo) - // BCC is one-time only, user is supposed to add it manually. - finalBCC = []string{} - - return -} - // ExtractUUID finds and returns the first valid UUID v4 in the given text. // Returns empty string if no valid UUID is found. func ExtractUUID(text string) string { diff --git a/internal/stringutil/stringutil_test.go b/internal/stringutil/stringutil_test.go index 137f8189..59be477b 100644 --- a/internal/stringutil/stringutil_test.go +++ b/internal/stringutil/stringutil_test.go @@ -102,64 +102,6 @@ func TestFormatDuration(t *testing.T) { } } -func TestStripConvUUID(t *testing.T) { - tests := []struct { - name string - email string - expected string - }{ - { - name: "no plus addressing", - email: "support@domain.com", - expected: "support@domain.com", - }, - { - name: "with valid UUID v4", - email: "support+conv-13216cf7-6626-4b0d-a938-46ce65a20701@domain.com", - expected: "support@domain.com", - }, - { - name: "short non-UUID preserved (user email)", - email: "support+conv-21321@domain.com", - expected: "support+conv-21321@domain.com", - }, - { - name: "non-conv plus addressing unchanged", - email: "support+other@domain.com", - expected: "support+other@domain.com", - }, - { - name: "empty string", - email: "", - expected: "", - }, - { - name: "uppercase UUID v4", - email: "support+conv-13216CF7-6626-4B0D-A938-46CE65A20701@domain.com", - expected: "support@domain.com", - }, - { - name: "invalid UUID format preserved", - email: "support+conv-abc123-def456@domain.com", - expected: "support+conv-abc123-def456@domain.com", - }, - { - name: "missing 4 in UUID preserved", - email: "support+conv-13216cf7-6626-ab0d-a938-46ce65a20701@domain.com", - expected: "support+conv-13216cf7-6626-ab0d-a938-46ce65a20701@domain.com", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := StripConvUUID(tt.email) - if result != tt.expected { - t.Errorf("StripConvUUID(%q) = %q, want %q", tt.email, result, tt.expected) - } - }) - } -} - func TestExtractConvUUID(t *testing.T) { tests := []struct { name string @@ -233,90 +175,6 @@ func TestExtractConvUUID(t *testing.T) { } } -func TestDedupAndExcludePlusVariants(t *testing.T) { - tests := []struct { - name string - list []string - baseEmail string - expected []string - }{ - { - name: "removes exact match", - list: []string{"other@domain.com", "support@domain.com"}, - baseEmail: "support@domain.com", - expected: []string{"other@domain.com"}, - }, - { - name: "removes valid UUID v4 plus-addressed variant", - list: []string{"other@domain.com", "support+conv-13216cf7-6626-4b0d-a938-46ce65a20701@domain.com"}, - baseEmail: "support@domain.com", - expected: []string{"other@domain.com"}, - }, - { - name: "removes both exact and UUID v4 plus variant", - list: []string{"support@domain.com", "other@domain.com", "support+conv-a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d@domain.com"}, - baseEmail: "support@domain.com", - expected: []string{"other@domain.com"}, - }, - { - name: "keeps non-conv plus addresses", - list: []string{"support+other@domain.com", "other@domain.com"}, - baseEmail: "support@domain.com", - expected: []string{"support+other@domain.com", "other@domain.com"}, - }, - { - name: "keeps non-UUID conv addresses (user email)", - list: []string{"support+conv-21321@domain.com", "other@domain.com"}, - baseEmail: "support@domain.com", - expected: []string{"support+conv-21321@domain.com", "other@domain.com"}, - }, - { - name: "deduplicates", - list: []string{"other@domain.com", "other@domain.com", "another@domain.com"}, - baseEmail: "support@domain.com", - expected: []string{"other@domain.com", "another@domain.com"}, - }, - { - name: "removes empty strings", - list: []string{"", "other@domain.com", ""}, - baseEmail: "support@domain.com", - expected: []string{"other@domain.com"}, - }, - { - name: "case insensitive base email match", - list: []string{"SUPPORT@domain.com", "other@domain.com"}, - baseEmail: "support@domain.com", - expected: []string{"other@domain.com"}, - }, - { - name: "empty list", - list: []string{}, - baseEmail: "support@domain.com", - expected: []string{}, - }, - { - name: "empty inboxEmail preserves all non-empty emails", - list: []string{"user@example.com", "other@domain.com"}, - baseEmail: "", - expected: []string{"user@example.com", "other@domain.com"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := DedupAndExcludePlusVariants(tt.list, tt.baseEmail) - if len(result) != len(tt.expected) { - t.Errorf("got len %d, want %d", len(result), len(tt.expected)) - return - } - for i := range result { - if result[i] != tt.expected[i] { - t.Errorf("at index %d got %s, want %s", i, result[i], tt.expected[i]) - } - } - }) - } -} func TestExtractReferenceNumber(t *testing.T) { tests := []struct {