mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-23 02:53:32 +00:00
Merge pull request #310 from abhinavxd/fix/auto-reply-contact-only
fix auto-replies and csat fanning out to last message's cc list
This commit is contained in:
@@ -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 = []
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user